skribbl
productpricingfree!questionswriting
download
guides · 6 August 2026 · 12 min read

Building a full-stack app with three agents

The plan file, the three worktrees, the merge order, and where it goes wrong.

skribbl/writing/guides
a worked example, start to merge

A worked example of AI agent orchestration: one plan file, three coding agents in three git worktrees building the schema, the API and the UI of the same small app, and the merge order that keeps them from fighting. Every command is real, and the parts that need a human are named rather than glossed over.

the short version

You build an app with three AI agents by deciding the split before any agent starts, writing that split into a plan file the agents can read, and giving each one its own git worktree so they cannot overwrite each other. The split that works is by layer, not by feature: one agent owns the database schema, one owns the API, one owns the UI, and the interface between them is written down as exact route shapes and exact table names before a line is generated.

The example below is a small internal expenses tracker: people submit expense claims, a manager approves or rejects them. Postgres, a TypeScript API, a React front end. The stack is not the point and the pattern does not depend on it, so read Postgres as "whatever holds your data" if you like. It is deliberately a boring app, because a walkthrough of something ambitious would have to invent the interesting parts and this one does not invent anything.

SLICE ONEschema and migrations: db/, migrations/. Owns table and column names
SLICE TWOthe API: server/. Owns route shapes. Reads the schema, never edits it
SLICE THREEthe UI: web/. Calls routes by name. Never edits server/ or db/
THE CONTRACTroute shapes plus table names, fixed in PLAN.md before anyone starts
MERGE ORDERschema, then API, then UI. Never two at once
NOBODY TOUCHESpackage.json, CI config, or PLAN.md itself

If you want the vocabulary behind this rather than the walkthrough, read what AI agent orchestration means first. This post is the long version of the composition block in it.

1. the decomposition, decided before anything starts

The first decision is the only one that is hard to undo, and it is made with no agents running. You are choosing a partition of the codebase into three sets of files that do not intersect. Everything else in this post is bookkeeping in service of that partition holding.

For the expenses tracker the partition is by layer. The schema agent owns db/ and migrations/. The API agent owns server/. The UI agent owns web/. Three directories, no overlap, and each one can be named in a sentence with no commas in it.

Why not one agent per feature

The obvious alternative is to give one agent "claim submission", one "approval" and one "the expenses list". It reads better on a ticket board and it is the wrong split for concurrent agents, because every feature in a small app touches every layer. All three agents would need to add tables. All three would edit the router. All three would touch the shared types file. You have not partitioned anything; you have given three processes write access to the same twenty files and hoped.

The failure that follows is not a merge conflict, which git would at least announce. It is a lost update: the approval agent reads server/routes.ts, thinks for forty seconds, and writes back a version that quietly does not contain the claims agent's route. Nothing errors. The tests that exist still pass. You find it later.

The cost of the layer split, stated plainly

Splitting by layer buys isolation and pays for it in sequencing. The schema slice is upstream of the other two, so it is on the critical path and the other two are, for a while, guessing. That is a real cost and it is the reason the contract in the next section has to be exact. A guess against a written contract is a safe guess. A guess against nothing is a rewrite.

2. the plan file, and why the contract is the load-bearing part

No agent can see another agent's context window. The schema agent deciding to call a column submitted_at is invisible to the API agent unless you write it down somewhere both of them read. That somewhere is a plan file committed to the base branch before any worktree exists, so every worktree gets it for free.

Short is better than complete. This is the whole of it:

# PLAN.md

## Goal
Internal expenses tracker. Staff submit claims, managers approve or reject.
Postgres + TypeScript API + React. No auth work in this pass: assume a
trusted x-user-id header, we will replace it later.

## Slices
schema  db/, migrations/       owns table + column names
api     server/                owns route handlers, reads db/schema.ts
ui      web/                   owns components, calls the API by URL

## Contract  (fixed. if it must change, a human changes this file)

