DEV Community

Cover image for How to one shot your feature with this orchestration-workers graph engineering in Claude
Ian A. Drilon
Ian A. Drilon

Posted on

How to one shot your feature with this orchestration-workers graph engineering in Claude

I work in a workspace with eight repos. For a long time, every ticket started the same way.

Read the ticket. Read its parent ticket. Grep the same files to check if the field already exists. Start the services. Run lint and tests in each repo. Then make the branches by hand, and open two pull requests that have to match.

I counted the commands once. Fifty four of them are written down across those repos. Most of them I ran again for the next ticket.

I tried to fix this with better prompts. It did not work. One agent with a long prompt still forgets, still guesses, and still hands me code with no proof that it works.

What worked was building a graph.

The graph, one ticket from input to pull requests

What "graph" means here

A graph is just boxes and arrows.

  • A node is one agent with one job.
  • An edge is a handoff. Agent A finishes, agent B gets the result.
  • A barrier is a place where nothing moves forward until every branch comes back.
  • A cycle is an arrow that goes backward. Work that failed goes back to be fixed, then comes around again.

That is all. The rest of this post is how to build one in Claude Code.

The shape we are building

ticket -> plan -> [check] -> survey -> build -> [gate] -> 4 QA lanes -> judge -> PRs
                    |                                         |
                    +-------- backward arrows ----------------+
Enter fullscreen mode Exit fullscreen mode

One command in. A pull request out. If something fails, it loops until it is fixed, or until the round budget runs out.

Step 1: put the harness in its own folder

Do not put this inside a product repo. Make it a sibling folder.

workspace/
  orchestrator/        <- the harness. no product code.
    .claude/agents/
    .claude/commands/
    .claude/workflows/
    docs/
  api-engine/          <- your real projects
  web-pilet/
  tech-docs/
  e2e-suite/
Enter fullscreen mode Exit fullscreen mode

Why: a ticket usually touches two or three repos at once. If the harness lives inside one repo, it can only see that repo. From the workspace root it can see all of them.

Step 2: write down what you keep repeating

This is the boring step, and it is the one that pays.

Every time you explain something to the model twice, write it in docs/ instead. Mine ended up as six files:

  • which lint, type check and test command belongs to each repo
  • which service owns which decision
  • how to check if a query or field already exists before writing a new one
  • what a plan document must contain
  • the design rules, including what is not a bug
  • the words we use for statuses and roles

"The AI does not know our codebase" is not a model problem. It is an unwritten knowledge problem. Once it is written, every agent reads the same copy.

Step 3: write your agents

One file per agent in .claude/agents/. Markdown with a small header.

---
name: qa-backend
description: Reads the server half of a diff and returns a PASS or FAIL verdict. Never edits files.
tools: Read, Grep, Glob, Bash
model: inherit
---

You are QA for the server side.

You read the diff. You do not edit files, and you do not praise.
Your verdict is acted on, so it has to be right in both directions.
A false PASS ships a bug. A false FAIL sends good work back.

Report every finding with a file and a line number, and a severity of
BLOCKER, MAJOR, MINOR or NIT.
Enter fullscreen mode Exit fullscreen mode

Two things matter more than the prose.

Give each agent one job. I have six: a planner, a builder, and four reviewers (server, web, design, browser).

Use tools: to take away what an agent must not do. My two code reviewers have no Write and no Edit. They cannot change the code they are judging, because the tool list does not let them. That is stronger than asking them nicely.

Step 4: write the loop

The loop is one JavaScript file in .claude/workflows/. It starts with a meta block, then plain code.

export const meta = {
  name: 'build-ticket',
  description: 'Plan a ticket, build it, check it, judge it.',
  phases: [
    { title: 'Plan' },
    { title: 'Build' },
    { title: 'Gate' },
    { title: 'QA' },
    { title: 'Judge' },
  ],
};

phase('Plan');
const plan = await agent(
  `Read the ticket ${args.ticket} and its parent. Write the plan to tech-docs/.`,
  { agentType: 'planner', schema: PLAN_SCHEMA },
);
Enter fullscreen mode Exit fullscreen mode

agent() starts a subagent and waits for it. phase() groups the calls so you can watch progress. args is whatever you passed in.

You are writing normal code here. Loops, if statements, counters. That is the point. The control flow is yours, not the model's.

Step 5: type your handoffs

This is the part I would keep if I had to throw everything else away.

Pass a schema and the subagent must answer in that shape. It cannot hand back three paragraphs of maybe.

const FINDING = {
  type: 'object',
  additionalProperties: false,
  required: ['severity', 'file', 'line', 'problem'],
  properties: {
    severity: { type: 'string', enum: ['BLOCKER', 'MAJOR', 'MINOR', 'NIT'] },
    file: { type: 'string' },
    line: { type: 'integer' },
    problem: { type: 'string' },
  },
};

const QA_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  required: ['verdict', 'findings', 'summary'],
  properties: {
    verdict: { type: 'string', enum: ['PASS', 'FAIL'] },
    findings: { type: 'array', items: FINDING },
    summary: { type: 'string' },
  },
};
Enter fullscreen mode Exit fullscreen mode

Prose can be vague. "Severity is one of BLOCKER, MAJOR, MINOR, NIT" cannot be vague. Set additionalProperties: false everywhere, so an agent cannot slip in a field you never asked for.

