all posts

How to hand off work between AI agents

Ajay Kumar··8 min read

Here is the shape of the bug. Your planner agent finishes, and the last thing it does is write a paragraph summarising what it decided. Your coder agent receives that paragraph. It does not receive the repo the planner cloned, the three files it read before choosing an approach, the dependency it installed to check whether an API existed, or the reason it rejected the obvious solution. It receives a paragraph.

So the coder re-derives the plan from the summary, badly, because the summary was written to sound complete rather than to be complete. Then the reviewer receives a diff and a second summary, and cannot build the code it is reviewing, so it reviews the diff as text and approves something that does not compile.

The summary is not the problem. Using it as the only channel is. I build PandaStack, so that is the sandbox in the examples below, but the shape works with any provider and most of it works with none.

Three things to hand off, three transports

A handoff bundles three separable things together, and they have almost nothing in common. Treating them as one blob of text is what makes the chain lossy.

Intent is what to do and why. This is the part a summary is genuinely good at, and it should stay short. A goal, a list of acceptance criteria, an explicit out-of-scope list. Prose expands to fill whatever space you give it, so give it a small typed object instead of a text field.

Artifacts are files, diffs, datasets, test fixtures. These should not travel through the model's context at all. Putting a 400-line diff in a handoff message costs tokens on both ends, truncates unpredictably, and arrives as something the receiving agent has to reconstruct rather than read. Artifacts belong on a filesystem or in object storage, and the handoff carries a path.

Environment is the third thing, and it is the one everybody forgets. The installed packages. The virtualenv with the exact resolved versions. The database seeded with the fixtures that make the failing test fail. The repo at the commit the planner actually looked at. This is what produces the most infuriating class of failure in a chain: the planner's approach was correct in the planner's environment, and the coder is working in a different one.

A good test for whether your handoff is complete: could the receiving agent reproduce the sender's last successful command? Not the conclusion of it, the command itself. If the answer is no, you are handing over a story about the work rather than the work.

Make the payload a schema, not a paragraph

Free text fails silently. A required field that is missing from a typed payload fails loudly, at the boundary, before the receiving agent burns a single token guessing. That is the entire argument for a schema and it is enough on its own.

# pip install pandastack pydantic
from typing import Literal
from pydantic import BaseModel, Field, field_validator

Role = Literal["planner", "coder", "reviewer"]

class Handoff(BaseModel):
    """The whole contract between two agents. If it does not validate, nobody runs."""

    from_role: Role
    to_role: Role

    # 1. Intent — the only free text, and it is capped on purpose.
    goal: str = Field(..., min_length=20, max_length=800)
    acceptance: list[str] = Field(..., min_length=1)
    out_of_scope: list[str] = []

    # 2. Artifacts — references, never contents.
    workdir: str
    artifacts: list[str] = Field(..., min_length=1)

    # 3. Environment — a handle to it, not a description of it.
    sandbox_id: str

    # Provenance — why, not just what.
    decisions: list[str] = []
    rejected: list[str] = []
    hop: int = 0

    @field_validator("artifacts", "workdir")
    @classmethod
    def absolute_paths(cls, v):
        paths = v if isinstance(v, list) else [v]
        bad = [p for p in paths if not p.startswith("/")]
        if bad:
            raise ValueError(f"paths must be absolute inside the sandbox: {bad}")
        return v

Two fields there are doing more work than they look like they are. The 800-character cap on the goal is not a token optimisation. It stops the planner from writing an essay that the coder will skim, and forces the detail into artifacts where the coder can actually read it at the moment it needs it.

The decisions and rejected lists are provenance. A reviewer that can see "chose the existing retry helper over adding tenacity, because the acceptance criteria said no new dependencies" reviews a different piece of code than one that just sees a hand-rolled retry loop and files it as a reinvented wheel. Rejected alternatives are worth as much as chosen ones and they are almost never recorded.

Keep the validated payloads. Write each one to a file in the working directory as the chain runs. When the output is wrong three hops later, you want the actual handoff objects, not a trace of model messages you have to read backwards.

Hand off the environment, do not rebuild it

Most chains rebuild. The coder gets a fresh container, clones the repo again, runs the install again, and hopes the lockfile resolves to what the planner had. That is slow, it is a place for drift to enter, and it discards work that has already been paid for.

