November 3, 2026

A steered turn speaks twice

You watch the agent start migrating every fetcher when you only wanted the forfaits-* ones. Cancelling loses forty seconds of correct work. Waiting loses two minutes. So send a message into the turn.

Thin amber linework on charcoal: a running track of steps with a small message arrow joining it between two steps.

ts
const h = ai.chat.stream({ message: "migrate every fetcher" }, { onToken: write });
const landed = await h.steer("only the forfaits-* ones");
if (!landed) { /* the turn ended first — send it as a normal message */ }

Simple enough. Three things about it are not.

It lands at a step boundary, not immediately

The message is picked up at the agent's next step, after any tool in flight finishes.

That is deliberate and it is not a performance compromise. Cancelling a half-done tool call would leave a call that nothing answered — and that state fails every later turn on the session, permanently. A correction that breaks the conversation it was correcting is not a correction.

So a steer during a thirty-second sandbox command waits for the command. Which is usually fine, because the thing you are correcting is the plan, and the plan is re-read at every step.

true and false mean exactly what they say

If your message lands while the model is already writing its answer — which is most of the window on a fast tool — the turn takes one more step for it rather than finishing and dropping it.

So true means the agent will see it before this turn ends. false means the turn was already over and it is yours to send as a normal message. There is no third state where it was accepted and then quietly lost.

And done.steered says what actually landed

The boolean above is about the message being accepted. This is about the agent having read it:

ts
const done = await h.done;
done.steered;        // ["only the forfaits-* ones"] — empty on an ordinary turn

Read this before you tell a user their correction took effect.

It also explains something about content that surprises people, and which will break your tests: a steered turn speaks twice.

The agent answers the instruction it had. You correct it. It answers again. So content holds both, because it is the same text onToken gave you — the stream and the response cannot disagree about what was said.

If you want them separated, render chat.sessions.messages: the correction is a user row between the two answers, marked extra.steered, which is also how the transcript shows why the agent changed course. And if your tests assert "the reply is exactly X", remember that a corrected turn is not one reply.

What steering is not

It arrives as a normal user message. So it cannot outrank your project's system prompt, and it is not a privileged channel.

That is worth being explicit about because "send a message into a running turn" sounds like it might be one. It is not. Every constraint that applies to what a user can say applies here — guardrails, the system prompt's authority, the capability model. Steering changes when a message arrives, not what it is allowed to do.

Only streaming turns can be steered. A blocking /chat call has no run to send to.

And a run id is not a capability: steering is checked as a write against the same per-subject ACL that reading the transcript checks. So a conversation another user may read is still not one they may interrupt.

Queueing needs no capability

Worth separating, because people reach for steer when they want this. Holding a message until the turn ends is just your next send. If several arrive at once, pass them together and they land as separate user messages with one reply:

ts
await ai.chat.send({ session_id, message: "start it", messages: ["skip archived"] });

Two different products. steer is for "stop, you have misunderstood". Queueing is for "and also".

The two affordances the agent never sees

While we are on the back-and-forth around a turn: there are two things generated beside a conversation that the agent has no knowledge of at all. That is the design, not an implementation detail.

ts
const { suggestions } = await ai.chat.sessions.followups(sessionId, 3);
// ["do the box-* ones too", "show me a diff first", "what breaks if I skip archived?"]

const { recap } = await ai.chat.sessions.recap(sessionId);
// "You were migrating the forfaits-* fetchers and hadn't decided about archived ones."

Follow-ups are written in the user's voice, for rendering as buttons. A clicked one is sent as an ordinary message and the agent is not told it was suggested — because an agent that knew it had written the question would answer the one it meant rather than the one that was asked. Which sounds like a subtlety and is actually the difference between a suggestion feature that works and one where the answers are slightly, unaccountably off.

The recap is for someone returning after a while, and when to ask is your call, because only your client knows the tab has been idle. It is not context compaction, which summarizes for the model.

Both return empty on failure rather than erroring — a missing affordance is not worth breaking a chat over — and ask for them after the turn, never inside it. A turn that waited on a suggestion would make the user wait for something they never asked for.

The plan, if the work has one

The last piece of the same picture: for genuinely multi-step work the agent writes a checklist and updates it as it goes.

ts
await ai.chat.stream({ message: "migrate every fetcher" }, {
  onTodos: (items) => render(items),
});

Stored per session and injected into the agent's context each turn, so it survives a tool loop, a resumed stream, or a restart — and the agent never spends a call reading it back. Each item's number is stable for the life of the session and never reused, so ticking off item 3 still means item 3 after item 1 is deleted.

The agent is told to open a list only for genuinely multi-step work. A one-item checklist is noise, and an agent that opens one for every question trains people to stop reading it.

Read next