Skip to content

Steering Modes

Choose how phase instructions and per-turn context reach the Live session. The modes change message timing and instruction updates; measure their effect with the model and conversation you deploy. Configuration alone does not establish lower latency or better adherence.

The system instruction is set once at connect time and never updated. Phase instructions and per-turn modifiers are delivered as model-role context turns via send_client_content.

Live::builder()
.instruction("You are a restaurant reservation assistant at Sapore d'Italia.")
.steering_mode(SteeringMode::ContextInjection)
.phase("greeting")
.instruction("Welcome the guest warmly and ask how you can help.")
.done()
.phase("booking")
.instruction("Help the guest find an available time slot.")
.done()
.initial_phase("greeting")

What happens on phase transition:

  1. The phase instruction (“Welcome the guest…”) is sent as a model-role content turn
  2. Per-turn modifiers (with_context, show_state, when) are also sent as model-role turns
  3. The system instruction (“You are a restaurant…”) is never touched

When to use: Most multi-phase voice apps. The base persona stays stable across phases, and phase-specific behavior is guided through conversational context. Lower latency, no instruction re-processing spikes.

The system instruction is replaced on every phase transition. Per-turn modifiers are baked into the instruction text.

Live::builder()
.instruction("You are a helpful assistant.")
.steering_mode(SteeringMode::InstructionUpdate) // this is the default
.phase("receptionist")
.instruction("You are a medical receptionist. Schedule appointments.")
.done()
.phase("triage_nurse")
.instruction("You are a triage nurse. Assess symptom severity.")
.done()
.initial_phase("receptionist")

What happens on phase transition:

  1. The entire system instruction is replaced with the new phase’s instruction
  2. Per-turn modifiers are appended to the instruction text
  3. The model re-processes its full context with the new instruction

When to use: When phases represent genuinely different personas or roles. The model needs a complete context reset to shift behavior convincingly.

On Google AI a system-role update closes the session, so the instruction goes out as a user-role turn that says it replaces the previous instructions. Gemini 3.1 and 3.8 Live follow it; Gemini 2.5 accepts it without following it, so on 2.5 prefer ContextInjection. Vertex AI takes the system role, as it documents. See Gemini 3.8 Live.

System instruction is replaced on phase transition (like InstructionUpdate), but per-turn modifiers are delivered as model-role context turns (like ContextInjection).

Live::builder()
.steering_mode(SteeringMode::Hybrid)
.phase("sales")
.instruction("You are a sales representative.")
.with_context(|s| format!("Customer budget: {}", s.get::<String>("budget").unwrap_or_default()))
.done()
.phase("support")
.instruction("You are a technical support engineer.")
.with_context(|s| format!("Ticket: {}", s.get::<String>("ticket_id").unwrap_or_default()))
.done()

When to use: When you need persona shifts on transition but also want lightweight per-turn context updates within each phase. Uncommon in practice – pick ContextInjection or InstructionUpdate unless you have a specific reason for both.

Under the other modes, a phase’s tools(..) list and a flow step’s allow list are enforced by refusing calls: the model is still offered every tool the session declares. ContextUpdate changes what the model is offered. At each phase transition or flow-step change, the runtime sends a contextUpdate message that replaces the declared tools with those the phase lists and an enforcing flow admits, and the system instruction with the connect-time instruction followed by the phase’s instruction.

Live::builder()
.model(ModelId::LIVE_3_8)
.instruction("You are a bank's phone agent.")
.steering_mode(SteeringMode::ContextUpdate)
.phase("verify")
.instruction("Verify the caller before anything else.")
.tools(vec!["verify_identity".into()])
.transition("serve", |s| s.get::<bool>("verified").unwrap_or(false))
.done()
.phase("serve")
.instruction("Answer the caller's account questions.")
.tools(vec!["get_balance".into(), "list_transactions".into()])
.done()
.initial_phase("verify")

What happens:

  1. The setup message already declares only the initial phase’s tools, with the base instruction followed by the initial phase’s instruction.
  2. On a phase transition, one contextUpdate carries the new instruction and the new tool list. It goes out before that turn’s context turns.
  3. When a flow step changes at a turn boundary, or on a tool result, the tools are re-declared. On a tool result the update goes out before the tool response, so the model reads the result with the next step’s tools.
  4. Per-turn modifiers are delivered as model-role context turns, as in Hybrid. No tool advisory is sent: the declared list is the advisory.

Calls to tools outside the list are still refused, so a model that calls a tool it remembers from earlier in the conversation gets the same error as before. An instruction_template still replaces the whole instruction. The names declared now are in session state under declared_tools.

