> ## Documentation Index
> Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Background injection

> Inject context from background work into the agent's conversation: self-review, RAG augmentation, or any async analysis.

## Overview

`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](https://trigger.dev/docs/ai-chat/pending-messages.md). Pending messages come from the user via the frontend, while `chat.inject()` comes from your task code.

## Basic usage

```ts theme={"theme":"css-variables"}
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.

## Common pattern: defer + inject

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.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  registry,
  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, streamText }) => {
    return streamText({
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    });
  },
});
```

### Timing

1. Turn completes, `onTurnComplete` fires
2. `chat.defer()` registers the background work
3. The run immediately starts waiting for the next message (no blocking)
4. Background work completes, `chat.inject()` queues the messages
5. User sends next message, turn starts
6. Injected messages are appended before `run()` executes
7. The LLM sees the injected context alongside the new user message

If the background work finishes *during* a tool-call loop (not between turns), the messages are picked up at the next `prepareStep` boundary instead.

An injection made from a tool mid-turn is recorded in the conversation history at the step where it happened, after the tool result. The next turn's prompt prefix is unchanged.

## Example: self-review

A cheap model reviews the agent's response after each turn and injects coaching for the next one. Uses [Prompts](https://trigger.dev/docs/ai/prompts.md) for the review prompt and `generateObject` for structured output.

```ts theme={"theme":"css-variables"}
import { chat } from "@trigger.dev/sdk/ai";
import { prompts } from "@trigger.dev/sdk";
import { 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",
  registry,
  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, streamText }) => {
    return streamText({
      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, because `chat.inject()` persists across the idle wait.

## Other use cases

* **RAG augmentation**: After each turn, fetch relevant documents and inject them as context for the next response
* **Safety checks**: Run a moderation model on the response, inject warnings if issues are detected
* **Fact-checking**: Verify claims in the response using search tools, inject corrections
* **Context enrichment**: Look up user/account data based on what was discussed, inject it as system context

## `chat.defer` standalone

`chat.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.

```ts theme={"theme":"css-variables"}
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, streamText }) => {
    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`.

> [!WARNING]
>
> **Don't use `chat.defer()` for the message-history write in `onTurnStart`.** `chat.defer` only promises the work finished before `onTurnComplete`, which is long after the answer started streaming. A page refresh in between reads `[]` from your DB and loses the user's message from the rendered conversation. Use [`chat.deferBeforeOutput()`](#chat-deferbeforeoutput) for that write instead, and reserve `chat.defer` for writes whose timing has no resume implication.

## `chat.deferBeforeOutput`

`chat.deferBeforeOutput()` is `chat.defer()` for work the next page load has to see. The work starts immediately and the hook does not await it, so it runs alongside the model and costs no time to first token. The difference is that the output stream waits for it: no part of the answer is written to the session until it settles.

That ordering is the point. A reader that can see the answer can also see whatever the work persisted, so a refresh mid-answer never renders a reply to a question that is missing.

```ts theme={"theme":"css-variables"}
export const myChat = chat.agent({
  id: "my-chat",
  onTurnStart: async ({ chatId, uiMessages }) => {
    // The next page load has to see this, so the answer waits for it.
    chat.deferBeforeOutput(
      db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })
    );
  },
  run: async ({ messages, signal, streamText }) => {
    return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
  },
});
```

Use it for the conversation write, a message insert, or anything else the frontend reads back on reload. Keep analytics, audit logs and search-index updates on `chat.defer`.

> [!WARNING]
>
> **This is not a consistency barrier for the turn.** It orders the write against what the frontend can see, and nothing else. The work is still in flight while the model runs, so a tool, a `prepareStep`, or another service reading the same row during the turn can still see the state as it was before the write.
>
> If the turn's own code reads the write back, `await` it instead:
>
> ```ts theme={"theme":"css-variables"}
> onTurnStart: async ({ chatId, uiMessages }) => {
>   // A tool later in this turn reads this row, so the turn has to wait for it.
>   await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
> },
> ```
>
> That costs time to first token, which is the price of the stronger guarantee. `chat.deferBeforeOutput` is for writes whose only reader is the next page load.

A registered promise that rejects, or that runs longer than the internal timeout, lets the answer through rather than stalling the conversation. It is a best-effort ordering guarantee, not a lock.

| | `chat.defer()` | `chat.deferBeforeOutput()` |
| - | - | - |
| **Awaited by the hook** | No | No |
| **Costs time to first token** | No | No |
| **Answer waits for it** | No | Yes |
| **Settled by** | Before `onTurnComplete` | Before the first chunk reaches the session |
| **Read back by tools in the same turn** | No | No |
| **Use for** | Analytics, audit logs, index writes | Conversation and message writes the frontend reads back |

> [!NOTE]
>
> If your agent persists through a [transcript storage](https://trigger.dev/docs/ai-chat/transcript-storage.md), the runtime already does this for the incoming message on your behalf. `chat.deferBeforeOutput` is for writes your app owns on top of that.

## How it differs from pending messages

| | `chat.inject()` | [Pending messages](https://trigger.dev/docs/ai-chat/pending-messages.md) |
| - | - | - |
| **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` becomes an instruction, others join the conversation (see below) | Typically `user` |
| **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook |

