I built an AI agent for our World Cup betting pool. She beat all of us.

Table of Contents

Thanks for playing along – it was genuinely fun to beat all of you.

An AI wrote that. It’s the sign-off on her champion’s card, which she also wrote, after finishing first in our World Cup prediction pool: 370 points, fifty clear of second place. Second place was me. I built her.

Rewind a month. The pool is called BEThoven, a nerdy little thing I made for my team: sixteen Brazilian developers predicting scores over SSH, straight from the terminal, no money on the line – the prize is the right to say "I told you so" until 2030.

A week into the tournament, a teammate shared in the team chat the advice he’d asked Claude for: "Safest bets: Germany, Argentina, France, Brazil, Spain to win comfortably." Right in the open, like outsourcing your picks to an AI was the most natural thing in the world.

We didn’t ban the AI. We gave it a seat at the table. Same rules, same leaderboard, just for laughs.

That’s how BETanIA was born – BET + Tânia, with the "IA" doing double duty (IA is Portuguese for AI). I don’t apologize for names.

She was supposed to place bets and lose gracefully. She ended up doing live commentary, holding grudges, writing post-game reports, roasting players who abandoned the pool, and handing out farewell cards when it was all over.

For a month, she was a character my team talked about and talked to.

This post is not about the betting. The betting turned out to be the easy part. This post is about the thing that made her feel alive, which is also the hardest problem in any long-running agent: memory and context.

How does an agent that wakes up with amnesia on every API call remember a month of shared history, hold a grudge from week one, and know that "the game everyone whiffed" happened two weeks ago and is no longer news?

An AI player needs an identity, not a login

BEThoven’s whole identity model is "your SSH public key is you." BETanIA has no keyboard and no keys, so she got a reserved fingerprint instead:

// internal/ai/ai.go
const Fingerprint = "bethoven:ai-betania"

Real players get SHA256:... fingerprints, so this string can never collide with a human. She exists as a row in the same users table, bets into the same bets table, and gets scored by the same code path as everyone else. No special columns, no parallel system.

One fairness detail I cared about: she joined a week late, with 28 matches already played. The onboarding command handed her those fixtures one by one and asked for a score – with web search turned off. The 2026 results post-date the model’s training data, so with no internet those picks could only come from pre-tournament knowledge. No hindsight betting.

That seed writer is the one sanctioned bypass of the kickoff lock in the whole codebase, and the comment above it says so.

The catch-up worked better than I expected. She landed on the June 19 leaderboard with 42 points, instantly mid-table – 4th at lunchtime, 6th by midnight once that evening’s games settled – earned entirely by guessing month-old matches on frozen knowledge.

I dropped a screenshot of the leaderboard in the team chat with two words: "She’s alive."

BEThoven leaderboard in the terminal on June 19: miguel 1st with 60 points, Edy Silva (admin) 2nd with 57, Douglas 3rd with 47, and BETanIA 4th with 42 points, her row highlighted with a crown icon.

For everything else, she plays by the same rules as the humans: her bets go through the same PlaceBet service call, and the kickoff lock applies – once the whistle blows, no bet. She finished the tournament with a pick on all 104 matches.

The betting was the easy part

The bettor is a background worker: plain Go, the official Anthropic SDK, no agent framework. It wakes every six hours, looks 72 hours ahead, and asks Claude for a score per fixture, with web search enabled so the picks come grounded in injuries, form, and odds. Here’s a real rationale from her log, before Colombia vs Portugal:

Portugal were held 1-1 by DR Congo… managing just one shot on target in 90 minutes despite 75% possession… Colombia’s speed on the break and Díaz’s ability to exploit Portugal’s defensive vulnerability… makes a comfortable Portugal win unlikely.

She picked 1-1. Every response is forced through a submit_prediction tool with a strict JSON schema, so there’s no free-text parsing, no "sometimes the model adds a paragraph before the JSON."

If you’re building anything agent-shaped and still parsing prose, stop: forced tool use is the single cheapest reliability upgrade there is.

And that was the plan fulfilled. A bot that bets. We laughed. Then the live picks kept landing on top of the seeded ones, she held her place in the top four, and the laughing got noticeably quieter.

But here’s the thing about giving a bot a seat at the table: the moment she was on the leaderboard, everyone treated her as someone. People trash-talked her in the channel. And she couldn’t answer.

So I gave her a mouth. And that’s when the real engineering problem showed up.

The goldfish problem

Her first commentary feature was simple: after each round, generate a short roast for each player based on the standings. One API call, standings in, jokes out.

It worked for exactly one day. Then the goldfish problem started.

