Run your vault strategy off-chain. Decide when to mint, redeem, or rebalance, and let the runner sign and submit the transactions for you.
This repo is a runtime you clone and run — not a library you install. It
ships with the engine plus a single starter plugin, my-strategy, registered
and ready to dry-run. You edit its decide() (or add your own plugin under
src/plugins/), point it at your vault, and run.
You write a plugin: a TypeScript function that looks at your vault's
current state and returns the actions you want to take. The agent handles
the rest — scheduling, signing as your operator_address, submitting to Sui,
retrying on transient errors, logging.
You focus on strategy logic. Everything else is plumbing.
import type { StrategyPlugin } from "../../engine/runtime-core";
import { Market } from "@automark/sdk/market";
export default function createMyStrategy(): StrategyPlugin {
const vaultId = process.env.PLUGIN_MY_STRATEGY_VAULT_ID;
if (!vaultId) throw new Error("PLUGIN_MY_STRATEGY_VAULT_ID not set");
return {
name: "my-strategy",
vaultId,
triggers: [{ kind: "cron", everySeconds: 60 }],
async decide(ctx) {
const { isFrozen, maxSinglePosition, exposureHeadroom } = ctx.vault;
if (isFrozen) {
return [{ kind: "noop", reason: "vault frozen" }];
}
// Pick the NEAREST active BTC market whose expiry is still > 5 min away.
// `pick: "nearest"` is the default — pass `pick: "furthest"` if you want
// the longest-dated instead. See "Markets and live prices" for more.
const btc = await Market.find({
asset: "BTC",
expiryAfterMs: ctx.now + 5 * 60 * 1000,
client: ctx.suiClient,
});
// Read live spot + forward price + SVI sigma
const p = await btc.price();
ctx.logger.info("market", { spot: p.spotUsd, forward: p.forwardUsd });
// Your decision logic — replace with your real signal
if (p.forwardUsd <= p.spotUsd) {
return [{ kind: "noop", reason: "no edge" }];
}
// Size trade against the smaller of (per-position cap, total headroom),
// then take half for a safety margin
const ceiling =
maxSinglePosition < exposureHeadroom ? maxSinglePosition : exposureHeadroom;
const quantity = ceiling / 2n;
if (quantity === 0n) {
return [{ kind: "noop", reason: "no headroom" }];
}
// Place strike 3% above the forward (snap-to-grid handled by helper)
const strike = btc.strikeAbove(p.forwardRaw, { pctBps: 300 });
return [
{
kind: "vault.mintBinary",
params: { marketId: btc.id, strike, isUp: true, quantity },
},
];
},
};
}That is the whole contract. No transaction building, no signing, no retry
logic, no logger setup. The decide function takes context, returns
intentions.
The import path
../../engine/runtime-coreis relative because the runtime is vendored inside this repo undersrc/engine/. A plugin lives atsrc/plugins/<name>/index.ts, so it reaches the runtime types two levels up.
git clone https://github.com/automark-protocol/automark-runner.git
cd automark-runner
pnpm install # or: npm installScaffolded with
npx create-automark-runner? A starter plugin namedmy-strategyis already registered and listed inRUNNER_PLUGINS— skip to step 3 and just edit itsdecide()insrc/plugins/my-strategy/.
Open src/plugins/index.ts and add your factory:
import createMyStrategy from "./my-strategy";
export const PLUGINS: Record<string, PluginFactory> = {
"my-strategy": createMyStrategy, // ← add this line
};This is the only runtime file you edit. Everything else (loader, runtime, scheduler, executor) is infrastructure — don't touch it. When you pull updates to the agent later, those files take updates; this registry stays yours.
# Your operator keypair (the one registered as operator_address on the vault)
OPERATOR_PRIVATE_KEY=suiprivkey1...
# Which plugins to load (comma-separated)
RUNNER_PLUGINS=my-strategy
# Per-plugin config — convention: PLUGIN_<NAME_UPPER_SNAKE>_<KEY>
PLUGIN_MY_STRATEGY_VAULT_ID=0x...
# Optional
SUI_NETWORK=testnet # testnet | mainnet | devnet (default: testnet)
LOG_LEVEL=info # debug | info | warn | error (default: info)That is it. Sui RPC URL, Predict package, and shared object IDs are resolved
automatically from SUI_NETWORK using the Mysten public fullnode and the
addresses pinned in the SDK. See "Overriding defaults" below if you need a
custom fullnode or a forked deployment.
# Run as a daemon (cron triggers fire continuously)
pnpm start
# Or fire one tick of a specific plugin (useful for debugging)
pnpm trigger my-strategySee the full CLI reference below for dry-run, plugins,
inspect, and show-config.
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string |
✅ | Unique. Used in logs and state scoping. |
vaultId |
string |
✅ | The vault this plugin operates. One plugin, one vault. |
triggers |
Trigger[] |
✅ | When decide() is invoked. Multiple triggers allowed. |
decide(ctx) |
Promise<Action[]> |
✅ | Your strategy. Returns declarative intents. |
onExecuted(ctx, result) |
Promise<void> |
— | Optional. Runs after every outcome (submitted/noop/failed). Use for audit log, cooldown-on-success, telemetry. |
onError(ctx, err) |
Promise<void> |
— | Optional. Runs only on failed outcomes. Use for dedicated error handling without branching result.outcome. |
Both lifecycle hooks (onExecuted, onError) are independent and
isolated — exceptions are caught + logged, never impact the next tick or
the scheduler's healthy/unhealthy state. When both are defined and the
tick fails, order is onExecuted → onError.
{ kind: "cron"; everySeconds: number } // run every N seconds
{ kind: "manual" } // only via `pnpm trigger <name>`
{ kind: "event"; topic: string } // reserved for future useA plugin can have multiple triggers. Same decide() runs for each.
{
vault: Vault; // fresh state from the SDK — see field reference below
suiClient: SuiClient; // shared client; pass to Market.find({client}), etc.
now: number; // Date.now() at trigger time (use this, NOT Date.now())
state: PluginStateAPI; // KV scoped to your plugin (get/set/setWithTTL/delete/has/keys)
logger: Logger; // structured JSON logger, pre-bound with plugin/vault fields
}ctx.vault is a snapshot of the vault state, fresh at the trigger moment.
Every field is a readonly getter — you can destructure it however you want:
async decide(ctx) {
const {
paused, strategyPaused, // is the vault frozen?
nav, navPerShare, // current valuation
quoteBalance, // free quote (not deployed to PM)
aggregateExposure, // sum of open position values
pendingDepositsTotal, // queued deposits awaiting processing
pendingWithdrawalSharesTotal, // queued withdrawals
accruedFeesQuote, // fees waiting for crystallize
trackedMarketKeys, // open binary positions
trackedRangeKeys, // open range positions
lastSnapshotTs, // last NAV snapshot
} = ctx.vault;
if (paused || strategyPaused) {
return [{ kind: "noop", reason: "vault frozen" }];
}
// ...
}All risk caps and fees are in basis points (bps) — the DeFi standard.
1 bps = 0.01%, so500 bps = 5%and10_000 bps = 100%. Matchesmax_position_bpsetc. in the vault Move source verbatim, so abort codes likeEPositionTooLargecorrelate directly with the value you read here.
| Question | Field | Type |
|---|---|---|
| Is the vault frozen? | paused, strategyPaused |
boolean |
| What is the NAV? | nav, navPerShare |
bigint |
| How much free capital? | quoteBalance |
bigint (in quote units, e.g. dUSDC × 1e6) |
| How much is locked up in positions? | aggregateExposure |
bigint |
| Are there pending deposits/withdrawals? | pendingDepositsTotal, pendingWithdrawalSharesTotal |
bigint |
| What positions are open? | trackedMarketKeys, trackedRangeKeys |
arrays |
| What was the all-time-high NAV/share? | highWaterMark |
bigint |
| When was the last NAV refresh? | lastSnapshotTs |
bigint (ms since epoch) |
| When were fees last crystallized? | lastCrystallizationTs |
bigint |
| Which Predict Manager is this vault using? | predictManagerId |
string | null |
| Which strategy module is authorized? | authorizedWitnessType |
string |
| What ops can the strategy invoke? | strategyPermissions |
bigint (bitfield) |
| What are the snapshot/cooldown intervals? | navParams |
{ snapshot_interval_seconds, deposit_cooldown_seconds, ... } |
| What are the risk caps? | riskParams |
{ max_position_bps, max_aggregate_exposure_bps, max_drawdown_bps, max_share_concentration_bps } |
| What is the fee schedule? | feeParams |
{ performance_fee_bps, management_fee_bps_annual, entry_fee_bps, exit_fee_bps, platform_split_bps } |
| Which ops is my strategy allowed to call? | canMintBinary · canMintRange · canRedeemBinary · canRedeemRange · canPmDeposit · canPmWithdraw · canSupplyPlp · canWithdrawPlp |
boolean (derived from strategyPermissions bitfield) |
| Which permission names are granted? | grantedPermissions |
PermissionName[] (e.g. ["MINT_BINARY", "PM_DEPOSIT"]) — useful for logs |
| Question | Helper | Type |
|---|---|---|
| Is the vault frozen for any reason? | isFrozen |
boolean (paused || strategyPaused) |
| Largest single position I can open right now? | maxSinglePosition |
bigint (nav × max_position_bps / 10_000) |
| Largest total exposure the vault allows? | maxAggregateExposure |
bigint (nav × max_aggregate_exposure_bps / 10_000) |
| How much more exposure before hitting the aggregate cap? | exposureHeadroom |
bigint (clamped >= 0n) |
Each helper is a one-liner that computes the value from the fields above.
They exist because that math repeats in every plugin and getting it wrong
trips EPositionTooLarge / EExposureLimitExceeded on chain.
const { isFrozen, maxSinglePosition, exposureHeadroom } = ctx.vault;
if (isFrozen) return [{ kind: "noop", reason: "frozen" }];
// Trade up to whichever cap binds first, with a safety margin
const ceiling = maxSinglePosition < exposureHeadroom
? maxSinglePosition
: exposureHeadroom;
const quantity = ceiling / 2n;For the raw on-chain struct (advanced — every field), see ctx.vault.rawState.
Every mintBinary / mintRange action targets a market. A market is
uniquely identified by (asset, expiry) — there are usually multiple active
per asset (BTC weekly, BTC monthly, etc.). Use Market from
@automark/sdk/market to find them and read live prices:
import { Market } from "@automark/sdk/market";Find which market to trade:
import { Market } from "@automark/sdk/market";
import { days, hours, minutes } from "@automark/sdk/duration";
// Pick one market by asset + expiry filter + strategy
const btc = await Market.find({
asset: "BTC",
expiryAfterMs: ctx.now + hours(1), // at least 1h of life
pick: "nearest", // or "furthest" (default: "nearest")
client: ctx.suiClient, // needed for market.price() later
});
// Or list everything for an asset and pick yourself
const all = await Market.list({ asset: "BTC", client: ctx.suiClient });
// → sorted by expiry ascending; each entry has { id, asset, expiresAtMs, minStrike, tickSize, ... }
// Look up by id (useful for refreshing tickSize/expiry on a market you already track)
const known = await Market.byId("0x...", { client: ctx.suiClient });Filter by expiry window (no manual .filter() needed):
// Everything BTC expiring in the next 7 days
const week = await Market.list({
asset: "BTC",
expiringWithinMs: days(7),
client: ctx.suiClient,
});
// BTC markets expiring in the next 15-30 min window
const veryShort = await Market.list({
asset: "BTC",
expiryAfterMs: ctx.now + minutes(15),
expiringWithinMs: minutes(30),
client: ctx.suiClient,
});
// Absolute range — markets expiring between two fixed timestamps
const window = await Market.list({
asset: "BTC",
expiringBetweenMs: [fromMs, toMs],
client: ctx.suiClient,
});
// find() takes the same time filters
const nearestUpToOneHour = await Market.find({
asset: "BTC",
expiringWithinMs: hours(1),
client: ctx.suiClient,
});The three time filters compose:
| Filter | Semantics |
|---|---|
expiryAfterMs: T |
Floor — only markets with expiresAtMs > T |
expiringWithinMs: D |
Ceiling relative to now — expiresAtMs ≤ Date.now() + D |
expiringBetweenMs: [a, b] |
Absolute range — expiresAtMs ∈ [a, b]. Mutually exclusive with expiringWithinMs (pick one) |
Combine expiryAfterMs + expiringWithinMs to express "between N minutes
and M hours from now" without writing two .filter() calls.
Duration helpers (@automark/sdk/duration):
seconds(30) // 30_000
minutes(15) // 900_000
hours(2) // 7_200_000
days(7) // 604_800_000
hours(1) + minutes(30) // 5_400_000 (composable arithmetic)Use anywhere you'd otherwise write 7 * 24 * 3600 * 1000 — cooldowns,
TTL, time filters, retry windows. Fewer typos, more intent.
Read live prices from the market (one read-only RPC, ~200-400ms, no gas):
const p = await btc.price();
p.spotUsd // 67_421.5 — live spot, USD with 4 decimals
p.forwardUsd // 67_530.2 — live forward (probabilistic center of SVI)
p.spotRaw // 67421500000000n — same as spotUsd but bigint, u64 1e9 scale
p.forwardRaw // 67530200000000n
p.sviSigmaRaw // 850000000n — SVI sigma raw; volatility parameter
p.atMs // read timestampCaller decides whether to cache (most strategies want fresh state every tick).
Picking a strike without touching the grid math. The market exposes 4
helpers that accept either USD numbers (67_500) or raw bigint
(67_500_000_000_000n), snap to the grid, and clamp to [minStrike, maxStrike]:
btc.strikeNear(67_500); // builder saw $67,500 on a chart
btc.strikeNear(p.spotRaw); // ATM (at-the-money)
btc.strikeAbove(p.forwardRaw, { pctBps: 300 }); // 3% above forward
btc.strikeBelow(p.spotUsd, { pctBps: 500 }); // 5% below spot (USD input ok)
// Probabilistic strike — k σ from forward, scaled by √(time-to-expiry)
btc.strikeAtSigma(p, { k: 1, direction: "up", atMs: ctx.now });pctBps is basis points (300 = 3%, 500 = 5%). Same convention as the risk
caps — see the bps note in the field reference above.
The forward price is what the market treats as "neutral" — the
probabilistic center of the SVI curve. Strikes priced relative to forward
align with how the market quotes options. Use forwardRaw for arithmetic
that needs precision, forwardUsd for logs and display.
strikeAtSigma adapts to time and volatility: a 1σ strike is closer to
forward on a short-dated market and further out on a long-dated one,
without you doing the math.
{ kind: "noop"; reason: string }
// Mint / redeem (binary + range) — strategy permission required
{ kind: "vault.mintBinary"; params: { marketId, strike, isUp, quantity, skipAutoFund? } }
{ kind: "vault.mintRange"; params: { marketId, lowerStrike, higherStrike, quantity, skipAutoFund? } }
{ kind: "vault.redeemBinary"; params: { marketId, strike, isUp, quantity } }
{ kind: "vault.redeemRange"; params: { marketId, lowerStrike, higherStrike, quantity } }
// PM custody (mint companion — runtime auto-funds before mint by default)
{ kind: "vault.pmDeposit"; params: { amount } }
{ kind: "vault.pmWithdraw"; params: { amount } }
// PLP — supply / withdraw to the Predict Liquidity Pool
{ kind: "vault.supplyPlp"; params: { amount } }
{ kind: "vault.withdrawPlp"; params: { sharesAmount } } // PLP shares, NOT quote
// Permissionless — anyone can call (no strategy permission needed)
{ kind: "vault.refreshNavSnapshot" }
{ kind: "vault.crystallizeFees" }Multiple actions in one return get bundled into a single atomic transaction. For example, fund the predict manager and open a position in one tx:
return [
{ kind: "vault.pmDeposit", params: { amount: 500_000_000n } },
{
kind: "vault.mintBinary",
params: { marketId, strike, isUp: true, quantity: 100_000_000n },
},
];If your strategy needs memory across ticks (last_trade_ts, counters, etc.),
use ctx.state. KV interface — get / set / setWithTTL / delete /
has / keys:
async decide(ctx) {
const lastTradeMs = (await ctx.state.get<number>("lastTradeMs")) ?? 0;
if (ctx.now - lastTradeMs < 3600_000) {
return [{ kind: "noop", reason: "cooldown" }];
}
await ctx.state.set("lastTradeMs", ctx.now);
return [/* your action */];
}State scoping: each plugin has its own KV namespace (keyed by plugin.name).
Default backend is in-memory and lost on restart — see Persistent state
below to swap in SQLite/Postgres.
Auto-expiring entries (setWithTTL). For bans, cooldowns, dedupe
windows, or any key that should not accumulate forever, use
setWithTTL(key, value, ttlMs). The key behaves as missing after the TTL
elapses — no cleanup pass needed:
// Ban this strike for 10 min — auto-expires
await ctx.state.setWithTTL(`ban:${marketId}:${strike}`, { reason: "EInvalidStrike" }, 10 * 60_000);
// Later, check presence — `has()` returns false automatically once TTL is over
if (await ctx.state.has(`ban:${marketId}:${strike}`)) {
return [{ kind: "noop", reason: "still banned" }];
}This works in the in-memory backend out of the box (lazy expiration on read).
Custom backends (Drizzle/Postgres/SQLite) implement setWithTTL via an
expires_at column.
By default, ctx.state is in-memory only — lost on restart. Fine for
stateless strategies, but most real plugins need to remember something
across ticks (last trade timestamp, cooldown counters, custom schedules).
Recommended setup: Drizzle ORM. Cross-DB (Postgres, SQLite, MySQL, libSQL), TypeScript-first, no codegen step.
Where it lives: put your adapter in src/plugins/state.ts (or any file
in src/plugins/) and export const stateFactory = ... from
src/plugins/index.ts. The engine picks it up automatically — you never
touch the engine files.
Quick taste with SQLite (zero infra):
// src/plugins/state.ts (new file)
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core";
import { and, eq } from "drizzle-orm";
import type { PluginStateAPI } from "../engine/runtime-core";
const sqlite = new Database("./operator-state.db");
sqlite.exec(`CREATE TABLE IF NOT EXISTS plugin_state (
plugin_name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (plugin_name, key))`);
const db = drizzle(sqlite);
const ps = sqliteTable("plugin_state", {
pluginName: text("plugin_name").notNull(),
key: text("key").notNull(),
value: text("value").notNull(),
}, (t) => ({ pk: primaryKey({ columns: [t.pluginName, t.key] }) }));
export class DrizzleStateAPI implements PluginStateAPI {
constructor(private pluginName: string) {}
async get<T>(key: string) {
const r = await db.select().from(ps)
.where(and(eq(ps.pluginName, this.pluginName), eq(ps.key, key))).get();
return r ? JSON.parse(r.value) as T : null;
}
async getOrDefault<T>(key: string, fallback: T) {
return (await this.get<T>(key)) ?? fallback;
}
async set<T>(key: string, value: T) {
await db.insert(ps).values({ pluginName: this.pluginName, key, value: JSON.stringify(value) })
.onConflictDoUpdate({ target: [ps.pluginName, ps.key], set: { value: JSON.stringify(value) } });
}
async setWithTTL<T>(key: string, value: T, _ttlMs: number) {
// Add an `expires_at` column + filter on read for real TTL support.
await this.set(key, value);
}
async delete(key: string) {
await db.delete(ps).where(and(eq(ps.pluginName, this.pluginName), eq(ps.key, key)));
}
async has(key: string) {
const r = await db.select({ k: ps.key }).from(ps)
.where(and(eq(ps.pluginName, this.pluginName), eq(ps.key, key))).get();
return !!r;
}
async keys() {
const rows = await db.select({ k: ps.key }).from(ps)
.where(eq(ps.pluginName, this.pluginName)).all();
return rows.map((r) => r.k);
}
}// src/plugins/index.ts — set the stateFactory
import { DrizzleStateAPI } from "./state";
export const stateFactory: (pluginName: string) => PluginStateAPI =
(pluginName) => new DrizzleStateAPI(pluginName);Done. Swap better-sqlite3 for pg and the schema for pgTable(... jsonb)
to move to Postgres — same query API, same adapter shape.
ctx.state is for the plugin's own memory — small, key-value, written and
read every tick. It is not the right tool for:
- Time series (historical prices, candles, signals) — define a richer
Drizzle schema and query it directly in
decide(). - External feeds (social sentiment, on-chain analytics) — call your own pipeline from the plugin.
- Cross-plugin coordination — each
PluginStateAPIis scoped per plugin name; spin up a service if plugins truly need to share.
decide() is one-way — it returns intent. The optional onExecuted hook
closes the loop: the runtime calls it after trying to submit your
actions, passing the outcome (digest, on-chain events, gas, errors). Use it
to record trades, set cooldowns on success only, or schedule follow-ups.
async onExecuted(ctx, result) {
if (result.outcome !== "submitted") return;
// Persist every trade for audit, keyed by digest
for (const ev of result.events ?? []) {
if (ev.type.endsWith("::vault::VaultMintBinary")) {
await ctx.state.set(`trade:${result.digest}`, ev.parsedJson);
}
}
}What's in result:
| Field | When set | What |
|---|---|---|
outcome |
always | "submitted" (chain ok) · "noop" (nothing to send) · "failed" (client error or Move abort) |
actions |
always | Echo of what decide() returned (for correlation) |
digest |
tx reached chain | Sui tx digest (correlate with indexer / Suiscan) |
effectsStatus |
tx reached chain | "success" or "failure" |
events |
submitted | All events the tx emitted (vault events + Predict events) |
gasUsed |
submitted | Total gas in MIST (computation + storage − rebate) |
error |
failed | Error message (client error string or Move abort code description) |
Key contract guarantees:
- Dry-runs skip the hook. The runtime never calls
onExecuted(oronError) when the tick was a simulation — most hooks mutate state and a sim firing them would corrupt cooldowns / audit ledgers / ban TTLs. - Hook exceptions are caught and logged, never rethrown. Bad code here does not impact the next tick.
- Hook is informational — the scheduler decides healthy/unhealthy from the outcome before the hook runs. The hook cannot swallow a failure.
onExecuted runs for every outcome. If you only care about failures
and don't want to branch on result.outcome everywhere, define onError
instead — it fires only on failed ticks with a focused payload:
async onError(ctx, err) {
if (err.phase === "client") {
// RPC dropped / signer rejected / preflight tripped — transient
ctx.logger.warn("transient error", { error: err.error });
return;
}
// on-chain abort — has digest, has Move abort code in error string
if (err.error.includes("EInvalidStrike")) {
await ctx.state.set("strikeBanUntil", ctx.now + minutes(5));
}
}ErrorContext payload:
| Field | Type | Notes |
|---|---|---|
actions |
Action[] |
What you returned from decide() — correlate which action failed |
phase |
"client" | "on-chain" |
client = RPC/signing/preflight failed; on-chain = Move abort |
digest |
string? |
Set when phase === "on-chain" |
error |
string |
Move abort code names (EPositionTooLarge, etc.) leak through |
When both hooks are defined and the tick failed, onExecuted runs first,
then onError. They share ctx (state writes from onExecuted are
visible to onError).
Auto-populated per tick from on-chain events of the previous tick's submitted tx. No wiring needed — the data is just there, ready for the plugin to read, log, aggregate, or ship to attribution analytics.
async decide(ctx) {
// Closures since the LAST tick — delta semantics. Empty on most ticks.
for (const c of ctx.lifecycle.closures) {
ctx.logger.info("closure", {
kind: c.kind, // "redeem" | "liquidation"
marketId: c.marketId,
quantityClosed: c.quantityClosed.toString(),
quotePayout: c.quotePayout.toString(),
remainingQuantity: c.remainingQuantity.toString(), // 0 = fully closed
pnlBps: c.pnlBps, // undefined when entry mint wasn't tracked
});
}
// ... rest of decide
}What's covered:
- Both partial and full closures. Move emits
VaultRedeem*regardless ofremaining_quantity; the tracker surfaces both. Discriminate withremainingQuantity === 0nif needed. - Both early exits and post-settle closes. Same Action (
vault.redeemBinary) handles both; the closure event reportsquotePayoutas whatever the contract paid (live ask price pre-settle, settlement payout post-settle). - Forced liquidations during LP withdrawal flows (
PositionLiquidatedevent) surface withkind: "liquidation", no PnL paired. - VWAP across multiple mints on the same key — the second mint updates the running average, the close uses VWAP × quantity for cost basis.
The tracker is in-memory only — opens registered before a process restart can't contribute PnL when their close happens later. The on-chain data is reconstructable from an indexer if forensics is needed.
The runtime tracks per-plugin outcome counters (ticks, noop, submitted,
failed) and a consecutive-noop streak. When a plugin runs the
threshold of noops without ever acting (default 100), the runtime
emits a single warn log — typically the first sign of "plugin is alive
but stuck" (signal source is dead, cooldown never expires, condition
never met). The warn is one-shot per streak — once any non-noop
outcome happens, the streak resets and the next threshold crossing
warns again.
Counters surface in /health (see below) and via runtime.getMetrics():
[
{
plugin: "my-strategy",
counters: {
ticks: 240, noop: 235, submitted: 4, failed: 1,
consecutiveNoops: 120, lastTickMs: 1700_000_000_000,
},
},
]Tune via Runner:
noopWarnThreshold(default100,0disables warn — counters still tracked)
When multiple plugins target the same vault and tick in the same
window, the runtime deduplicates the Vault.fetch RPC — first plugin
fetches, others hit the cache. TTL is short by default (2s) and the cache
is invalidated automatically after every submitted (non-dry-run) tx
that touched that vault (state changed, next read must be fresh).
| Scenario | Vault.fetch RPCs |
|---|---|
| 5 plugins on different vaults, same tick | 5 |
| 5 plugins on the same vault, same tick, all read-only | 1 |
| 5 plugins on the same vault, one submits a tx, others tick after | 2 (first + post-submit refetch) |
| Anything in dry-run mode | doesn't invalidate (sim didn't change chain) |
| Tick after TTL window | fresh fetch |
Tune via the vaultCacheTtlMs option on Runner (default 2000,
set 0 to disable). The cache is a generic TTLCache<K, V> from the runtime
core — you can reuse it in your plugins for other per-vault computed values
that should survive a few ticks.
Market-level caching (
Market.find,market.price()) is not done automatically — those are called insidedecide()by the plugin. If you have many plugins repeatedly callingMarket.findfor the same asset in the same tick, wrapMarket.findwith a localTTLCacheof your own.
The daemon starts a tiny HTTP server alongside the runtime so K8s / Fly / Railway / your monitoring can probe it:
| Path | Status | Body |
|---|---|---|
GET /ready |
always 200 | { ok, pid } — liveness probe (is the process up?) |
GET /health |
200 if all plugins healthy, 503 if any unhealthy | { ok, plugins: [{name, state, metrics}], pid, uptimeSec } — readiness probe (is work happening?) |
Port via HEALTH_PORT env (default 3030, set to 0 to disable). Binds
127.0.0.1 by default — set HEALTH_HOST=0.0.0.0 when the probe comes from
outside the host (Kubernetes, Fly, Railway).
curl http://localhost:3030/health
# → { "ok": true, "plugins": [{ "name": "my-strategy", "state": "healthy",
# "metrics": { "ticks": 240, "noop": 235, "submitted": 4, "failed": 1,
# "consecutiveNoops": 120, "lastTickMs": 1700000000000 } }],
# "pid": 12345, "uptimeSec": 600 }A plugin that throws (or whose action aborts on-chain) is marked
unhealthy by the scheduler; the next /health poll returns 503. The
plugin auto-retries after 5 skipped ticks; until then, state: unhealthy.
ctx.logger emits structured JSON, one line per event. Pre-bound with your
plugin name and vault id so you do not need to repeat them.
ctx.logger.info("decision", { signal: "long", strength: 0.8 });
ctx.logger.warn("oracle drift detected", { driftBps: 120 });Levels: debug, info, warn, error. Control via LOG_LEVEL env var.
A mint on Predict pulls quote from the PredictManager. Without enough PM
balance, the tx aborts. The runtime handles this for you by default:
before submitting a batch with mints, it estimates the total cost via
DevInspect, reads the PM's current balance, and prepends a pmDeposit
sized to cover the gap (plus a 2% buffer).
The result: your plugin can just return:
return [
{
kind: "vault.mintBinary",
params: { marketId, strike, isUp: true, quantity: 100_000_000n },
},
];And the runtime takes care of funding. No pmDeposit in your code, no
manual cost math, no thinking about PM balance.
Auto-fund is skipped when:
autoFundMints: falseinRunnerOptions(disable globally)- The batch already contains a manual
vault.pmDeposit(you intentionally manage funding — runtime defers, no quote, no balance read) - The mint has
skipAutoFund: truein its params (per-mint opt-out) - The action is not a mint (redeem/PLP/refresh don't trigger funding)
Important to know:
- Latency: adds 1–2 DevInspect RPCs per tick that contains mints (~200–500 ms typical). Fine for cron ≥ 30s.
- PM_DEPOSIT permission required: your vault must grant it. The preflight rejects with a clear local message if not.
- Redeem doesn't auto-pmWithdraw: redeem proceeds land in PM. Append
{ kind: "vault.pmWithdraw", params: { amount } }manually when you want them back in the vault. - Large market impact: above roughly 5% of AUM the 2% buffer can under-fund. Tune
autoFundBufferBpsor opt out and pre-deposit yourself.
Tune via Runner options:
new Runner({
...,
autoFundMints: true, // default — set false to disable globally
autoFundBufferBps: 200, // default — 2% slippage buffer
});The runtime always checks, before submitting, whether your vault grants
each Action in your batch. If strategyPermissions lacks a bit, the batch
is rejected locally — no gas, no chain mutation, no half-applied tx.
Error: executor: action "vault.mintBinary" not permitted by vault 0x...
(strategy_permissions lacks MINT_BINARY). Granted: PM_DEPOSIT, PM_WITHDRAW.
The vault primitive also enforces permissions on-chain — this is defense in depth, not the security guarantee. The on-chain check is the real one; the local preflight is for fast, gas-free, informative feedback.
This guard runs whether you call ctx.vault.can* or not. What can* lets
you do is adjust your decision ahead of time so a doomed Action doesn't
even leave decide() — useful when you have a valid fallback to return
instead:
if (!ctx.vault.canMintBinary) {
return [{ kind: "noop", reason: "vault doesn't grant MINT_BINARY — skipping" }];
}Permission map:
| Action | Permission bit required |
|---|---|
vault.mintBinary |
MINT_BINARY |
vault.mintRange |
MINT_RANGE |
vault.redeemBinary |
REDEEM_BINARY |
vault.redeemRange |
REDEEM_RANGE |
vault.pmDeposit |
PM_DEPOSIT (also required for auto-fund) |
vault.pmWithdraw |
PM_WITHDRAW |
vault.supplyPlp |
SUPPLY_PLP |
vault.withdrawPlp |
WITHDRAW_PLP |
vault.refreshNavSnapshot |
none (permissionless) |
vault.crystallizeFees |
none (permissionless) |
noop |
none |
You can write simple, direct strategies because the vault primitive on Sui enforces hard limits on every operation, regardless of what your plugin asks for. Your action can:
- Never exceed the position size cap (
max_position_bps× NAV) - Never exceed the aggregate exposure cap
- Never trigger during pause (vault or strategy)
- Never bypass the permissions bitfield set at vault creation
- Never drain capital (the operator key signs operations, but cannot custody — your wallet leaking means at worst trades within the existing risk caps, not capital loss)
You do not need to re-implement these checks in your plugin. If your plugin returns an action that violates a cap, the transaction reverts on-chain with a clear error code. Read the error, adjust, move on.
Two triggers can fire simultaneously (cron + manual, or overlapping ticks
under load). Your decide() may run more than once for the same logical
"decision moment". Two patterns to handle this:
-
State guard. Record
last_action_tsand skip if too recent:const last = (await ctx.state.get<number>("lastActionMs")) ?? 0; if (ctx.now - last < MIN_INTERVAL_MS) return [{ kind: "noop", reason: "too soon" }];
-
Read fresh state.
ctx.vaultis always a fresh fetch. Use it to detect "did the previous tick already act?" — for example, checkctx.vault.aggregateExposureto see if your position is already open.
The runtime filters noop actions automatically — they get logged at debug
level and no transaction is submitted.
Commands are pnpm scripts in package.json. Run them with pnpm <script>.
| Command | What it does | Needs env? |
|---|---|---|
start |
Start the daemon — registers plugins, opens the HTTP healthcheck, ticks cron triggers forever. SIGINT/SIGTERM clean shutdown (waits for the in-flight tick). | Full (RPC + signer + plugin vars) |
trigger <plugin> |
Fire ONE tick of one plugin. Real submission. Useful for ops/debug. | Full |
dry-run <plugin> |
Same as trigger but simulates via DevInspect — no signature, no gas, no chain mutation. Prints the decision it took. |
Full (signer needed to set sender, but never signs) |
plugins |
List every plugin in the registry with its vault id + triggers. Shows ❌ for plugins that can't instantiate (missing per-plugin env vars). | None — pure registry introspection |
inspect <plugin> |
Read-only snapshot: wallet balance, vault state, granted permissions, plugin's persisted ctx.state keys. Use to answer "why isn't my plugin acting?" or "what state has it accumulated?". |
Full |
show-config |
Print the resolved config (network, RPC URL, Predict refs, state backend) without secrets. Verifies env wiring before daemon start. | RUNNER_PLUGINS (so it knows what to load) |
typecheck |
tsc --noEmit over the whole app. |
None |
test |
Node test runner over the engine tests. | None |
Naming note: start (not run), trigger (not exec), show-config
(not config) and plugins (not list) because npm and pnpm have builtin
commands with those names that would shadow the script — bare pnpm run
lists scripts instead of starting anything, and npm exec <name> reaches for
the npm registry. The verbose names are intentional — they always work.
pnpm show-config # 1. verify env is wired before starting
pnpm plugins # 2. see what plugins this build registers
pnpm dry-run my-strategy # 3. dry-run a new plugin before going live
pnpm inspect my-strategy # 4. plugin not acting? inspect what it sees now
pnpm trigger my-strategy # 5. force a single real tick for ops debugging
pnpm start # 6. start the daemonpnpm dry-run my-strategyRuns decide() once, then simulates the resulting transaction via
devInspectTransactionBlock — no signature, no gas, no on-chain state
change. You see exactly what would happen (events that would emit, gas
estimate, Move abort if any), without burning anything.
Hooks are skipped on dry-runs, and a dry-run failure does not mark
the plugin unhealthy — a simulation that would have aborted is feedback, not
a production incident. The command prints the decision it took (actions,
noop reasons, gas estimate) regardless of LOG_LEVEL; for anything deeper,
log inside decide().
LOG_LEVEL=debug pnpm startEvery tick, decision reason, action submitted, and transaction digest gets logged.
| Error | Likely cause |
|---|---|
PLUGIN_<NAME>_VAULT_ID not set |
Set the per-plugin env var |
loader: plugin "X" not registered |
Add the factory to src/plugins/index.ts |
executor: action "X" not permitted by vault Y (strategy_permissions lacks Z) |
Local preflight rejection — your plugin returned an Action the vault doesn't grant. Check vault.canMintBinary etc. before returning. No gas was spent. |
EFairPriceAlreadySettled |
Strike is degenerate for the oracle — pick another strike |
EPositionTooLarge |
Quantity exceeds max_position_bps of NAV |
EExposureLimitExceeded |
Aggregate exposure cap hit |
ETradingPaused |
Predict-wide pause; wait it out |
The agent pulls sensible defaults from @automark/sdk/network based on
SUI_NETWORK. Override any field via env var when you need a custom
endpoint or a forked deployment:
SUI_RPC_URL=https://your-quicknode-url # custom fullnode (latency, rate-limit)
PREDICT_PACKAGE_ID=0x... # forked or unreleased Predict
PREDICT_OBJECT_ID=0x... # forked Predict shared objectAnything you leave unset falls back to the network defaults. The full list of
network-pinned constants lives in @automark/sdk/network.
src/
├── plugins/ # ← YOUR ZONE (edit freely)
│ ├── index.ts # registry + optional stateFactory
│ └── my-strategy/ # your plugin (one folder per plugin)
│ └── index.ts
│
└── engine/ # ← ENGINE (don't touch)
├── bin/ # CLI entrypoints (run, exec, dry-run, ...)
├── runtime-core/ # vendored execution core (scheduler, signer, executor)
├── __tests__/ # engine tests
├── config.ts # env var loader
├── loader.ts # name → factory resolver
├── runtime.ts # orchestrator (Scheduler + Executor)
└── index.ts # barrel
Two zones, two intents:
| Zone | What it does | Edit? | What survives updates? |
|---|---|---|---|
plugins/ |
Your strategies + optional persistent state backend | Yes — only place you change | Your files |
engine/ |
Scheduler, executor, signer, RPC client, lifecycle | No — pull updates straight in | Always upgrades cleanly |
Add a new plugin file in plugins/, add one line in plugins/index.ts,
done. Everything else is infrastructure — leave it alone and pulling new
versions of the agent is a no-conflict merge.
Beyond the starter, src/plugins/ stays deliberately empty so you build
from your own thesis. A numbered set of reference plugins is on the way: a
learning curve from "hello world" to a production-shaped plugin, each
introducing one or two new patterns on top of the previous.
Planned curve:
| Tier | What it covers |
|---|---|
| Basics (01–05) | Minimum shape → read ctx.vault → Market.find + live price → fetch an external signal → first real mintBinary |
| Intermediate (06–10) | Size against risk caps → cooldown via ctx.state → combined signal+sized+cooldown → multi-source consensus → onExecuted cooldown-on-success |
| Advanced (11–15) | Multi-action atomic tx → failure-aware strike bans → multi-trigger (cron + event) → conviction-weighted sizing + audit ledger → full pipeline |
| Extra (16) | Plugin as a folder with a co-located Drizzle DB (moving-average over a price-history table) |
Until they land, the inline snippets throughout this README cover every
pattern: decide() shape, market discovery, strike helpers, state, hooks,
lifecycle closures, and multi-action batches.
MIT.