chat.forwardSubAgent streams each sub-agent’s reply to the frontend inside the parent’s tool call.
Every chunk the sub-agent streams is written to the parent chat once, so the cost of streaming a sub-agent grows with the length of its reply. When the sub-agent finishes, its final message becomes the tool’s output and persists with the parent’s history.
This builds on Trigger.dev’s AgentChat for server-side agent interaction.
How it works
- The parent LLM calls a tool (for example
research). - The tool’s
executecreates anAgentChatand sends a message to the sub-agent. chat.forwardSubAgent(stream, { toolCallId })writes each sub-agent chunk to the parent chat as a transientdata-subagentpart.- The frontend rebuilds the sub-agent’s message from those chunks with
useSubAgentMessagesand renders it inside the parent’s tool card. - When the sub-agent finishes,
forwardSubAgentresolves with its finalUIMessage, which becomes the tool’s output. toModelOutputcompresses that output into a summary for the parent LLM.
Single-turn sub-agent
The simplest pattern: one tool call, one sub-agent turn, conversation closes.forwardSubAgent needs the parent chat to write to, so call it from a tool that runs inside a chat.agent or chat.customAgent run. Combining with chat.agent() below shows the agent side.
Render sub-agents in the frontend
While the sub-agent runs, the parent’s tool part has no output yet.useSubAgentMessages collects the forwarded chunks and gives you the sub-agent’s message so far for each tool call:
app/components/chat.tsx
throttleMs (100ms by default), so a long reply costs a bounded number of rebuilds. After a reload mid-turn, the transport replays the current turn and the hook rebuilds the sub-agent’s message from it; batches it has already seen are skipped. Outside React, createSubAgentMessages() from @trigger.dev/sdk/chat does the same job.
Multi-turn sub-agent (LLM-driven)
The parent LLM drives a persistent conversation with a sub-agent across multiple tool calls. Each call with the sameconversationId hits the same durable agent run.
researchAgent({ conversationId: "competitors", message: "Research competitors in AI agents" })— first call triggers a new sub-agent runresearchAgent({ conversationId: "competitors", message: "Go deeper on pricing" })— same run, sub-agent has full contextresearchAgent({ conversationId: "new-topic", message: "..." })— different ID = different sub-agent
Limiting parallel sub-agents per session
Give every sub-agent task a shared named limit with aperKey, pass the parent session’s id as each sub-agent’s concurrencyKey, and raise that key’s cap when the user picks a parallelism:
trigger/limits.ts
trigger/research-agent.ts
ttl sets it, up to 30 days), so a session that outlives that should call override again. See Overriding a limit for one key.
Cross-turn persistence
Sub-agent conversations persist across parent turns because theMap lives in the parent’s process heap. When the parent suspends and restores via snapshot, the heap is preserved — the Map still has the conversations, the sessions still have the run IDs.
How sub-agents clean up
Sub-agents clean up through three mechanisms:- Explicit close: Call
chat.close()oragent.close()when done - Idle timeout: The sub-agent’s idle timeout expires, it suspends
- Suspend timeout: The sub-agent’s suspend timeout expires, the run ends
onComplete for managed agents, or at the end of the loop for custom agents). Without explicit cleanup, sub-agents close on their own via timeouts — no leaked resources or cost while suspended.
What the frontend sees
While the sub-agent runs, the parent’s tool part stays instate: "input-available", and the parent stream carries transient data-subagent parts. Each one holds a batch of the sub-agent’s chunks, tagged with the parent’s toolCallId and a sequence number:
useChat’s onData and are not added to message.parts, so they never persist. When the sub-agent finishes, the tool part moves to state: "output-available" with the full UIMessage as its output: text, the sub-agent’s own tool calls and results, reasoning, and so on. That output is what reloaded history renders.
If the user stops the parent turn while a sub-agent is running, the tool call ends without an output, the same as any stopped tool. The sub-agent’s work up to that point was only ever in the transient parts, so neither the parent’s history nor the next turn’s model context includes it.
Controlling what the parent LLM sees
toModelOutput transforms the tool’s output before it enters the parent LLM’s context. The full UIMessage streams to the frontend, but the model only sees the compressed version:
Snapshots with stream.messages()
ChatStream.messages() wraps the AI SDK’s readUIMessageStream and yields the whole accumulated UIMessage after almost every chunk. Yielding those snapshots from an async function* tool (yield* stream.messages()) turns each one into a preliminary tool result, which streams the sub-agent too, but every snapshot repeats the whole message so far. The bytes written grow with the square of the reply length:
Every one of those bytes goes to the chat’s stream storage and to each browser watching the chat. Use
forwardSubAgent for sub-agents, and keep stream.messages() for reading a short reply in server code:
Combining with chat.agent()
Sub-agent tools work inside bothchat.agent() (managed) and chat.customAgent() (manual lifecycle):
chat.customAgent(), define the tool and sub-agent Map inside the run closure so they survive across turns. Since you own the turn loop there, convert history with your tools in scope so toModelOutput is re-applied each turn: convertToModelMessages(uiMessages, { tools }). See Tools: manual turn loops.
Streaming progress from a subtask to the parent chat
When a tool invokes a subtask viatriggerAndWait, the subtask can stream custom data parts directly to the parent chat using chat.stream.writer({ target: "root" }). The frontend receives these as DataUIPart objects in message.parts on the parent’s message stream:
id (a full message history, say) stores every copy. Mark progress updates that don’t need to persist with transient: true, and use chat.forwardSubAgent when the thing you are streaming is a sub-agent’s reply.
On the frontend, render the custom data part:
target option accepts:
"self"— current run (default)"parent"— parent task’s run"root"— root task’s run (the chat agent)- A specific run ID string
Inside ai.toolExecute: accessing tool + chat context
When a subtask runs via execute: ai.toolExecute(task), it can read the parent’s tool call ID and chat context from inside the subtask body:
The subtask body also has read-only access to any
chat.local values initialized in the parent — auto-hydrated from the parent’s metadata on first access.
