Skip to main content
Sub-agents let a parent agent delegate work to other agents running as durable Trigger.dev tasks, and 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

  1. The parent LLM calls a tool (for example research).
  2. The tool’s execute creates an AgentChat and sends a message to the sub-agent.
  3. chat.forwardSubAgent(stream, { toolCallId }) writes each sub-agent chunk to the parent chat as a transient data-subagent part.
  4. The frontend rebuilds the sub-agent’s message from those chunks with useSubAgentMessages and renders it inside the parent’s tool card.
  5. When the sub-agent finishes, forwardSubAgent resolves with its final UIMessage, which becomes the tool’s output.
  6. toModelOutput compresses 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
The hook rebuilds each message at most once per 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 same conversationId hits the same durable agent run.
The parent LLM naturally calls this tool multiple times:
  1. researchAgent({ conversationId: "competitors", message: "Research competitors in AI agents" }) — first call triggers a new sub-agent run
  2. researchAgent({ conversationId: "competitors", message: "Go deeper on pricing" }) — same run, sub-agent has full context
  3. researchAgent({ 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 a perKey, 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
Three sub-agent runs for this session execute at once; a fourth waits. Other sessions keep the declared cap of one, and any tenant-level limit the sub-agent task also holds keeps applying. The override expires after 7 days by default (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 the Map 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:
  1. Explicit close: Call chat.close() or agent.close() when done
  2. Idle timeout: The sub-agent’s idle timeout expires, it suspends
  3. Suspend timeout: The sub-agent’s suspend timeout expires, the run ends
For the multi-turn pattern, the parent should clean up sub-agents when it exits (in 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 in state: "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:
Transient parts reach 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:
This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary.
toModelOutput only runs when the SDK has your tools at conversion time. On a multi-turn parent, the SDK re-converts the persisted history at the start of each turn, so you must declare the sub-agent tool on the agent config (chat.agent({ tools })) for the compression to survive. Without it, the summary holds on turn 1 but turn 2 onward re-ingests the full sub-agent output. In a chat.customAgent loop you own the conversion, so pass the tools to convertToModelMessages(uiMessages, { tools }) yourself. See Tools: toModelOutput across turns.

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:
A ChatStream can be consumed once: by forwardSubAgent, stream.messages(), stream.text(), or by iterating its chunks. Pick one.

Combining with chat.agent()

Sub-agent tools work inside both chat.agent() (managed) and chat.customAgent() (manual lifecycle):
For 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 via triggerAndWait, 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:
Keep a part you update this way small. The frontend replaces the part on each write, but the chat stream stores every version, so writing a growing payload under one 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:
The 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.