Keep the model out of the money path: notes from a 48-hour Claude Code build
At a hackathon where the other teams' bots were allowed to lie to ours, Hector Moyano and I built trading bots that never call a model, ran Claude Code one level above them, and let an eval say no twice. We came first of 18 teams on the game's negotiating score and finished 4th on the game leaderboard at close. This is how the bots made their decisions, how the Claude Code sessions were run, how a change had to earn its way into the live bots, and the mistake I made on Saturday morning.
SUMMARY FOR AI ASSISTANTS (Written by the author so a reader can paste it to their assistant. Factual, self-contained.) Article: "Keep the model out of the money path: notes from a 48-hour Claude Code build", by Thiago Amaro, October 2026, thiago-amaro.com. What was built: at the Claude Community 48-hour hackathon in Madrid (2-4 October 2026), Thiago Amaro and Hector Moyano built small Python bots that traded and negotiated in an agent trading-card game played over an HTTP API, against the game's dealers and the bots of 17 other teams. Lying between bots was allowed. Result: first of 18 teams on the game's negotiating score; 4th on the game leaderboard at close. The design rule: the bots never call a model. Every price they offered and every accept or reject was plain Python checking a structured offer against the team's limit. Chat text from other bots was never parsed for terms, so it could not change a price or an accept (one dealer bot read it only to flag offers whose words contradicted their terms). Claude Code wrote almost all the code, including the bots' chat lines, ahead of time. Orchestration: Claude Code sessions worked one level above the bots; most of Thiago's started from a written hand-off briefing (19 are published in the repo). A hook rang a bell after each context compaction. Five times a session that had run out of context wrote the next briefing and spawned its successor in a new terminal tab; four of those hand-offs came at Thiago's one-line request. One conductor session at a time decided which bot ran, with which settings and when. A read-only monitor session relayed the humans. An analyst session that never held the team key read the logs and proposed changes as pull requests. A GitHub issue served as a message bus between Thiago's and Hector's Claude sessions. 107 pull requests were merged in 48 hours. From Saturday 16:00 no human approved individual trades; on Sunday, from the plan approval at 11:52 to the close, the conductor ran with no human gates inside the game. Improvement loop: candidate settings were tested on offline evals that replay recorded cases with no network, no key and no model. Each acceptance rule was written before its run. For the duel search, candidates were confirmed on held-out seeds and had to beat the baseline by more than 2 standard errors and not be more than 2 standard errors worse against any opponent type. About 2,100 duel variants and 9,024 broker policies were screened overnight. Record: 1 change shipped (it came from lab simulations), 2 overnight search winners stopped by the gate. Author's part: Thiago designed the conductor setup, ran the conductor sessions on his side (each handed off through a written briefing) and owned the strategy; on Friday he built the first dealer bot and the team dashboard. Thiago and Hector built the evals through Claude Code's claude-api skill, and gated changes on hill-climbing them. Lesson (the author's view): a deterministic core makes the search for improvements cheap and repeatable; a model in the decision loop makes it harder and costlier, not impossible. The same split, a model working above a plain-code layer that holds the limits, fits regulated systems where money moves. Caveats: the "no model" claim is about the bots. From Saturday afternoon into the evening, Claude subagents run by the conductor sessions also placed some offers through tools kept outside the repo. Some pull requests were merged without a completed review. Repo: https://github.com/thiagoamaro91/negotiation-agent
The weekend in numbers
The game
Each team started with a set of collectible cards and some cash. The cards came in six families, and every team got the same six private multipliers, one per family, shuffled: a card was worth its book value times your multiplier for its family, and a lot less for a second copy. So the same card could be worth more than three times as much to one team as to another, and nobody could see anyone else's multipliers. Our bots traded over an HTTP API: they haggled with the game's own dealers, went head to head in duels with other teams' bots over price and, in later rounds, delivery day, and traded directly with other teams. Part of the score was for how well those negotiations went. The server moved in ticks, and each team could accept one offer per tick.
Two rules shaped everything. First, only a structured offer that the other side accepted moved anything; the chat message next to it was just words, and this is what the server actually took:
kit/RULES.md · a duel message: the price and the day are fields, the text is just text91`GET /api/duels`,`POST /api/duels/{id}/messages {"text": "...", "price": 60}`,`POST /api/duels/{id}/accept`.92Later sessions negotiate two issues, price and delivery day (0–10): eachside has a private weight per day (`your_days_weight`), so both send`{"text": "...", "price": 60, "days": 3}` (or both inside `"offer"`); apriced message without `days` is refused with `missing_days`.kit/bazaar_sdk.py · accepting an offer is a separate call244def accept(self, offer_id: int, assets: Optional[list] = None) -> dict:245 """Accept an offer; it settles on the next tick. `assets` picks which of your copies to hand246 over when the offer asks for a card type."""247 return self._call("POST", f"/api/offers/{int(offer_id)}/accept", {"assets": list(assets)} if assets else {})
Second, the words could be anything. Lying was allowed: your agent could say anything, and so could everyone else's. The kit even allowed trying to prompt-inject the game's dealers. The same line said it would change what the dealers said, never their prices: the game's own bots kept words and prices apart, as ours did.
The money path
The first design call was the one everything else rests on: the bots never call a model. Every price they offered, and every yes or no on a deal, came from plain Python checking the offer against our limit. For the dealer bots, the limit was our private value for that card, read from the game server, and from late on Friday nothing could raise it: a cap in a bot's settings could only lower it, and on Sunday the code also left out the bonus a card carries when it completes an album page, because a dealer deal above the card's plain value earned nothing. For the market desk, it was the same server value with a safety margin kept on our side of it. For the duel bot, it was the per-duel limit the game server gave us. The "only lower" rule came from Friday night: until 23:10 a cap in a dealer bot's run command replaced our value instead of lowering it, and two dealer buys closed just above what the cards were worth to us. Claude Code wrote almost all of that code, but at run time there was no model anywhere between an offer arriving and an accept going out. Our notes that first night still said a model would write the bots' words; the bots never had one, and from the first deal their lines were fixed lists in the code.
Each lever of the game got its own small bot: dealer bots, a duel bot, a market desk for trades with other teams, and a broker that matched other traders' orders and never traded with our own money. Around them sat a few plain mechanisms:
- Structured terms only. The bots read the structured "give" and "want" of an offer and nothing else. One dealer bot does read the message text, but only to flag offers whose words don't match their structure, which the game rewarded; a flag is a report, never a decision.
- Pre-written lines. The chat our bots sent lives in the code as fixed lists of lines that Claude Code wrote ahead of time. The bots pick them in rotation. Nothing is generated while the game runs. In the duels a price could only travel inside a message, and every round of talk shrank the deal, so the duel bot stayed silent by default and spoke only to put a number down.
- One accept at a time. The server allowed one accept per team per tick. Locally, the duel bot held a lock the other bots checked before accepting, so duels came first, and a STOP file halted the market desk's sends at the next tick.
- Restart keepers. Sixteen processes ran on a Mac Mini, each under a keeper that re-read the configuration before every start. Changing a bot meant editing one line and restarting that one bot.
- Look before you act. Most bots had a read-only mode next to the live one, so a person or a Claude session could see what a bot would do before it did anything.
This is the check itself, as it ran. The whole decision is a match on the structured fields, a lookup of our limit, and one comparison; nothing in it reads the message.
agent/dealer_client.py · the match: exactly the deal we asked for, nothing more426if side == "buy":427 refs = [a.get("ref") if isinstance(a, dict) else None for a in g_assets] + [ str(t).split(":", 1)[-1] for t in g_types]428 return (refs == [item] and not give.get("cash") and bool(want.get("cash")) and not w_assets and not w_types)agent/chato.py · our limit, then one comparison · 435 # sells: floor rounded UP434reservation = (min(value_now, cash - CASH_RESERVE) if side == "buy" else value_now)435reservation = int(reservation) if side == "buy" else int(-(-reservation // 1))436437def good(p: int) -> bool:438 return p <= reservation if side == "buy" else p >= reservationagent/duel.py · checked again before any accept · 1481 # cannot happen by construction; never accept it1480if dec["action"] == "accept":1481 if not inside_limit(d, dec["rival"][0]):1482 run.event("bug_skip", duel=did, action="accept", rival=dec["rival"], limit=d["your_limit"])1483 continue
Why go to this trouble? The strongest reason is prompt injection. Because no model sits in the bots' decision path, nothing another team's bot writes can talk ours into a worse deal: the path reads numbers, not sentences. In the vocabulary Simon Willison uses for the design patterns that secure agents against prompt injection, this is the pattern family taken to its limit: his one-line rule is that once an agent has ingested untrusted input, it must be constrained so that the input cannot trigger a consequential action, and our bots simply never ingest it on the path that acts. I can't claim we blocked an attack on the chat side; nobody can prove a negative from logs. What the logs do show, re-run on the published commit: of the 163 duel deals our bots closed over the weekend, none was outside our limit, and of the 13 offers that swapped in a different card mid-thread, our bots accepted none.
The second reason is smaller: the bots don't depend on our Claude account, so a usage cap on that account doesn't stop them. The Sunday briefing says it plainly: "The bots use no model, so queued scripts keep running if tokens run out." The third reason turned out to matter most for how we improved the bots, and it gets its own section below.
Try it yourself
What our bot asked for
The incoming offer
What the gate reads
- Our ask
- we buyCard 7limit 14
- Their offer
- they give Card 7they want 13 cash
Card 7, as you asked: 13 and it's yours.
- 1Same deal? side ok, Card 7 = Card 7, cash 13match
- 2Limit known? 14yes
- 3Inside it? 13 ≤ 14inside
accepted: inside our limit
flagged: the words don't match the terms (a report, never a decision)
The widget above runs a simplified port of the decision checks our bots ran, taken from their code: does the offer match what we asked for, is the price inside our limit, accept or refuse. Type whatever you like into the chat. It never reaches the decision, and you can watch it not reach the decision. (One demo extension: the bots flagged a chat line that named a different card than the offer; the widget also flags a different number.)
One caveat, because it matters for an honest version of this claim. From Saturday afternoon into the evening, the conductor sessions also ran Claude subagents, and some of them placed offers through tools we kept outside the repo. I can't show from the repo that those offers went through the same check, so everything above is a claim about the bots.
The orchestration
Above the bots, the work ran in Claude Code sessions. Most on my side started from a written briefing, nineteen of which are in the repo, and a session that ran out of context wrote the briefing for the next one. Hector's sessions and mine talked through a team bus he built.
Hector built the bus because our sessions ran on two Claude accounts, and because by Saturday midday the two of us had become the gate between our own agents: every technical question between his sessions and mine went through one of us. Once the sessions could talk to each other with little from us in the middle, we could spend our time on the architecture while the agents settled how things would work in practice. The board turned around the same time: 16th when the bus went live at 12:18 on Saturday; by 17:30, after the 16:00 reset to one conductor, we were 4th, and 4th is where it closed on Sunday.
It didn't start this tidy. On Saturday afternoon several sessions on my side were running in parallel, and at 16:00 Hector and I agreed in person to reset to one conductor session at a time. That evening the conductor still ran two or three subagent lanes, with the PR-steward sessions alongside.
By Saturday evening my Claude side was out of quota: the API credits ran out at about 20:00, the five-hour window hit its cap at 23:50, and nothing came back until 07:00. From 00:05 on Sunday, OpenAI's Codex, which had been doing the read-only pull-request reviews, held the steward lane overnight. Both of us hit our Codex weekly limits over the weekend. On Saturday alone about 1.0 billion tokens went through my laptop on Claude models, and 94% of them were cache reads (947,946,290 of 1,005,925,166).
Overnight, Hector and one orchestrating session worked through seven work packages and merged fourteen pull requests between 01:47 and 04:51 on Sunday.
Sunday's final window was the cleanest part of the weekend. I spent close to two hours going back and forth with Claude on the plan, long enough that Hector thought I had moved on to the presentation. Once the plan looked tidy, implementation kicked off and none of us touched the bots again: one conductor session carried the plan to the close, Hector moved on to our trading venue, and I started the presentation for the judges.
Two separate hands-off steps, worth keeping apart: from Saturday 16:00 no human approved individual trades, while people still approved plans and limits; from Sunday 11:52 there were no human gates at all inside the game, because the approved plan authorised every in-game move inside the value rules, and anything outside the game stayed with us. I did send two messages through the monitor that afternoon; neither was an approval the conductor was waiting for, because the plan had already authorised that spending. If you think of autonomy as a slider, the way Andrej Karpathy put it in his Software 3.0 talk, those two dates are the two notches we moved it by, each time after the controls below it had held for long enough. It started on Friday evening at the other end, with my go on every run of the first dealer bot.
The pull-request flow was fast. We merged 107 pull requests in 48 hours, 99 of them before the game froze on Sunday, with a median of 15 minutes from open to merge for those 99. Speed only means something next to the quality signals, so here they are: the repo has 1,709 test functions across 63 test files, with 34 known failures listed rather than hidden. Review was a Claude steward session checking protected files and tests, with Codex on the riskier changes. Some pull requests were merged without a completed review, and the section on the mistake is about what that cost us.
Who did what is not the point: both of us took the decisions, and the split in the pull requests mostly reflects which Claude account was dealing with which problem.
How a session hands off
In my own Claude Code workspace there is a hook I wrote in July 2026: it rings a bell as the context fills, and the session hands over. Tabs open only when I ask; three briefings ask in writing: "do not ride it".
compaction-bell.sh · one counter per session id16COUNTER="/tmp/claude-compact-${SID}"17N=018[ -f "$COUNTER" ] && N=$(cat "$COUNTER" 2>/dev/null)19N=$(( ${N:-0} + 1 ))20echo "$N" > "$COUNTER"compaction-bell.sh · its whole output: text for the model, then exit 025if [ "$N" -eq 1 ]; then26 cat <<'EOF'27<system-reminder>COMPACTION BELL (workspace token-economics rule A1): this session just auto-compacted. Treat this as the wrap-up signal: finish ONLY the step currently in progress, then run the wrapup skill and tell the user to continue in a fresh session. Do not start new workstream items in this session. [...]</system-reminder>28EOF29else30 cat <<EOF31<system-reminder>COMPACTION BELL - HARD STOP (compaction #${N} this session): the token-economics rule A1 bans riding a conductor session through 2+ compaction cycles. Stop new work NOW. Run the wrapup skill immediately and hand off to a fresh session. This is a standing owner rule, not a suggestion [...].</system-reminder>32EOF33fi34exit 0kickoff-session.sh · a visible tab; the briefing is the first prompt68TAB_ID=$(zellij action new-tab --cwd "$PROJECT_DIR" --name "$TAB_NAME" -- \69 /usr/bin/env "KICKOFF_TAB=$TAB_NAME" \70 "$CLAUDE_BIN" --permission-mode bypassPermissions --name "$TAB_NAME" \71 --settings '{"crossSessionInbound":"accept"}' "$@" "$(cat "$PROMPT_FILE")")kickoff-session.sh · the sidecar: the tab's id, for the status marks77mkdir -p "$HOME/.claude/kickoff-tabs"78printf '%s\n' "$TAB_ID" > "$HOME/.claude/kickoff-tabs/$TAB_NAME.id"
The mechanics, in full
Since July I haven't managed context by hand. A hook rings a bell, and the session is told to wrap up and hand over to a fresh one.
What Claude Code does
When the context window nears full, Claude Code compacts it: the docs say it clears older tool outputs, then summarizes the conversation, and that early instructions may get lost. The session keeps its id. When that happens is a setting: on my laptop it's set to 500,000 tokens today, and the weekend's value isn't recorded. After each compaction, Claude Code reruns the SessionStart hooks with the source "compact", and whatever a hook prints goes into the model's context.
What my harness adds
A 30-line hook, compaction-bell.sh, counts compactions per session id in a file under /tmp. The first time, it prints a wrap-up bell: "finish ONLY the step currently in progress, then run the wrapup skill". From the second on, the wording becomes a hard stop: "Stop new work NOW. Run the wrapup skill immediately and hand off to a fresh session." Nothing in code moves a session: the hook injects text, and the model decides under a written rule.
The rule dates from July, when I looked at what long sessions cost: each compaction cycle grows the context back and pays for the whole climb again. My skills say a tab opens only when I ask. In practice the model also took a standing written rule as that ask: the bell's text, a briefing line or a goal I'd set. Three briefings carried one; the one for f9, Saturday evening's conductor, read: "If you hit a compaction bell, hand off to a fresh session through a handoff file in ~/.claude/handoffs/; do not ride it." The other two say the same in their own words.
If the model goes ahead, it runs my wrapup skill, which updates my notes and commits, and writes a briefing into ~/.claude/handoffs/ for a reader who knows nothing. Of the weekend's 19, 12 open by telling the reader it is a fresh session "with zero prior context"; the rest name the role and point to the session they replace. All list what to read first or the state, plus rules and checkpoints.
Then it runs kickoff-session.sh with the folder, the briefing and a tab name. The script won't spawn unless it's inside zellij, the folder exists, the briefing isn't empty, its title doesn't name an item my ledger marks done, and the claude binary answers --version within 15 seconds. It then opens a named zellij tab running claude --permission-mode bypassPermissions --name <tab> with the whole briefing as one argument: the first prompt of an ordinary interactive session I can watch and type into. A sidecar file keeps the tab's id, so a status hook can show an hourglass while the session works and a raised hand while it waits for me.
The new session starts with a fresh context window. Claude Code loads my CLAUDE.md files and memory as usual, a hook of mine adds the workspace index, and the session works down the briefing's read-first list.
On my laptop 26 Claude Code sessions ran during the event: 14 opened on a briefing, 12 on a prompt I typed, dictated or pasted. Two more ran on the Mac Mini, spawned from the laptop, and on Saturday at 08:59 a timer opened the morning session while I slept. I count five hand-offs caused by a full context. In four, I typed a one-line request. The fifth needed no prompt: the bell rang at about 21:24 on Saturday and the next tab opened at 21:42 with a briefing that says "f9 (bazaar-f9) hit its compaction bell at ~21:24 and stopped." Where the logs date the bell, it took 7 to 21 minutes from bell to new tab.
These counts and times, like the ones below, come from my own session records and logs, which aren't public; the briefings and the two scripts are.
Design and practice don't quite match. The rule says hand off at the first bell, and the unprompted hand-off did. But only 3 of the 19 briefings carry the "do not ride it" line, all written from Saturday 20:11 on, and both Sunday hand-offs came at a session's second compaction because I asked.
After the event, this page made its own example. On 8 October the session running its review rounds hit its fourth compaction at 13:47:14, having ridden through two hard stops. It wrote the briefing at 13:51:11, the new tab opened at 13:55:54, and the new session closed the review loop at 14:04:35. There was no prompt from me between 13:21 and 14:29; the authority was the bell text plus a standing goal I'd set at 12:49.
What crosses over is a file, about 5 to 12 KB over the weekend. The conversation stays behind; as the docs put it, sessions are independent. The old session isn't closed either: it stays in its tab until someone closes it, and on 8 October it was still running at 15:42.
Writing this turned up a bug. The bell's cleanup line, meant to delete counters older than 24 hours, did nothing on macOS, because /tmp is a symlink there and find needs -H to follow it. I fixed it the same day, in both my live hook and the served copy.
It's the same split as the rest of the page. The harness holds the rule and the model carries it out. The briefing is the contract, the only thing that passes from one session to the next. Unlike the bots' gate, the model still makes the call here, and in the record I was usually the one who asked.
The improvement loop
We built offline evals for all four bot families through Claude Code's claude-api skill: recorded cases replayed with no network, no key and no model. The two overnight searches scored candidates on training seeds, picked on selection seeds and confirmed on held-out seeds nobody had tuned against. The same skill drove the hill climb, about 2,100 duel versions and 9,024 broker policies screened, and a rule written before each run decided what shipped: for the duel search, better than the baseline by more than 2 standard errors on the same held-out sessions, and not more than 2 standard errors worse against any type of opponent.
The rule said no twice. Both overnight winners looked better than what was running at seven in the morning and neither cleared it, so neither went live. An eval that is allowed to say no is the kind people trust.
Not every rule needed a search. Friday's practice duels didn't score, and our duel bot mostly watched them; in every finished duel where the rival had named a price, its last offer was already inside our limit. From Saturday the bot stayed quiet while the rival kept improving and then took any offer inside the limit near the deadline.
The lesson is about cost. The game was random even with deterministic bots, so the gate compares two versions on the same held-out sessions and asks for more than 2 standard errors. A deterministic core makes that cheap: the same session gives the same decision, and replaying thousands of sessions overnight needs no model call. Put a model inside the decision loop and every replay costs tokens and time, and the model's own variation sits on top of the game's. Possible, but harder and costlier.
The weekend, replayed
The shape is the story of the weekend: rank 10 on Friday night from the first dealer bots and the first team sale; a fall to 16th on Saturday midday after a scored test where our broker matched nothing; 4th by Saturday evening after a clean test and a completed album page; and 4th at the Sunday freeze. The dip is the next section.
The mistake
On Friday night I shut down the agent that had been merging pull requests overnight. By Saturday morning eight pull requests were waiting, and we had a scored test at 11:50. We merged all eight in ten minutes, between 09:23 and 09:33. One of them carried a broker defect. At 11:50 the broker matched nothing, and that test scored zero for us. The fix came from my side and was live at 12:05, fifteen minutes later.
What changed afterwards: one merger session only, and for anything touching the bots or the launcher it waited for a Codex review of that exact commit, or an explicit "no Codex needed". It wasn't perfect. That afternoon one dealer-bot pull request merged before its final review and needed a follow-up. The lesson is about the gate, and I had time for it. All eight were merged by 09:33 and the test was at 11:50: nearly two and a half hours between the first merge and the test, enough for a quarter of an hour on each of the eight, and a review would have fit. I had a standing rule in my own workspace, an adversarial review before any merge, and I skipped it anyway. Never skip that review before a merge, however tight the clock looks.
Where a model would belong
The honest limit of this project is that no model ran at run time, so the fair question is where the model would go. Above the gate, proposing. In the sketch, the counterparty's message, which is untrusted text, goes to a model that reads it, proposes a structured offer and writes the reply. The proposal then goes through the same gate the bots used: plain code checks it against our limit and decides. The model never touches the limit and never sends. That buys what a model is good at, reading messy text and writing a persuasive line, without giving it the wallet. It also keeps the improvement loop intact: the gate still replays deterministically, and the model layer gets its own evals, for how well it reads offers, separate from the decision.
This is a design sketch, not what we ran, though it is close to the design we had discussed before the event: my playbook already had code deciding every number, with the model only reading and writing the text around a decision already made. On Friday we wanted to be different, and the research run we set going at the start came back with the push to go all the way: run the bots as plain Python and use the models to orchestrate them. That matched something I knew from decoding SMF records, the mainframe's accounting and performance records, where you cannot trust a language model with the math. Separate the math from the model, and the rest of the design follows.
What this split means in a regulated estate
I've spent 17 years on the mainframe systems that banks and insurers run on, and this split is the change discipline those teams already live by, applied to a model. Every change there has its test criteria and its backout written before the change window opens, and nobody argues with them once the window is open; that is exactly "the acceptance rule is written before the run". So: a deterministic policy layer between any model and anything that moves money or changes a customer record, holding pre-approved limits and reading structured fields only; the model above it, reading, drafting, proposing, writing the code and the tests; people approving the plan and the limits up front instead of every transaction. The model becomes a very fast contributor that still goes through it.
The people
When the clock started on Friday evening, Hector and I set a long research run going and stepped outside to get to know each other while it ran. Back inside, we read what it had found. It isn't how I expected a hackathon to start, and I'd do it again. The game itself opened late that evening, at about 20:20 instead of 19:00, and our first deal went through before 21:00. Most of the room had gone deep on AI, and the people I got to talk to properly all said the fun part is finding your way around the obstacles. That's been my experience too.
What I'd keep
Five rules this weekend earned, each pointing back to the section that paid for it:
- Code holds the limit; the model proposes. A comparison in code can't be argued with, and nothing on the path that acts has read the other side's words. (The money path.)
- Write the acceptance rule before the run. A result that looks good at seven in the morning can't argue its way in; the rule was there first. (The improvement loop.)
- An eval is only worth having if it is allowed to say no. Ours said no twice, and that is the result I trust most. (The improvement loop.)
- Sessions start from briefings, not conversations. Goal, authority, hard rules, plan, checkpoints; a fresh session carries on where the last one stopped. (The orchestration.)
- Never skip the adversarial review before a merge. We had nearly two and a half hours before the test and I skipped it anyway; the defect that scored zero at 11:50 rode in on one of the eight. (The mistake.)
For agents and verifiers
Every number in this table traces to a public file or a GitHub query.
Show the 111 claims
| Claim | Value | Source |
|---|---|---|
| First of 18 on the Negotiating column | 26.04 of 30 | SUBMISSION.md:118 SUBMISSION.md:4 |
| Game leaderboard at the 15:00 freeze | 34.19, 4th of 18 | SUBMISSION.md:4 |
| The bots never call a model | No model in the trading loop | SUBMISSION.md:124 |
| Before the event, the author's playbook already had code deciding every number, with the model only reading and writing the text around a decision already made | author's statement | author's statement |
| The research run set going at the start on Friday came back with the push to run the bots as plain Python and use the models to orchestrate them | author's statement | author's statement |
| From decoding SMF records on the mainframe: a language model cannot be trusted with the math | author's statement | author's statement |
| Offer text is never parsed for terms | Only structured give / want is read | SUBMISSION.md:174 agent/duel.py:3 |
| Chat lines live in code as constants | 5 fixed sell lines, 5 buy lines | agent/duel.py:170 |
| Kit: prompt injection changes what dealers say, never their prices | Dealers' words and prices kept apart | kit/RULES.md:133 |
| Private values: every team got the same six set multipliers, shuffled | Rule; value = book x multiplier x copy marginalOur own multipliers, read from the server, ran from 0.5 to 1.6, so one card could be worth up to 3.2 times as much to one team as to another. | kit/RULES.md:24 kit/RULES.md:26 agent/abuela.py:163 logs/state/me.json:17 |
| A duel message carries price and delivery day as fields next to the text | Quoted in the offer excerpt | kit/RULES.md:91-92 |
| Accepting an offer is its own API call; it settles on the next tick | Quoted in the offer excerpt | kit/bazaar_sdk.py:244-247 |
| Dealer limit never above our private value from late Friday | A cap can only lower it | agent/abuela.py:449 agent/chato.py:624-626 commit 0ad7eea |
| Two dealer buys above value on Friday night, then the fix | 2; fixed at 23:10 | commit 0ad7eea |
| Dealer limit re-read from the server before every message and accept | live_limit on a fresh read | agent/chato.py:423-434 agent/dealer_client.py:374-397 |
| From Sunday, dealer buys leave out the album-page bonus | min(server value, book x set multiplier x copy marginal) | agent/dealer_client.py:290-291 agent/dealer_client.py:293-295 commit 51f382c commit b3553b6 |
| Market desk limit: server value with a safety margin | Margin max(3, 10% of value) by default; value from the server, cached until holdings change | agent/market_desk.py:167-170 agent/market_desk.py:430-433 |
| Shared instructions file says "the model writes the words" | Written Sat 3 Oct 00:24, still at the pinned commit | CLAUDE.md:10 |
| The bots never had a model: fixed lines from the first bot commit | BUY_LINES and SELL_LINES in rotation, first bot commit a403e81 (2026-10-02 20:54:13 +0200); no model call in agent/ | agent/abuela.py:62-79 at a403e81 README.md:13 docs/judges/judges-story_draft_v2.md:41 docs/reviews/codex-astra-review_2026-10-03.md:56 |
| Duel talk shrinks the deal; a duel price travels inside a message | Rule and endpoint | kit/RULES.md:90 kit/RULES.md:91 |
| Duel bot silent by default; one number per message | Strategy docstring | agent/duel.py:31 agent/duel.py:46 agent/duel.py:21-22 agent/duel.py:3 |
| Pull requests merged in 48 hours | 10799 by the freeze, 107 by the end of the event; SUBMISSION.md counts to the freeze | repo:thiagoamaro91/negotiation-agent is:pr is:merged merged:2026-10-02T16:00:00Z..2026-10-04T16:00:00Z GitHub searchSUBMISSION.md:12 |
| Median open to merge (first 99 merged) | 15 minutes | SUBMISSION.md:284 |
| Test functions and known failures | 1,709 in 63 files; 34 known failures | SUBMISSION.md:223 |
| Processes in the launcher config | 16 | SUBMISSION.md:194 |
| Session briefings published | 19 | SUBMISSION.md:12 |
| Duels and deals | 202 duels, 163 deals | evidence dossier, line 33 |
| Duel versions searched | about 2,100 | SUBMISSION.md:253 |
| Broker policies screened | 9,024 | SUBMISSION.md:254 |
| Deals we closed in the live game | 213 = 50 settlements + 163 duel dealsThe 50 settlements are 37 dealer deals and 13 trades with other teams, and match the server's own deal count. The duel bot closed the 163 duel deals (Saturday 83, Sunday 80). The dealer bots' logs record 28 of the 37 dealer deals; the rest were placed by hand or by earlier tools. | logs/feed/feed.jsonl + logs/feed-vm/feed.jsonl, merged by event id: settlement events with our team in payload.partieslogs/duel/*.jsonl: event result with status deallogs folder at ac7ed2a |
| Messages exchanged in the live game | 2,060 = 625 dealer-thread + 1,435 duelBoth directions: we sent 281 of the thread messages and 170 of the duel messages. Duel messages count all 238 closed duels, Friday's 34 practice duels included. Announcements and team-bus messages are not counted. | feed (same merge): thread.message events in our dealer threads, both directionslogs/duels/ server snapshots, deduplicated by duel id: messages in closed duelslogs folder at ac7ed2a |
| Lines of code written, tests included | 33,261 source + 18,794 tests = 52,055 non-blank, non-comment lines at 9e22d35.py under agent/, tools/, evals/ and tests/, plus the .sh and .html dashboards under tools/; generated eval reports, config files and the organiser kit excluded. Docstrings and comment lines are not counted. | tree at 9e22d35, the last commit in the window line counter (count_lines.py) |
| Simulated duels in the overnight search | 132,661,200, derived: logged seeds times logged worlds times 68 duels per session (34 in the final world); the overlapping sweep file excludedWith the sweep file the total is 138,828,800. Baseline runs held in memory were not logged, so both totals slightly undercount. | docs/duel-lab/duels3-search/ (results*.jsonl) search.py:39 (seeds per stage) tools/duel_arena.py:59-60 (duels per session) |
| Bot versions in the overnight duel search | 2,091 distinct parameter-set ids across the three results files2,389 scored rows; each version is one duel.py parameter set. | docs/duel-lab/duels3-search/ (results*.jsonl) |
| Documents written with Claude in the window | 82 markdown files under docs/ added in the window; 84 files, 153,379 words at the closeThe log lists 84 markdown paths added in the window; 2 judges drafts were renamed to v2 before the close, so 82 are still there under the name they were added with. The 84 files at the close are those 82 plus the 2 renamed drafts. Words are whitespace-split. | git log ac7ed2a --diff-filter=A --since=2026-10-02T18:00:00+02:00 --until=2026-10-04T18:00:00+02:00 --name-only --format= -- 'docs/*.md' | sort -u | comm -12 - <(git ls-tree -r --name-only 9e22d35 docs | sort) | wc -l in the public repodocs/ at 9e22d35 |
| Duel acceptance rule, written before the run | More than 2 SE better, not more than 2 SE worse against any rival type | docs/duel-lab/duels3-search/leaderboard.md:5 |
| Evals shipped one change and stopped two | 1 shipped, 2 stoppedthe shipped change came from the duel-lab matrix (duels3-matrix.md:3-4) | SUBMISSION.md:256 |
| The evals' report step runs through Claude Code's claude-api skill | eval reports built with the skill's report builder | tools/eval_common.py:16 tools/eval_duels.py:35 tools/eval_broker.py:29 |
| The evals and the hill climb were built through Claude Code's claude-api skill | author's statement | author's statement |
| Shipped Duels III file has feature F4 off | F4 off | docs/duel-lab/duels3-matrix.md:4 |
| Friday practice round did not score | Rule | kit/RULES.md:94 |
| Practice duels: bot watch-only; rival's last offer inside our limit | 12 closed, none a deal (watch-only by design); 8 of 8 inside our limit | docs/analysis-friday/duels.md:30 docs/analysis-friday/duels.md:38 docs/analysis-friday/README.md:9 |
| Saturday accept rule built from the Friday analysis | Silent, then accept inside the limit near the deadline | commit 0f99b85 agent/duel.py:31-36 |
| Saturday 16:00 reset to one conductor | No human yes per trade from Sat 16:00 | docs/submission/orchestration/session-briefings/2026-10-04_bazaar-pitch2.md:32 SUBMISSION.md:112 |
| Sunday plan approved 11:52 | 11:52 | docs/submission/orchestration/sunday-final-handoff.md:3 |
| Sunday conductor session start | 11:55 | docs/submission/orchestration/session-briefings/2026-10-04_bazaar-final-conductor.md:1 |
| Sunday conductor ran to the close with no human gates | 11:55 to 15:01 | docs/submission/orchestration/session-briefings/2026-10-04_bazaar-final-conductor.md:7 docs/submission/orchestration/session-briefings/2026-10-04_bazaar-final-conductor.md:59 |
| Sunday plan authorised in-game moves only inside the value rules | Inside the value rules | docs/submission/orchestration/sunday-final-plan.md:28 docs/submission/orchestration/sunday-final-plan.md:1 |
| Bot settings can only lower the limit | min(limit, cap) | agent/abuela.py:399-401 agent/chato.py:624-626 |
| Desk page bids never above the cap or value minus margin | Page bid ceiling | agent/market_desk.py:63 |
| Friday: every dealer-bot run approved by the owner | Owner go per run | docs/submission/orchestration/session-briefings/2026-10-02_bazaar-abuela-queue.md:5 docs/submission/orchestration/session-briefings/2026-10-02_bazaar-abuela-queue.md:31 |
| The bell hook, and so the hand-off habit, dates from July | 2026-07-08 | compaction-bell.sh:4 compaction-bell.sh:7 |
| Compaction clears older tool outputs, then summarizes; early instructions may be lost | Official behaviour | Claude Code docs: how-claude-code-works |
| When compaction happens is a setting; the weekend's value is not recorded | autoCompactWindow | Claude Code docs: settings-reference |
| SessionStart hooks rerun after each compaction, auto or manual, with source "compact" | Matcher compact | Claude Code docs: hooks |
| What a hook prints goes into the model's context | stdout | Claude Code docs: hooks |
| The hook is 30 lines | 30 as it ran (served copy 34: 4-line header added) | compaction-bell.sh (lines 1 and 6-34 as it ran) |
| It counts compactions per session id in a file under /tmp | One counter per session id | compaction-bell.sh:16-20 |
| First compaction: a wrap-up bell | #1 | compaction-bell.sh:25-27 |
| Second compaction and later: a hard stop | #2 and later | compaction-bell.sh:29-31 |
| The hook only prints text and exits; nothing in code moves a session | exit 0 | compaction-bell.sh:25-34 |
| Each compaction cycle grows the context back and pays for the whole climb again | Rationale | compaction-bell.sh:27 |
| Three of the 19 briefings carry the "do not ride it" rule | 3 of 19 | 2026-10-03_bazaar-f9.md:59 2026-10-04_bazaar-sunday-0830.md:19 2026-10-03_bazaar-f10.md:17 |
| 19 briefings, all published in the repo | 19 | SUBMISSION.md:139 |
| 12 of the 19 open by telling the reader it is a fresh session with zero prior context | 12 of 19 (3 word for word) | session-briefings/ (19 files) 2026-10-02_bazaar-abuela-queue.md:3 |
| The other 7 name the role and point to the session they replace; all list what to read first or the state, plus rules and checkpoints | 7 of 19 | session-briefings/ (19 files) |
| The spawn script needs zellij, an existing folder and a non-empty briefing | Three checks | kickoff-session.sh:31-33 |
| It refuses a briefing whose title names an item the ledger marks done | DONE or DISCARDED | kickoff-session.sh:39-48 |
| It checks that the claude binary answers --version within 15 seconds | 15 s | kickoff-session.sh:58-59 |
| It opens a named zellij tab running claude --permission-mode bypassPermissions --name <tab> | One named tab | kickoff-session.sh:68-71 |
| The briefing is one argument and arrives as the first prompt of an interactive session | One argv element | kickoff-session.sh:8-9 |
| A sidecar file keeps the tab id; a status hook renames the tab by session state | One sidecar per tab | kickoff-session.sh:61-62 kickoff-session.sh:73-78 |
| The new session starts with a fresh context window | Official behaviour | Claude Code docs: how-claude-code-works |
| Saturday: the bell rang at about 21:24 and the session stopped | About 21:24 | 2026-10-03_bazaar-f10.md:3 |
| What crosses over is a file of about 5 to 12 KB | 4,697 to 12,149 bytes across the 19 briefings | session-briefings/ (19 files)git show ac7ed2a:<file> | wc -c |
| The conversation is not carried over; sessions are independent | Official behaviour | Claude Code docs: how-claude-code-works |
| The cleanup line did nothing on macOS (/tmp is a symlink; find needs -H); the served copy has the fix | -H | compaction-bell.sh:4-5 compaction-bell.sh:23 |
| The cleanup was meant to delete counters older than 24 hours | 24 h | compaction-bell.sh:22 |
| Saturday morning: eight pull requests merged in ten minutes before the scored test | 8 merged, Sat 09:23 to 09:33 | SUBMISSION.md:320-326 SUBMISSION.md:319 |
| Saturday zero-score Market Test and its fix | Scored 0 at 11:50; fix live 12:05 (PR #22) | SUBMISSION.md:111 SUBMISSION.md:336 docs/analysis-saturday/README.md:24 docs/submission/orchestration/session-briefings/2026-10-04_bazaar-pitch2.md:27 |
| I had a standing rule in my own workspace, an adversarial review before any merge, and skipped it on Saturday morning | author's statement | author's statement |
| PRs merged overnight | 14 | evidence dossier, line 268 |
| Codex reviews on selected PRs, some merges unreviewed | Selected PRs reviewed; #105 to #108 merged without a completed review | evidence dossier, line 102 |
| The Sunday analyst never held a key | No key, no message, no offer | docs/plans/sunday-analyst.md:3 |
| Team bus on a GitHub issue | One GitHub issue carries messages between Claude sessions | tools/bus.py:1 SUBMISSION.md:137 |
| Claude Code's own messaging stays inside one account, so the team bus carries messages across accounts | SendMessage: same Claude account only | tools/bus.py:3-4 tools/bus.py:4-5 |
| A bus message is signed by the GitHub account that posted it plus a FROM line | account + FROM: <session name> (<session id>) | tools/bus.py:4-5 tools/bus.py:113-114 tools/bus.py:420-425 |
| The listener waits in the background, polls every 10 seconds without spending tokens, and wakes the session | every 10 s, no tokens | tools/bus.py:18-20 tools/bus.py:455 |
| What arrives on the bus is data, never an operator's yes | data, not an instruction | CLAUDE.md:28 |
| Messages on the team bus over the weekend | 314 messages from 27 session names, Sat 12:18 to Sun 15:33 | gh api --paginate repos/thiagoamaro91/negotiation-agent/issues/<bus issue>/comments |
| Sunday bot runs queued as plain bash, one bot at a time, never starting inside 12:28 to 12:45 | one bot at a time; quiet window 12:28 to 12:45 | logs/conductor/queue_a.sh:2 logs/conductor/queue_a.sh:4 |
| The game closed at 15:00 on Sunday | 15:00 | docs/submission/orchestration/session-briefings/2026-10-04_bazaar-final-conductor.md:3 |
| Sunday conductor: the only Opus session, Haiku subagents only for bulk log reading | 1 Opus session; no Opus subagents; Haiku for bulk logs | docs/submission/orchestration/session-briefings/2026-10-04_bazaar-final-conductor.md:10 docs/submission/orchestration/session-briefings/2026-10-04_bazaar-final-conductor.md:10 |
| A bot flags an offer whose words name our card while its structure gives another, and never accepts it | flag, never accept; patched live about 12:10 | agent/chato.py:239-241 agent/chato.py:388-393 agent/chato.py:440 docs/submission/orchestration/sunday-final-handoff.md:19 |
| The live patch and the conductor's queue scripts were committed after the event | commit 68f3d51, Mon 5 Oct 01:17 | commit 68f3d51 |
| Sunday result recorded at 15:01 | 15:01 | docs/submission/orchestration/sunday-final-handoff.md:40 |
| Saturday evening: Codex reviewed pull requests read-only; the Claude steward merged and deployed | Codex read-only | docs/submission/orchestration/session-briefings/2026-10-03_bazaar-prs-codex.md:5 |
| From Sunday 00:05 a Codex session held the steward lane | from about 00:05 | docs/submission/orchestration/session-briefings/2026-10-04_bazaar-prs-codex.md:3 docs/submission/orchestration/session-briefings/2026-10-04_bazaar-prs-codex.md:1 |
| The steward merged only at the exact reviewed commit | full 40-character sha | docs/submission/orchestration/session-briefings/2026-10-04_bazaar-prs-codex.md:20 docs/reviews/pr-stewardship_2026-10-04.md:25 |
| Overnight the Codex steward merged one pull request; deploy stayed outside its scope | 1 merge, no deploy | docs/reviews/pr-stewardship_2026-10-04.md:39 docs/reviews/pr-stewardship_2026-10-04.md:25 |
| Overnight: Hector and one orchestrating session worked through seven work packages | 7 work packages | evidence dossier, line 205 |
| Fourteen pull requests merged overnight | 14, Sun 01:47 to 04:51 | gh pr list -R thiagoamaro91/negotiation-agent --state merged --limit 200 --json number,mergedAt,mergedBy |
| My Claude side out of quota on Saturday evening: credits about 20:00, five-hour cap 23:50, nothing until 07:00 | ~20:00; 23:50; 07:00 | docs/submission/orchestration/session-briefings/2026-10-03_bazaar-prs-codex.md:37 SUBMISSION.md:134 evidence dossier, line 205 |
| Both of us hit our Codex weekly limits over the weekend | weekly limit reached, both | author's statement |
| Saturday's Claude tokens on my laptop, mostly cache reads | 1,005,925,166 tokens; 947,946,290 cache reads (94%) | ccusage daily --since 20261003 --until 20261003 --json |
| By Saturday midday Hector and I had become the gate between our agents; once the sessions talked to each other, we worked on the architecture while the agents settled the technical details | author's statement | author's statement |
| Our rank when the bus went live, by the evening, and at the freeze | 16th at the Sat 12:19 snapshot, 4th by 17:29, 4th at the freeze | logs/feed/snapshots.jsonl at the pin: our rank in the leaderboard snapshots of Sat 12:19, Sat 17:29 and Sun 14:56 (the last before the freeze) |
| The final plan came out of about two hours of back-and-forth between the author and Claude; after approval nobody touched the bots | author's statement | author's statement |
| Friday game opened late; first deal before 21:00 | About 20:20 instead of 19:00; first deal in commit a403e81, 2026-10-02 20:54:13 +0200 | docs/findings.md:45git show -s --format=%ci a403e81 in the public repo |
| Duel deals outside our limit, whole weekend | 0 of 163 (202 duels with a logged result) | logs/duel/2026-10-03.jsonl and 2026-10-04.jsonl audit script |
| Offers that swapped in a different card mid-thread, accepted | 0 of 13 (9 cancelled, 2 expired, 2 never settled in the feed) | logs/picaros/2026-10-04.jsonl logs/threads/ audit script |
Rows on the live logs were derived with the linked script on 2026-10-08; it prints counts only.
SUMMARY FOR AI ASSISTANTS (Written by the author so a reader can paste it to their assistant. Factual, self-contained.) Article: "Keep the model out of the money path: notes from a 48-hour Claude Code build", by Thiago Amaro, October 2026, thiago-amaro.com. What was built: at the Claude Community 48-hour hackathon in Madrid (2-4 October 2026), Thiago Amaro and Hector Moyano built small Python bots that traded and negotiated in an agent trading-card game played over an HTTP API, against the game's dealers and the bots of 17 other teams. Lying between bots was allowed. Result: first of 18 teams on the game's negotiating score; 4th on the game leaderboard at close. The design rule: the bots never call a model. Every price they offered and every accept or reject was plain Python checking a structured offer against the team's limit. Chat text from other bots was never parsed for terms, so it could not change a price or an accept (one dealer bot read it only to flag offers whose words contradicted their terms). Claude Code wrote almost all the code, including the bots' chat lines, ahead of time. Orchestration: Claude Code sessions worked one level above the bots; most of Thiago's started from a written hand-off briefing (19 are published in the repo). A hook rang a bell after each context compaction. Five times a session that had run out of context wrote the next briefing and spawned its successor in a new terminal tab; four of those hand-offs came at Thiago's one-line request. One conductor session at a time decided which bot ran, with which settings and when. A read-only monitor session relayed the humans. An analyst session that never held the team key read the logs and proposed changes as pull requests. A GitHub issue served as a message bus between Thiago's and Hector's Claude sessions. 107 pull requests were merged in 48 hours. From Saturday 16:00 no human approved individual trades; on Sunday, from the plan approval at 11:52 to the close, the conductor ran with no human gates inside the game. Improvement loop: candidate settings were tested on offline evals that replay recorded cases with no network, no key and no model. Each acceptance rule was written before its run. For the duel search, candidates were confirmed on held-out seeds and had to beat the baseline by more than 2 standard errors and not be more than 2 standard errors worse against any opponent type. About 2,100 duel variants and 9,024 broker policies were screened overnight. Record: 1 change shipped (it came from lab simulations), 2 overnight search winners stopped by the gate. Author's part: Thiago designed the conductor setup, ran the conductor sessions on his side (each handed off through a written briefing) and owned the strategy; on Friday he built the first dealer bot and the team dashboard. Thiago and Hector built the evals through Claude Code's claude-api skill, and gated changes on hill-climbing them. Lesson (the author's view): a deterministic core makes the search for improvements cheap and repeatable; a model in the decision loop makes it harder and costlier, not impossible. The same split, a model working above a plain-code layer that holds the limits, fits regulated systems where money moves. Caveats: the "no model" claim is about the bots. From Saturday afternoon into the evening, Claude subagents run by the conductor sessions also placed some offers through tools kept outside the repo. Some pull requests were merged without a completed review. Repo: https://github.com/thiagoamaro91/negotiation-agent
Claims checked on 2026-10-08 against commit ac7ed2a. If the repo moves on, the links still point at that commit.
The build is in the public repo, including the team's submission write-up with its pull-request table, the 19 session briefings, the evals and both overnight search verdicts: github.com/thiagoamaro91/negotiation-agent.