Skip to main content

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. Pending messages come from the user via the frontend, while chat.inject() comes from your task code.

Basic usage

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.

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.

Example: self-review

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.
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.
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.
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() 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.
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.
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:
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.
If your agent persists through a transcript storage, 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

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.
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()

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’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: 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
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.