Tables:
  users(id uuid pk, email text unique, name text, is_manager boolean)
  claims(id uuid pk, user_id uuid fk users.id, amount_cents integer,
         currency text, description text, status text, submitted_at timestamptz,
         decided_at timestamptz null, decided_by uuid null fk users.id)

  claims.status is one of: 'pending' | 'approved' | 'rejected'
  money is amount_cents, an integer. never a float, never a string.

Routes:
  GET  /api/claims?status=pending
       -> 200 { claims: Claim[] }
  POST /api/claims
       <- { amountCents: number, currency: string, description: string }
       -> 201 { claim: Claim }
  POST /api/claims/:id/decision
       <- { decision: 'approve' | 'reject' }
       -> 200 { claim: Claim }
       -> 409 { error: 'already_decided' }

  Claim = { id, userId, amountCents, currency, description,
            status, submittedAt, decidedAt, decidedBy }

  JSON is camelCase. The database is snake_case. The API layer does the
  mapping; the UI never sees a snake_case key.

## Merge order
schema -> api -> ui

## Nobody touches
package.json, pnpm-lock.yaml, .github/, PLAN.md

The contract section is the part that makes this work and the rest is scaffolding. Two agents that have agreed on the JSON shape do not need to talk to each other, and two agents that have not agreed on it will talk constantly and still be wrong. Notice how much of the contract is naming rather than design: the exact column names, the exact casing rule, the fact that money is an integer of cents. Those are the decisions agents improvise differently when left to improvise, and improvising differently is the whole problem.

Notice also the last block. Every file that all three would plausibly want to edit is declared off limits, which converts the one genuinely shared surface into a human decision. If a dependency needs adding, an agent asks and you add it.

git add PLAN.md
git commit -m "plan: expenses tracker, three slices"

3. one worktree per agent

A git worktree is a second checkout of the same repository, on its own branch, in its own directory, sharing a single object database. It is far cheaper than a clone, and unlike a clone everything committed in it is already in the same repository.

cd ~/code/expenses

git worktree add ../wt-db  -b feat/schema
git worktree add ../wt-api -b feat/api
git worktree add ../wt-ui  -b feat/ui

git worktree list
# /Users/you/code/expenses   4c1f9de [main]
# /Users/you/code/wt-db      4c1f9de [feat/schema]
# /Users/you/code/wt-api     4c1f9de [feat/api]
# /Users/you/code/wt-ui      4c1f9de [feat/ui]

All three branch from the commit that contains PLAN.md, so all three worktrees have the plan already. Leave your original checkout on main and do not run an agent in it. It is where you read diffs and merge, and having one directory that is always clean is what lets you tell whether anything is actually broken.

Per-worktree environment, which nobody remembers the first time

A worktree carries tracked files only. Anything gitignored stays behind, which means your .env and your node_modules did not come with you. Both need doing per worktree, and the environment files need editing rather than copying, because three agents running three dev servers on the same port is three agents reporting that the port is in use.

for w in ../wt-db ../wt-api ../wt-ui; do
  cp .env "$w/.env"
  (cd "$w" && pnpm install)
done

# then edit each .env so the three do not collide:
#   wt-db   DATABASE_URL=postgres://localhost:5432/expenses_db
#   wt-api  DATABASE_URL=postgres://localhost:5432/expenses_api  PORT=3001
#   wt-ui   DATABASE_URL=postgres://localhost:5432/expenses_ui   PORT=3002

Separate databases matter more than separate ports. The schema agent will run its migrations more than once, and a shared database means it drops a table out from under a test the API agent is in the middle of.

If setting up a worktree takes more than a couple of commands in your stack, put those commands in a script in the repository now. You are about to run them three times, and you will run them three times again on the next fan-out.

4. launching the three, and what each first prompt says

Three terminals, three working directories, three agents. The commands are ordinary:

# terminal 1
cd ~/code/wt-db  && claude

# terminal 2
cd ~/code/wt-api && codex

# terminal 3
cd ~/code/wt-ui  && claude

