--- title: "Pending Messages" sidebarTitle: "Pending Messages" description: "Inject user messages mid-execution to steer agents between tool-call steps." --- ## Overview When an AI agent is executing tool calls, users may want to send a message that **steers the agent mid-execution** — adding context, correcting course, or refining the request without waiting for the response to finish. By default (without `pendingMessages`), a message sent while the agent is responding never interrupts the in-flight response: it's buffered and processed as its own turn once the current turn completes, with multiple messages running sequentially in arrival order. The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. If there are no more step boundaries (single-step response or final text generation), the message becomes the next turn automatically. ## How it works 1. User sends a message while the agent is streaming 2. The message is sent to the backend via input stream (`transport.sendPendingMessage`) 3. The backend queues it in the steering queue 4. At the next `prepareStep` boundary (between tool-call steps), `shouldInject` is called 5. If it returns `true`, the message is injected into the LLM's context 6. A `data-pending-message-injected` stream chunk confirms injection to the frontend 7. If `prepareStep` never fires (no tool calls), the message becomes the next turn ## Backend: chat.agent Add `pendingMessages` to your `chat.agent` configuration: ```ts import { chat } from "@trigger.dev/sdk/ai"; import { streamText, stepCountIs } from "ai"; import { anthropic } from "@ai-sdk/anthropic"; export const myChat = chat.agent({ id: "my-chat", pendingMessages: { // Only inject when there are completed steps (tool calls happened) shouldInject: ({ steps }) => steps.length > 0, }, run: async ({ messages, signal }) => { return streamText({ ...chat.toStreamTextOptions({ registry }), messages, tools: { /* ... */ }, abortSignal: signal, stopWhen: stepCountIs(15), }); }, }); ``` The `prepareStep` for injection is automatically included when you spread `chat.toStreamTextOptions()`. If you provide your own `prepareStep` after the spread, it overrides the auto-injected one. ### Options | Option | Type | Description | |--------|------|-------------| | `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean` | Decide whether to inject the batch. Called once per step boundary. If absent, no injection happens. | | `prepare` | `(event: PendingMessagesBatchEvent) => ModelMessage[]` | Transform the batch before injection. Default: convert each message via `convertToModelMessages`. | | `onReceived` | `(event) => void` | Called when a message arrives during streaming (per-message). | | `onInjected` | `(event) => void` | Called after a batch is injected. | ### shouldInject Called once per step boundary with the full batch of pending messages. Return `true` to inject all of them, `false` to skip (they'll be available at the next boundary or become the next turn). ```ts pendingMessages: { // Always inject shouldInject: () => true, // Only inject after tool calls shouldInject: ({ steps }) => steps.length > 0, // Only inject if there's one message shouldInject: ({ messages }) => messages.length === 1, }, ``` The event includes: | Field | Type | Description | |-------|------|-------------| | `messages` | `UIMessage[]` | All pending messages (batch) | | `modelMessages` | `ModelMessage[]` | Current conversation | | `steps` | `CompactionStep[]` | Completed steps | | `stepNumber` | `number` | Current step (0-indexed) | | `chatId` | `string` | Chat session ID | | `turn` | `number` | Current turn | | `clientData` | `unknown` | Frontend metadata | ### prepare Transform the batch of pending messages before they're injected into the LLM's context. By default, each UIMessage is converted to ModelMessages individually. Use `prepare` to combine multiple messages or add context: ```ts pendingMessages: { shouldInject: ({ steps }) => steps.length > 0, prepare: ({ messages }) => [{ role: "user", content: messages.length === 1 ? messages[0].parts[0]?.text ?? "" : `The user sent ${messages.length} messages:\n${ messages.map((m, i) => `${i + 1}. ${m.parts[0]?.text}`).join("\n") }`, }], }, ``` ### Stream chunk When messages are injected, the SDK automatically writes a `data-pending-message-injected` stream chunk containing the message IDs and text. The frontend uses this to: - Confirm which messages were injected - Remove them from the pending overlay - Render them inline at the injection point in the assistant response A "pending message injected" span also appears in the run trace. ## Backend: chat.createSession Pass `pendingMessages` to the session options: ```ts const session = chat.createSession(payload, { signal, idleTimeoutInSeconds: 60, pendingMessages: { shouldInject: () => true, }, }); for await (const turn of session) { const result = streamText({ model: anthropic("claude-sonnet-4-5"), messages: turn.messages, abortSignal: turn.signal, prepareStep: turn.prepareStep(), // Handles injection + compaction stopWhen: stepCountIs(15), }); await turn.complete(result); } ``` Use `turn.prepareStep()` to get a prepareStep function that handles both injection and compaction. Users who spread `chat.toStreamTextOptions()` get it automatically. ## Backend: MessageAccumulator (raw task) Pass `pendingMessages` to the constructor and wire up the message listener manually: ```ts const conversation = new chat.MessageAccumulator({ pendingMessages: { shouldInject: () => true, prepare: ({ messages }) => [{ role: "user", content: `[Steering]: ${messages.map(m => m.parts[0]?.text).join(", ")}`, }], }, }); for (let turn = 0; turn < 100; turn++) { // The wire payload carries at most one new message per turn. const messages = await conversation.addIncoming( payload.message ? [payload.message] : [], payload.trigger, turn ); // Listen for steering messages during streaming const sub = chat.messages.on(async (msg) => { if (msg.message) await conversation.steerAsync(msg.message); }); const result = streamText({ model: anthropic("claude-sonnet-4-5"), messages, prepareStep: conversation.prepareStep(), // Handles injection + compaction stopWhen: stepCountIs(15), }); const response = await chat.pipeAndCapture(result); sub.off(); if (response) await conversation.addResponse(response); await chat.writeTurnComplete(); } ``` ### MessageAccumulator methods | Method | Description | |--------|-------------| | `steer(message, modelMessages?)` | Queue a UIMessage for injection (sync) | | `steerAsync(message)` | Queue a UIMessage, converting to model messages automatically | | `drainSteering()` | Get and clear unconsumed steering messages | | `prepareStep()` | Returns a prepareStep function handling injection + compaction | ## Frontend: usePendingMessages hook The `usePendingMessages` hook manages all the frontend complexity — tracking pending messages, detecting injections, and handling the turn lifecycle. ```tsx import { useChat } from "@ai-sdk/react"; import { useTriggerChatTransport, usePendingMessages } from "@trigger.dev/sdk/chat/react"; function Chat({ chatId }: { chatId: string }) { const transport = useTriggerChatTransport({ task: "my-chat", accessToken: ({ chatId }) => mintChatAccessToken(chatId), startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }), }); const { messages, setMessages, sendMessage, stop, status } = useChat({ id: chatId, transport, }); const pending = usePendingMessages({ transport, chatId, status, messages, setMessages, sendMessage, metadata: { model: "gpt-4o" }, }); return (