Skip to content

Hand-offs ​

A tool can start a run of its own agent: startHandoff(ctx, { payload, idempotencyKey?, concurrencyKey?, address? }). The new run (the child) has its own budget, its own approvals and its own concurrency key, and the calling run (the parent) goes on at once: a hand-off is fire and forget. A daily run that finds ten pull requests needing work hands off one run per pull request instead of fixing them all inline under one budget.

Hand-offs are not delegation: nothing waits for the child, and nothing of its result comes back to the parent. The tool gets { runId, deduplicated } and that is all.

Opting in ​

An agent accepts hand-offs only with handoff.on() in its channels. The child comes in on the channel agent (subscription id handoff), which agentChannel() registers:

ts
const maintainer = defineAgent({
    name: 'maintainer',
    model,
    instructions: 'Every morning, hand off each pull request that needs work.',
    channels: [
        cron.on('0 6 * * *'),
        // the opt-in: runs of this agent may start runs of this agent
        handoff.on({
            schema: z.object({ pr: z.string() }),
            concurrencyKey: (p) => p.pr,
            maxPerRun: 20,
        }),
    ],
    tools: { hand_off_pr: handOffPr },
});
ts
export const engine = new Engine({
    store: createMemoryStore(),
    agents: [maintainer],
    // a bare engine lists the agent channel; @chamvi/app contributes it
    channels: [cronChannel(), agentChannel()],
});

With @chamvi/app the engine module contributes agentChannel(), so the name agent is taken (a user channel of that name clashes at setup). Without handoff.on() a call is a ValidationError tool error: Agent "<a>" accepts no hand-offs: add handoff.on() to its channels.

handoff.on(options?) takes:

OptionMeaning
schemaA Standard Schema the payload goes through; the child gets the parsed value. A failing payload is a ValidationError tool error and starts nothing. Without one the payload passes unchanged
maxPerRunHow many hand-offs one run may start over all its executions, default DEFAULT_MAX_HANDOFFS_PER_RUN (100). A positive integer, else InvalidAgentDefinitionError (also for a subscription built by hand, when the agent is registered)
concurrencyKey(payload)The child's concurrency key when the call gives none
idempotencyKey(payload)The child's idempotency key when the call gives none

Starting a hand-off ​

ts
const handOffPr = defineTool({
    description: 'Starts a run of its own for one pull request that needs work.',
    inputSchema: z.object({ pr: z.string().describe('owner/repo#number') }),
    execute: async ({ pr }, { context }) => {
        // the day the daily run was fired, not the wall clock: a redo keeps the key
        const day = context.run.trigger.receivedAt.toISOString().slice(0, 10);

        return startHandoff(context, {
            payload: { pr },
            idempotencyKey: `${pr}@${day}`,
            address: pr,
        });
    },
});
  • payload is required; idempotencyKey, concurrencyKey and address are optional non-empty strings without NUL (the concurrency key and the address at most 255 characters). Anything else is a ValidationError tool error.
  • The child's envelope is { agent: <own agent>, trigger: 'handoff', channel: 'agent', payload, idempotencyKey, concurrencyKey?, address?, invoker?, parent }. The model sees the channel, the subscription id and the payload, like any run.
  • startHandoff() runs inside a tool call only; in the tools factory it throws InvalidRunStateError.

Keys and dedupe across runs ​

The claimed idempotency key is handoff:<key> (or sha256:<hex> of it when it is longer than 255 characters), where <key> is the call's idempotencyKey, else the subscription's idempotencyKey(payload), else <parent run id>:<hash of payload, concurrency key and address>.

A given key is not scoped to the parent run: a second run that hands off the same key (the job run again, a replica) gets the first child back with deduplicated: true. Key it by what makes the work unique, for example the pull request and the day (${pr}@${day}), taking the day from ctx.run.trigger.receivedAt so a redo keeps the same key. Without a key, the default is scoped to the parent, so another parent with the same payload starts its own child. The idempotency scope is <agent>:agent (plus the invoker, see below), apart from every other channel's.

The concurrency key works as for any run: two children with the same key never run at the same time, so one key per pull request keeps two fixes of one pull request apart.

The effect record ​

Each call is recorded under the effect key <runId>:handoff:<hash of payload, key, concurrency key and address> with the value { runId, deduplicated }, so a step redone after a crash or after an approval returns the recorded result and starts nothing. Identical calls in one step (parallel tool calls) share one hand-off. Dispatch is idempotent by itself, so a crash between the dispatch and the record still yields one child (the redo reads deduplicated: true).

Limits ​

  • Per run: at most maxPerRun hand-offs, counted durably across executions (the effect handoff-count:<runId>), so a run that paused on an approval keeps its count. The count goes up before the dispatch, also for a deduplicated one; a crash in between spends that call's slot, and its redo reuses the slot (it never fails at the limit). Past the limit: Run <id> reached its <n> hand-offs (handoff.on({ maxPerRun })).
  • Depth 1 (MAX_HANDOFF_DEPTH): a handed-off run cannot hand off again (A handed-off run cannot hand off again (depth 1).), so a fan-out never recurses.
  • The invoker is inherited: the child gets the parent's invoker snapshot (none for a cron parent), so requireToolPermission() decides the same in the child as in the parent and a hand-off never widens grants. The agent, the invoker and the parent come from the engine's copy of the run, so tool code that writes to ctx.run cannot change what a hand-off starts.

Outbound in a child ​

The agent channel has no out, so ctx.channels.findOne() without a name is undefined in a child. Tools of a child name their channel and address, for example ctx.channels.findOneOrFail('github').approve({ address: 'o/r#5', title: 'Merge?' }). The address given to startHandoff() becomes the child's trigger.address and run.address (shown in the dashboard), so a tool can read it back from ctx.run.trigger.address.

Lineage ​

The child's trigger.parent is { kind: 'handoff', runId, agent, depth: 1 } (TriggerParent). Only startHandoff() sets it: engine.dispatch() and a channel's ctx.dispatch() drop a parent a caller passes, so nobody forges lineage. It lives in the stored envelope, so both repositories keep it without a migration. The dashboard's run page shows "handed off from" with a link to the parent run.

Under chamvi run-job ​

A one-shot job starts a whole engine over the shared store, so it may pick up the children its run handed off. The drain at the end of the job interrupts the children still running; they requeue through recovery on the deployment and go on from their latest checkpoint.