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 Modes
Section titled “The Modes”ContextInjection (recommended)
Section titled “ContextInjection (recommended)”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:
- The phase instruction (“Welcome the guest…”) is sent as a model-role content turn
- Per-turn modifiers (
with_context,show_state,when) are also sent as model-role turns - 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.
InstructionUpdate (default)
Section titled “InstructionUpdate (default)”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:
- The entire system instruction is replaced with the new phase’s instruction
- Per-turn modifiers are appended to the instruction text
- 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.
Hybrid
Section titled “Hybrid”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.
ContextUpdate (Gemini 3.8 Live)
Section titled “ContextUpdate (Gemini 3.8 Live)”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:
- The setup message already declares only the initial phase’s tools, with the base instruction followed by the initial phase’s instruction.
- On a phase transition, one
contextUpdatecarries the new instruction and the new tool list. It goes out before that turn’s context turns. - 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.
- 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.
What a flow offers
Section titled “What a flow offers”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.
Decision Matrix
Section titled “Decision Matrix”| 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 |
Anti-Patterns
Section titled “Anti-Patterns”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 overkillLive::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 steeringLive::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 injectionLive::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.
Over-engineering with Hybrid
Section titled “Over-engineering with Hybrid”Problem: Using Hybrid when ContextInjection alone would suffice. Adds complexity without benefit.
// Anti-pattern: Hybrid when the persona doesn't actually changeLive::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 instructionLive::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 modifiersLive::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)) }))How It Works Under the Hood
Section titled “How It Works Under the Hood”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 frameBatched 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.
Context Delivery Timing
Section titled “Context Delivery Timing”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:
- During TurnComplete, context turns are pushed into a
PendingContextbuffer (instead of sent) - The
DeferredWriterwraps the session writer at theLiveHandlelevel - When user code calls
handle.send_audio(),send_text(), orsend_video(), the writer drains the buffer and sends the context immediately before the user content - 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 audioInteraction with Other Features
Section titled “Interaction with Other Features”| 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 |
See also
Section titled “See also”- Phase System — defining phases, modifiers, and lifecycle callbacks
- Phase Transitions Deep Dive — the full turn-complete pipeline where steering fires