We recently shipped a way for AI agents (Claude Code, Codex, anything that takes a prompt and reads stdin, or speaks MCP) to act on the live trades of Polymarket wallets and place orders of their own. The data side streams fills to the agent; the trading side is a command the agent calls:
pmwallets-copytrade order buy <tokenId> --usdc 5 --max-price 0.45 --key <eventId>
The happy path needs little code. Most of what we wrote handles failures that could cost money; those are the cases I'll cover here.
The failures we planned for were ordinary ones:
- The agent retries a command after it seemed slow.
- The model misspells an argument.
- The process crashes, or the machine loses power, halfway through something.
- The agent CLI starts child processes that outlive it.
Each of those, unhandled, turns into either a second order or an order without the limit you meant to set.
1. A spend ceiling that's actually a ceiling
--usdc 5 reads as "spend $5". What we wanted was "never spend more than $5".
A BUY is fill-or-kill at a limit price, and the share count is computed at that limit, not at the current ask:
// usdc is a ceiling: sized at the limit, the order cannot cost more than it whatever it matches
shares = roundBuyShares((microToward(req.usdc, 'down') * UNIT) / limit, limit);
If the ask is 0.40 and the agent sets --max-price 0.45, it gets 5 / 0.45 shares. If the book moves up to 0.45 between the check and the match, the cost is still at most $5. A loose --max-price buys fewer shares, but keeps the order within the budget.
The rounding took more thought than expected. Prices and amounts are integers in millionths, and the caller's numbers come in as JavaScript floats. Rounding to nearest turns usdc 1.9999999 into 2.00 and minPrice 0.3800001 into 0.38, both of which loosen the caller's limit slightly. So every caller number is rounded in the direction that can't loosen it: a budget, a max price or a share count down, a min price up. It's done on the number's shortest decimal string, exactly, so no binary floating-point digit decides the direction:
export function microToward(x: number, dir: 'up' | 'down'): bigint {
const m = /^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/.exec(String(x));
if (!m) throw new Error(`cannot read ${x} as a decimal`);
const digits = BigInt(`${m[1]}${m[2] ?? ''}`);
const shift = Number(m[3] ?? 0) - (m[2]?.length ?? 0) + 6;
if (shift >= 0) return digits * 10n ** BigInt(shift);
const div = 10n ** BigInt(-shift);
const q = digits / div;
return dir === 'down' || digits % div === 0n ? q : q + 1n;
}
The same rule applies to the market's tick size: a BUY limit is rounded down onto the tick grid, a SELL floor up, and a floor above the highest price the market accepts is refused rather than lowered.
2. The idempotency key is written before the order leaves
A claude -p run that saw a slow response may run the same command again. --key prevents that retry from sending a second order; the fill's eventId is a suitable choice.
The key is claimed on disk and synced before the order is sent:
claim(key: string): boolean {
mkdirDurable(this.dir);
let fd: number;
try {
fd = openSync(this.file(key), 'wx'); // exclusive create: two callers can't both win
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'EEXIST') return false;
throw e;
}
try {
writeSync(fd, JSON.stringify({ status: 'sending', key, claimedAt: new Date().toISOString() }));
fsyncSync(fd);
} finally { closeSync(fd); }
syncDir(this.dir); // the directory entry too
return true;
}
These details each came from a specific failure case:
-
wx(O_CREAT | O_EXCL) means two concurrent calls with the same key can't both claim it. -
fsyncon the file isn't enough for a new file. Its directory entry also has to reach the disk, or a power cut can lose the file entirely. HencesyncDir, andmkdirDurable, which syncs each directory level it creates into its parent. - If the claim can't be synced, it throws and nothing is sent.
- A claim with no recorded result (the process died while sending) comes back to the next caller as
status: unknown, verify: true, not as "free". The order may have filled; the agent is told to checktradesfirst.
To record the result, we write a temporary file, fsync it, rename it and sync the directory. A crash between the claim and the send means that key won't order again, even though nothing was sent. We accepted that missed-trade risk to avoid sending the same order twice.
3. Handing fills to the agent at most once
pmwallets watch --exec "<agent command>" holds a WebSocket to the fill stream and runs the agent once per batch of fills. The stream itself delivers each fill once, in order, and replays gaps. The question was what happens between the stream and the agent.
At-least-once is the usual choice for queues. Here, if the agent placed an order and then the process crashed, running the batch again could place it again. We hand each batch over at most once:
- A fill is appended to
queue.jsonland synced before the stream cursor moves past it. - To run a batch, the batch is written to
batch-<id>.jsonl, the queue is rewritten without it, and the batch's eventIds are appended tohanded.txtand synced. Only then is the batch renamed torunning-<id>.jsonland the command started. - If the command exits 0, the file is deleted. If it fails or times out, it moves to
failed/. - At startup, any
running-*file means the previous process died while the agent ran. It moves tofailed/and is never run again. Abatch-*file that was never started goes back to the front of the queue, unless its ids are already inhanded.txt, in which case it also goes tofailed/for a human.
A fill whose eventId is in handed.txt is never queued again, even if the stream replays it because the cursor wasn't saved before a crash. handed.txt is trimmed occasionally, but only of ids at or before the stream cursor as it is on disk (synced first), since those are the only ones the stream can never replay.
A crash can leave fills in failed/ that nobody acted on. We log those for manual checking and accept that risk with this design.
4. Stopping the agent means stopping everything it started
An agent CLI is rarely one process. claude -p and codex exec start their own children, and a child left running after a timeout might still be about to call order buy.
The command runs in its own process group:
child = nodeSpawn(this.o.exec!, { shell: true, env, stdio: ['pipe', 'inherit', 'inherit'], detached: process.platform !== 'win32' });
A timeout or a stop sends SIGTERM to the whole group (process.kill(-pid, ...)), then SIGKILL five seconds later. On Windows it's taskkill /T /F. When the command exits normally, the group is killed anyway, because a grandchild may still be running.
Two things went wrong here before they were fixed:
- The pending
SIGKILLtimers weren't cancelled when the command ended on its own. Five seconds later they could hit a different process that had been given the same pid. - A process can leave its group (
setsid), and after a crash of the watcher itself, there's no parent left to remember the group.
For the second, every batch's command gets PMW_BATCH_FILE=<absolute path> in its environment. That doubles as a marker. On Linux, startup scans /proc/*/environ for the marker of any running-* batch and stops those groups before handing anything new out. On other systems, where that can't be checked, a running batch with no pid record makes startup refuse with instructions, instead of guessing.
5. Lock files that are never reused
We need one watcher per directory to avoid sending every fill to the agent twice. The classic pid lock file (create it, delete it on exit, take it over if the pid is dead) has a race. Starter A reads the lock, sees a dead pid, and pauses. Starter B also sees it dead, deletes it, creates its own and starts running. A wakes up, deletes B's live lock and creates its own, leaving two watchers running.
The fix was to stop reusing names. Locks are generations, watch.lock.0, watch.lock.1, and so on. The highest number is the lock. A new one is created with exclusive create, as highest + 1, only when the current highest is released or its owner is dead. Releasing writes { released: true } into the file instead of deleting it, and old generations are never removed. A starter that paused on an old scan tries to create a number that already exists and gets EEXIST. Each file is a few bytes, one per start.
6. Rejecting misspelt arguments
Over MCP, a model calls order_buy with a JSON object. If it sends max_price instead of maxPrice, a lenient server ignores the unknown field and the order goes out with the default limit, which is the current ask, whatever that is. MCP hosts don't have to validate against the schema, so the server does, strictly:
for (const [k, v] of Object.entries(args)) {
const p = props[k];
if (!p) throw new Error(`unknown argument ${JSON.stringify(k)} (allowed: ${Object.keys(props).join(', ') || 'none'})`);
// ...type checks; null is rejected with "leave it out instead"
}
The error lists the allowed names so the model can fix the call. The command line also rejects unknown flags such as --max_price. A flag with no value, like --key --all, returns an error; otherwise it could place an order under the key --all.
7. Secrets are removed before anything is serialized
Exchange errors sometimes echo the request back, and the request can contain credentials. Anything written to a key record, a log line or stderr goes through redact() first. It walks the whole value, keys included, and replaces every known secret value plus anything shaped like an API key or a user:pass@ URL. It runs on the object before JSON.stringify, so field names are covered as well as values.
Finding failure cases in review
Many of the failure cases above came out of an adversarial review loop with Codex over the diff. We asked it specifically for crash windows, races and partial writes, and successive rounds found more cases to fix. The data CLI went from 0.6.0 to 0.6.5 on the day it shipped:
- 0.6.1: cancel pending
SIGKILLs once the command is over; keep a batch recorded as handed but never started infailed/. - 0.6.2: sync the stream cursor before forgetting handed ids;
new-fillsmoves its cursor only once the answer is complete. - 0.6.3: a new queue or handed file is created durably, directory synced, before a fill counts as queued.
- 0.6.4: cut a half-written last line off the queue and the handed record at start (otherwise the next append is glued onto the fragment and lost with it); one
new-fillsqueue per file however its path is spelt. - 0.6.5: on Linux, find a batch's processes by the environment marker even when there's a pid record, and stop any that left the group when the batch ends.
The trading CLI's 0.4.1 was the redaction fix in section 7.
None of those would show up in a normal test run. They need a crash at one specific line, or two processes starting within milliseconds of each other. What made the review useful was asking a narrow question: where can a crash between two lines cause an order to be sent twice, or a fill to be handed over twice?
Does it work
In a production test, 57 real fills reached the agent command between 0.01 and 0.16 seconds after they were pushed. A headless claude -p took about 11 seconds to read a fill, look up the market and place a dry-run order; codex exec took about 27. Most of the time went into the model run. That delay is another reason we start the trading side in dry-run mode, where orders are simulated against the live book and nothing is sent.
For a similar setup, I'd check the following:
- Make the money limit a hard ceiling, computed at the limit price, and round caller numbers in the safe direction.
- Claim idempotency keys durably before the side effect, and report a claim with no recorded result as "unknown", never as free to reuse.
- Pick at-most-once when the consumer has side effects, and keep what fell through somewhere a human will look.
- Stop the whole process group and leave a marker you can find after the watcher crashes.
- Use a new lock file name for each generation.
- Reject unknown arguments so a model's typo can't silently change an order.
The code is MIT-licensed: src/watch.ts and src/mcp.ts in polymarketwallets/pmwallets-node, and src/agent.ts in polymarketwallets/polymarket-copy-trading-bot on GitHub.
Nothing here is financial advice, and Polymarket's regional restrictions apply to placing orders.
I help maintain PMWallets.
Top comments (0)