The alternative is to fork the sandbox the previous agent worked in. A copy-on-write fork clones the disk without copying it, so the coder starts inside the planner's filesystem: the same repo at the same commit, the same installed packages, the same fixtures on disk. Same-host that lands in 400 to 750 milliseconds, which is what makes this a default rather than a special occasion.

from pandastack import Sandbox

def run_chain(task: str) -> Handoff:
    # Agent A does real work in a real machine: clone, install, poke at things.
    plan_sbx = Sandbox.create(
        template="base",
        ttl_seconds=3600,
        metadata={"role": "planner", "task": task},
    )
    plan = planner_agent(plan_sbx, task)

    # Flush before forking. A fork copies the disk, so anything still sitting
    # in the page cache is not in the child.
    plan_sbx.exec("sync")

    # The coder inherits the planner's node_modules, its venv, its clone and
    # its fixtures — not a paragraph describing them.
    code_sbx = plan_sbx.fork(metadata={"role": "coder", "task": task})

    return Handoff(
        from_role="planner",
        to_role="coder",
        goal=plan.goal,
        acceptance=["pytest -q passes", "no new runtime dependencies"],
        out_of_scope=["refactoring the auth module"],
        workdir="/workspace/repo",
        artifacts=["/workspace/repo", "/workspace/PLAN.md"],
        sandbox_id=code_sbx.id,
        decisions=plan.decisions,
        rejected=plan.rejected,
        hop=1,
    )

The reviewer gets the same treatment, forked from the coder rather than from the planner. That is the step that fixes the reviewer that cannot build the code: it is standing in the tree the coder just built in, with the coder's dependencies present, so running the test suite is a command rather than a project.

Be precise about what a fork carries. On PandaStack a fork captures on-disk state, so unflushed writes are not in the child — run sync first. It also means a process that was running in the parent is not running in the child; the fork gives you the machine the process ran on, and you start it again. If you need a live process to survive the handoff, keep the same sandbox and pause and resume it rather than forking.

One scheduling detail worth knowing before you build a long chain: a same-host fork is cheap because the disk is already there, while a cross-host fork has to move bytes and takes 1.2 to 3.5 seconds. Keep a chain's hops on one host unless you have a reason not to, and the cost of handing over the environment stays roughly free relative to a single model call.

When B rejects the handoff

A validated payload can still be wrong. The goal can be underspecified, the acceptance criteria can contradict each other, an artifact path can point at something that is not there. The receiving agent should be able to say no, and saying no should mean sending it back with a reason rather than proceeding on a guess.

This introduces the failure mode you have to design against explicitly, because it will not announce itself: two agents that disagree will bounce a handoff back and forth forever, each rewrite triggering the other's objection. Left uncapped, that is a loop that spends real money overnight and produces nothing. Cap the hops.

MAX_HOPS = 4

class Rejected(Exception):
    def __init__(self, reason: str, missing: list[str] | None = None):
        super().__init__(reason)
        self.reason = reason
        self.missing = missing or []

def deliver(h: Handoff, receiver, sender):
    """Hand h to receiver; on rejection send it back to sender, with a cap."""
    while True:
        if h.hop > MAX_HOPS:
            raise RuntimeError(
                f"handoff ping-pong between {h.from_role} and {h.to_role} "
                f"hit the cap at hop {h.hop}: {h.goal[:120]}"
            )
        try:
            return receiver(h)
        except Rejected as r:
            # Back to the sender WITH the objection attached. Re-asking from
            # scratch is how you get the same payload a second time.
            h = sender(h, reason=r.reason, missing=r.missing)
            h.hop += 1

When the cap trips, escalate to a human with the last payload and every rejection reason attached. Do not fall back to running the work anyway. An agent that rejected a handoff four times is telling you something true about the task, and the useful output of that loop is the disagreement, not a forced result.

The rejection should be structured too. A reason string and a list of what was missing is enough, and it gives the sender something to act on. "Insufficient context" is not actionable. "acceptance[1] refers to a file that does not exist at /workspace/repo/api/schema.py" is.

What the frameworks do, and what they leave to you