Eleven of my twelve agent calls are typed this way. I will come back to the twelfth.

Do not trust the verdict on its own. A schema is happy to accept verdict: PASS next to a BLOCKER finding. So check the evidence yourself, in code:

const BLOCKING = ['BLOCKER', 'MAJOR'];
const blockingOf = (qa) => (qa?.findings ?? []).filter((f) => BLOCKING.includes(f.severity));

// PASS only counts when the findings agree with it.
const passed = (qa) => Boolean(qa) && qa.verdict === 'PASS' && blockingOf(qa).length === 0;
Enter fullscreen mode Exit fullscreen mode

Step 6: one barrier, in the right place

Run things at the same time when they do not need each other. Use parallel().

phase('QA');
const lanes = [
  () => agent(prompt('qa-backend'),  { agentType: 'qa-backend',  schema: QA_SCHEMA }),
  () => agent(prompt('qa-frontend'), { agentType: 'qa-frontend', schema: QA_SCHEMA }),
];
if (hasDesign && touchedFrontend) lanes.push(() => agent(prompt('qa-design'), { agentType: 'qa-design', schema: DESIGN_SCHEMA }));
lanes.push(() => agent(prompt('qa-e2e'), { agentType: 'qa-e2e', schema: E2E_SCHEMA }));

const results = await parallel(lanes);   // <- the barrier. nothing moves until all four return.
Enter fullscreen mode Exit fullscreen mode

parallel() waits for everything. That is a barrier, and I only have one, because only one decision needs all of the answers at once.

Two lessons from getting this wrong:

Run the test suites once, before the lanes, not inside them. Four reviewers running the same test suite at the same time fight over the same test database and the same test cache. You get failures that are not real, and good work gets sent back.

A lane that could not run did not pass. My browser lane can return NOT_RUN when the app will not start. NOT_RUN blocks the round. It is not a neutral answer, and it is never a pass.

Step 7: point the backward arrows at the right node

Here is the mistake I shipped and later fixed.

I said "a failed review goes back to planning". That sounded wise. It was wrong, and my own diagram said it too.

What actually makes sense:

if (qaFailed) {
  // Design is the planner's call. A builder who decides a 4px gap is fine
  // has removed the only check on it.
  if (designNeedsAdjudication) {
    await agent(replanPrompt, { agentType: 'planner', schema: REPLAN_SCHEMA });
  }
  continue;   // <- back to build, with the findings attached
}
Enter fullscreen mode Exit fullscreen mode

Most failures go straight back to the builder. Only the ones that are really an argument about the plan go to the planner. If you send every failure through planning, you rewrite the plan for a typo. If you send none, the builder gets to overrule the design.

Four different failures point back at build in my loop: a failed test run, a failed review, a design fix, and an incomplete judgement. That is what makes it settle instead of drifting.

Step 8: keep git out of the loop

The loop never commits, never pushes, and never opens a pull request. My builder agent is told this in plain words, and the agent that owns git is a separate command with one confirmation step.

Rules I wrote down and do not bend:

  • one branch per repo that changed, never a shared branch
  • never merge
  • never force push
  • never open a pull request from a loop that did not finish
  • ask again next time, because approval for one ticket is not approval for the next

The reason is simple. The loop is fast and I am not reading every line as it goes. So the last step before anything leaves my machine is a human saying yes.

Step 9: install it so every project sees it

The harness resolves agents from the session's .claude/ folder. Sessions run from the workspace root. So link the files up one level:

# from workspace/orchestrator/
mkdir -p ../.claude/agents ../.claude/commands ../.claude/workflows
for kind in agents commands workflows; do
  for f in .claude/$kind/*; do
    ln -sfn "../../orchestrator/.claude/$kind/$(basename "$f")" "../.claude/$kind/$(basename "$f")"
  done
done
Enter fullscreen mode Exit fullscreen mode

Use per file links, not one folder link. The workspace .claude/ may hold other settings, and other tools add files of their own. Use a relative target so the folder can be moved.

Symlinks mean an edit in the harness takes effect on the next run. No copy step to forget.

Step 10: run it

/build-ticket TKT-1234
/build-ticket TKT-1234 --plan-only
/build-ticket TKT-1234 --rounds 3 --dry-run
Enter fullscreen mode Exit fullscreen mode

One note if you build this in Claude Code: workflows are not started on their own. You ask for one, or a command asks for one. Nothing here runs while you are asleep.

What it cannot do

Be honest about this part, in your README and to your team.

The one agent with no typed output is the builder. The only node that writes code hands back prose, and that prose gets quoted into the review prompts. I have not worked out a good shape for "here is what I changed and why". It is the weakest edge in my graph.

I also have no hours saved number. Nothing in the harness measures duration, so I will not invent one. What I can say is what changed in shape: I type one command and confirm once, instead of running fifty four commands across eight folders. I still read the diff myself.

The short version

  1. Own folder, next to your projects, no product code in it.
  2. Write down the things you keep repeating.
  3. One agent, one job. Take away the tools it must not use.
  4. Write the loop as code, so you own the control flow.
  5. Type every handoff, and then check the evidence in code anyway.
  6. One barrier, where the decision really needs everything.
  7. Send failures back to whoever should fix them, not always to the top.
  8. Keep git behind a human yes.

If you build one, I would like to know where you put your barrier, and where your backward arrows point.

Top comments (0)