Each contextUpdate invalidates the server’s prefix cache, so the runtime sends one only when the instruction or the tool list actually changes. Fewer declarations make each turn’s prompt smaller: shrinking twelve declarations to one cut the prompt from about 1,650 to about 700 tokens per turn on Gemini 3.8 Live (see Gemini 3.8 Live).

On a model without contextUpdate (Gemini 2.5 Live), the session logs a warning at connect and steers as Hybrid, declaring every tool. In a SessionSpec, set "runtime": {"steering": "context_update"}.

A session of skills (see skills and tasks) steers by its foreground task instead, and uses contextUpdate whenever the model accepts it, unless another steering mode is set.

An update changes the tools the model has from the next thing it reads. It does not reach back into a reply already being formed: measured on Gemini 3.8 Live with examples/context-update-spike (--only discovery, five runs each), a tool declared before the caller’s question was used in that turn’s reply 5 of 5 times; declared 0, 300 or 800 ms after the question, 0 of 5 each. Declared before the response to a call the model made for it, it was used 5 of 5 times, about 450 ms later than a tool declared ahead. So a transition decided at the turn boundary, after the caller’s words, comes too late to give the model the next step’s tools in that turn.

The flow therefore offers what the caller’s next words could open, and the gate decides each call when it is made:

Offered Admitted when
The active steps’ tools Their never(..).until(..) guards (a stage’s commit) hold
A commit tool before its guard holds The gate asks the guard’s questions about the turn in progress
The tools of a step one decision away: what stands between it and active is a decided atom in its gate, an incoming edge, or the done guard of the active step it follows The gate asks those questions about the turn in progress and relatches; the call goes through only if the step opened
The first steps’ tools of a digression whose trigger is a decision The gate asks the trigger’s questions and enters the digression first

“One decision away” is three-valued: every decided atom is unknown, since its question is asked again about each turn, and everything else is evaluated as it stands (Guard::possible, FlowMonitor::frontier_steps). A step behind an extracted flag (is_true) is not offered ahead: the flag is set at the turn boundary. A call no decision governs waits for none.

When to use: Gemini 3.8 Live sessions whose phases or flow steps use different tools, where the model should not see tools it may not call yet.

To compare it with Hybrid on your model, run cargo run -p example-context-update-spike --bin context-update-steering. It drives a two-phase agent through the same script under both modes and reports the tools called in each phase, any refused calls, and the prompt tokens per turn.

Question Yes No
Does the model’s core persona change between phases? InstructionUpdate ContextInjection
Is latency on phase transitions a concern? ContextInjection Either works
Do you need per-turn dynamic context (state summaries, conditional hints)? ContextInjection or Hybrid InstructionUpdate is fine
Are phases just different stages of the same conversation? ContextInjection –
Are phases genuinely different agents (receptionist vs doctor)? InstructionUpdate –
On Gemini 3.8 Live, do phases or flow steps use different tools? ContextUpdate Any of the others

Using InstructionUpdate for minor context changes

Section titled “Using InstructionUpdate for minor context changes”

Problem: Every phase has the same persona but slightly different focus areas. Using InstructionUpdate causes unnecessary instruction re-processing latency on each transition.

// Anti-pattern: same persona, different focus -- InstructionUpdate is overkill
Live::builder()
.steering_mode(SteeringMode::InstructionUpdate) // unnecessary latency
.phase("gather_name")
.instruction("You are a restaurant host. Ask for the guest's name.")
.done()
.phase("gather_party_size")
.instruction("You are a restaurant host. Ask for the party size.")
.done()

Fix: Use ContextInjection. The base persona is set once, and phase-specific focus is delivered as context turns.

// Better: stable persona, lightweight phase steering
Live::builder()
.instruction("You are a friendly host at Sapore d'Italia.")
.steering_mode(SteeringMode::ContextInjection)
.phase("gather_name")
.instruction("Ask for the guest's name for the reservation.")
.done()
.phase("gather_party_size")
.instruction("Ask how many guests will be dining.")
.done()

Using ContextInjection when personas differ radically

Section titled “Using ContextInjection when personas differ radically”

Problem: Phases represent genuinely different agent personas (e.g., switching from a receptionist to a clinical nurse). Context injection is too subtle – the model may not fully shift behavior.

// Anti-pattern: radically different personas via context injection
Live::builder()
.instruction("You work at a medical clinic.")
.steering_mode(SteeringMode::ContextInjection) // too subtle for persona shift
.phase("receptionist")
.instruction("You are the front desk receptionist. Be warm and administrative.")
.done()
.phase("triage")
.instruction("You are a clinical triage nurse. Be precise and medical.")
.done()

