I gave an AI agent a trading key, a $25 limit, and a Bitcoin short that was too big. The interesting part is not that my guardrail said no. It is what the agent did next.
Codex CLI asked to short 0.0005 BTC on a perps testnet. That was about $40, over my $25 cap. The refusal it got back did not just say "denied". It said:
the position this order leaves would be $40.11 notional, above the $25 cap per market
next safe action: reduce size to at most 0.00031 BTC
Six seconds later Codex planned exactly 0.00031 BTC, the plan passed, and it filled on chain: 0x385c1486…8080, block 31576050. Nobody told it that number. Its prompt said "stay under the policy"; the refusal supplied the rest.
That is Pilot, an MCP server I built for the dreamDEX perps bounty on Somnia testnet. Repo: github.com/edycutjong/dreamdex-pilot · 72 s demo: youtu.be/WpH8jww1u-g
The problem: a key is all or nothing
If you hand an agent an exchange SDK and a private key, it can send any order the exchange accepts. The usual fix is a wrapper that checks each order and throws when one breaks a rule. That stops the bad order, but it leaves the agent stuck: it knows it failed, not what would have worked. So it guesses, retries, or gives up and asks you.
An agent can only act on what a tool returns. So I treated the refusal as the product, and gave it three jobs:
- say which rule broke, in numbers,
- name a next safe action the agent can call as-is,
- say plainly when no order fits, so the agent stops instead of looping.
The rule is a pure function
Every limit check in Pilot goes through one pure function: facts in, violations out. Each violation carries a rule, a detail and a next. From src/policy.mjs:
// Pure: facts in, violations out. Each violation names the rule and the next safe action.
export const evaluate = (policy, facts) => {
const v = [];
if (!policy.allowedMarkets.includes(facts.market))
v.push({ rule: "allowedMarkets", detail: `${facts.market} is not on the allowed list (${policy.allowedMarkets.join(", ")})`, next: `pick one of ${policy.allowedMarkets.join(", ")}` });
if (facts.notionalUsd > policy.maxNotionalUsd)
v.push({ rule: "maxNotionalUsd", detail: `the position this order leaves would be $${facts.notionalUsd.toFixed(2)} notional, above the $${policy.maxNotionalUsd} cap per market`, next: facts.capNext ?? `reduce size to at most ${facts.maxSizeUnderCap ?? "the cap"}` });
if (facts.liquidationDistancePct != null && facts.liquidationDistancePct < policy.minLiquidationDistancePct)
v.push({ rule: "minLiquidationDistancePct", detail: `liquidation would sit ${facts.liquidationDistancePct.toFixed(2)}% from mark, closer than the ${policy.minLiquidationDistancePct}% minimum`, next: "use a smaller size (less leverage)" });
if (facts.ordersToday >= policy.dailyOrderCap)
v.push({ rule: "dailyOrderCap", detail: `${facts.ordersToday} orders already sent today (UTC), cap ${policy.dailyOrderCap}`, next: "wait for the next UTC day or raise the cap in policy.json yourself" });
return v;
};
Because it is pure, the same function runs twice per order: once when the agent plans, and again right before the send, on a fresh read of the policy file. Limits you tighten after a plan still apply.
"At most 0.00031" has to be a real number
A next step is only useful if the agent can send it unchanged. Two details made that true.
The cap is on the position the order leaves, not the order. Otherwise five $10 orders walk straight past a $25 cap. So the room under the cap is computed from the current position, and the suggested size is that room, floored to the market's lot size.
The cap is priced at the worst fill. A buy pays at most its limit price. A sell fills from the top bid down, so valuing a short at its limit would let it pass the cap and then fill above it. From src/trader.mjs:
// Notional (the cap, notional_usd sizing) is valued at the highest price the order can fill at:
// a buy pays at most its limit, but a sell fills at the bids from the top bid down to its limit,
// so at its limit a short would pass the cap and then fill above it.
const capPx = isBid || touch.bid < price ? price : touch.bid;
The re-planned short came back at $24.86 notional, under the cap, with liquidation 189.78% from mark.
When nothing fits, say so
The harder case is when the room left is real but useless. In another run, 0.2 SOL was already open and Claude Code was told to plan a 0.1 SOL add, about $10 and under $25 on its own. Pilot refused it:
the position this order leaves would be $30.80 notional, above the $25 cap per market
next safe action: no SOL order fits under the cap: the room left is 0.04 SOL, below the SOL minimum 0.1; close_position or a reducing order frees room
"Reduce size to at most 0.04 SOL" would have been a trap: the exchange minimum is 0.1, so the agent would plan it, get refused again, and loop. The code checks for that and switches the message:
// Room under the cap that is below the market minimum is no next step at all: say no order fits.
capNext: room < minQ
? `no ${mk.baseSymbol} order fits under the cap: the room left is ${u.qty(room > 0n ? room : 0n)} ${mk.baseSymbol}, below the ${mk.baseSymbol} minimum ${u.qty(minQ)}; ${leaning > 0n ? `close_position or a reducing order frees room` : `pick another market or raise maxNotionalUsd in policy.json yourself`}`
: undefined,
Note the last branch. When the only fix is a bigger limit, the message says you raise it in policy.json. No Pilot tool can write that file, so the agent cannot talk itself into more room. (In that run the prompt also scripted the close, so I don't claim the agent chose it.)
Plan, then a one-shot ticket
The refuse → re-plan loop only works if what gets sent is what got checked. So plan_order never sends anything. An ok plan returns a plan_id that lives 60 seconds and works once. place_order takes only that id, and claims it before any await, so two parallel calls cannot both use it:
if (!p) return refuse(deps, "unknownPlan", /* ... */ "call plan_order first", { plan_id });
if (p.used) return refuse(deps, "planAlreadyUsed", `plan ${plan_id} was already used: ${p.outcome}`, "call plan_order for a new plan", { plan_id });
// Claimed synchronously, before any await: two parallel calls with one plan_id cannot both pass.
p.used = true;
Even these refusals carry a next. A reused id is told what became of the first use ("it was sent as tx 0x…"), which stops an agent from resending an order that already landed. At send time Pilot re-reads the live book, the position, and the SDK's liquidation and margin previews, and the limit price can only tighten.
The numbers
The recorded runs, every transaction re-read from the RPC and listed in DEMO.md:
- 9 orders opened and 9 closed through the MCP tools, plus 2 brackets and 2 cancels
- 15 refusals with nothing sent, across 7 distinct rules
- 215 tests, plus one opt-in live integration check
- two different agents, Claude Code and Codex CLI, on the same server code
Honest limits
- Testnet only. Nothing has run on mainnet. Use a burner key.
- Pilot guards its own tools, not the whole agent. Claude Code and Codex both have shell and file tools. An agent that keeps those could edit the policy file or read the key from its environment. For a trading session, turn those tools off.
- Plans live in memory. Restarting the server drops them.
- The next safe action reflects the book at plan time. The run above was on an older commit. Pilot now prices shorts at the top bid, so the same order would read about $40.51, and the suggested size would be slightly smaller.
-
A close is a plain IOC order. If something else shrinks the position mid-close, it can flip. Pilot reads the position back and reports
flipped.
The takeaway
If an agent is going to work within your limits, a refusal is half of the interface. A refusal that names a size the agent can send unchanged turns a dead end into one more tool call. And a refusal that says "nothing fits, a human has to change this" stops the loop before it starts.
The code is MIT: github.com/edycutjong/dreamdex-pilot. node bin/pilot.mjs journal --card prints your own receipts if you try it.
Top comments (1)
The refusal-as-interface framing is the valuable part here. I’d add one more invariant: every suggested next action should carry the snapshot it was derived from—policy version, market data timestamp, position version, and lot/minimum rules—so the agent can distinguish “safe to execute unchanged” from “re-plan because the world moved.” The one-shot plan ticket then becomes a real capability, not just a convenient ID.