The first prompt each agent gets is the highest leverage text in the whole run, and the useful thing about it is how little it contains. It points at the plan, states the file boundary as a hard rule, names the one deliverable, and stops. Everything the agent needs to know about the other two slices is in PLAN.md, and repeating it in the prompt only creates a second version that can disagree with the first.

The schema agent

Read PLAN.md. You are the "schema" slice.

Write the initial migration and the schema types for the users and
claims tables exactly as the Contract section specifies. Column names
and types must match the contract character for character.

You may write to db/ and migrations/ only. If you believe the contract
is wrong or incomplete, stop and tell me. Do not fix it yourself and do
not touch server/ or web/.

Export the row types from db/schema.ts so the API slice can import them.
When you are done, run the migration against the dev database and leave
a short note in db/NOTES.md: what exists, the exact names, anything you
deliberately left out.

The API agent

Read PLAN.md. You are the "api" slice.

Implement the three routes in the Contract section against Postgres.
The schema slice is being built in parallel and is not merged yet, so
write against the contract, not against the database: import row types
from db/schema.ts and assume they exist with exactly the contract's
names. Do not create db/schema.ts yourself.

You may write to server/ only. Do not touch db/, migrations/, web/,
package.json or PLAN.md.

The API owns the snake_case to camelCase mapping. Do it in one place.
Include the 409 on a second decision for a claim that is already
decided, and write a test for it.

The UI agent

Read PLAN.md. You are the "ui" slice.

Build two React views: a claim submission form, and a manager list of
pending claims with approve and reject buttons. Call the API routes in
the Contract section by URL. They do not exist on your branch yet;
write against the contract and type the responses from the Claim shape
it defines.

You may write to web/ only. Do not touch server/, db/ or migrations/,
and do not add dependencies. Use what is already in package.json.

Amounts are integer cents. Format them for display; never do arithmetic
on the formatted string. Show a plain error state for a 409.

Three things recur in all three and all three are load bearing. The pointer to PLAN.md means the contract has one home. The explicit list of directories the agent may write to means a boundary violation is a rule it broke rather than a mistake you failed to anticipate. And the instruction to stop and ask rather than fix the contract is what stops two agents independently patching an ambiguity in two incompatible ways, which is the failure in section six.

5. what happens while they run

They do not proceed in step. The UI agent tends to produce visible output first, because a form that renders is reachable in a handful of files and needs nothing from anyone. The API agent is next, and spends a chunk of its time on the shape of the mapping layer. The schema agent is often last to say it has finished, partly because migrations invite care and partly because it is the only slice with nothing upstream of it to copy from.

That ordering is inconvenient, because it is the reverse of the merge order. The slice everyone else depends on is the one you are waiting on, and until it lands the other two branches are written against a promise. This is the structural cost of the layer split and it is worth knowing before you feel it: the critical path runs through one agent.

You are the other constraint, and this is the part worth being honest about. Three agents working unattended does not give you a free hour. It gives you three transcripts arriving at unpredictable moments, each of which stops on a permission prompt or a question at a time you did not pick. The useful thing to do with that time is read the diffs as they appear, in the main checkout, one slice at a time:

cd ~/code/expenses
git diff main...feat/schema --stat
git diff main...feat/api    --stat
git diff main...feat/ui     --stat

Read --stat before reading any code. It is the cheapest possible check on whether the boundaries held, and a UI branch that has touched migrations/ is something you want to learn in one line rather than after three hundred. The temptation is to read the agent's summary instead of its diff. The summary was written by the thing being reviewed.

6. the first thing that goes wrong

Here is the realistic one. The API agent finishes its list route and it selects a column called created_at. The schema agent never created a created_at; the contract says submitted_at. Nothing failed while they were running, because the API branch has no database of its own with that table in it and the query is a string until something executes it. You find out at merge time, when the API tests run against the merged schema and the query errors.

