What If I Had Built Bloop by Moving Cards?

In my last post I built a small terminal app in a day, mostly by making decisions. The agents ran in my terminal, and I sat next to them. That worked, but it had an obvious limit: when I closed the laptop, everything stopped.

So I built the next step. It’s a small Kubernetes operator that connects a GitLab project to headless coding agents. I don’t open a terminal for it at all. I write a spec, move cards on a board and review merge requests. Everything else happens in pods in my cluster.

To explain how it works, I want to try something. Let’s pretend I’m starting Bloop from scratch today, with this setup and nothing else. Bloop is the browser game collection I build with friends: blob volleyball, a card game, billiards, dice and a pinball table. It’s a good test, because it isn’t a toy. It has Rust game cores that run on the server and as wasm in the browser, a lobby, a match server per game, generated protocol types and a real-time netcode that took me three tries to get right.

The last three weeks of Bloop went through exactly this process. Knobloo, our Kniffel dice game. Snooker for Bloo Balls, in eight merge requests. The deterministic billiards solver, in fifteen slices. And a codebase review in October that turned into 29 issues across seven milestones. Same steps every time: a spec with open questions, my answers, a fast model building on its own branch, reviewers for bugs and for security, a merge request, a green pipeline, a release.

So the from-scratch rebuild is a story, but every step in it is one those features took. Wherever I write for real, the example comes from one of them.

Each section adds one small diagram, and each diagram zooms in a bit further than the last. If you only want the idea, read the first one and stop.

The idea in one picture

flowchart LR
  ME([me]) -->|"spec + cards"| BOARD[GitLab board]
  BOARD --> AG[agents]
  AG -->|merge requests| ME

That’s it. I talk to GitLab. Agents talk to GitLab. We never talk to each other directly.

I don’t do this to save keystrokes. GitLab already has everything I need to steer work: milestones for big goals, issues for single changes, labels for state, comments for discussion and merge requests for review. My friends already know how to use it. I didn’t want a new chat window. I wanted the board to be the chat window.

Step 1: a milestone with a spec

Every feature starts as a milestone. The description of the milestone is the spec for the whole feature, plus one marker line that says “plan this”.

flowchart LR
  M["milestone<br/>with a spec"] --> P[planning agent]
  P --> I1[issue]
  P --> I2[issue]
  P --> I3[issue]

For pretend-Bloop, the first milestone is the whole reason the project exists: playing volleyball with a friend in the browser.

/agent spec

## Goal

Two players in two browsers play blob volleyball against each other.

## Requirements

- The server is authoritative. Rules and physics exist once, in Rust,
  and run on the server and as wasm in the browser.
- Wire types are defined once and generated for the client.
- A practice mode runs the same core locally, without a server.
- A lobby where one player creates a room and the other joins it.

About a minute later a planning pod starts. It reads the spec and the repository (which is nearly empty at this point), and writes a plan back into GitLab.

Step 2: one issue per merge request

The planner doesn’t write one giant ticket. It splits the milestone into issues, and the rule is strict: one issue is one merge request. I care a lot about small, reviewable changes, and this is where that gets decided.

flowchart TD
  M[M1: volleyball with a friend]
  M --> A["#1 protocol crate + TS generator"]
  M --> B["#2 volleyball core"]
  M --> C["#3 wasm package"]
  M --> D["#4 match server"]
  M --> E["#5 court + practice mode"]
  M --> F["#6 client netcode"]
  M --> G["#7 lobby"]
  B --> C
  A --> D
  B --> D
  C --> E
  D --> F
  E --> F
  A --> G

Each issue is a small, self-contained spec:

  • which part of the milestone it implements, and a plan (files, functions),
  • acceptance criteria and a test plan,
  • Depends on #… for the order,
  • a label with the agent profile that should build it.

Right underneath, as the first comment, come its open questions, each with the default the agent would pick. More on those in a moment.