Fix: Use InstructionUpdate so the model gets a clean persona reset.

Problem: Using Hybrid when ContextInjection alone would suffice. Adds complexity without benefit.

// Anti-pattern: Hybrid when the persona doesn't actually change
Live::builder()
.steering_mode(SteeringMode::Hybrid) // unnecessary complexity
.phase("greeting").instruction("Welcome the user.").done()
.phase("main").instruction("Help with their request.").done()

Fix: Use ContextInjection. If the persona is stable, there’s no reason to replace the system instruction.

Putting volatile state in the base instruction

Section titled “Putting volatile state in the base instruction”

Problem: The base instruction (set at connect time) includes dynamic state that changes every turn. With ContextInjection, this instruction is never updated.

// Anti-pattern: dynamic content in the base instruction
Live::builder()
.instruction(format!("You are helping {}. Their order has {} items.",
customer_name, order_count)) // stale after the first turn
.steering_mode(SteeringMode::ContextInjection)

Fix: Keep the base instruction static. Use with_context() modifiers for dynamic state.

// Better: static base, dynamic context via modifiers
Live::builder()
.instruction("You are a helpful order assistant.")
.steering_mode(SteeringMode::ContextInjection)
.phase_defaults(|d| d.with_context(|s| {
format!("Customer: {}. Items in order: {}.",
s.get::<String>("customer_name").unwrap_or_default(),
s.get::<u32>("order_count").unwrap_or(0))
}))

The three-lane processor evaluates steering at two points in the turn lifecycle:

TurnComplete event
|
[Step 7] Phase machine evaluates transitions
| --> if transition fires, resolved_instruction is set
|
[Steps 7d/7e/7f/12/13] Context accumulation
| --> tool advisory, repair nudge, steering modifiers,
| phase instruction, on_enter_context all push into
| a single context_buffer (Vec<Content>)
|
[Step 14] Batched context send
| --> ONE send_client_content(context_buffer, false)
| --> eliminates burst of separate WebSocket frames
|
[Step 14b] prompt_on_enter (triggers model response)
| --> send_client_content([], true) — separate frame

Batched delivery: All model-role context turns are accumulated into a single Vec<Content> and sent as one atomic WebSocket frame. This eliminates the burst of 3-5 separate send_client_content calls that could confuse the model or clash with concurrent user input.

The key insight: with ContextInjection, step 12 sends the phase instruction as Content::model(instruction_text). The model sees it as its own prior speech, which naturally steers its behavior without the overhead of system instruction replacement.

By default, the batched context frame is sent immediately during TurnComplete processing (ContextDelivery::Immediate). For voice apps where isolated WebSocket frames during silence can cause glitches, use ContextDelivery::Deferred:

Live::builder()
.steering_mode(SteeringMode::ContextInjection)
.context_delivery(ContextDelivery::Deferred)
.phase("greeting")
.instruction("Welcome the guest")
.done()
.initial_phase("greeting")

How deferred delivery works:

  1. During TurnComplete, context turns are pushed into a PendingContext buffer (instead of sent)
  2. The DeferredWriter wraps the session writer at the LiveHandle level
  3. When user code calls handle.send_audio(), send_text(), or send_video(), the writer drains the buffer and sends the context immediately before the user content
  4. The context arrives in the same burst as user input — no isolated frames during silence

When context is sent immediately regardless:

If a prompt is needed (prompt_on_enter: true or a repair nudge on the first attempt), the context is sent immediately — you can’t defer a prompt because the model needs to respond now.

Deferred delivery: Immediate delivery:
TurnComplete TurnComplete
| |
[context → PendingContext] [context → wire now]
| |
... silence ... ... silence ...
| |
User speaks User speaks
| |
DeferredWriter.send_audio() SessionHandle.send_audio()
1. flush PendingContext 1. send audio
2. send audio
Feature InstructionUpdate ContextInjection Hybrid
with_context(fn) Appended to instruction text Sent as model-role turn Sent as model-role turn
show_state(&[keys]) Baked into instruction Sent as model-role turn Sent as model-role turn
when(pred, text) Baked into instruction Sent as model-role turn Sent as model-role turn
instruction_amendment Appended to instruction Appended to context turn Appended to instruction
instruction_template Replaces instruction Sent as context turn Replaces instruction
navigation() Baked into instruction Baked into instruction Baked into instruction
greeting() Works normally Works normally Works normally
prompt_on_enter Works normally Works normally Works normally
enter_prompt Works normally Works normally Works normally