The interesting question is why an agent given an exact column name used a different one. It is almost never invention for its own sake. Look at the contract again: it names submitted_at in the table definition, and the Claim shape further down lists submittedAt, but nothing in the file says what the timestamp means, and the surrounding convention in a hundred thousand codebases is created_at. The contract was ambiguous by omission, and the model filled the gap with the most common thing. That is what models do with gaps.

The fix, and the fix that is tempting and wrong

The tempting fix is to let the two agents work it out: tell the API agent to go and read the schema branch, or tell the schema agent to add the column the API wanted. Both are bad. The first turns a fixed contract into a negotiation between two parties who each see half the picture. The second lets a downstream slice edit an upstream one, which is exactly the boundary you drew the partition to protect.

The fix is that you amend PLAN.md, on main, and then tell one agent:

cd ~/code/expenses
# in PLAN.md, under Tables, make the ambiguity impossible:
#   submitted_at timestamptz not null default now()
#     the moment the claim was submitted. there is no created_at.
#     do not add one.

git commit -am "plan: name submitted_at explicitly, forbid created_at"

# push the amended plan into the branch that has to change
cd ~/code/wt-api
git merge --no-ff main

Then, in the API agent, one instruction: PLAN.md changed, re-read the Tables section, replace every use of created_at with submitted_at. One agent, one change, one place the decision is recorded. The schema agent is not interrupted and does not need to know this happened.

The general rule: when two agents disagree, the contract was underspecified, and the repair belongs in the contract. An orchestration setup where agents resolve their own disagreements is one where you cannot answer, a week later, why a column is called what it is called.

7. review and merge, in the order the plan named

Three branches, one at a time, in the order PLAN.md fixed before anything started. Schema first because the other two depend on it. Never two at once, because a test failure after a double merge tells you something is broken and nothing about which branch broke it.

Rebase each branch on the updated base immediately before merging it, so each merge sees the state the previous merge left:

cd ~/code/expenses
git checkout main

# 1. schema
cd ~/code/wt-db && git rebase main
cd ~/code/expenses
git merge --no-ff feat/schema
pnpm test

# 2. api, rebased onto main *after* the schema landed
cd ~/code/wt-api && git rebase main
cd ~/code/expenses
git merge --no-ff feat/api
pnpm test

# 3. ui, rebased onto main after both
cd ~/code/wt-ui && git rebase main
cd ~/code/expenses
git merge --no-ff feat/ui
pnpm test

The rebase is what makes the API agent's guesses collide with reality on its own branch rather than on main. If it wrote against a column that does not exist, the rebase brings in the real schema and the API tests fail in a worktree you can hand straight back to the agent that owns it. Fixing it there costs one instruction. Fixing it on main costs you the clean checkout you were relying on.

--no-ff keeps each slice as a distinguishable merge commit. When something breaks later, "which slice introduced this" is a question you will actually ask, and a fast-forward has thrown the answer away.

When a rebase conflicts

Boundaries leak at the edges even when they hold in the middle: a shared type file, a barrel export, a route table. Resolve it yourself, in the worktree, by hand. Handing the conflict back to one of the two agents involved is tempting and usually wrong, because it has context on one side only and will resolve in its own favour, confidently and without saying so.

8. cleanup

Once all three have landed, remove the worktrees and delete the branches:

cd ~/code/expenses
git worktree remove ../wt-db
git worktree remove ../wt-api
git worktree remove ../wt-ui
git worktree prune                 # clears records of directories deleted by hand
git branch -d feat/schema feat/api feat/ui

If remove refuses, it is because that worktree has uncommitted changes or untracked files, which is exactly the check you want. Go and look before you reach for --force. An agent that wrote a file and never committed it is a thing you would rather discover now than never.

Leaving worktrees around is not free. Git will refuse to check out a branch that another worktree already has, which produces a confusing error weeks later when you have forgotten ../wt-api exists. Stale directories also mean stale node_modules and stale .env files pointed at databases you have since changed, and next time you fan out you will half-reuse one and spend twenty minutes on a bug that is entirely bookkeeping. Remove them when the branch lands.