## Two lanes: trusted and untrusted

The role you inject with decides more than position. It decides whether the model
treats the content as trustworthy.

**`role: "system"` goes to the instructions lane.** The block is appended to the
system instructions for subsequent inference calls, so it carries the same standing
as your system prompt. This is the lane for context the agent should believe:
entitlements, plan changes, operational notices.

It has to work this way. On AI SDK 7 a system message inside `messages` is rejected
for every provider. `standardizePrompt` throws before any provider is called, and
its own advice is to use the instructions option, so the injected block goes there
rather than into the transcript.

> [!WARNING]
>
> The instructions lane is delivered by `chat.toStreamTextOptions()`, because that
> is the only place the SDK can set `streamText`'s instructions for you. If your
> `run()` calls `streamText({ model, messages, abortSignal })` without spreading
> `chat.toStreamTextOptions()`, a `role: "system"` injection never reaches the
> model. The conversational lane has no such requirement: it arrives through
> `messages` either way.

* An injection applies to the next turn only. A block injected in `onTurnComplete`
  shapes the following turn and is cleared after it, so it is not repeated on every
  turn from then on. Within that turn it is consumed once rather than once per read,
  so a `run()` that builds options more than once sees the same instructions in
  every build.
* The injected text is merged into a single instruction rather than added as a
  second block, because AI SDK 5 rejects an array of system blocks while accepting
  one structured block. Merging changes the cached prefix, so a cached system prompt
  gets no cache hit for as long as an injection is live. If you rely on prompt
  caching, inject sparingly and prefer facts that go stale, so the injection clears.

**Any other role joins the conversation, and is untrusted by construction.** A
message injected as `user` is indistinguishable from something the user typed, and a
well-aligned model treats it accordingly, and may say so and re-derive the answer
from tools instead of taking it at face value:

> "that text arrived embedded in your message, not from a tool I called, so I
> verified it myself rather than trusting it"

That is correct behaviour, not a bug. So inject **checkable facts** in the
conversational lane and put **directives** in the instructions lane. A conclusion
injected as a user message is the worst of both: the model neither trusts it nor
ignores it, and may contradict it in front of the user.

## API reference

### chat.inject()

```ts theme={"theme":"css-variables"}
chat.inject(messages: ModelMessage[]): void
```

Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns, and are not reset when a new turn starts.

Lifetime: a conversational message (`role: "user"` or `"assistant"`) becomes part of the model's context from the next turn onward, for the rest of the conversation. It is written to the [transcript storage](https://trigger.dev/docs/ai-chat/transcript-storage.md)'s `state`, anchored to the message it followed, so it survives a continuation run and comes back in the same place. It does not appear in the UI transcript. A history edit that rebuilds the context drops it. This holds however the message reached the model: drained before `run()` or at a step boundary inside a multi-step turn. A message that is still queued when the run ends (injected from the last `onTurnComplete` before an exit, for example) is carried in the storage's `state` too and is queued again when the next run boots, so it reaches the next turn. A `role: "system"` message is appended to the instructions for the next turn only and is consumed once. Injecting the same notice every turn adds a copy every turn; dedupe on your side.

**Parameters:**

| Parameter | Type | Description |
| - | - | - |
| `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) |

Messages are drained (consumed) when:

1. A new turn starts, before `run()` executes
2. A `prepareStep` boundary is reached, between tool-call steps during streaming

> [!NOTE]
>
> `chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task: lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.