Subagents

Subagents let one agent route hand a focused task to another agent route. When the parent has at least one child it can dispatch to, it gets an internal task({ subagent, input }) tool and a # Subagents prompt section. The child is still a full B4.run route with its own prompt, tools, state, memory, planning, skills and subagents.

Use subagents when a specialist should handle a piece of the work.

Quick startCopy link to section: Quick start

Put child agent routes under the parent's subagents/ directory:

text
src/app/support/[tenant]/
  index.ts
  subagents/
    research/
      index.ts
      tools/
        searchDocs.ts

The child route exports an agent(). Give it a description, since the parent uses it to decide when to delegate:

src/app/support/[tenant]/subagents/research/index.ts
import { agent } from "@b4run/sdk"
 
export default agent({
  model: "gpt-5-mini",
  description: "Find relevant policy and product documentation before a support reply.",
  systemPrompt: "You research internal documentation and return concise findings.",
})

When the parent route runs, B4.run gives it a task tool that the model calls like this:

ts
task({
  subagent: "research",
  input: "Find the current refund policy for annual plans.",
})

The parent gets the child's final text back as the tool result.

A subagent can set its own tools: { approve: [...] } to ask a person before each call to those tools. The kind: "tool" interrupt shows up on the parent's stream, next to the other subagent.* events. See Per-tool approval.

Convention discoveryCopy link to section: Convention discovery

B4.run finds direct child routes at:

text
<parent route>/subagents/<leaf>/index.ts

The model names a child by its folder name, such as "research".

Only direct children appear in the parent's prompt. A child can have its own subagents/ directory, and those children belong to it.

If a child route has no description, B4.run lists it as No description provided. The route still works, but the parent won't pick it as reliably.

Children found by folder alone get the parent's delegation.default rule. Named rules are typed from registration keys, so to give a child its own rule, import and register it.

Keyed registrationCopy link to section: Keyed registration

Register children in a keyed object when you want to choose their names or give them their own policy rules:

src/app/support/[tenant]/index.ts
import { agent } from "@b4run/sdk"
import researcher from "./shared-researcher/index.js"
import writer from "./subagents/writer/index.js"
 
export default agent({
  model: "gpt-5-mini",
  systemPrompt: "You coordinate customer support work.",
  subagents: {
    policyResearch: researcher,
    draftWriter: writer,
  },
  delegation: {
    default: "deny",
    rules: {
      policyResearch: { action: "allow" },
      draftWriter: {
        action: "approve",
        reason: "Draft generation requires review.",
      },
    },
  },
})

The key is the name the parent model sees. Delegation policy and saved permissions use it too. It only applies to that parent, so two parents can register the same child under different names.

TypeScript limits the keys in delegation.rules to the keys in subagents. Names must match ^[A-Za-z0-9][A-Za-z0-9_-]*$, and B4.run uses them as written (no trimming or case folding).

If you register a child that also lives in subagents/, your key replaces the folder name for that parent. The folder name stops working, so the child can't be reached around its named rule.

Registration is always an object, such as subagents: { researcher, writer }. Arrays aren't accepted.

Delegation policyCopy link to section: Delegation policy

Each parent sets the policy for dispatching to its direct children:

ts
delegation: {
  default: "deny",
  rules: {
    researcher: { action: "allow" },
    writer: { action: "deny", reason: "Drafting is disabled." },
    reviewer: { action: "approve", reason: "Review external input." },
  },
}

default can be "allow", "deny", or "approve", and it defaults to "allow". A named rule uses one of four actions:

ActionResult
allowDispatch immediately
denyReturn [B4_E3002] with the configured or default reason
approvePause for a kind: "subagent" permission decision
constrainEvaluate the current input and return allow, deny, or approval

A constraint gets { input } plus the parent route, the child's name and route, the thread, the route parameters, and the cancellation signal:

ts
import { agent, type DelegationConstraintPredicate } from "@b4run/sdk"
import researcher from "./shared-researcher/index.js"
 
