Your first plugin
The scaffold ships my-strategy registered. Open it, understand the factory contract, and dry-run it.
The scaffold ships your first plugin: src/plugins/my-strategy/, registered and listed in RUNNER_PLUGINS. It is the smallest complete plugin — it reads vault state and decides to do nothing, with a reason. Writing your strategy is editing its decide(), not starting from a blank file. (Want more worked code? Every rung of the Build ladder is a complete plugin you can copy into src/plugins/ and adapt.)
The shape of a plugin
Open the starter. A plugin is a factory: a function that reads its own env vars, validates them, and returns the plugin object. The registry calls it at startup, so a bad config fails loudly before the first tick.
// src/plugins/my-strategy/index.ts — what the scaffold ships
import type { StrategyPlugin } from "../../engine/runtime-core";
export default function createMyStrategy(): StrategyPlugin {
const vaultId = process.env.PLUGIN_MY_STRATEGY_VAULT_ID;
if (!vaultId) throw new Error("my-strategy: PLUGIN_MY_STRATEGY_VAULT_ID not set");
return {
name: "my-strategy",
vaultId,
triggers: [{ kind: "cron", everySeconds: 60 }],
async decide(ctx) {
const nav = ctx.vault.nav;
// ...your logic. Return the actions you want, or a noop with a reason.
return [{ kind: "noop", reason: `nav=${nav}, nothing to do yet` }];
},
};
}Three things to notice:
- The runtime is vendored into your project: types import from
../../engine/runtime-core, not from an npm package. - Per-plugin config follows one convention:
PLUGIN_<NAME_UPPER_SNAKE>_<KEY>. The factory reads and validates its own vars; the engine imposes no shape. decide()is a pure function: it gets fresh state inctxand returns declarative intent. It never signs, never calls RPC, never builds a transaction; the runtime does all of that. Anoopmust carry areason—dry-runprints it as the decision, and the daemon keeps it in the debug log.
Registration
A plugin runs only if its factory is in src/plugins/index.ts and its name is listed in RUNNER_PLUGINS. The starter ships with both done:
// src/plugins/index.ts — as scaffolded
import createMyStrategy from "./my-strategy";
export const PLUGINS: Record<string, PluginFactory> = {
"my-strategy": createMyStrategy,
};The only value left for you is the vault the starter operates:
# .env (RUNNER_PLUGINS=my-strategy is pre-filled)
PLUGIN_MY_STRATEGY_VAULT_ID=0x…Adding a second plugin later is the same two lines: an import and an entry in PLUGINS, plus its name in RUNNER_PLUGINS.
Dry-run it
Now see it think — no gas, no signature, nothing touches the chain:
pnpm dry-run my-strategy # with npm: npm run dry-run my-strategyOne requirement that surprises people: OPERATOR_PRIVATE_KEY must be set even here. The simulation runs with your operator address as the sender (that is how the contract's permission checks get exercised), but nothing is signed and nothing is submitted.
A dry-run executes decide(), simulates the resulting transaction, and prints the decision it took: the actions, their params, noop reasons, and the simulated gas estimate. It skips all lifecycle hooks so it can't corrupt persistent state. This is your inner loop: edit decide(), dry-run, repeat.
When the dry-run shows the decision you want, you're ready to run it live.
Next: run it for real, and watch it →