what this actually bought

Three slices built in parallel finish sooner in wall-clock time than three slices built one after another. That is the whole benefit and it is a real one. It is not three times faster, and it is worth being specific about why not.

Review is serial. Three branches produce three diffs and one person reads all of them, one after the other, and that reading does not get faster because the writing was concurrent. The schema slice blocks the other two in the sense that matters: until it lands, the API and UI branches are written against a contract rather than against code, and some fraction of what they wrote has to be corrected once the real schema arrives. The merge sequence is serial by construction, with a test run between each step. Setup has a fixed cost: three worktrees, three dependency installs, three environments.

No timings, token counts or cost figures appear anywhere in this post, because this run was not measured. The commands are real and the failure in section six is a real failure mode, but nobody put a stopwatch on it, so quoting a speed-up would be inventing a number and dressing it as evidence. If you want to know what the split buys in your repository, the measurement is easy and it is yours to take: build one slice alone first, note how long it took you including review, then fan out for the next feature.

Without numbers, we will still say where the benefit concentrates: it is largest when the slices are independent and each is substantial enough that an agent works unattended for a meaningful stretch, and it shrinks toward nothing as the slices get small.

when not to do this

Two cases, and both are common enough that reaching for three agents by default is a mistake.

The project is small enough for one agent. If the whole change is a few files and one layer, a single agent with the full picture in its context will do it better than three agents each holding a third of it. Everything in this post has a fixed cost: writing the contract, three worktrees, three environments, three diffs to read, three merges to sequence. Below some size that cost is larger than the work, and the honest answer is one agent, one branch.

The slices genuinely touch the same files. Some changes do not partition. A rename across the codebase, a framework upgrade, a refactor of a shared abstraction: these are single-file-set changes wearing a multi-part disguise, and splitting them three ways produces three agents editing the same file and a conflict per file. If you cannot finish the sentence "this agent touches only ___" for each slice without a comma in it, the decomposition is not ready, and running it anyway does not make it ready. Do it with one agent, or spend another ten minutes finding a boundary that is real.

what skribbl automates here, and what it does not

Plainly, because the post is a walkthrough rather than an advert. Skribbl automates the bookkeeping in sections three, four and five: spawning a fleet creates the worktrees and branches for you rather than leaving it as three commands, each agent is a node on a canvas holding a real terminal in its own worktree so you can see which is which without counting tmux panes, sessions survive a restart, and the combined spend across all three sits in the top bar while they are running instead of in a command you have to remember. Whether an agent is blocked on a permission prompt is visible on the canvas rather than discovered by cycling through terminals.

It does not do the parts that decide whether the run works. It does not write your decomposition, it does not write the contract in PLAN.md, it does not review the diffs, and it does not merge anything. The commands in sections six through eight are yours, run in a terminal, exactly as written above. That is the correct division: the mechanical parts of orchestration can be automated, and the judgement in section one is the work.

macOS on Apple Silicon, no model or subscription included. Try it or read the docs. If you are running one agent at a time and it is working, keep doing that.
READ NEXT
How to set up a multi-agent coding workspaceThe commands, in order, and what goes wrong when you skip one.12 minWhat is AI agent orchestration? A working definitionFour topologies, five problems, and an honest account of which one to reach for first.9 minRunning Claude Code, Codex and Grok togetherReal commands, one worktree per agent, and the merge order that stops them fighting.8 min
ON THIS PAGE
the short version1. the decomposition2. the plan file3. the worktrees4. launching the three5. while they run6. the first thing that breaks7. review and merge8. cleanupwhat this actually boughtwhen not to do thiswhat skribbl automates
run them on a canvasSkribbl puts every agent, its terminal and what it is spending on one board. macOS, one day free.

get the next one by email.

One email when there is something worth reading. Unsubscribe is one click and it is in every issue.

get me
productpricingdocsquestionswhat it iscomparereleaseswritingnewsletterlaunchesprivacycancel
give them infinity.© skribbl