const restrictResearch: DelegationConstraintPredicate = ({ input }, context) => {
  if (context.params?.tenant === "blocked") return "Tenant cannot delegate research."
  if (input.includes("external")) {
    return { approve: true, reason: "External research requires review." }
  }
  return true
}
 
export default agent({
  model: "gpt-5-mini",
  systemPrompt: "Coordinate support work.",
  subagents: { researcher },
  delegation: {
    rules: {
      researcher: { action: "constrain", predicate: restrictResearch },
    },
  },
})

Return true to allow the dispatch, a string to deny it with that reason, or { approve: true, reason? } to ask a person. If the function throws or returns anything else, the dispatch fails closed and the child never starts. A constraint can read the input but can't change it.

Each level checks its own policy. A parent's rules cover only its direct children. When a child dispatches a grandchild, the child's own delegation policy decides.

Approvals work the same way. always saves the pair of parent route id and subagent name. Approving researcher under /support doesn't approve a child with the same name under /finance, another child of /support, or anything deeper. See Subagent approval.

What the model seesCopy link to section: What the model sees

B4.run adds a prompt section like this:

text
# Subagents
 
The following subagents are available. Call `task({ subagent, input })`
to dispatch a sub-task. Use the description to choose the right subagent
for each piece of work.
 
- **research** - Find relevant policy and product documentation before a support reply.

The task schema lists the parent's subagent names as an enum, so the model can only name known subagents. Children that are always denied are left out of both the prompt and the schema. Allowed, approval-gated, and constrained children stay listed, since a call to them can succeed.

Runtime behaviorCopy link to section: Runtime behavior

At runtime, B4.run looks up the child and applies the parent's policy right before dispatch. The child gets the task as a user message:

ts
{ messages: [{ role: "user", content: input }] }

Each dispatch runs as its own LangGraph subgraph, using the root thread's checkpointer. Nested and parallel children keep separate checkpoint namespaces, and you resume all of them through the root thread.

B4.run tracks the depth of nested calls and stops at a depth of 3. That's also what stops a cycle in explicit registrations.

Dispatch failuresCopy link to section: Dispatch failures

When a dispatch fails, the parent model gets a coded result:

  • B4_E3002 means policy, a constraint, an approval decision, or non-interactive mode denied the dispatch.
  • B4_E5003 means the requested identity is unavailable, stale, or could not be started.

An invalid registration or policy fails b4 check or route preparation with B4_E1004, and never falls back to allow. The model never sees a constraint's exception. Set B4_DEBUG_CONSTRAINTS=1 locally to log it, with the parent and child names.

StreamingCopy link to section: Streaming

When you run the parent through runs/stream, B4.run forwards the child's activity as subagent.* events:

  • subagent.start
  • subagent.token, subagent.reasoning and subagent.message_end
  • subagent.tool_call, subagent.tool_call_args and subagent.tool_result
  • subagent.usage
  • capability events such as subagent.plan_update
  • subagent.end

Each forwarded event carries the child's call_id, name, route id and depth, and otherwise has the shape of the root event of the same name. subagent.start also includes the child's declared description and, for a nested child, parent_call_id. subagent.end includes either final_message or error.

While a child runs, its tokens arrive as subagent.token with the child's messageId, and B4.run drops the duplicate parent message chunks. Child tool calls carry the model's tool-call id, the same id their results carry. See Streaming output for the payloads.

If a child hits a tool, path, command, memory, or delegation approval, B4.run sends a top-level interrupt on the root parent's stream. Resume the root thread with every pending interrupt, by ID. Don't start or resume the child yourself. See Resuming an interrupted run.

Names and reserved policy fieldsCopy link to section: Names and reserved policy fields

Folder-discovered and registered subagents share one set of names per parent. Route preparation reports B4_E1004 for:

  • invalid names
  • children it can't resolve, or can't tell apart
  • the same route registered twice
  • name collisions

task is the internal tool the model uses to dispatch. It's invalid in tools.allow, tools.deny, tools.approve and tools.constrain, because only the parent's delegation policy controls dispatch. A subagent can still be registered under the key task, since registration keys are a separate set of names.

Don't create a route tool named task. To resume, send the full resume body described in Permissions.