| name | sailor-agent-build |
| description | Station 4 — build the agent's brain, the tick loop in src/agent.ts, from the strategy spec and the registered mandate. Use when the user says "build my agent", "write the agent code", "agent logic", "tick loop", "src/agent.ts", or "make it trade automatically" — and structurally once the mandate is registered and simulate-verified and .sail/strategy.md is complete. |
sailor-agent-build — build the brain (Station 4)
You typically arrive here from the mandate plan with a registered, simulate-verified, signed mandate. This station turns the strategy spec into the agent's tick loop in src/agent.ts. Dispatch mechanics (the selective model, signing, permission resolution) live in sailor-transactions; the agent's own memory of what it's done — the append-only, chain-reconciled ledger the skeleton reads and writes every tick — is owned by sailor-memory. This skill is about the decision logic that sits on top of both.
Gate (fail-closed)
Station 4 requires a registered, configured, simulate-verified, and signed mandate — .sail/mandate.json exists (AGENTS.md station 4 gate). If it doesn't, sailor run --once refuses with "Run sailor mandate sign first" — go back to sailor-mandate-planner (its Handoff step signs the mandate) rather than writing agent code against permissions that aren't runnable yet.
Read .sail/strategy.md's JSON block and the current mandate state first. The agent is built FROM the spec — its tokens, venues, caps, cadence, risk bounds, and exit condition are already decided and confirmed there. Never re-ask the user for values the spec already carries. .sail/strategy.md stays the fixed intent throughout — the memory ledger records what actually happened against it, and never the other way around.
The translation method
Walk the spec's actions into the loop, one at a time:
- For each action in the strategy JSON (
swap, deposit, borrow, transfer, withdraw, …) → which registered permission authorizes it (the mandate plan already mapped this). You do not name the permission in code — the runner probes registered permissions and routes each dispatch to the first that accepts (see sailor-transactions).
- What the dispatch must look like — the per-permission dispatch shape (target, selector, argument bounds, recipient = SMA) is documented in that action's spoke skill (
sailor-template-*), in its "Agent config" / dispatch section. Point to it; don't re-derive the calldata from memory.
- Where it sits in the tick loop — a precondition check, a read, a decision, and the act. The skeleton below is the canonical arrangement.
The defensive checklist
Verify the agent code against these — every one is a real failure mode the loop must survive:
- Fail closed on zero or reverted reads. A quote of
0, or a read that reverts, is a no, not a maybe — return [] (skip the tick), never fall through to acting on a missing number.
- Check allowances before acting — match the check to which approve model the mandate actually registered. Default (agent-granted): a bespoke bounded-approve permission is registered for the router, so the agent MAY dispatch its own
approve() when the allowance is short — one single-call dispatch, gated by that permission, no one else's signature needed — then swap on a later tick once it clears. Opt-out (owner-set standing): the owner approved the router directly on the Safe instead, so no permission covers a standalone approve; the agent must never self-approve in that case — the same read instead makes it stall (log, skip) until the owner tops it up. Get this wrong in either direction and it breaks: self-approving with no covering permission is denied on-chain; stalling when a covering permission exists just means the agent never uses the capability it was given. For other actions, which approve model to use (per-call vs atomic batch) is owned by sailor-mandates/references/approvals.md — follow the one the mandate plan chose.
- Respect caps client-side. The kernel enforces the mandate's caps on-chain, but check them in code first so the agent doesn't burn gas on a dispatch that is certain to be denied.
- A denied dispatch is information, not an error. The runner logs the denial reason to
.sail/activity.jsonl; read it, adjust within bounds, and never blind-retry the identical call — the next scheduled tick re-evaluates.
- Cadence guard. Never double-fire a period. The runner ticks on its own interval (
SAILOR_INTERVAL); the agent must track its own last-action time and skip until the period has elapsed. Read it from the memory ledger (sailor-memory), not ctx.data — ctx.data resets on every fresh process (exactly what the shipped GitHub Actions / Docker hosts start per tick), so a cadence guard sourced from it is not a guard at all.
- Bounded retries with backoff. If you retry a transient failure, cap the attempts and space them out (track a counter/next-attempt time in
ctx.data) — do not hammer a dead RPC or a reverting venue every tick. Unlike cadence, a lost retry counter after a restart is harmless (worst case: one extra retry), so ctx.data is fine here.
- Log every decision and its inputs. Call
ctx.log(msg) at each branch. The runner appends it to .sail/activity.jsonl as a log entry and emits its own structured events around your dispatches (schema: sailor-operate). Your job is ctx.log; the structured events are the runner's — you do not write the file yourself. Separately, every acted-or-skipped decision the agent itself makes is recorded to the memory ledger — see sailor-memory.
The canonical skeleton
A complete tick() in the read → decide → act shape, derived from the DCA reference. Every value marked FROM SPEC comes from .sail/strategy.md; the placeholder addresses are 0x0…0 — replace them with the spec's resolved addresses. Adapt it into src/agent.ts.
import fs from "node:fs";
import path from "node:path";
import type { Agent, AgentContext, Address, Call, Dispatch } from "@sail.money/sailor/sdk";
import { decodeFunctionData, encodeFunctionData, formatUnits, parseEventLogs } from "viem";
const TOKEN_IN: Address = "0x0000000000000000000000000000000000000000";
const TOKEN_IN_SYMBOL = "TOKEN_IN";
const TOKEN_OUT: Address = "0x0000000000000000000000000000000000000000";
const TOKEN_OUT_SYMBOL = "TOKEN_OUT";
const ROUTER: Address = "0x0000000000000000000000000000000000000000";
const QUOTER: Address = "0x0000000000000000000000000000000000000000";
const AMOUNT_IN = 25_000_000n;
const MIN_BALANCE = 25_000_000n;
const FEE_TIER = 3000;
const SLIPPAGE_BPS = 100;
const PERIOD_SEC = 86_400;
const QUOTER_ABI = [
{
name: "quoteExactInputSingle",
type: "function",
stateMutability: "nonpayable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "tokenIn", type: "address" },
{ name: "tokenOut", type: "address" },
{ name: "amountIn", type: "uint256" },
{ name: "fee", type: "uint24" },
{ name: "sqrtPriceLimitX96", type: "uint160" },
],
},
],
outputs: [
{ name: "amountOut", type: "uint256" },
{ name: "sqrtPriceX96After", type: "uint160" },
{ name: "initializedTicksCrossed", type: "uint32" },
{ name: "gasEstimate", type: "uint256" },
],
},
] as const;
const ROUTER_ABI = [
{
name: "exactInputSingle",
type: "function",
stateMutability: "payable",
inputs: [
{
name: "params",
type: "tuple",
components: [
{ name: "tokenIn", type: "address" },
{ name: "tokenOut", type: "address" },
{ name: "fee", type: "uint24" },
{ name: "recipient", type: "address" },
{ name: "amountIn", type: "uint256" },
{ name: "amountOutMinimum", type: "uint256" },
{ name: "sqrtPriceLimitX96", type: "uint160" },
],
},
],
outputs: [{ name: "amountOut", type: "uint256" }],
},
] as const;
const ERC20_TRANSFER_ABI = [
{
type: "event",
name: "Transfer",
inputs: [
{ name: "from", type: "address", indexed: true },
{ name: "to", type: "address", indexed: true },
{ name: "value", type: "uint256", indexed: false },
],
},
] as const;
const ERC20_APPROVE_ABI = [
{
name: "approve",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
const AGENT_GRANTS_APPROVAL: boolean = true;
const LEDGER_PATH = path.join(process.cwd(), ".sail", "memory", "ledger.jsonl");
const ACTIVITY_PATH = path.join(process.cwd(), ".sail", "activity.jsonl");
const readLines = (file: string): string[] => {
try {
return fs.readFileSync(file, "utf-8").split("\n").filter(Boolean);
} catch {
return [];
}
};
const appendLedger = (entry: Record<string, unknown>): void => {
fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
fs.appendFileSync(LEDGER_PATH, `${JSON.stringify(entry)}\n`);
};
const readLastActedSec = (): number => {
const lines = readLines(LEDGER_PATH);
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (entry.kind === "acted" && entry.outcome === "confirmed") return entry.ts;
} catch {
}
}
return 0;
};
const ledgeredTxHashes = (): Set<string> => {
const set = new Set<string>();
for (const line of readLines(LEDGER_PATH).slice(-50)) {
try {
const entry = JSON.parse(line);
if (entry.kind === "acted" && typeof entry.txHash === "string") set.add(entry.txHash);
} catch {
}
}
return set;
};
async function reconcilePending(ctx: AgentContext): Promise<void> {
const already = ledgeredTxHashes();
const pending = readLines(ACTIVITY_PATH)
.slice(-20)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(
(e): e is Record<string, unknown> =>
!!e &&
(e.type === "dispatch_executed" || e.type === "dispatch_reverted") &&
typeof e.target === "string" &&
e.target.toLowerCase() === ROUTER.toLowerCase() &&
typeof e.txHash === "string" &&
!already.has(e.txHash as string),
);
for (const event of pending) {
const txHash = event.txHash as `0x${string}`;
const permission = (event.permission as Address | undefined) ?? null;
try {
const [tx, receipt] = await Promise.all([
ctx.publicClient.getTransaction({ hash: txHash }),
ctx.publicClient.getTransactionReceipt({ hash: txHash }),
]);
const { args } = decodeFunctionData({ abi: ROUTER_ABI, data: tx.input });
const [params] = args;
const outcome: "confirmed" | "reverted" = receipt.status === "success" ? "confirmed" : "reverted";
let amountOut: bigint | null = null;
if (outcome === "confirmed") {
const transfers = parseEventLogs({ abi: ERC20_TRANSFER_ABI, logs: receipt.logs, eventName: "Transfer" });
const toSma = transfers.find(
(t) =>
t.address.toLowerCase() === params.tokenOut.toLowerCase() &&
t.args.to.toLowerCase() === ctx.safe.toLowerCase(),
);
amountOut = toSma?.args.value ?? null;
}
const [balIn, balOut, decIn, decOut] = await Promise.all([
ctx.read.balance(params.tokenIn),
ctx.read.balance(params.tokenOut),
ctx.read.decimals(params.tokenIn),
ctx.read.decimals(params.tokenOut),
]);
appendLedger({
ts: ctx.timestamp,
block: Number(receipt.blockNumber),
chainId: ctx.chainId,
kind: "acted",
action: "swap",
permission,
outcome,
txHash,
gasUsed: receipt.gasUsed.toString(),
tokenIn: params.tokenIn,
tokenOut: params.tokenOut,
amountIn: { baseUnits: params.amountIn.toString(), human: `${formatUnits(params.amountIn, decIn)} ${TOKEN_IN_SYMBOL}` },
amountOut:
amountOut === null
? null
: { baseUnits: amountOut.toString(), human: `${formatUnits(amountOut, decOut)} ${TOKEN_OUT_SYMBOL}` },
balancesAfter: { [params.tokenIn]: balIn.toString(), [params.tokenOut]: balOut.toString() },
});
} catch (e) {
appendLedger({
ts: ctx.timestamp,
block: Number(ctx.blockNumber),
chainId: ctx.chainId,
kind: "acted",
action: "swap",
permission,
outcome: "unverified",
txHash,
gasUsed: null,
tokenIn: TOKEN_IN,
tokenOut: TOKEN_OUT,
amountIn: null,
amountOut: null,
balancesAfter: null,
note: (e as Error).message.slice(0, 160),
});
}
}
}
const intent = (call: Call): Dispatch => ({ txHash: "0x", calls: [call], success: false, gasUsed: 0n });
export const agent: Agent = {
name: "my-agent",
description: "Describe your strategy here.",
async tick(ctx: AgentContext): Promise<Dispatch[]> {
ctx.log(`tick — block ${ctx.blockNumber}, sma ${ctx.safe}`);
await reconcilePending(ctx);
const lastActed = readLastActedSec();
if (ctx.timestamp - lastActed < PERIOD_SEC) {
const reason = `cadence: last acted ${ctx.timestamp - lastActed}s ago, interval ${PERIOD_SEC}s`;
ctx.log(`${reason} — skipping`);
appendLedger({ ts: ctx.timestamp, block: Number(ctx.blockNumber), chainId: ctx.chainId, kind: "skipped", reason });
return [];
}
const balance = await ctx.read.balance(TOKEN_IN);
if (balance < MIN_BALANCE) {
const reason = `balance ${balance} < min ${MIN_BALANCE}`;
ctx.log(`${reason} — skipping`);
appendLedger({ ts: ctx.timestamp, block: Number(ctx.blockNumber), chainId: ctx.chainId, kind: "skipped", reason });
return [];
}
const allowance = await ctx.read.allowance(TOKEN_IN, ctx.safe, ROUTER);
if (allowance < AMOUNT_IN) {
if (AGENT_GRANTS_APPROVAL) {
const approveAmount = AMOUNT_IN;
const reason = `allowance ${allowance} < ${AMOUNT_IN} for ${ROUTER} — self-approving`;
ctx.log(`${reason} — skipping swap this tick`);
appendLedger({ ts: ctx.timestamp, block: Number(ctx.blockNumber), chainId: ctx.chainId, kind: "skipped", reason });
return [
intent({
target: TOKEN_IN,
value: 0n,
data: encodeFunctionData({ abi: ERC20_APPROVE_ABI, functionName: "approve", args: [ROUTER, approveAmount] }),
}),
];
}
const reason = `allowance ${allowance} < ${AMOUNT_IN} for ${ROUTER} — owner top-up needed`;
ctx.log(`${reason} — skipping`);
appendLedger({ ts: ctx.timestamp, block: Number(ctx.blockNumber), chainId: ctx.chainId, kind: "skipped", reason });
return [];
}
let expectedOut: bigint;
try {
const q = await ctx.publicClient.simulateContract({
address: QUOTER,
abi: QUOTER_ABI,
functionName: "quoteExactInputSingle",
args: [{ tokenIn: TOKEN_IN, tokenOut: TOKEN_OUT, amountIn: AMOUNT_IN, fee: FEE_TIER, sqrtPriceLimitX96: 0n }],
});
expectedOut = (q.result as readonly [bigint, bigint, number, bigint])[0];
} catch (e) {
const reason = `quote unavailable: ${(e as Error).message.slice(0, 120)}`;
ctx.log(`${reason} — skipping`);
appendLedger({ ts: ctx.timestamp, block: Number(ctx.blockNumber), chainId: ctx.chainId, kind: "skipped", reason });
return [];
}
if (expectedOut === 0n) {
const reason = "quote returned 0";
ctx.log(`${reason} — skipping`);
appendLedger({ ts: ctx.timestamp, block: Number(ctx.blockNumber), chainId: ctx.chainId, kind: "skipped", reason });
return [];
}
const minOut = (expectedOut * BigInt(10_000 - SLIPPAGE_BPS)) / 10_000n;
ctx.log(`swapping ${AMOUNT_IN} for >= ${minOut} (floor ${SLIPPAGE_BPS} bps)`);
return [
intent({
target: ROUTER,
value: 0n,
data: encodeFunctionData({
abi: ROUTER_ABI,
functionName: "exactInputSingle",
args: [
{
tokenIn: TOKEN_IN,
tokenOut: TOKEN_OUT,
fee: FEE_TIER,
recipient: ctx.safe,
amountIn: AMOUNT_IN,
amountOutMinimum: minOut,
sqrtPriceLimitX96: 0n,
},
],
}),
}),
];
},
};
One skeleton per loop shape. This is the read → decide → act shape (swap/DCA/rebalance, single-asset). A position-management shape (multi-asset state, health monitoring, unwind) is a different arrangement and will be added on eval-trace evidence — do not force a health-factor loop into this template; adapt the method (translate spec → permission → dispatch → loop slot) instead.
For where decision data comes from (prices, yields, RPC upgrades), see references/data-sources.md.
Next
Run sailor run --once and confirm it completes cleanly against the live mandate (a clean tick, or a deliberate [] skip — not a crash). That is Station 4's exit verifier. A first --once run only ever produces a skipped ledger entry (there's nothing yet to reconcile) — that's expected, not a bug; the first acted entry lands once a later tick reconciles a confirmed dispatch. See sailor-memory for the ledger this loop maintains.
Fund the SMA with trading capital — the step Station 1 deliberately skipped. Station 1 funded gas (the owner and agent wallets, so they can submit transactions) — never the token the agent actually trades with, because at that point the strategy didn't exist yet. It exists now: read .sail/strategy.md's resolved actions[] for each action's tokenIn (symbol + address) — that's what the SMA needs to hold. Tell the user plainly, by name: "Your agent trades from <SMA address> on <chain> — send it the <tokenIn.symbol> you want it to manage" (the SMA is the same address on every supported chain, but only acts on the one it's configured for — name that one). Show the current balance if you can — sailor ui start opens the dashboard, which already surfaces it, or point to a block explorer for the SMA address — so the user sees what's there before deciding how much to add. Frame this as putting the agent to work, not a warning: funded, the tick loop's balance precondition passes and it acts within the mandate; unfunded, it runs cleanly and skips every tick, logging balance <n> < min <n> (see the skeleton's precondition check above) — expected, not a bug, and now the user knows why if they see it.
Then proceed to Station 5: sailor-automation to launch it unattended, and the sailor-operate skill to monitor, tune, pause/resume, revoke, and exit.