There are several reasonable ways to route control between agents, and they differ less than the marketing suggests.

  • LangGraph models the chain as a graph with explicit shared state. You define what the state contains and how each node updates it, which makes it the most natural home for a typed handoff object — the schema is the state.
  • The OpenAI Agents SDK has handoffs as a first-class concept: one agent transfers control to another, and the framework manages the transfer. It is the least ceremony for a straightforward chain.
  • CrewAI chains tasks, passing each task's output into the next as context. Good when the work really is a pipeline and the interesting structure is in the roles rather than the routing.

All three give you a way to move control and a place to put state. None of them solves the environment problem, because none of them owns your execution environment. If your LangGraph state carries a summary string and your coder node spins up a fresh container, you have the same lossy handoff with better routing around it. Pick the framework on how you like to express control flow, then solve the artifact and environment transports yourself, because that is where the actual loss happens.

When a string is the right answer

Now the concession, because this is easy to over-build. If your chain is two steps and step two needs a filename, pass the filename. A Pydantic model with nine fields, a fork, and a rejection protocol around a function call that takes one path is a worse system than the string, and you will spend more time maintaining the protocol than you ever lost to the string.

The schema starts paying at three or more hops, where nobody can hold the whole chain in their head, and at the point where a rejection is a real outcome rather than a theoretical one. The environment fork starts paying the first time an agent's work depends on something installed, cloned, or seeded — which for a coding chain is immediately, and for a summarising chain may be never.

The heuristic I use: hand over a string when the receiving agent could do its job on a laptop with nothing installed. Hand over a machine when it could not.

  1. Split the handoff into intent, artifacts, and environment before you write any code — they are three different problems.
  2. Intent goes in a validated typed object with a hard cap on the prose, so a missing field fails at the boundary instead of halfway through the receiver's run.
  3. Artifacts go on the filesystem; the payload carries absolute paths, never contents.
  4. Environment goes over as a fork of the sender's sandbox, after a sync, so the receiver stands where the sender stood.
  5. Record decisions and rejected alternatives, so the reviewer reviews the reasoning rather than guessing at it.
  6. Let the receiver reject, always attach a reason, and cap the hops before the ping-pong finds you.

Frequently asked questions

Why is a summary a bad handoff between agents?

Because it is lossy by construction and nothing in the chain reports the loss. A summary is written to sound complete, so the receiving agent has no way to tell what was omitted, and it will confidently re-derive the missing parts from whatever it does have. The specific casualties are always the same: the files the sender read but did not quote, the alternatives it rejected, and everything about the machine it worked in. Summaries are genuinely good at intent — keep using one for that, and move artifacts and environment onto transports that do not truncate.

Should I put the diff in the handoff message?

No. A diff in a message is expensive on both ends, truncates in ways you cannot predict, and arrives as text the receiving agent has to reconstruct rather than something it can apply or run. Write the diff to a file, put the absolute path in the payload, and let the receiver read exactly the hunks it needs. The same applies to datasets, logs, and test output. The rule of thumb is that anything you would not want to paste into a chat window by hand should travel as a reference, not as content.

What does forking a sandbox actually give the next agent?

The disk. On PandaStack a fork is a copy-on-write clone of the sandbox's filesystem, so the child starts with the parent's cloned repo, installed packages, resolved virtualenv, and any fixtures written to disk — without re-running the install. Same-host that takes 400 to 750 milliseconds. Two caveats matter: run sync in the parent first, because unflushed writes are not captured; and processes are not carried across, so a dev server running in the parent has to be started again in the child. If you need a live process to survive, keep one sandbox and pause and resume it.

How do I stop two agents from rejecting each other's handoffs forever?

Count the hops in the payload itself and refuse to deliver past a cap — four is a reasonable starting point. When the cap trips, escalate to a human with the last payload and every rejection reason, rather than falling back to running the work anyway. The loop happens because each agent's rewrite triggers the other's objection, and neither has any notion of how many times this has already happened. Putting the counter in the handoff object rather than in the orchestrator means it survives whatever routing the framework does.

Does LangGraph or the OpenAI Agents SDK solve this for me?

Partly. They give you a way to route control between agents and a place to keep shared state, which is real work you do not have to do yourself, and a typed handoff object fits naturally into a LangGraph state schema. What none of them solves is the environment, because none of them owns where your code runs. If the state carries a summary and each node starts a clean container, you have the same lossy handoff with better plumbing around it. Choose the framework on control flow, then handle artifacts and environment separately.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.