docs/ai-chat/background-injection.mdx
chat.inject() queues model messages for injection into the conversation. Messages are picked up at the start of the next turn or at the next prepareStep boundary (between tool-call steps).
This is the backend counterpart to pending messages — pending messages come from the user via the frontend, while chat.inject() comes from your task code.
import { chat } from "@trigger.dev/sdk/ai";
// Queue a system message for injection
chat.inject([
{
role: "system",
content: "The user's account was just upgraded to Pro.",
},
]);
Messages are appended to the model messages before the next LLM inference call. The LLM sees them as part of the conversation context.
The most powerful pattern combines chat.defer() (background work) with chat.inject() (inject results). Background work runs in parallel with the idle wait between turns, and results are injected before the next response.
export const myChat = chat.agent({
id: "my-chat",
onTurnComplete: async ({ messages }) => {
// Kick off background analysis — doesn't block the turn
chat.defer(
(async () => {
const analysis = await analyzeConversation(messages);
chat.inject([
{
role: "system",
content: `[Analysis of conversation so far]\n\n${analysis}`,
},
]);
})()
);
},
run: async ({ messages, signal }) => {
return streamText({
...chat.toStreamTextOptions({ registry }),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
});
},
});
onTurnComplete fireschat.defer() registers the background workchat.inject() queues the messagesrun() executesIf the background work finishes during a tool-call loop (not between turns), the messages are picked up at the next prepareStep boundary instead.
A cheap model reviews the agent's response after each turn and injects coaching for the next one. Uses Prompts for the review prompt and generateObject for structured output.
import { chat } from "@trigger.dev/sdk/ai";
import { prompts } from "@trigger.dev/sdk";
import { streamText, generateObject, createProviderRegistry, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const registry = createProviderRegistry({ anthropic });
const selfReviewPrompt = prompts.define({
id: "self-review",
model: "anthropic:claude-haiku-4-5",
content: `You are a conversation quality reviewer. Analyze the assistant's most recent response.
Focus on:
- Whether the response answered the user's question
- Missed opportunities to use tools or provide more detail
- Tone mismatches
Be concise. Only flag issues worth fixing.`,
});
export const myChat = chat.agent({
id: "my-chat",
onTurnComplete: async ({ messages }) => {
chat.defer(
(async () => {
const resolved = await selfReviewPrompt.resolve({});
const review = await generateObject({
model: registry.languageModel(resolved.model ?? "anthropic:claude-haiku-4-5"),
...resolved.toAISDKTelemetry(),
system: resolved.text,
prompt: messages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => {
const text =
typeof m.content === "string"
? m.content
: Array.isArray(m.content)
? m.content
.filter((p: any) => p.type === "text")
.map((p: any) => p.text)
.join("")
: "";
return `${m.role}: ${text}`;
})
.join("\n\n"),
schema: z.object({
needsImprovement: z.boolean(),
suggestions: z.array(z.string()),
}),
});
if (review.object.needsImprovement) {
chat.inject([
{
role: "system",
content: `[Self-review]\n\n${review.object.suggestions.map((s) => `- ${s}`).join("\n")}\n\nApply these naturally.`,
},
]);
}
})()
);
},
run: async ({ messages, signal }) => {
return streamText({
...chat.toStreamTextOptions({ registry }),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
});
},
});
The self-review runs on claude-haiku-4-5 (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected — chat.inject() persists across the idle wait.
chat.defer standalonechat.defer() is also useful on its own, without chat.inject(). Any work whose timing has no resume implication — analytics, audit logs, search-index writes, cache warming — can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before onTurnComplete fires.
export const myChat = chat.agent({
id: "my-chat",
onTurnStart: async ({ chatId, runId }) => {
// Analytics — fire-and-forget, irrelevant to resume.
chat.defer(analytics.track("turn_started", { chatId, runId }));
},
run: async ({ messages, signal }) => {
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
},
});
chat.defer() can be called from anywhere during a turn — hooks, run(), or nested helpers. All deferred promises are collected and awaited together before onTurnComplete.
chat.inject() | Pending messages | |
|---|---|---|
| Source | Backend task code | Frontend user input |
| Triggered by | Your code (e.g. onTurnComplete + chat.defer()) | User sending a message during streaming |
| Injection point | Start of next turn, or next prepareStep boundary | Next prepareStep boundary only |
| Message role | Any (system, user, assistant) | Typically user |
| Frontend visibility | Not visible unless you write custom data-* chunks | Visible via usePendingMessages hook |
chat.inject(messages: ModelMessage[]): void
Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns — they are not reset when a new turn starts.
Parameters:
| Parameter | Type | Description |
|---|---|---|
messages | ModelMessage[] | Model messages to inject (from the ai package) |
Messages are drained (consumed) when:
run() executesprepareStep boundary is reached — between tool-call steps during streaming