Skip to content
Runner · Reference

Plugin state API

The PluginStateAPI surface.

Derived from packages/runtime-core/src/types.ts

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's name. State is never shared across plugins. Two plugins can both write lastTradeMs and 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 for key. Returns null if the key was never set, was deleted, or has expired.
  • getOrDefault: like get, but returns fallback when the key is missing or expired. Skips the ?? defaultValue boilerplate that wraps almost every cooldown or counter read.
  • set: upsert — creates if absent, overwrites if present. No expiration; the value lives until you delete it.
  • setWithTTL: same upsert as set, plus the key is treated as missing after ttlMs milliseconds. Throws if ttlMs <= 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: true if the key exists and has not expired. Cheaper than get when 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.