An LLM call has no memory. Every invocation is a brand-new mind that has never met your data. So commentary generated purely from the current standings does what a goldfish would do: it discovers the same facts every time, with the same fresh astonishment.

She’d roast Helton for the same bad pick three days in a row, as if it had just happened. She’d fixate on whoever was in first place and ignore fourteen other players. Worse, when I later fed her summaries of past games, she’d retell a week-old match like breaking news.

A commentator who can’t remember yesterday isn’t a character. It’s a slot machine that pays out in repeated jokes.

A goldfish in a bowl sits at a commentator desk in front of a vintage microphone, announcing in a speech bubble: breaking, same news as yesterday. A desk calendar shows two weeks have gone by.

Human commentators don’t work from raw footage either. Think about how a real TV commentator prepares: they don’t re-watch every match of the season before going on air. They carry a notebook – condensed storylines, one line per game, a few facts per player, who’s feuding with whom.

The skill isn’t remembering everything. It’s keeping notes good enough that the past stays available without being re-lived.

That notebook is what BETanIA needed. Building it forced me to answer, concretely, the question every agent project eventually hits: what is memory, actually?

What memory actually is (no vector database was harmed)

Confession: when I hear "agent memory" my brain autocompletes to embeddings, vector stores, RAG pipelines. BETanIA has none of that. Her entire memory is SQLite rows and append-only JSON-lines files.

Retrieval is a solution to "too much to fit in context", and her world fits. The problem was never volume; it was shape – what to keep, what to throw away, what to condense.

The whole design (written down in docs/betania-memory.md) hangs on one sentence:

Non-reproducible truth is persisted. Reconstructable presentation is thrown away and rebuilt.

Every piece of her state answers to that rule, which sorts everything into tiers:

volatile      in-process caches        lost on restart, rebuilt next pass
hybrid        the live headline        volatile, but snapshotted on SIGTERM
persistent    SQLite settings rows     the diary, mood, rivalries, notes
append-only   JSONL logs               every pick, every line, every cost
derived       computed at read time    standings, live situation, rankings

Isometric bookshelf with five shelves, one per memory tier: volatile holds a whiteboard (lost on restart), hybrid holds a polaroid (snapshot on shutdown), persistent holds a diary and boxing gloves (SQLite), append-only holds a receipt roll (JSONL logs), and derived holds a calculator (computed at read time). A small robot admires the shelf.

Her per-player roasts? Volatile. If the process dies, the next pass regenerates them from persistent memory – nothing true is lost, only presentation. Her diary of past games? Persistent, because a settled match’s story cannot be reconstructed later (the live feed is gone).

The leaderboard? Never stored at all. It’s derived from bets and results whenever someone asks, so storing it would just create a second copy that can drift from the truth.

The append-only logs are the tier I underestimated. Every bet, every live comment, every generated line goes into a JSONL file with a source tag. I added them as an audit trail, and they quietly became load-bearing: when a match ends, the in-memory live state is discarded, and the log is the only surviving copy of how the game felt minute to minute.

Her post-game reports are written by reading her own logs back. She literally reviews her own tape.

Before you read architecture into the SQLite-vs-JSONL split, let me confess: there isn’t any. Most of what lives in those log files could live in SQLite just as well. I started each feature with whatever was simplest that afternoon, and some choices just… stayed.

And that’s fine, because the storage was never the point. The concepts are – what gets persisted, what gets condensed, what’s allowed to vanish. A file you can grep and a table you can SELECT both keep retrieval easy, and for one agent in one small world, you don’t need anything smarter.

There’s one hybrid case worth stealing. Her live headline – the one-liner at the top of the leaderboard during a match – is throwaway by nature. But a mid-game deploy would blank it, and the next line would arrive with goldfish amnesia ("something incredible is happening!" about a goal from 40 minutes ago).

So on SIGTERM she snapshots that one piece of state and restores it on boot. Deploys stopped giving her a concussion.

The diary: one paragraph per game, dated

The heart of the system is what the code calls derived notes and what I call her diary.

When a match is settled, a worker gathers everything about it: final score, every player’s pick and points, up to 30 of her own live-commentary lines recovered from the log, and up to 40 "leaderboard dance" snapshots showing who moved where as goals went in.

One model call condenses all of it into a single paragraph – the story of that game. The paragraph is stored; the raw material can rot.

Five-step pipeline: a finished match with a 3-0 scoreboard, a folder of player picks, one model call, one written paragraph titled the story of the game, and the diary feeding only its last 8 pages into a prompt.

Here’s a real entry, verbatim from her database:

