Steering, Queuing, and Changing Direction
What happens when you type while Pi is working — steering enters after the current turn, follow-ups wait for the run to finish, and Escape returns the queue.
The agent is eleven tool calls into migrating your schema. You can see it is going to be wrong in about three more steps. Aborting would stop the current run, but it would not erase the conversation or undo tool calls that have already completed. You want to preserve the useful investigation while correcting what happens next.
You type. You press Enter. And the message waits — because Pi was not going to interrupt mid-tool-call, and the moment it delivers your instruction is a specific, documented point in the loop.
Two queues, not one
how-pi-works.md puts it in one sentence: “Steering messages enter after the current assistant turn. Follow-up messages enter after the agent has finished its pending work. Aborting stops the current run and returns queued messages to the editor.”
That is the distinction. A steering message asks the model to change direction at the next turn boundary; it does not cancel the tool calls already in flight or guarantee that the model will obey. A follow-up requests work after the current run’s pending work is finished. Both can be queued, but Pi delivers them at different boundaries.
In the terminal, usage.md maps them to keys:
| What you want | Action |
|---|---|
| Adjust the current task | Type a message and press Enter |
| Add work after the current task | Type a message and press Alt+Enter (Ctrl+Q by default on Windows/WSL) |
| Return queued messages to the editor | Press Alt+Up |
| Stop the current task | Press Escape |
“A message sent with Enter waits until the current response and its tool calls finish, then guides the next response. A follow-up sent with Alt+Enter waits until Pi finishes the current task.”
The key binding is named app.message.followUp and defaults to alt+enter, with ctrl+q on Windows and WSL — because “Windows Terminal binds Alt+Enter to fullscreen by default.” Run /hotkeys to see what your session actually has.
What a turn is
“Steering messages enter after the current assistant turn.” A turn is defined in json.md: “A turn is one assistant response plus any tool calls and tool results produced by that response.”
So the delivery point is precise: after the assistant response has finished executing all of its tool calls, and before the next LLM call. Not after one tool. After the batch the model asked for.
This is why steering feels late sometimes and immediate other times. If the model asked for three tool calls in one response, your steering message lands after all three. If it asked for one, it lands after that one.
The compaction reference confirms this sits inside the turn scheduler rather than beside it: Pi compacts during prepareNextTurn when the threshold is crossed, “then performs the existing catch-up steering poll before turn_start.” Steering is polled at a defined point in the turn pipeline.
Drawn out, that is one steering message in flight:
sequenceDiagram
participant U as You
participant P as Pi
participant M as Model
P->>M: request for turn 1
M-->>P: one response, three tool calls
P->>P: begin executing the tool-call batch
U->>P: steer, typed while the batch runs
Note over P: queued the current batch is not interrupted
P->>P: finish the tool-call batch turn_end
P->>M: next request, carrying the steering message
M-->>P: turn 2 responds to the correction
P-->>U: agent_end later agent_settled if no work remains
A follow-up sent at the same moment is also queued, but it does not skip or interrupt the ongoing tool calls. It waits until Pi has finished the current work, including any steering-driven continuation: it is “delivered only when agent has no more tool calls or steering messages.” Here, “later” is defined by outstanding work, not by a clock.
How many messages arrive at once
Two settings, both defaulting to one at a time:
| Setting | Type | Default | Description |
|---|---|---|---|
steeringMode |
"all" | "one-at-a-time" |
"one-at-a-time" |
How queued steering messages are delivered. |
followUpMode |
"all" | "one-at-a-time" |
"one-at-a-time" |
How queued follow-up messages are delivered. |
From rpc-commands.md:
"all": deliver all steering messages after the current assistant turn finishes executing its tool calls."one-at-a-time": deliver one steering message per completed assistant turn. This is the default.
The same two modes exist for follow-ups, where "all" delivers “all follow-up messages when agent finishes” and "one-at-a-time" delivers “one follow-up message per agent completion.”
One-at-a-time can be useful when later corrections depend on the outcome of earlier ones. With "all", Pi delivers the queued messages together at the next eligible boundary; they remain distinct messages rather than becoming one instruction, but the model receives them in the same request. With "one-at-a-time", Pi delivers one per eligible turn, allowing intervening results to affect what happens next. Neither mode guarantees that the model will acknowledge or follow each message.
From a program
Three commands, and one of them is not what it looks like.
{"type": "steer", "message": "Stop and do this instead"}
Steering is delivered “after the current assistant turn finishes executing its tool calls, before the next LLM call.” Skill commands and prompt templates are expanded. Extension commands are not allowed — the doc says use prompt instead.
The response tells you what happened:
{"type": "response", "command": "steer", "success": true, "data": {"disposition": "queued"}}
“data.disposition is "handled" if an input handler consumed this steer, or "queued" if Pi queued it (including after a handler transformed it). It does not guarantee this message remains queued.”
That last sentence matters for a UI. queued is not a promise that the message is still pending at the moment you receive the response.
Follow-ups are the same shape:
{"type": "follow_up", "message": "After you're done, also do this"}
“Delivered only when agent has no more tool calls or steering messages.” Note the ordering that implies: steering drains before follow-ups. If you queue both, the follow-up waits for the steering to land and be answered.
And prompt has a third mode:
{"type": "prompt", "message": "New instruction", "streamingBehavior": "steer"}
“If the agent is already streaming, you must specify streamingBehavior to queue the message. If the agent is streaming and no streamingBehavior is specified, the command returns an error.” "followUp" is the other accepted value.
Its disposition has three states rather than two: "handled", "queued", or "started" — "started" meaning Pi accepted it to begin a run.
Watching the queue
Every queue change is an event, and it carries the whole current queue:
{"type":"queue_update","steering":["Change direction"],"followUp":["Summarize when finished"]}
“Both fields contain the complete current queue.” Complete, not delta — so you do not need to track what you sent against what arrived. You can render this straight into a status line.
A related snapshot is available through get_state, but it is not the same data: the response below reports the pending-message count and queue modes, not the complete text of each queued message. Use queue_update to maintain a live queue display, or clear_queue when you need to retrieve and remove pending text. For example, get_state can return:
{
"type": "response",
"command": "get_state",
"success": true,
"data": {
"isStreaming": false,
"steeringMode": "all",
"followUpMode": "one-at-a-time",
"pendingMessageCount": 0
}
}
The SDK exposes the same split: “steer() and followUp() expose those behaviors directly and return "queued" if the input was queued (including after an extension transformed it), or "handled" if an extension consumed it.”
Aborting without losing your text
Escape stops the run. But what happens to two queued messages?
“Aborting stops the current run and returns queued messages to the editor.”
rpc-commands.md describes the client-side implementation for that behaviour, and it is not simply abort: “Remove queued steering and follow-up messages and return their text” — clear_queue. “To implement interactive Esc behavior, send clear_queue before abort, then restore the returned text in the client editor. abort continues queued messages when they remain in the session.”
sequenceDiagram
participant U as You
participant P as Pi
Note over P: turn running, two messages queued
U->>P: clear_queue
P-->>U: steering and followUp text
U->>P: abort
Note over P: run stops with nothing left pending
U->>P: text restored into the editor
The ordering is the whole point for an RPC client emulating interactive Escape. clear_queue first removes the pending text and gives it back to the client; abort then requests cancellation with no queued messages left to continue automatically. Sending abort alone does not provide the same queue-recovery guarantee. Neither operation undoes file writes, external API calls, or other side effects from tool calls that already completed.
Extension commands run immediately
One asymmetry catches people writing RPC clients. From rpc-commands.md: “If the message is an extension command (e.g., /mycommand), it executes immediately even during streaming. Extension commands manage their own LLM interaction via pi.sendMessage().”
So an extension command such as /guard status can execute while the agent is mid-run, but the same command text is not accepted as a steering command. This is a dispatch distinction, not a promise that extension commands are only UI actions: their handlers may also mutate state, call external services, or use pi.sendMessage() to schedule model work. Treat command side effects independently of the steering queue.
When the work is actually finished
This is the trap in every client you will write.
Wrong: “agent_end means the run is over.”
Correct: agent_end closes one low-level agent run, and “Automatic retry, overflow recovery, compaction retry, steering, or follow-up work can still continue.” extensions.md names turn_end and agent_before_settle as “actionable boundaries” whose handlers can chain entries and return continue: true for one more model request. agent_settled declares no result type — it is notification-only — and its documented meaning is “Pi will not continue automatically through retries, compaction recovery, or queued messages.”
rpc.md says the same to protocol authors: “Wait for agent_settled when the client needs to know Pi will not continue automatically.”
If you print a final answer on agent_end and then a steering message arrives, you have printed something incomplete. If you declare success on agent_end and a retry follows, you have declared success twice.
Choosing what to type
Two questions. Is what you want to say a correction to what is happening now, or an item for afterwards? A correction goes with Enter — it lands inside the run and steers the next response. An item goes with Alt+Enter and waits until the run has nothing left to do.
If both are true — stop the current activity now and schedule work for afterwards — use Escape to request cancellation, then provide the corrected instruction and subsequent task. Steering is not an immediate stop: it waits for the current assistant turn’s tool-call batch to finish. If waiting for that batch is acceptable, send a steering message and queue a follow-up; Pi delivers steering before follow-up work.
Typing while the agent works feels like an interruption and is not one. If you need the current run to stop now, that is Escape, not a message.
That is the run loop from the outside. What it carries is the next question, because a queue that delivers the right message to the wrong point is still a lost instruction — and the shapes it delivers are not the shapes you assume.
What was run
examples/ch25-queue/ queues messages into a real session while a deliberately slow tool is running, and inspects the transcript of the next request:
- steering lands after the turn’s tool calls and before the next request: the request contained
user:start, the assistant message, the tool result, thenuser:change direction; - a follow-up was not delivered while the agent still had work, and was delivered once it was done;
- steering drains before follow-ups, even when the follow-up was queued first;
queue_updatecarries the complete queue each time: the second update still listed the first message, and the last update was empty;one-at-a-time, the default, delivered one steering message in the next request, andalldelivered both;clearQueue()returned{ steering: ["S1"], followUp: ["F1"] }, the text an interface restores into its editor;- an extension command ran while the agent was streaming, and a steering message containing one was refused;
- a prompt while streaming, with no behaviour given, is an error and not a guess.
These tests support the reported delivery order, queue snapshots, command dispatch and clearQueue() results. They do not by themselves demonstrate the interactive Escape key path, cancellation of an in-flight tool, or restoration of queued text into a real terminal editor; those are separate UI and cancellation behaviours to verify when building a client.