Skip to main content

An agent should ship a diff, not a paragraph

00:05:08

The nervous part of handing a repository to an AI agent isn't that it might make a mistake. It's the shape of how most agents work: they edit your files in place, run commands in your checkout, and hand back a paragraph that says it went well. You're being asked to trust a summary. I wanted to trust a diff.

So when I built Atelier, a workshop where a crew of agents does real work on a real git repo, I built it on two rules. A finished task is a diff you approve, not a paragraph that claims success. And the rules an agent must obey live in code, not in its prompt.

A task runs in its own worktree and produces a diff

Every task an agent picks up runs in its own git worktree, branched off its parent. Not a copy of the files, an actual worktree:

ts
export async function createWorktree(
  repoPath: string, branch: string, worktreePath: string, base = 'HEAD',
): Promise<Worktree> {
  await pexec('git', ['worktree', 'add', '-b', branch, worktreePath, base], { cwd: repoPath });
  return { path: worktreePath, branch };
}

Two agents working at the same time can't step on each other, and neither can touch your main branch, because each has its own branch and its own directory. And when an agent reports that it's done, "done" is not a message. It's the staged diff:

ts
export async function captureDiff(worktreePath: string): Promise<string> {
  await pexec('git', ['add', '-A'], { cwd: worktreePath });    // stage so untracked files show up
  const { stdout } = await pexec('git', ['diff', '--cached'], { cwd: worktreePath });
  return stdout;
}

That is the unit of work a human reviews: a real git diff, the same artifact you'd review from a teammate, not a self-report from the thing that did the work.

Merging happens away from your checkout

When you approve a task, the merge does not happen in your working tree. It happens in a dedicated integration worktree, so your actual checkout is never switched off the branch you're sitting on:

ts
// Runs entirely inside the integration worktree, never the user's checkout.
// Reports conflicts (and aborts the merge) instead of throwing. Called serially.
export async function mergeIntoIntegration(integrationWorktreePath: string, taskBranch: string): Promise<MergeResult> {
  try {
    const { stdout } = await pexec('git', ['merge', '--no-edit', taskBranch], { cwd: integrationWorktreePath });
    return { ok: true, conflict: false, output: stdout };
  } catch (e: any) {
    const output = String(e?.stdout ?? '') + String(e?.stderr ?? '');
    if (/CONFLICT|Automatic merge failed/i.test(output)) {
      await pexec('git', ['merge', '--abort'], { cwd: integrationWorktreePath }).catch(() => {});
      return { ok: false, conflict: true, output };
    }
    return { ok: false, conflict: false, output };
  }
}

A conflict comes back as a result, not an exception, so the orchestrator holds that task and tells you instead of crashing the whole run. Your files never move while any of this happens.

Permissions live in code, not in the prompt

This is the rule I'd argue hardest for. It's tempting to put the guardrails in the system prompt: "only edit files under src/, only run these commands." That's a suggestion, not a control. The prompt is input to the very thing you're trying to constrain, and a model that's confused, jailbroken, or just having an off moment can sail right past it. So the real check is code the model calls into and cannot rewrite:

ts
export function checkPermission(agent: AgentConfig, req: GateRequest): GateDecision {
  if (agent.autonomy === 'autonomous') return { allowed: true };

  if (req.kind === 'write') {
    const p = req.path ?? '';
    return pathAllowed(agent.allowedPaths, p)
      ? { allowed: true }
      : { allowed: false, reason: `write to "${p}" is outside allowed paths` };
  }

  const cmd = req.command ?? '';
  return agent.commandAllowlist.some((pat) => globMatch(pat, cmd))
    ? { allowed: true }
    : { allowed: false, reason: `command "${cmd}" is not on the allowlist` };
}

A write is checked against allowed path globs, a command against an allowlist. The agent's prompt can say whatever it likes. A write outside allowedPaths does not happen.

The human is in the loop, and "no" is just an observation

The runner ties it together. Before a tool with side effects runs, it asks the gate. If the gate refuses, the runner doesn't silently block. It yields an awaiting_approval event and waits for a person:

ts
const req = tool.permission?.(call.args, toolCtx) ?? null;
if (req) {
  const decision = checkPermission(ctx.agent, req);
  if (!decision.allowed) {
    yield { type: 'awaiting_approval', tool: call.name, args: call.args, reason: decision.reason };
    const approved = await ctx.approve(req);                          // waits for a human
    if (!approved) {
      messages.push(toolResult(call.id, call.name, `DENIED by user: ${decision.reason}`));
      continue;                                                       // model sees the no and adapts
    }
  }
}

const observation = await tool.run(call.args, toolCtx);

The detail I like most: a denial isn't a crash, it's handed back to the model as a DENIED by user tool result. The agent sees the refusal the same way it sees any other tool output, and reacts, tries a different file, asks for help, finishes without that step. The control is hard, the action genuinely cannot happen without approval, but the conversation stays soft, the model isn't thrown an exception, it's told no and keeps going.

Why you need both

Worktrees give you isolation: a mistake is contained to a branch you can delete, and parallel agents can't corrupt each other or your main branch. The gate gives you control: a dangerous action needs a human, and the rule is enforced where the model can't reach it. Neither is enough alone. Isolation without a gate still lets an agent run something destructive inside its sandbox. A gate without isolation means one approved-but-wrong edit lands straight on your tree.

The demos that edit your files in place and ask you to trust the model are answering the wrong question. You don't make an agent safe by making it smarter, or by writing a sterner paragraph at the top of its context. You make it safe by changing what "doing the work" produces, a diff, in isolation, and by putting the guardrails somewhere the agent can't argue with them, in code. Do that and watching an agent work on your repo stops being an act of faith. In Atelier you literally watch it, in 3D, but the part that lets you exhale isn't the visualization. It's the worktree and the gate underneath it.