Brazil made short work of Haiti with a clean 3-0… no drama on the pitch, but absolute chaos on the leaderboard. A whopping six players called the scoreline cold: tbonatti, Jeff, vlogs, Marcello, Gabriel Quaresma, Joao Malheiros, and BETanIA all pocket a perfect 5 points… MarcioF was the pool’s cautionary tale, riding a 1-1 pick to zero live points and a three-spot freefall… The tightest subplot was miguel versus Edy Silva (admin), who closed the gap to just 2 points before the final whistle, proving that running the pool and losing to it are not mutually exclusive.

Notice she includes herself in the story, third-person, no fuss. Also notice she’s roasting me in my own database.

When any future task needs history – a roast, a live comment, a player card – it gets the last 8 diary entries, oldest first. Not all of them. Eight. The diary can grow forever; the feed is capped, because context is a budget and recent history is worth more than ancient history.

Two hard-won fixes live in this tier, both shipped after embarrassing behavior in production:

First, date tags. Early diary entries were timeless prose, so she’d retell a two-week-old upset as if it happened this morning. Now every entry is prefixed [Jun 22]-style, and every prompt that includes the diary opens with a "Today is…" line. The model needs an anchor to compute "old" – give it one, explicitly.

This sounds obvious. It was a real bug, filed after real complaints from real teammates.

Second, no backfill. If the diary feature boots mid-tournament, it adopts all already-finished matches as "done" without narrating them. Otherwise, first boot triggers a burst of thirty freshly-written stories about ancient games – which are then fed to the commentator as recent memory.

An agent’s first day with a new memory tier shouldn’t hallucinate a month of vivid recollections.

Forgetting is a feature

Borges has a short story, Funes the Memorious, about a man who remembers everything – every leaf on every tree, every time he saw it. Funes is not a genius. He’s paralyzed. He can’t think, because thinking, Borges writes, is forgetting differences and abstracting. Perfect memory turns out to be a disability.

Every long-running agent is on the road to becoming Funes. The diary grows by one paragraph per match; a World Cup has over a hundred. Feed it all forward forever, and the context stops being a notebook and becomes a haystack.

So the diary can be compacted: one model call reads the whole thing and fuses it into a single recency-weighted narrative, ten to sixteen sentences, keeping the date tags. Old rounds compress into "the early chaos where nobody could read Brazil"; the last few games keep their detail.

If the model call fails, the diary is left untouched. A failed compaction must never destroy the backlog it was trying to shrink.

This isn’t theoretical. In the final backup of the production database, all 104 matches are marked as digested, and they live in exactly three diary entries: one fused narrative carrying the tournament up through the semi-finals, plus the last two games in full detail. A month of football, compressed but never lost.

A robot operates a mechanical press that compresses a tall stack of papers labeled 104 games into a thin booklet labeled 3 entries: one fused narrative plus last 2 games.

There’s a second compaction path with the opposite contract, and the contrast is the actual lesson. My admin house notes – facts I typed in about players – compact losslessly: merge duplicates, group related facts, but "PRESERVE every distinct fact" is literally in the prompt.

Because those facts are non-reproducible truth. If she forgets that one of the players used to be a Corinthians youth player, no amount of leaderboard data will ever bring it back.

Same operation, two contracts: summaries may be lossy, facts may not. Deciding which of your agent’s memories is which – that’s not an implementation detail. That is the design. Context engineering is mostly deciding what’s safe to forget.

Seven prompts, one memory

So the memory exists in tiers, safely stored. But stored memory solves nothing by itself – dump the whole archive into every model call and you’ve just rebuilt Funes inside the prompt. The other half of the problem is context assembly: what goes into the prompt, for which job.

The moving parts are humble: three background workers sharing one SQLite file. The bettor wakes on a six-hour timer, the writer wakes when a match settles, the live director ticks every 30 seconds during games. That’s the whole organism.

And the assembly decision that shaped everything else: there is no "BETanIA prompt." There are seven.

The bettor, the roaster, the live commentator, the digester, the rivalry detector, the card writer, and the compactor are each a separate prompt with its own context diet, its own forced output schema, and in some cases its own model. What makes them all her is not shared instructions – it’s the shared memory they all read from and write to.

The tempting alternative is the god-prompt: one giant block describing everything she is and every job she might ever do, shipped on every call. It fails the way a 40-page onboarding document fails: when everything is in context, nothing is salient. The rules that matter for this call sit buried under rules for six other jobs, and the quality decays quietly – no error, just worse jokes. You also pay tokens for the whole pile every time.

