Dispatch from a Route
Sometimes one route needs to hand work to another. For example, a coordinator agent might pass a research step to a specialist. There are two ways to do it, depending on where the other agent lives.
Idiomatic: use task() with subagentsCopy link to section: Idiomatic: use task() with subagents
When both agents live in the same project, use B4.run's built-in subagent dispatch. Add a subagents/researcher/ directory next to your coordinator route, and the runtime generates a task tool for the coordinator:
src/app/(public)/research/
index.ts ← coordinator agent
subagents/
researcher/
index.ts ← specialist subagent
tools/
webSearch.tsHere is the subagent:
import { agent } from "@b4run/sdk"
export default agent({
model: "gpt-5-mini",
description: "Search the web and return concise findings for a given query.",
systemPrompt: "You are a research specialist. Search thoroughly and cite your sources.",
})The coordinator's model then emits a task tool call like this one (you don't write this call yourself):
task({
subagent: "researcher",
input: "Find the latest LLM benchmark results for 2025.",
})B4.run wires up the child route, runs it in the same process, and returns its final output as the tool result. See Subagents for the full convention and discovery rules.
Advanced: cross-service dispatchCopy link to section: Advanced: cross-service dispatch
To call an agent in a different service, such as a separately deployed B4.run project or any AP-compatible endpoint, use plain HTTP:
import type { RuntimeContext } from "@b4run/sdk"
import type { z } from "zod"
import type state from "./state.js"
type OrchestratorState = z.infer<typeof state> & { readonly job: string }
export async function workflow(state: OrchestratorState, _ctx: RuntimeContext) {
// The dev server binds an EPHEMERAL port unless `b4 dev --port <n>` is
// passed. Set B4_RUNTIME_URL explicitly rather than hard-coding a port.
const baseUrl = process.env.B4_RUNTIME_URL
if (!baseUrl) throw new Error("B4_RUNTIME_URL is not set")
// Create a thread first.
const threadRes = await fetch(`${baseUrl}/threads`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
})
if (!threadRes.ok) {
throw new Error(`thread creation failed: ${threadRes.status}`)
}
const { thread_id } = (await threadRes.json()) as { thread_id: string }
// Run the remote route and wait for the result.
const res = await fetch(`${baseUrl}/threads/${thread_id}/runs/wait`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
route: "/research#agent",
input: { messages: [{ role: "user", content: state.job }] },
}),
})
if (!res.ok) {
throw new Error(`dispatch failed: ${res.status}`)
}
// `null` when the remote route returned nothing, so narrow before reading a
// property. `?.` guards the property, not the object it hangs off.
const remoteState = (await res.json()) as {
readonly messages?: readonly { readonly content?: unknown }[]
} | null
const lastMessage = remoteState?.messages?.at(-1)
const summary = typeof lastMessage?.content === "string" ? lastMessage.content : ""
return { ...state, summary }
}Notes on cross-service dispatchCopy link to section: Notes on cross-service dispatch
- The dev port changes.
b4 devpicks a random port unless you pass--port <n>, so passB4_RUNTIME_URLthrough the environment, or fix the port withb4 dev --port 3001. - AP body shape. The request body is
{ route, input }, whererouteis theassistant_idstring (for example"/research#agent") andinputis the route's state payload. - Forward headers when needed. Middleware runs on every
runs/waitandruns/streamcall, so if the target route's middleware expects auth headers, pass them on the innerfetch. - Cancellation stays local. Cancelling or shutting down the parent leaves the remote Agent Protocol run going. Dropping the wait request only disconnects a viewer. To pass cancellation along, keep the remote thread id and call
POST /threads/:thread_id/cancelwhen the caller's ownctx.signalaborts. - Use
/threads/:id/runs/streamfor long-running children. It takes the same body and returns SSE. See Stream Output.