Skip to content
Runner · Understand the runtime

Runner architecture

The runtime that loads plugins, fires them on a trigger, and signs txs as the vault's operator.

Derived from runner/notes/architecture.md

The runner is the strategy execution runtime: a Node daemon that loads N plugins (one per active strategy), fires each one on a trigger (cron, event, manual), and signs transactions as the vault's operator_address through @automark/sdk.

Core principle: the plugin returns declarative intent; the runtime executes. The plugin never touches a key, never calls RPC directly, never builds tx.moveCall.

That buys three properties. The plugin is a pure function, testable without mocks. A buggy plugin can't escape the runtime: at worst it returns a bad Action[], which hits the vault's risk caps. And the path is ready for a sandbox once external builders come on board.

The four layers

Plugin            decide(ctx) → Action[]   — no signer, no RPC, no tx

Trigger adapter   translates a stimulus (cron · event · manual) into decide()

Executor          Action[] → moveCall via SDK → signs, submits, records digest

Runtime core      scheduler, wallet, retry, logs, metrics, healthcheck, isolation

The plugin contract

interface StrategyPlugin {
  /** Stable name for logs, metrics, state. Unique per runtime instance. */
  name: string;
  /** Vault this plugin operates. One plugin operates a single vault. */
  vaultId: string;
  /** When the runtime should invoke decide(). Multiple triggers OK. */
  triggers: Trigger[];
  /**
   * Decision. Receives fresh state, returns declarative intent.
   * Does NOT touch a key, does NOT call RPC directly, does NOT build a tx.
   */
  decide(ctx: PluginContext): Promise<Action[]>;
}

type Trigger =
  | { kind: "cron"; everySeconds: number }
  | { kind: "event"; topic: string }   // indexer pg_notify topic
  | { kind: "manual" };                 // fired via CLI

Non-obvious points about the interface:

  • A noop with a reason is mandatory. A plugin that decides to "do nothing" has to say why. The reason becomes a structured log of why the strategy didn't act; without it, debugging behavior is impossible.
  • Multiple Actions become a single PTB. Returning [mintBinary, mintRange] from one decide() assembles sequential moveCalls in the same tx.
  • State is externalized and optional. The plugin doesn't keep state in memory (it's lost on restart). ctx.state is a key-value store scoped per plugin name. A stateless plugin ignores it, at no cost.
  • Multiple triggers on the same plugin. It can react to time (cron) and to flow (event) at once.
  • No automatic mutex. If two triggers fire at the same time, decide() runs twice in parallel, and the plugin owns its idempotency via state. A global mutex was rejected because it would stall the whole pipeline on the latency of one tx.

Available triggers

TriggerWhat for
cronThe base case. Every strategy needs a periodic tick.
eventReacts to the indexer's realtime pipeline via pg_notify; the plugin subscribes to a topic.
manualDebug and emergency ops, fired from the CLI.

Runner vs keeper

The runner is not apps/keeper. They are different jobs:

Aspectkeeperrunner
AuthPermissionless (anyone can run it)Privileged (the operator_address key)
PurposeSystem maintenance (NAV refresh, settle, fees)Proprietary strategy execution
Who runs itProtocol and volunteersVault owner

They share @automark/runtime-core (scheduler, signer, retry, observability) without mixing semantics.

Who the operator is

A discretionary vault has exactly one operator_address. If the runtime operates a plugin while a human triggers the same action through a UI, both sign with the same key. That's valid for the contract but an operational mess: double execution, and nobody knows what the other did.

Rule: per vault, pick one regime. Either the operator_address is the runtime's wallet (bot-operated, the manual UI exposes no actions), or it's the human's wallet (manually operated, the runtime loads no plugin for it). Don't mix. The "bot plus human override" case needs a 2-of-N multisig operator, a special case.

Failure isolation

A plugin that crashes or throws does not bring down the runtime. The runtime catches it, logs it, marks the plugin unhealthy (skips it for a few ticks), and carries on with the others.