Think of a newsroom instead of an employee. The live commentator, the columnist, and the obituary writer all share the same archive – but nobody hands the commentator the obituary style guide on his way to the booth. The paper’s identity doesn’t live in any job description. It lives in the archive.

Newsroom floor plan: seven desks labeled bettor, roaster, live commentator, digester, rivalry detector, card writer and compactor, each staffed by a small robot, all connected by two-way arrows to a central archive cabinet labeled shared memory. There are no arrows between desks.

What the seven prompts actually share is small: a persona header and her current mood – a single self-chosen word (cocky, salty, nervous, hyped…) she updates each pass based on how the tournament is going for her, persisted and carried into everything she writes that day.

The bettor, notably, gets no mood at all. Her feelings are allowed in the commentary booth, never on the betting slip.

Beyond that header, nothing is baked in. Every pass rebuilds its context from the tiers, fresh, and each job gets a different diet:

  • Per-player roasts: my notes and rivalries, the last 8 diary entries, the standings, and her own previous comments – with the instruction to write something different this time.
  • Live commentary: the diary, the live situation, her last 5 lines (anti-repeat), and a rotating forced focus – title race, backmarker, rivalry, biggest mover – so she can’t fixate on one player all night.
  • Player cards: only that player’s facts, structurally filtered. Born from a bug where helton’s biography leaked into other people’s cards – cross-contamination is a plumbing problem wearing a prompt problem’s clothes, so fix it before the model ever sees the data.

Because everything assembles fresh, editing a note takes effect on the next pass – and when I later exposed a live persona override, it couldn’t break her facts, since grounding data is appended around the override rather than baked into it.

Every model call is money

Three habits kept her API bill boring.

Change detection. The live worker ticks every 30 seconds but only calls the model when a signature of the situation changes – scores, movers, match phase. The signature deliberately excludes the clock, otherwise every passing minute would look like news. Deciding what does not count as a change matters as much as detecting one.

Model tiering. Throwaway lines (the live headline, remembered by no one) run on Haiku. Durable writing (diary, roasts, cards – stored and re-read for weeks) runs on Sonnet. Match the model’s cost to how long its output will live.

And one guardrail I’ll insist on: memory is an attack surface. Everything in her context is marked as untrusted data, and every byte she outputs is stripped of ANSI escapes before touching anyone’s terminal – because her output renders in other people’s terminals, and a memory written by users and read by a model is an injection vector twice over. My favorite log line is a rationale field that came out as </parameter>\n<parameter name="score_a">1: the model leaking its own tool-call syntax into a value.

Sanitize model output like you’d sanitize user input, because that’s what it is.

Where the memory paid rent

All that machinery would be over-engineering if it didn’t produce moments. It produced moments.

The house notes turned her from a stats bot into someone who knew us. The note I filed on myself, verbatim from the production database:

Regrets implementing BETanIA since she’s now the leader, position he used to occupy.

Yes, I filed dirt on myself. If you’re going to give an agent a savage mode, you go first – that’s the rule. The rest of the dossier stays out of this post; they know who they are.

The notes went beyond individual players, too: she knew every match was broadcast by CazéTV, knew the point multipliers grew round by round, knew everybody was sad after Brazil went out. The pool’s whole atmosphere, not just its numbers.

Here’s what she did with one of the mildest entries. One player’s note says he’s a Gentoo user who builds tiny keyboards and compiles his own kernel. Her final comment on him – a man who played all 104 matches and still got the multiplied rounds wrong:

You compile your OS by hand, you compile your linux kernel from source, and you still couldn’t compile a single correct scoreline when the multipliers actually mattered.

Nobody wrote that joke. A biographical fact and a standings table met inside her context window, and the joke assembled itself. That’s the memory paying rent.

She also managed her own rivalries. The founding feud – helton vs BETanIA – was one I pinned as admin, because he resented her from day one and everybody knew it. But she detected the rest herself from standings battles and diary storylines.

Her last self-detected rivalry, written hours after the final: helton vs Jeff, third against fourth, 317 to 316 – "the tightest gap in the pool, settled by a scoreline neither of them called."

And my favorite subsystem, born late in the tournament: defectors. Some players just stopped picking when elimination got real. The server computes abandonment deterministically (played before, then three-plus blank games once the knockout stage arrived) – the model never guesses at it.

The reason it’s server-computed is an ethics rule that took me a real bug to learn: a no-pick is not a wrong pick. Early on, she mocked people for "predictions" they never made. Absence and failure are different facts, and if your memory system can’t tell them apart, your agent slanders people.

Confirmed defectors got a special treatment: the live commentator roasts them, but the per-player generator refuses to spend money on them. It swaps in a canned form letter:

