Plugin state API
The PluginStateAPI surface.
A plugin keeps nothing in memory across ticks — a restart would lose it. Instead the runtime hands you ctx.state, a small key-value store scoped to your plugin. Use it for cooldowns, counters, last-timestamps, bans, "I'm holding position X" flags: the running memory your decide() reads at the top and writes at the bottom.
Scoping: every key is namespaced by your plugin'sname. State is never shared across plugins. Two plugins can both writelastTradeMsand never collide.
Values are generic over T — you store and read structured objects, not just strings; serialization is the backend's concern.
The surface
interface PluginStateAPI {
get<T>(key: string): Promise<T | null>;
getOrDefault<T>(key: string, fallback: T): Promise<T>;
set<T>(key: string, value: T): Promise<void>;
setWithTTL<T>(key: string, value: T, ttlMs: number): Promise<void>;
delete(key: string): Promise<void>;
has(key: string): Promise<boolean>;
keys(): Promise<string[]>;
}get: reads the value forkey. Returnsnullif the key was never set, was deleted, or has expired.getOrDefault: likeget, but returnsfallbackwhen the key is missing or expired. Skips the?? defaultValueboilerplate that wraps almost every cooldown or counter read.set: upsert — creates if absent, overwrites if present. No expiration; the value lives until youdeleteit.setWithTTL: same upsert asset, plus the key is treated as missing afterttlMsmilliseconds. Throws ifttlMs <= 0. Reach for it for anything that should not accumulate: bans, cooldowns, dedup windows.delete: removes the key. Idempotent — a no-op if the key doesn't exist.has:trueif the key exists and has not expired. Cheaper thangetwhen you only need existence, not the value.keys: every key currently set and not expired. For debug, cleanup, and migrations.
TTL semantics
A TTL'd key carries an expiration deadline. After ttlMs elapses, get returns null, has returns false, and keys omits it — the key behaves as if it was never set.
Expiration is lazy: an expired entry is purged on the next read of that key (get, has), not on a timer. The in-memory backend works exactly this way; a database backend may choose lazy-on-read or a background sweep — that's an implementation choice. With the Drizzle backend the deadline lives in an expires_at column (ms-epoch, or null for no TTL), and every read filters for "not expired." Expired rows stay on disk until a periodic job deletes them, so for long-running deploys you schedule that cleanup yourself.
Backends
The default backing store is a database table; an InMemoryStateAPI ships for tests and for bootstrapping before a DB is wired. You choose the backend by supplying a stateFactory to the runtime — and you can mix per plugin: in-memory for a scalping strategy where a restart wipe is fine, persistent for the rest.
For the full setup — Drizzle with SQLite or Postgres, the plugin_state schema, and how to wire stateFactory — see Persistent state.