keel

Delegation

Call a specialist, receive its result, and keep ownership of the turn.

Delegation borrows execution, not ownership. Every child returns to its caller; the root agent settles the user's turn.

TURN · user → supervisoradmitfitstepscloserun-researchera tool call, budget: {steps: 6}briefTURN · supervisor → researcheradmitfitstepscloseits own model chain, tools, and window — ambient inherited, never wideneddeliverable | defect
Every child returns to its caller. Results are private by default; the root agent owns the final response.

Treat a sub-agent as a function

The caller supplies validated input and a budget. The child runs its own turn, then returns a structured result or an explicit failure. The caller resumes with that result before it can finish.

User → Agent A → Agent B → Agent C
                  ← result ← result
     ← final response

A child turn crosses the same four gates as any other, minus the root-only admit checks — the ambient context was validated when the root turn opened and is inherited as-is, the thread claim belongs to the root, and only a root turn carries a request envelope. A child can delegate further, but cannot transfer ownership of the root conversation or directly finish its caller's turn. Delegation is not a handoff.

Declare the connection

asTool(researcher, {
  budget: { steps: 6, timeMs: 15_000 },
  deliverable: sourcesPart,
  onError: "feedback",
});

Only budget is required — asTool throws without one. Everything else defaults on the caller's behalf:

OptionDefaultMeaning
budgetrequiredthe steps (and optionally time and output tokens) the child may spend
retries0how many times a non-completed child is re-run — each retry is a fresh child turn
onError"feedback"a child failure returns as the call's result; "fail" settles the caller failed with the child's cause
forwardoffopt-in visibility: progress and an allowlist of parts
deliverablenonea part the child must submit; the plain part form grants 1 nudge round, { part, nudges } sets your own
idrun-<agent.id>the tool name the caller's model sees
descriptionDelegate to the <agent.id> agentthe tool description in the caller's window
inputz.object({ brief: z.string() })the call's schema, validated before any child opens
access"public"who may open the connection — the one deliberate asymmetry with defineTool, which requires a rule

deliverable requires the child to submit schema-valid data for a declared content part. Without a deliverable contract, successful delegation returns the child's text and structured parts. Use a deliverable when downstream work requires a specific result shape.

Receive the result

The caller's model receives a JSON tool result matching DelegationResult. With the reference app's sources deliverable, the shapes are:

// Success under a deliverable contract: the validated part data itself
{ ok: true, deliverable: { query: "decree 42", items: [{ title: "Result A", ref: "ws://doc-1" }] } }

// Success without a deliverable contract
{ ok: true, result: { text: "…", parts: [/* the child's custom parts */] } }

// Failure, including the underlying child's cause
{ ok: false, cause: { code: "delegation-failed", message: "…", detail: { /* cause, agent */ } } }

result.parts carries custom parts only — the child's text is already in result.text, and progress and outcome frames never cross the edge.

Invalid arguments return ok: false with tool-input-invalid before any child runs. Other child failures return to the caller as feedback by default. With onError: "fail", the caller settles failed instead of continuing. Retries are explicit and bounded; a user stop is never retried.

A completed child does not prove the caller completed its own work. Keel requires another caller model step after delegation, even when the model that requested the child also reported a stop. Reserve enough caller steps to process the result.

Keep child output private by default

Child text, custom parts, and outcome frames stay on the child's own event stream. They do not automatically appear on the caller's user-facing wire. Studio can still inspect every child turn, including its tools and failures.

The caller can summarize a returned result or emit its own selected parts. This keeps the final response with the agent that accepted the request.

Forward visibility explicitly

When the UI needs progress or selected live parts, opt in on the connection:

asTool(researcher, {
  budget: { steps: 6 },
  deliverable: sourcesPart,
  forward: {
    progress: true,
    parts: [sourcesPart],
  },
});

progress exposes compact status and character-count summaries, not raw child text — one progress part per delegation call, updating in place as the child advances (replace-by-id merge, one stable id). parts is an allowlist of typed parts; each must be declared in both the child's and the caller's emits, matched by name. Forwarded part ids are scoped to the delegation call and attempt, so retries never collide with each other or with the caller's own parts.

Forwarding is visibility, not substance. Forwarded parts land on the caller's wire like anything else — they appear in the caller's payload.parts and persist into thread history for clients to render. The exclusion is precise: at the close gate they do not count toward the caller's substance check, and they never satisfy the caller's own deliverable contract. Forwarded events are also provisional — a child can still fail after emitting them, and retries may expose multiple attempts. Child outcome frames are never forwarded as caller outcome frames.

In nested delegation, each caller controls its own outward visibility. An inner forwarding choice does not automatically expose output at the root.

Cancel through the tree

Pass a signal on the root turn:

const controller = new AbortController();

const outcome = await runtime.runTurn({
  agent: assistant,
  ambient,
  envelope,
  budget: { steps: 8, timeMs: 30_000 },
  hooks: { signal: controller.signal },
});
// The UI's stop handler can call controller.abort() while this runs.

The root envelope is one of the admit checks children skip: it is schema-validated, and its threadId must equal ambient.threadId — a mismatch is a client/turn parity bug, refused at the door as envelope-invalid.

The signal is inherited by descendants. Stop propagates back through callers without retrying or resuming their model loops. Each child also receives a time ceiling no larger than its caller's remaining time; delegation cannot reset the parent's deadline.

Cancellation is cooperative at execution boundaries. It does not forcibly terminate an already-running provider request or tool function. Once that work returns, the runtime observes cancellation before continuing.

Next: Tools.