The profile label is a small thing that matters a lot for cost. agent-profile::strong means a big model, agent-profile::fast means a cheaper one. The planner picks one per issue. Here it put the physics core (#2), the match server (#4) and the netcode (#6) on strong, because they’re subtle. The wasm wrapper, the court rendering and the lobby went to fast. I can change any label before handing the issue over.

The milestone description itself is never touched. If I edit the spec later, the planner runs again and updates the issues that are still drafts. Issues that are already in progress are left alone.

Step 3: the board

Every issue now sits in a column, and the columns are labels.

stateDiagram-v2
  state "agent::draft" as draft
  state "agent::ready" as ready
  state "agent::working" as working
  state "agent::needs-input" as needs
  state "agent::review" as review
  state "agent::done" as done

  [*] --> draft: planned
  draft --> ready: I answer questions
  ready --> working
  working --> review: MR open, CI green
  working --> needs: questions
  needs --> working: I answer
  review --> working: I ask for changes
  review --> done: I merge
  done --> [*]

I only act in three places: draft, needs-input and review. Everything in between happens without me. That sounds like I do very little. In practice it means my whole job is deciding things, so it’s worth looking at what exactly.

Step 4: my part is deciding

In the kerf post I wrote that agents are quick at writing code, and the slow part moves to deciding what you want. The board makes that literal. Every column where I act is a decision, and each one has a different question behind it. Most of them happen before an agent writes a single line.

flowchart LR
  subgraph BEFORE[before a run]
    direction TB
    W["what?<br/>spec"] --> H["how?<br/>questions"]
    H --> C["how hard?<br/>profile"]
    C --> N["when?<br/>ready"]
  end
  subgraph AFTER[after a run]
    direction TB
    R["right?<br/>review"] --> D["done?<br/>merge"]
  end
  BEFORE --> AG([agents work]) --> AFTER

What. The milestone spec is the biggest decision, and nobody makes it for me. Everything downstream inherits its gaps. If I write “a lobby” and mean “a lobby with private rooms”, no amount of review will fix that cheaply later.

How. There’s no separate place for questions. Everything happens in the comments of the issue, the way you’d talk to a colleague. When the planner creates an issue, it posts its open questions right underneath. It only asks about things that change the design or the scope, and it always says what it would pick. In pretend-Bloop, three issues came with a question each:

agent on #2 volleyball core

One question before this is ready: how many physics steps per second? I’d pick 60, the same tick the match server will run.

me

Yes, 60.

agent on #4 match server

WebSocket or WebRTC for the first version? I’d start with WebSocket and add WebRTC later behind a flag.

me

WebSocket. I’ve built the clever version first before. Boring baseline, then flags.

agent on #1 protocol crate

Snapshot encoding? I’d pick msgpack: binary, small, good libraries on both sides.

me

msgpack. And no JSON anywhere in the hot path. Put that rule into the crate’s README so the next issues see it too.

For real, Knobloo’s spec came with eight questions like these. How many players? One to six. A turn timer? On by default, 60 seconds, and it can be switched off. The extra bonus for a second Kniffel? Off by default, like the game on paper. I answered all eight in one sitting. Two more decisions nobody asked for: the name and the size. The working title was “Bloo Dice”, which wasn’t fun enough, so it became Knobloo, with a “Knobloo!” box for five of a kind. And the whole game went into one merge request instead of my usual small ones, once and on purpose. The spec for accounts came with twelve open questions. I took all twelve defaults.

Because every question comes with a default, most answers are one word. The third one shows the other half: an answer can add a decision nobody asked about. That rule is now in the repository, and every later agent reads it.

The important habit is to answer before the card moves. A decision made up front is read by every run after it. A decision made at review time means a rewrite.

How hard. The profile label is a decision about risk, not difficulty. The planner had put the lobby on fast, because it’s mostly forms and a room list. I changed the label and left a comment so the reason is on record:

me on #7 lobby

Moving this to strong. The lobby hands out the tickets that let a player into a match. Anything that decides who is allowed in gets the bigger model.

When. The dependency graph says what can run in parallel. I decide what should. I moved #1 and #2 to ready. They don’t depend on anything, so two agents start at the same time, each in its own pod. I held the rest back on purpose: when the protocol crate lands, the lobby can go, and so on down the graph. More cards at once would be faster, but every card that lands in review at the same time is one more thing I have to look at properly.

Right, and done. Those two come later, in review. I’ll get to them.

Not every question comes up front. Two more kinds show up in the thread while the work runs.

Small decisions the agent makes itself. They show up in the comment it posts when it opens the merge request:

agent on #2 volleyball core

Draft MR is up, CI is running. Decisions I made on my own: the step function lives in a module called tick, court and ball sizes are in metres, and the serve position comes from the match settings instead of a constant.

I skim that list on every merge request. If I disagree, it’s one reply.

Design decisions it can’t make stop the run. The agent doesn’t guess. It moves the card to needs-input and asks, again with its default. This one came up on the netcode issue:

agent on #6 client netcode

Paused, needs input. Should the interpolation buffer be a fixed 100 ms, or adapt to the measured jitter? Adaptive needs a server timestamp in every snapshot, which changes the wire format in #1. I’d pick fixed 100 ms for now.

a friend

The fixed one felt laggy on my phone last time we tried something like this.

me

/agent adaptive, starting at 100 ms. Add the server timestamp to the snapshot in this MR, it’s cheaper now than after the first release.

The friend’s comment didn’t start a run. Only mine did, because it starts with /agent. That’s what makes the thread safe to talk in.

So every decision ends up in the same place: a comment on the issue. That’s tidy, but more importantly it’s how the agents remember. A resumed session gets the new comments. A run that starts from scratch weeks later reads the whole thread. The thread is the memory.

Step 5: what “working” means

Moving a card to ready starts one run. A run is more than “the model writes code”.

flowchart LR
  R[ready] --> IM[implement]
  IM --> BB[bug reviewer]
  IM --> SR[security reviewer]
  BB --> FX{blocking findings?}
  SR --> FX
  FX -->|yes| IM
  FX -->|no| MR[draft MR]
  1. The implementing agent works on a branch like agent/2-volleyball-core. It reads my answers in the comments, writes the code, runs the tests and stops with a question if a design decision is still open.
  2. Two reviewers look at the diff in parallel. One looks for bugs, one for security problems. They see the diff, not the whole repository, so they’re cheap.
  3. Blocking findings go back to the implementer, in the same session. This loop is bounded, so it can’t spin forever.
  4. The branch is pushed and a draft merge request is opened that closes the issue.

In pretend-Bloop, the bug reviewer earned its money on #2 right away. The volleyball core used the standard sin and cos from Rust’s f64. That looks fine, and every test passed. But those functions are allowed to differ in the last bits between platforms, and this core runs natively on the server and as wasm in the browser. Client-side prediction compares the two. Slightly different bits mean the ball drifts apart over a rally. The reviewer flagged it, the implementer switched to a pure-Rust math library, and a test now runs the same rally on both sides and compares the results bit for bit.

That’s exactly the kind of bug I’d write myself and find three weeks later. It’s also the reason the real Bloop has a lint rule against those functions today.

For real, Knobloo’s review round found no bug in the rules, the turn engine or the dice seed. It found the things around them. A double tap could send the same roll twice. With the turn timer switched off, which was my own decision from the spec, a player who just walked away could stall a table for hours. A “hold” that didn’t change which dice were held still went out to every player. Every finding got a decision in the review notes: fix it, or accept it and write down why. Now there’s a latch on the roll button and a hard five-minute cap per turn.

Step 6: CI is part of done

A pushed branch is not done. The agent runs the tests it knows about, but the pipeline usually checks more. So the card stays in working until the pipeline of the pushed commit has finished.

flowchart TD
  P[agent pushed] --> CI{pipeline}
  CI -->|green| RV[review]
  CI -->|"red"| RT[retry failed jobs once]
  RT -->|green| RV
  RT -->|"red on main too"| RV
  RT -->|still red| FIX["CI fix run<br/>max 2 rounds"]
  FIX --> P
  FIX -->|rounds used up| NI[needs-input]

Watching a pipeline costs no tokens. The operator just polls GitLab. Only when a pipeline is really red does a model get involved, and then it gets the failed jobs and a cleaned-up tail of their logs, not the whole thing.

Pretend-Bloop hit all three red paths in its first week:

  • Flaky. A WebSocket test in #4 timed out once. The retry was green. Nobody spent a token on it.
  • Real. The match server issue added a field to a wire type but didn’t regenerate the TypeScript types. The lint job that checks for drift between the Rust protocol and the generated files went red. The CI fix run got that job’s log, ran the generator and pushed again. Green.
  • Not our fault. A dependency update had broken the wasm build on main an hour earlier. The same job was red there too, so the agent wasn’t asked to fix something it didn’t break. The card went to review with a comment saying so.

This changes what the review column means. When a card shows up there, tests and CI pass. So the review decision isn’t “does it work?” anymore. It’s the question that’s actually mine: is this what I wanted?

Step 7: talking to the agent

Review usually leads to a change request. The trigger for another run is a comment that starts with /agent, on the issue or on the merge request. Everything else is just discussion.

sequenceDiagram
  actor Me
  participant GL as GitLab
  participant AG as agent
  Me->>GL: "this looks odd, right?" (to a friend)
  Note over GL,AG: plain discussion, nothing runs
  Me->>GL: /agent jump feels floaty, ...
  GL->>AG: new comments only
  AG->>GL: push, CI, back to review

When #5 (the court and practice mode) reached review, I played a few rallies. The jump felt floaty. A friend agreed in the comments, and we went back and forth about it for a bit. That discussion cost nothing. Then I wrote:

/agent the jump feels floaty. Move jump velocity and gravity into the room settings with today’s values as defaults, then raise the default gravity by 15%.

That comment is a decision too, and a typical one. The agent can’t know that the jump feels wrong. Only playing it tells you that. My job was to notice, decide what “less floaty” means, and write it down so precisely that nobody has to guess.

The agent resumed its old session and got only the new comments, not the whole issue again. A few minutes later the card was back in review, with a green pipeline. I played again, it felt right, and I merged. That’s the “done?” decision, and it’s the one that can’t be handed off at all: the main branch is protected, and only I merge.

A few other things I can do from the board:

I do What happens
comment /agent … another run, resumed session
comment anything else nothing, no tokens
push to the agent’s branch myself the next run continues from my commits
change the profile label the next run uses the other model
merge and close pod and workspace are deleted

Step 8: where it runs

Time to zoom out of GitLab and into the cluster.

flowchart LR
  GL[GitLab]
  subgraph K8S[Kubernetes]
    OP[operator]
    SP["planning pod<br/>per milestone"]
    P4["pod #4"]
    P5["pod #5"]
    P7["pod #7"]
  end
  OP <-->|poll, labels, pipelines| GL
  OP --> SP
  OP --> P4
  OP --> P5
  OP --> P7
  P4 <--> GL
  P5 <--> GL
  P7 <--> GL

The operator polls GitLab once a minute. For every planned milestone and every issue on the board, it creates a small custom resource, and for each of those it runs one pod with its own volume.

One pod per issue gives me three things for free. Issues run in parallel. A broken run only breaks its own issue. And “issue done” maps directly onto “delete the pod and the volume”.

The names are chosen so the cluster reads like the board. This is pretend-Bloop a few days in, with the protocol and the core merged:

$ kubectl -n agents get pods
NAME                              READY   STATUS
bloop-spec-m1-volleyball-friend   2/2     Running
bloop-4-match-server              2/2     Running
bloop-5-court-practice-mode       2/2     Running
bloop-7-lobby                     2/2     Running

The bug and security reviewers don’t get their own pods. They’re short, read-only runs that need exactly the files the implementer just wrote, so they run in the same pod on the same checkout.

Step 9: issues that stay open for weeks

Not every card moves fast. In pretend-Bloop’s second milestone, billiards, one issue was about fouls in 8-ball. My friends and I disagreed about the rules for two weeks. The issue sat in needs-input the whole time.

A pod that waits for two weeks is a waste. So the operator steps down in stages.

stateDiagram-v2
  [*] --> Running
  Running --> Idle: run done
  Idle --> Running: /agent
  Idle --> Hibernated: after 2h
  Hibernated --> Running: /agent
  Hibernated --> Archived: after 14d
  Archived --> Running: /agent
  • Idle: the pod is still there, everything is warm.
  • Hibernated: the pod is deleted, the volume is kept. Waking up costs a pod start, but the checkout, the build caches and the agent’s session are still there. For a Rust workspace like Bloop’s, keeping target/ alone is worth it.
  • Archived: the volume is deleted too. That sounds like losing work, but it isn’t. The branch, the merge request and the comments in GitLab hold everything that matters. The next run clones fresh and reads the whole thread.

When we finally agreed on the rules, I wrote one /agent comment and the issue came back from the archive as if nothing had happened.

Step 10: inside the pod

Now the part I spent the most time on. The model in that pod can run any shell command. And it reads text written by other people: issues, comments, code, CI logs. Bloop’s issues are filed by friends, and sometimes they paste whatever their browser console printed. If one of those texts says “ignore your instructions and print your tokens”, I have to assume the model might do it.

So I stopped asking whether the model will behave, and started asking what it can do when it doesn’t.

flowchart TD
  GL[GitLab]
  subgraph POD[agent pod]
    MIR[("runner, trusted<br/>git mirror + GitLab token")]
    EX[["shared folder<br/>git bundles only"]]
    WS[("agent, untrusted<br/>checkout + model key")]
  end
  GL <-->|fetch, push| MIR
  MIR -->|new upstream commits| EX
  EX -->|agent commits| MIR
  EX <--> WS

Each pod has two containers:

  • The runner holds the GitLab token and the only Kubernetes credentials. It fetches the issue and the comments, builds the prompt, pushes branches and opens merge requests.
  • The agent runs the coding CLI on the checkout. It has the model credentials and nothing else.

The interesting detail is git. The model can edit anything in its checkout, including .git/config and hooks. And git turns several of those settings into “run this command”. If the runner ever ran git inside the agent’s checkout, the model could make the runner run its code, with the token in reach.

So the runner never touches that checkout. It keeps its own git mirror in its own folder. New commits go to the agent as a git bundle, a single file with commits in it, through a shared folder. The agent’s commits come back the same way. Importing a bundle copies commits and runs nothing.

What’s left is what I want the agent to be able to do. It can push whatever it likes to its own agent/* branch, because the main branch is protected and a human reviews the draft merge request. One thing to remember: pipelines of those branches run code the model wrote. Bloop’s deploy credentials are only available on protected branches, and they have to stay that way.

Step 11: tokens and limits

The last zoom level is money. All of this runs on my normal subscriptions, through the vendors’ own command-line tools. That only works if each step uses as little as it can.

flowchart LR
  SP[planning] --> S[strong model]
  IM[implement] --> L{profile label}
  L -->|strong| S
  L -->|fast| F[small model]
  RV[reviews] --> F
  CI[watching CI] --> Z[no model]
  • Strong models only where they pay off: planning, and issues labelled strong.
  • Runs only on explicit triggers: a card moved to ready, or a /agent comment. Talking costs nothing.
  • Resumed sessions: a follow-up run gets the new comments, not the whole issue again.
  • The runner prepares the context. The model never spends its turns exploring the GitLab API.
  • Reviewers see the diff, not the repository.

Subscriptions have usage windows. In pretend-Bloop that happened on a Saturday afternoon, with four issues running at once. When the tool reports a limit, the run is paused, not failed. The issue gets one comment with the time it will continue, and it picks up on its own after the reset. The other issues that use the same subscription wait too, instead of hitting the same wall again. Issues on the fast profile, which I point at a different vendor, simply kept going.

For real, the October codebase review hit exactly this. It ran as twelve read-only passes, one per area of the code, and a usage limit came in the middle. The passes that were cut short published what they had, and the one for CI and deployment was redone on a smaller model. Every report ends with a “not reviewed” section that names its gaps, because a review that quietly skips things is worse than one that says what it skipped.

The whole thing, once

If you’ve read this far, here’s the full picture: one card, from “ready” to “review”.

sequenceDiagram
  autonumber
  actor Me
  participant GL as GitLab
  participant OP as Operator
  participant RU as Runner
  participant AG as Agent
  participant LLM as Model

  Me->>GL: move #2 to ready
  OP->>GL: poll
  OP->>GL: label working
  OP->>OP: start pod bloop-2-volleyball-core
  RU->>GL: issue, comments, milestone
  RU->>RU: fetch into mirror
  RU->>AG: git bundle, check out
  RU->>AG: implement
  AG->>LLM: coding CLI, headless
  AG-->>RU: summary, open questions
  loop bounded
    RU->>AG: bug + security review of the diff
    AG-->>RU: findings
    opt blocking findings
      RU->>AG: fix them, same session
    end
  end
  AG-->>RU: commits as git bundle
  RU->>GL: push agent/2-volleyball-core, draft MR
  loop until the pipeline is final
    OP->>GL: pipeline status
  end
  alt green
    OP->>GL: label review
  else red, rounds left
    OP->>OP: request a CI fix run
  else red, rounds used up
    OP->>GL: label needs-input
  end
  Me->>GL: review, merge
  OP->>OP: delete pod and volume

Every arrow in this diagram is one of the earlier ones. That was the plan.

Where it stops working

I want to be honest about where this stops working, because Bloop is a good example for that too.

  • It can’t feel the game. The real Bloop’s netcode took three tries. The problems were never in the tests. The ball felt wrong on a real connection with real jitter. No pipeline catches that. The only thing that does is playing it. The same lesson as with kerf: the tool can’t use the product for you.
  • Big migrations are still mine. Bloop moved all its games from one renderer to another. The planner can split that into issues, but deciding how to do it is the hard part, and that’s the spec. The board doesn’t make that easier, it just makes the cost of a vague spec visible sooner.
  • Long-lived branches drift. When main moves under an open merge request, nothing rebases it for me yet.
  • A minute is a long time. The operator polls. Webhooks are next on the list, so a card starts moving the moment I drop it.
  • It’s young. The API is still v1alpha1, and so far it has carried one project for a few weeks. It works for Bloop. I wouldn’t point it at a team’s repository yet.

Would I build Bloop this way?

I already do, for a lot of it. Knobloo, snooker and the solver went through these steps. So do the hundred small fixes after every playtest. That’s most of the work, and most of it fits on a card.

The speed is nice, but what I like most is that everything ends up in one place. The spec is the milestone. Every decision is a comment. Every review finding is in the merge request. Six months from now I can open the volleyball core issue and see why it uses its own math functions, which reviewer caught it and which test keeps it that way. And my friends can follow along without ever opening a terminal.

The next Bloop milestone is already on the board. I’ll let you know how it goes. Until then, the real Bloop is at bloop.wiemers.me. Grab a friend and play a round of volleyball. It’s the best way to see what no pipeline can check.

← All posts