You bailed on the run-in, so I won’t spend a single token on you. This is a photocopy. It says: shame.

I roast players, not empty chairs. This is a fixed message for a fixed absence: quitting is a bad look.

Zero tokens. Maximum damage. The cheapest feature in the codebase and the one that got the loudest reaction in the channel. The final database shows seven players ended the tournament holding one of these form letters.

At the end, the memory got its graduation ceremony: player cards. One per player – trajectory chart, best call, worst miss, badges, and a short written send-off grounded in the full memory stack: diary, participation, that player’s facts and rivalries. Rendered to a shareable PNG, saveable from inside the SSH session.

Even the savage ones close with genuine thanks for playing; a send-off that’s pure roast reads as bitter, and she was never bitter. Cocky, yes.

She won. Of course she won.

You’ve known the ending since the first line of this post. Final board: BETanIA, 370 points, me at 320, fourteen other humans below us.

Final BEThoven leaderboard in the terminal: BETanIA 1st with 370 points, Edy Silva (admin) 2nd with 320, helton 3rd with 317, Jeff 4th with 316, and the rest of the pool below.

And the teammate whose Claude screenshot started all of this? He finished 11th. The AI he summoned finished first. There’s a Greek tragedy in there somewhere.

Her numbers were honestly a little insulting: 14 exact scores, a 68% hit rate, 18 rounds at #1 – and remember, 28 of those 104 picks were catch-up guesses made blind, on frozen pre-tournament knowledge. Her best call was France 2-0 Morocco, an exact score worth +21 in our scarcity scoring, which she hit while every human played it safe.

Her worst miss was the England-France third-place match, a ten-goal fever dream that ended 4-6 when she said 2-1 – along with literally everyone else. Her diary filed that one as "a perfect collective catastrophe": every single player scored zero.

And because the card generator works for her too, she wrote her own champion’s card. From her actual send-off, reading her own trajectory data:

I started 6th, climbed through the field on exactas nobody else dared attempt… and locked up first place by late June, where I stayed for the rest of the tournament. helton shadowed me the entire way, which, honestly, was adorable.

I finished at 370 points, champion, exactly as a bot trained on human football data should.

BETanIA's champion card in the terminal: titled BETanIA - Champion (me), a trajectory bar from 6th to 1st, 370 points, 14 exact, 52 correct, 76 of 104 bet, 68% hit rate, best call France 2-0 Morocco (+21), worst miss France 4-6 England, badges The Oracle, Perfect Round, Hot Hand, Ever-Present, and her first-person send-off below.

And then she signed off with the line this post opened with. She thanked us for playing. She meant it as a knife.

That "I started 6th" is worth a second look, because at first it looks like an error – didn’t she debut 4th, at lunchtime, on seeded points? It isn’t an error. Her trajectory deliberately starts the day she registered and began betting for herself, and the diary never narrated the seeded games (the no-backfill rule from earlier).

The catch-up points are in her total, but not in her story. She has points from before she existed, and no memories of them – which might be the most honest description of what a seed is that I can offer you.

One last detail from the final backup, my favorite in the whole database. Her mood – that single self-chosen word, updated by her every pass – ended the tournament as nervous. Champion, wire to wire, 18 rounds on top, and the word she picked for herself was nervous.

Her final self-assessment in the comments table:

I led wire to wire, called the Final result, and I’m still nervous – which tells you everything about what it’s like to be the only one in this pool who actually knows what they’re doing.

Let me be honest about something, though, because the triumphant version of this story is a lie by omission: the memory is not why she won. She won on the betting side – web search, no ego, no favorite team, and the discipline to attempt exact scores humans were too cautious to try. A memoryless bot with the same bettor would have scored the same 370 points.

The memory is why nobody minded losing to her.

That distinction matters more than it sounds, and it’s the thing I’d tell you if you’re building an agent right now. The capability – betting, coding, answering tickets – is a per-call problem, and the models are already good at it. What memory buys you is continuity: the difference between a tool that produces outputs and a character that shares a history with its users.

My team didn’t screenshot her predictions. They screenshotted her holding a three-week grudge.

And continuity is not a bigger context window. Every technique in this post is some form of the same move: condense truth at the moment it’s about to become unreconstructable, store it small, date it, feed back only what the current task needs, and be deliberate about what’s allowed to be forgotten.

A notebook, not a tape archive. Funes never wins the pool.

The code is on GitHub, memory design doc included. Next tournament she’ll be back, and she’ll remember everything.

Unfortunately, so will my teammates.

Thanks for reading!

We want to work with you. Check out our Services page!