# Slice 3 — OpenAI Realtime Brain Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Swap slice-2's echo brain for a real OpenAI Realtime speech-to-speech brain, reached through slice-2's existing tap interface — proving agent integration end-to-end (browser speak → brain reply within ~700 ms).

**Architecture:** One new workspace member `rutster-brain-realtime` (library + binary, mirrors slice-2's `rutster-tap-echo` shape) holds the OpenAI Realtime translation layer. The brain process is a WS *server* (core-as-client dials it, unchanged from slice-2) that's simultaneously a WS *client* to `wss://api.openai.com/v1/realtime` (OpenAI is server on its leg). Three additive tap protocol event types (`speech_started`, `speech_stopped`, `function_call`, `function_call_output`, `tools.update`) extend slice-2's v1 protocol forwards-compatibly (old echo brains ignore them per slice-2 §3.4). An in-boundary tool registry (`crates/rutster/src/tool_registry.rs`, FOB) dispatches function-call events the brain proposes — `hangup` is the only wired tool; others reply `not_implemented`. `loop_driver.rs` and `rtc_session.rs` are byte-identical to slice-2's baseline (the seam test, §7.5 #6 of the spec).

**Tech Stack:** Rust 1.85 + edition 2024 · tokio (runtime + mpsc + oneshot, already pinned) · tokio-tungstenite 0.24 (WS client + server, already pinned, already has `connect` feature) · futures-util 0.3 · serde/serde_json · tracing · async-trait (new dep) · url 2. Brain process config: `OPENAI_API_KEY`/`OPENAI_API_KEY_FILE` env or `--features=mock` for an in-process mock OpenAI WS server (no real OpenAI calls).

## Global Constraints

- **License:** every crate manifest sets `license = "GPL-3.0-or-later"` (ADR-0004). The `[workspace.package]` already sets this; new crates inherit via `license.workspace = true`.
- **Workspace:** root `Cargo.toml` is `[workspace]` with `[workspace.dependencies]` (slice-1 §2.1). New deps go in `[workspace.dependencies]` in the root; member crates reference with `dep.workspace = true`. **One new dep this slice:** `async-trait = "0.1"`.
- **Workspace members (delta on slice-2):** slice-2's seven members (`crates/rutster`, `crates/rutster-media`, `crates/rutster-call-model`, `crates/rutster-trunk`, `crates/rutster-tap`, `crates/rutster-tap-echo`, `crates/rutster-spend`) plus ONE new member: `crates/rutster-brain-realtime`. Total = 8 members.
- **PCM format (slice-1 §3.1, §3.9, ARCHITECTURE.md):** 16-bit signed mono, 24 kHz, fixed 20 ms frame = **480 samples**. `PcmFrame { samples: [i16; 480] }` lives in `rutster-media` (single canonical home); `rutster-tap` re-exports it. **OpenAI Realtime uses the same format** (24 kHz mono PCM inside base64 LE i16) — pass-through, no resample.
- **Wire byte order (spec §3, §9):** PCM inside the base64 payload is **explicit little-endian** (`i16::to_le_bytes` encode / `i16::from_le_bytes` decode). OpenAI Realtime's API also uses LE — no endianness swap.
- **Tap protocol version:** `v: 1`. Slice-3's new event types are **additive** — slice-2's `#[serde(other)] FrameKind::Unknown` fallback means old echo brains ignore them (slice-2 §3.4 forward-compat).
- **S4 turn-ownership decision (load-bearing per ADR-0008, spec §4.3):** the brain process's `session.update` to OpenAI Realtime sets `turn_detection: null`. OpenAI's server-side VAD is **disabled**. The FOB reflex loop (step 4) owns turn-taking; tap playout stays core-authoritative (slice-2 §4.1).
- **API-key posture (spec §5.3):** `OPENAI_API_KEY` env default + `OPENAI_API_KEY_FILE` path override (mutually exclusive). KMS/Vault integration deferred to step 6. `cargo run -p rutster-brain-realtime` without either (and without `--features=mock`) fails fast at startup with a clear error.
- **Namespace / copy rules:** `ws://127.0.0.1:8082/realtime` for the brain process's WS server (slice-2's echo brain uses `:8081/echo`; the two coexist). `wss://api.openai.com/v1/realtime` for OpenAI's leg (in `openai_client.rs`).
- **Test discipline:** TDD — every code-bearing task writes a failing test first, watches it fail, implements minimal code to pass, verifies green, commits. Tests use the in-process `MockRealtimeBrain` (no real OpenAI credentials, no network calls to OpenAI). The Python reference brain (`examples/openai_realtime_brain/`) is **not** in CI (violates the zero-non-Rust-dev-deps dev loop).
- **Code style:** Verbatim from AGENTS.md — `snake_case` for fns/vars/modules/crates, `PascalCase` for types; newtype wrappers for type-safety where two ids could be confused; `cargo fmt` is the single source of truth for whitespace; `clippy -D warnings` is the lint bar; learner-facing doc comments on every public item + explanatory inline comments on the *mechanism* (the project-wide policy AGENTS.md §"Code style (Rust)" ratifies).
- **Tabular inclusive language,** per AGENTS.md: avoid "police/master/slave/blacklist." Protocol names from upstream specs (RFC, OpenAI's API) stay verbatim.

---

## File structure (landed shape — delta on slice-2)

```
rutster/
├── Cargo.toml                          # +async-trait dep; +rutster-brain-realtime in members
├── crates/
│   ├── rutster/                         # binary: +tool_registry module + side-channel drain
│   │   ├── src/main.rs                 # unchanged shape (calls AppState::new(default_tap_url))
│   │   ├── src/session_map.rs          # +function_call side-channel drain in drive_all_sessions
│   │   ├── src/routes.rs               # unchanged
│   │   ├── src/tap_engine.rs           # +tx_function_call Sender / rx_function_call_output Receiver
│   │   ├── src/tool_registry.rs        # NEW: Tool trait + hangup tool
│   │   └── static/index.html           # minor: surface brain connection state in <pre>
│   ├── rutster-media/                  # UNCHANGED — the seam test (spec §7.5 #6)
│   │   └── src/loop_driver.rs          # UNCHANGED
│   ├── rutster-call-model/             # UNCHANGED
│   ├── rutster-tap/                    # +additive protocol event types + TapClient pump arm
│   │   ├── src/protocol.rs            # +SpeechStarted / SpeechStopped / FunctionCall / FunctionCallOutput / ToolsUpdate
│   │   ├── src/tap_client.rs           # +new function_call & tools.update pump arms (forward to side-channel)
│   │   └── src/lib.rs                 # +re-exports for new event types
│   ├── rutster-tap-echo/               # UNCHANGED (still works against extended protocol)
│   ├── rutster-brain-realtime/         # NEW crate (library + binary)
│   │   ├── Cargo.toml                  # deps: rutster-tap, tokio, tokio-tungstenite, futures-util, serde_json, tracing, url, async-trait
│   │   ├── src/lib.rs                  # lib — re-exports + MockRealtimeBrain + mocks for tests
│   │   ├── src/main.rs                 # standalone binary: ws://127.0.0.1:8082/realtime server + wss:// OpenAI client
│   │   ├── src/translator.rs           # tap ⇄ OpenAI Realtime event translation (pure functions)
│   │   ├── src/openai_client.rs        # wss:// client to api.openai.com/v1/realtime
│   │   └── src/api_key.rs              # OPENAI_API_KEY / OPENAI_API_KEY_FILE loader
│   ├── rutster-trunk/                  # STUB (unchanged)
│   └── rutster-spend/                  # STUB (unchanged)
└── examples/
    ├── echo_brain/                     # unchanged (Python reference echo brain from slice-2)
    └── openai_realtime_brain/          # NEW: Python reference OpenAI Realtime brain (not in CI)
        ├── README.md                   # how to run
        ├── openai_realtime_brain.py    # ~120 lines (websockets + openai libs)
        └── requirements.txt            # websockets, openai
```

### Task-to-file mapping (quick reference)

| Task | Files |
|---|---|
| 1: workspace deps | `Cargo.toml`, `crates/rutster-brain-realtime/{Cargo.toml,src/lib.rs}` |
| 2: tap protocol extensions | `crates/rutster-tap/src/protocol.rs`, `crates/rutster-tap/src/lib.rs` |
| 3: API-key loader | `crates/rutster-brain-realtime/src/api_key.rs` |
| 4: translator (tap ⇄ OpenAI) | `crates/rutster-brain-realtime/src/translator.rs` |
| 5: OpenAI wss client | `crates/rutster-brain-realtime/src/openai_client.rs` |
| 6: Tool trait + registry | `crates/rutster/src/tool_registry.rs`, `crates/rutster/Cargo.toml` |
| 7: tap_client function_call arms + TapConn | `crates/rutster-tap/src/tap_client.rs`, `crates/rutster-tap/src/lib.rs`, `crates/rutster/src/tap_engine.rs` |
| 8: session_map tool-call side-channel drain | `crates/rutster/src/session_map.rs` |
| 9: brain binary (ws server + OpenAI client glue) | `crates/rutster-brain-realtime/src/main.rs` |
| 10: MockRealtimeBrain + integration test | `crates/rutster-brain-realtime/src/lib.rs`, `crates/rutster/tests/realtime_integration.rs` |
| 11: Python reference brain + LEARNING.md | `examples/openai_realtime_brain/`, `LEARNING.md`, README dev-loop |

---

## Task 1: Workspace deps + crate skeleton

**Files:**
- Create: `Cargo.toml` (modify — add `async-trait` to `[workspace.dependencies]` + `crates/rutster-brain-realtime` to `members`)
- Create: `crates/rutster-brain-realtime/Cargo.toml`
- Create: `crates/rutster-brain-realtime/src/lib.rs`
- Test: `cargo build -p rutster-brain-realtime`

**Interfaces:**
- Consumes: nothing (workspace manifest + empty crate).
- Produces: a new crate `rutster-brain-realtime` that compiles; later tasks add modules.

- [ ] **Step 1: Modify root `Cargo.toml` — add dep + member**

Open `Cargo.toml`. Add to `[workspace.dependencies]` (alongside `tokio-tungstenite`, `futures-util`, etc.):

```toml
# async-trait 0.1: async fns in trait objects (Tool trait, slice-3 spec §6.1).
async-trait = "0.1"
```

Add to `[workspace] members` (alongside `crates/rutster-tap-echo`):

```toml
    "crates/rutster-brain-realtime",
```

- [ ] **Step 2: Create the member crate's `Cargo.toml`**

Create `crates/rutster-brain-realtime/Cargo.toml`:

```toml
# crates/rutster-brain-realtime/Cargo.toml
[package]
name = "rutster-brain-realtime"
version = "0.1.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
description = "OpenAI Realtime speech-to-speech brain — translates slice-2's tap protocol to OpenAI Realtime's event schema (green-zone per ADR-0008; slice-3 spec §1.1, §4)."

[dependencies]
rutster-tap = { path = "../rutster-tap" }
tokio = { workspace = true, features = ["full"] }
tokio-tungstenite = { workspace = true, features = ["connect"] }
futures-util = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
url = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }

[features]
default = []
# Mock mode: in-process fake OpenAI Realtime WS server (no real API calls).
# Used by the integration test + the offline dev loop (spec §7.3).
mock = []
```

- [ ] **Step 3: Create the skeleton `src/lib.rs`**

Create `crates/rutster-brain-realtime/src/lib.rs`:

```rust
//! # rutster-brain-realtime
//!
//! **Slice-3 brain:** translates slice-2's tap protocol (the green-zone side of
//! the seam — core-as-client dials this brain's WS server; ADR-0008 classifies
//! the brain as green-zone) to OpenAI Realtime's event schema. The brain
//! process is a WS *server* (core-as-client dials it, unchanged from slice-2)
//! AND a WS *client* to `wss://api.openai.com/v1/realtime` (OpenAI is server
//! on *its* leg).
//!
//! See `docs/superpowers/specs/2026-06-30-slice-3-realtime-brain-design.md` for
//! the full design.
//!
//! ## Modules
//!
//! - [`api_key`] — load API key from env var or file (spec §5.3).
//! - [`translator`] — pure-function event translation (tap ⇄ OpenAI).
//! - [`openai_client`] — wss:// client to api.openai.com/v1/realtime.
//!
//! ## Dev mode (`--features=mock`)
//!
//! When built with `mock`, the binary uses in-process `MockRealtimeBrain`
//! (defined in `lib.rs`'s test-support module) instead of dialing OpenAI.
//! No API key required, no real OpenAI calls. Used by the integration test +
//! the offline dev loop (spec §7.3).

pub mod api_key;
pub mod openai_client;
pub mod translator;
```

- [ ] **Step 4: Add `mock` Cargo feature placeholder modules so the crate compiles**

The other modules don't exist yet (Tasks 3–5 add them). For now, create minimal stubs so `cargo build -p rutster-brain-realtime` passes. Create `crates/rutster-brain-realtime/src/api_key.rs`:

```rust
//! API-key loader (spec §5.3) — implements the env-var + file-path posture.
//! Filled in by Task 3.
```

Create `crates/rutster-brain-realtime/src/translator.rs`:

```rust
//! Tap ⇄ OpenAI Realtime event translation (spec §4). Filled in by Task 4.
```

Create `crates/rutster-brain-realtime/src/openai_client.rs`:

```rust
//! wss://api.openai.com/v1/realtime client (spec §4). Filled in by Task 5.
```

- [ ] **Step 5: Verify the crate compiles**

Run: `cargo build -p rutster-brain-realtime`
Expected: builds with no errors; warnings about empty modules are okay (clippy with `-D warnings` may flag empty modules — if it does, add a `#![allow(clippy::empty_modules)]` at the top of `lib.rs`).

- [ ] **Step 6: Verify the full workspace still builds + tests green**

Run: `cargo test --all`
Expected: all existing slice-1/slice-2 tests pass; the new crate has no tests yet (0 tests in its test binary).

- [ ] **Step 7: Commit**

```bash
git add Cargo.toml crates/rutster-brain-realtime/
git commit -m "feat(slice-3): +rutster-brain-realtime crate skeleton + async-trait dep (spec §1.1)

Workspace member 8 of 8. Library + binary; mirrored slice-2's
rutster-tap-echo shape. Three stub modules (api_key, translator,
openai_client) filled in by Tasks 3-5. No behavioral code yet —
this task only locks the crate boundary + the new async-trait dep
(the Tool trait in Task 6 needs it for async-fns-in-trait-objects)."
```

---

## Task 2: Tap protocol extensions (additive v1 events)

**Files:**
- Modify: `crates/rutster-tap/src/protocol.rs`
- Modify: `crates/rutster-tap/src/lib.rs`
- Test: `crates/rutster-tap/src/protocol.rs` (add `tests` submodule)

**Interfaces:**
- Consumes: slice-2's `Envelope` / `Payload` / `DecodedPayload` / `WireEnvelope` (private) / `FrameKind` / `TapProtoError` (verified — slice-2 §3 of the spec + the source).
- Produces: 5 new variants in `FrameKind` + `Payload` + `DecodedPayload`; 5 new payload structs; 5 new `encode_*` functions; the deserialize dispatch in `decode_envelope` extended for the new kinds.

Per slice-2 §3.4 of the spec, `FrameKind::Unknown` (with `#[serde(other)]`) absorbs unknown wire `type` values — so the additions are forwards-compatible (slide-2's echo brain ignores them).

- [ ] **Step 1: Write the failing tests for the new event round-trips**

Add a `#[cfg(test)] mod tests` block at the end of `crates/rutster-tap/src/protocol.rs` (or extend the existing one if present):

```rust
#[cfg(test)]
mod tests {
    use super::*;

    /// Slice-3 additive event types must round-trip through (de)serialization
    /// without breaking slice-2's v1 contract. Every new kind + payload.
    #[test]
    fn speech_started_round_trips() {
        let s = encode_speech_started(7, 100).unwrap();
        assert!(s.contains("\"type\":\"speech_started\""));
        assert!(s.contains("\"v\":1"));
        let d = decode_envelope(&s).unwrap();
        assert_eq!(d.seq, 7);
        assert_eq!(d.ts, 100);
        assert!(matches!(d.payload, DecodedPayload::SpeechStarted));
    }

    #[test]
    fn speech_stopped_round_trips() {
        let s = encode_speech_stopped(9, 200).unwrap();
        assert!(s.contains("\"type\":\"speech_stopped\""));
        let d = decode_envelope(&s).unwrap();
        assert_eq!(d.seq, 9);
        assert!(matches!(d.payload, DecodedPayload::SpeechStopped));
    }

    #[test]
    fn function_call_round_trips() {
        let s = encode_function_call("abc-123", "hangup", "{}", 0, 0).unwrap();
        assert!(s.contains("\"type\":\"function_call\""));
        assert!(s.contains("\"id\":\"abc-123\""));
        assert!(s.contains("\"name\":\"hangup\""));
        let d = decode_envelope(&s).unwrap();
        match d.payload {
            DecodedPayload::FunctionCall(p) => {
                assert_eq!(p.id, "abc-123");
                assert_eq!(p.name, "hangup");
            }
            other => panic!("expected FunctionCall, got {other:?}"),
        }
    }

    #[test]
    fn function_call_output_round_trips() {
        let s =
            encode_function_call_output("abc-123", "ok", r#"{"channel_state":"Closing"}"#, 0, 0)
                .unwrap();
        assert!(s.contains("\"type\":\"function_call_output\""));
        assert!(s.contains("\"status\":\"ok\""));
        let d = decode_envelope(&s).unwrap();
        match d.payload {
            DecodedPayload::FunctionCallOutput(p) => {
                assert_eq!(p.id, "abc-123");
                assert_eq!(p.status, "ok");
            }
            other => panic!("expected FunctionCallOutput, got {other:?}"),
        }
    }

    #[test]
    fn tools_update_round_trips() {
        let tools_json = r#"[{"name":"hangup","description":"hang up the call"}]"#;
        let s = encode_tools_update(tools_json, 0, 0).unwrap();
        assert!(s.contains("\"type\":\"tools.update\""));
        assert!(s.contains("\"tools\":["));
        let d = decode_envelope(&s).unwrap();
        match d.payload {
            DecodedPayload::ToolsUpdate(p) => {
                assert!(p.tools.is_array());
                assert_eq!(p.tools.as_array().unwrap().len(), 1);
            }
            other => panic!("expected ToolsUpdate, got {other:?}"),
        }
    }

    /// Forwards-compat: slice-2's echo brain sees the new types as unknown
    /// (the `#[serde(other)]` on the old enum absorbed them). With the new
    /// enum in place, the kinds decode to their new variants (rather than
    /// Unknown). This test asserts the decode no longer drops them.
    #[test]
    fn new_kinds_decode_to_their_variants_not_unknown() {
        for s in [
            encode_speech_started(0, 0).unwrap(),
            encode_speech_stopped(0, 0).unwrap(),
            encode_function_call("x", "x", "null", 0, 0).unwrap(),
            encode_function_call_output("x", "ok", "null", 0, 0).unwrap(),
            encode_tools_update("[]", 0, 0).unwrap(),
        ] {
            let d = decode_envelope(&s).unwrap();
            assert!(
                !matches!(d.payload, DecodedPayload::Unknown),
                "new event type decoded as Unknown: {s}"
            );
        }
    }
}
```

- [ ] **Step 2: Run the tests — verify they fail to compile (functions + variants don't exist yet)**

Run: `cargo test -p rutster-tap --lib protocol::tests`
Expected: `error[E0425]: cannot find function 'encode_speech_started'` and `error[E0277]: no variant 'SpeechStarted' in DecodedPayload` (and similar for the other four). These are the missing-impl compile errors — the tests can't run until Tasks 3-7 add the variants + the encode functions.

- [ ] **Step 3: Add the new `FrameKind` variants**

In `crates/rutster-tap/src/protocol.rs`, extend the `FrameKind` enum (the existing one has `Hello`, `AudioIn`, `AudioOut`, `SessionEnd`, `Bye`, `Error`, `Unknown` with `#[serde(other)]` on `Unknown`). Keep `#[serde(other)]` on `Unknown` — it absorbs genuinely-unknown types beyond the new set:

```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameKind {
    Hello,
    AudioIn,
    AudioOut,
    SessionEnd,
    Bye,
    Error,
    /// brain → core: user speech started (advisory; translated from OpenAI
    /// `input_audio_buffer.speech_started`; spec §3.2).
    SpeechStarted,
    /// brain → core: user speech stopped (advisory; OpenAI
    /// `input_audio_buffer.speech_stopped`; spec §3.2).
    SpeechStopped,
    /// brain → core: brain proposes a tool call (translated from OpenAI
    /// `response.function_call_arguments.done`; spec §3.2).
    FunctionCall,
    /// core → brain: tool registry reply (spec §3.3).
    FunctionCallOutput,
    /// brain → core: brain declares its tool catalog on hello + on changes
    /// (spec §3.2).
    #[serde(rename = "tools.update")]
    ToolsUpdate,
    /// Unknown wire `type` values land here (slice-2 §3.4: log + count + drop).
    #[serde(other)]
    Unknown,
}
```

- [ ] **Step 4: Add the new payload structs**

Add after the existing `ErrorPayload` struct (which is the last payload type in slice-2):

```rust
/// `function_call` payload (brain → core; spec §3.2). Carries the
/// brain-minted id + tool name + args. Args is a raw JSON Value (not a
/// typed struct) so any tool schema is allowed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallPayload {
    pub id: String,
    pub name: String,
    /// Raw JSON arguments — the tool registry dispatches by name and lets
    /// each Tool impl parse the args itself. (OpenAI sends `arguments` as a
    /// JSON string; the translator parses it back to a Value before
    /// emitting the function_call tap frame.)
    pub args: serde_json::Value,
}

/// `function_call_output` payload (core → brain; spec §3.3). The reply for
/// a `function_call`. `status` is one of `"ok"`, `"error"`, `"not_implemented"`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallOutputPayload {
    pub id: String,
    pub status: String,
    pub result: serde_json::Value,
}

/// `tools.update` payload (brain → core; spec §3.2). The brain declares its
/// tool catalog so the core's tool registry can validate function_call
/// events by name.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsUpdatePayload {
    /// An array of tool descriptors (each has `name`, `description`,
    /// `parameters`). The shape is intentionally permissive (a JSON array,
    /// not a typed Vec<ToolSchema>) so the brain can declare schemas the
    /// core doesn't know about — the core only checks the `name` field for
    /// dispatch, ignores the rest.
    pub tools: serde_json::Value,
}
```

- [ ] **Step 5: Add the new `Payload` variants**

Extend slice-2's `Payload` enum:

```rust
#[derive(Debug, Clone)]
pub enum Payload {
    Hello(HelloPayload),
    AudioIn(AudioPayload),
    AudioOut(AudioPayload),
    SessionEnd(SessionEndPayload),
    Bye(ReasonPayload),
    Error(ErrorPayload),
    /// Slice-3 additive (spec §3.2): brain → core, advisory; empty payload
    /// (the event name IS the message).
    SpeechStarted,
    SpeechStopped,
    /// Slice-3 additive (spec §3.2).
    FunctionCall(FunctionCallPayload),
    /// Slice-3 additive (spec §3.3): core → brain reply.
    FunctionCallOutput(FunctionCallOutputPayload),
    /// Slice-3 additive (spec §3.2): brain → core catalog declaration.
    ToolsUpdate(ToolsUpdatePayload),
}
```

- [ ] **Step 6: Add the new `DecodedPayload` variants**

Extend slice-2's `DecodedPayload` enum:

```rust
#[derive(Debug, Clone)]
pub enum DecodedPayload {
    Hello(HelloPayload),
    AudioIn(AudioPayload),
    AudioOut(AudioPayload),
    SessionEnd(SessionEndPayload),
    Bye(ReasonPayload),
    Error(ErrorPayload),
    /// Slice-3 (spec §3.2): the brain detected user speech started/stopped.
    SpeechStarted,
    SpeechStopped,
    /// Slice-3 (spec §3.2): brain wants the core to execute a tool.
    FunctionCall(FunctionCallPayload),
    /// Slice-3 (spec §3.3): the core's tool-registry reply.
    FunctionCallOutput(FunctionCallOutputPayload),
    /// Slice-3 (spec §3.2): brain declares its catalog so the core can
    /// validate function_call events.
    ToolsUpdate(ToolsUpdatePayload),
    /// Unknown `type` — log + count + drop (spec §3.4 of slice-2).
    Unknown,
}
```

- [ ] **Step 7: Extend `Envelope::Serialize` for the new payload types**

In `impl Serialize for Envelope`'s `match &self.payload` block, add arms for the new payloads (alongside the existing `Hello`/`AudioIn`/`AudioOut`/`SessionEnd`/`Bye`/`Error` arms). Also update `payload_field_count`:

```rust
        let payload_field_count = match &self.payload {
            Payload::Hello(_) => 4,
            Payload::AudioIn(_) | Payload::AudioOut(_) => 2,
            Payload::SessionEnd(_) | Payload::Bye(_) => 1,
            Payload::Error(_) => 2,
            // Slice-3 adds: empty payload (0 fields), 3-field payloads, 1-field payload.
            Payload::SpeechStarted | Payload::SpeechStopped => 0,
            Payload::FunctionCall(p) => 3, // id, name, args
            Payload::FunctionCallOutput(p) => 3, // id, status, result
            Payload::ToolsUpdate(p) => 1, // tools
        };
        let mut st = serializer.serialize_struct("Envelope", 4 + payload_field_count)?;
        st.serialize_field("v", &self.v)?;
        st.serialize_field("type", &self.kind)?;
        st.serialize_field("seq", &self.seq)?;
        st.serialize_field("ts", &self.ts)?;
        match &self.payload {
            Payload::Hello(p) => { /* unchanged */ }
            Payload::AudioIn(p) | Payload::AudioOut(p) => { /* unchanged */ }
            Payload::SessionEnd(p) => { /* unchanged */ }
            Payload::Bye(p) => { /* unchanged */ }
            Payload::Error(p) => { /* unchanged */ }
            Payload::SpeechStarted | Payload::SpeechStopped => {
                // No payload fields — the envelope's `v`/`type`/`seq`/`ts`
                // is the whole message. The event name IS the message.
            }
            Payload::FunctionCall(p) => {
                st.serialize_field("id", &p.id)?;
                st.serialize_field("name", &p.name)?;
                st.serialize_field("args", &p.args)?;
            }
            Payload::FunctionCallOutput(p) => {
                st.serialize_field("id", &p.id)?;
                st.serialize_field("status", &p.status)?;
                st.serialize_field("result", &p.result)?;
            }
            Payload::ToolsUpdate(p) => {
                st.serialize_field("tools", &p.tools)?;
            }
        }
        st.end()
```

(Replace the existing `match` arms that the new arms replace — keep the existing Hello/AudioIn/etc arms verbatim; just add the five new arms.)

- [ ] **Step 8: Add the five `encode_*` functions**

After the existing `encode_error` function, add:

```rust
/// Build `speech_started` (brain → core, advisory; spec §3.2).
pub fn encode_speech_started(seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::SpeechStarted,
        seq,
        ts,
        payload: Payload::SpeechStarted,
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `speech_stopped` (brain → core, advisory; spec §3.2).
pub fn encode_speech_stopped(seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::SpeechStopped,
        seq,
        ts,
        payload: Payload::SpeechStopped,
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `function_call` (brain → core; spec §3.2). `args_json_str` is the
/// raw JSON string the brain's translator parses from OpenAI's
/// `response.function_call_arguments.done.arguments` (which is itself a
/// JSON string in OpenAI's wire format).
pub fn encode_function_call(
    id: &str,
    name: &str,
    args_json_str: &str,
    seq: u64,
    ts: u64,
) -> Result<String, TapProtoError> {
    let args: serde_json::Value = if args_json_str.is_empty() {
        serde_json::Value::Null
    } else {
        serde_json::from_str(args_json_str)?
    };
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::FunctionCall,
        seq,
        ts,
        payload: Payload::FunctionCall(FunctionCallPayload {
            id: id.to_string(),
            name: name.to_string(),
            args,
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `function_call_output` (core → brain; spec §3.3). `result_json_str`
/// is the raw JSON string the tool-registry dispatch returns (serialized
/// into the result field of the payload).
pub fn encode_function_call_output(
    id: &str,
    status: &str, // "ok" | "error" | "not_implemented"
    result_json_str: &str,
    seq: u64,
    ts: u64,
) -> Result<String, TapProtoError> {
    let result: serde_json::Value = if result_json_str.is_empty() {
        serde_json::Value::Null
    } else {
        serde_json::from_str(result_json_str)?
    };
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::FunctionCallOutput,
        seq,
        ts,
        payload: Payload::FunctionCallOutput(FunctionCallOutputPayload {
            id: id.to_string(),
            status: status.to_string(),
            result,
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `tools.update` (brain → core; spec §3.2). `tools_json_str` is the
/// raw JSON array of tool descriptors.
pub fn encode_tools_update(
    tools_json_str: &str,
    seq: u64,
    ts: u64,
) -> Result<String, TapProtoError> {
    let tools: serde_json::Value = if tools_json_str.is_empty() {
        serde_json::Value::Array(vec![])
    } else {
        serde_json::from_str(tools_json_str)?
    };
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::ToolsUpdate,
        seq,
        ts,
        payload: Payload::ToolsUpdate(ToolsUpdatePayload { tools }),
    };
    Ok(serde_json::to_string(&env)?)
}
```

- [ ] **Step 9: Extend `decode_envelope`'s dispatch for the new kinds**

In `decode_envelope`'s `match w.kind` block, add arms alongside the existing `Hello`/`AudioIn`/etc:

```rust
        FrameKind::SpeechStarted => DecodedPayload::SpeechStarted,
        FrameKind::SpeechStopped => DecodedPayload::SpeechStopped,
        FrameKind::FunctionCall => {
            let p: FunctionCallPayload = serde_json::from_value(extra_value)?;
            DecodedPayload::FunctionCall(p)
        }
        FrameKind::FunctionCallOutput => {
            let p: FunctionCallOutputPayload = serde_json::from_value(extra_value)?;
            DecodedPayload::FunctionCallOutput(p)
        }
        FrameKind::ToolsUpdate => {
            let p: ToolsUpdatePayload = serde_json::from_value(extra_value)?;
            DecodedPayload::ToolsUpdate(p)
        }
```

(The `FrameKind::Unknown` arm — `DecodedPayload::Unknown` — stays the final catchall.)

- [ ] **Step 10: Re-export the new types from `rutster-tap/src/lib.rs`**

Find the `pub use protocol::{...}` block in `lib.rs`. Add the new exports:

```rust
pub use protocol::{
    decode_envelope, decode_pcm, encode_audio_in, encode_audio_out, encode_bye, encode_error,
    encode_function_call, encode_function_call_output, encode_hello, encode_pcm,
    encode_session_end, encode_speech_started, encode_speech_stopped, encode_tools_update,
    AudioPayload, DecodedFrame, DecodedPayload, Envelope, ErrorPayload, FrameKind, HelloPayload,
    Payload, ReasonPayload, SessionEndPayload, TapProtoError, PROTOCOL_VERSION,
    SAMPLES_PER_FRAME as WIRE_SAMPLES_PER_FRAME,
};
// Slice-3 additive (spec §3).
pub use protocol::{
    FunctionCallPayload, FunctionCallOutputPayload, ToolsUpdatePayload,
};
```

- [ ] **Step 11: Verify the new tests pass + slice-2's tests stay green**

Run: `cargo test -p rutster-tap --lib`
Expected: all slice-2 tests + the 6 new tests pass (0 fail).

Run: `cargo test --all`
Expected: all workspace tests pass (no regression in slice-1/slice-2).

- [ ] **Step 12: Verify clippy + fmt clean**

```bash
cargo fmt --check
cargo clippy -p rutster-tap -- -D warnings
```

Expected: both clean.

- [ ] **Step 13: Commit**

```bash
git add crates/rutster-tap/src/protocol.rs crates/rutster-tap/src/lib.rs
git commit -m "feat(tap): additive v1 protocol extensions (spec §3) — speech_started/stopped, function_call, function_call_output, tools.update

Five new event types in slice-2's v1 protocol. Forwards-compatible
per slice-2 §3.4: FrameKind's #[serde(other)] Unknown absorbs the
new types in old brains (they log + count + drop). No wire-format
break, no version bump.

New kid on the dispatch: tools.update — brain declares its catalog
on hello so the core's tool registry can validate function_call
events by name (Task 6). The 'function_call'/'function_call_output'
pair is the FOB-boundary dispatch contract the brain's translator
(Task 4) wires to OpenAI Realtime's
response.function_call_arguments.done / conversation.item.create.

TDD: 6 tests fail-red on missing impl, pass-green after this task.
Slice-2's existing protocol tests stay green — the additions are
purely additive."
```

---

## Task 3: API-key loader

**Files:**
- Modify: `crates/rutster-brain-realtime/src/api_key.rs`
- Test: `crates/rutster-brain-realtime/src/api_key.rs` (add `tests` submodule)

**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `pub fn load_api_key() -> Result<String, ApiKeyError>` + `pub enum ApiKeyError` — used by Task 9 (the binary).

- [ ] **Step 1: Write the failing tests**

Replace the stub `crates/rutster-brain-realtime/src/api_key.rs` with:

```rust
//! # API-key loader (spec §5.3)
//!
//! Two-source config: `OPENAI_API_KEY` env var default + optional
//! `OPENAI_API_KEY_FILE` path override. KMS/Vault integration is deferred
//! to step 6 (spec §1.2); the file-path override makes secret-manager
//! injection (k8s secrets, Vault agent) trivial when that layer exists.
//!
//! # Why file-path override (not env-only)
//!
//! A k8s pod mounts secrets at file paths (e.g. `/var/secrets/openai_key`).
//! Env-var secrets are also fine, but file-path is the standard pattern for
//! k8s + Vault agent + sidecar secret rotators. The dev loop uses the env
//! var path (single-source, simplest).

use std::env;
use std::fs;
use std::path::PathBuf;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ApiKeyError {
    #[error("no API key: set OPENAI_API_KEY or OPENAI_API_KEY_FILE")]
    NotFound,
    #[error("OPENAI_API_KEY_FILE path is not valid UTF-8: {0:?}")]
    PathNotUtf8(PathBuf),
    #[error("failed to read OPENAI_API_KEY_FILE at {path}: {source}")]
    FileRead {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
}

/// Load the OpenAI API key per the env-var + file-path posture (spec §5.3).
///
/// Precedence: `OPENAI_API_KEY_FILE` wins over `OPENAI_API_KEY` (a file
/// path is a more specific override; this matches k8s-secret patterns where
/// an operator mounts a file to override the default env var).
///
/// Trims trailing whitespace from either source (some k8s secret mounts
/// add trailing newlines).
pub fn load_api_key() -> Result<String, ApiKeyError> {
    if let Ok(path_str) = env::var("OPENAI_API_KEY_FILE") {
        let path = PathBuf::from(&path_str);
        let raw = fs::read_to_string(&path).map_err(|source| ApiKeyError::FileRead {
            path: path.clone(),
            source,
        })?;
        return Ok(raw.trim().to_string());
    }
    if let Ok(raw) = env::var("OPENAI_API_KEY") {
        return Ok(raw.trim().to_string());
    }
    Err(ApiKeyError::NotFound)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn returns_error_when_neither_env_nor_file_set() {
        // Save then clear both env vars to make the test order-independent.
        let saved_key = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
        let saved_file =
            env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v));
        env::remove_var("OPENAI_API_KEY");
        env::remove_var("OPENAI_API_KEY_FILE");

        let r = load_api_key();
        assert!(matches!(r, Err(ApiKeyError::NotFound)));

        // Restore for any other test in the same process.
        if let Some((k, v)) = saved_key {
            env::set_var(k, v);
        }
        if let Some((k, v)) = saved_file {
            env::set_var(k, v);
        }
    }

    #[test]
    fn reads_from_env_var() {
        let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
        let saved_file =
            env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v));
        env::set_var("OPENAI_API_KEY", "sk-test-12345");
        env::remove_var("OPENAI_API_KEY_FILE");

        let r = load_api_key().unwrap();
        assert_eq!(r, "sk-test-12345");

        if let Some((k, v)) = saved {
            env::set_var(k, v);
        } else {
            env::remove_var("OPENAI_API_KEY");
        }
        if let Some((k, v)) = saved_file {
            env::set_var(k, v);
        }
    }

    #[test]
    fn trim_trailing_newline_from_env() {
        let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v));
        env::set_var("OPENAI_API_KEY", "sk-test-with-newline\n");
        env::remove_var("OPENAI_API_KEY_FILE");

        let r = load_api_key().unwrap();
        assert_eq!(r, "sk-test-with-newline");

        if let Some((k, v)) = saved {
            env::set_var(k, v);
        } else {
            env::remove_var("OPENAI_API_KEY");
        }
    }

    #[test]
    fn file_path_overrides_env() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("key.txt");
        std::fs::write(&path, "sk-from-file-98765\n").unwrap();

        env::set_var("OPENAI_API_KEY", "sk-from-env-should-be-overridden");
        env::set_var("OPENAI_API_KEY_FILE", path.to_str().unwrap());

        let r = load_api_key().unwrap();
        assert_eq!(r, "sk-from-file-98765");

        env::remove_var("OPENAI_API_KEY");
        env::remove_var("OPENAI_API_KEY_FILE");
    }

    #[test]
    fn file_path_missing_returns_file_read_error() {
        env::set_var("OPENAI_API_KEY_FILE", "/nonexistent/path/to/key.txt");
        env::remove_var("OPENAI_API_KEY");

        let r = load_api_key();
        assert!(matches!(r, Err(ApiKeyError::FileRead { .. })));

        env::remove_var("OPENAI_API_KEY_FILE");
    }
}
```

- [ ] **Step 2: Add `tempfile` + `thiserror` deps to the crate's Cargo.toml**

`tempfile` is needed only for tests; `thiserror` for the error enum. In `crates/rutster-brain-realtime/Cargo.toml`:

```toml
[dependencies]
# ... existing deps ...
thiserror = { workspace = true }

[dev-dependencies]
tempfile = "3"
```

Add `thiserror` to the root `Cargo.toml`'s `[workspace.dependencies]` if not already present:

```toml
thiserror = "2"
```

(Verify version by grepping the existing Cargo.lock — slice-2's `rutster-media` uses it. Match the version.)

```bash
grep '^name = "thiserror"' -A2 Cargo.lock | head -3
```

- [ ] **Step 3: Run the tests — verify they fail to compile (api_key.rs is just a stub comment)**

Run: `cargo test -p rutster-brain-realtime --lib api_key`
Expected: compile error if you missed replacing the stub — but once you've replaced it (Step 1 replaces the stub), tests should compile and pass. The "watch it fail" TDD step here is conceptual (the stub IS the failure state — no impl). Verify the tests genuinely exercise the impl by deliberately introducing a bug:

- Temporarily change `Ok(raw.trim().to_string())` to `Ok(raw.trim().to_uppercase())`.
- Run the tests — at least one should fail (the test asserting `r == "sk-test-12345"` will fail because of the `.to_uppercase()`).
- Revert the deliberate bug.
- Run again — tests pass.

- [ ] **Step 4: Verify tests pass + clippy + fmt clean**

```bash
cargo test -p rutster-brain-realtime --lib api_key
cargo clippy -p rutster-brain-realtime -- -D warnings
cargo fmt --check
```

Expected: 5 tests pass; clippy clean; fmt clean.

- [ ] **Step 5: Commit**

```bash
git add Cargo.toml Cargo.lock crates/rutster-brain-realtime/Cargo.toml crates/rutster-brain-realtime/src/api_key.rs
git commit -m "feat(brain-realtime): API-key loader + env-var/file-path posture (spec §5.3)

OPENAI_API_KEY default + OPENAI_API_KEY_FILE override (file wins —
k8s-secret pattern). Trims trailing whitespace (some k8s mounts add
newlines). KMS/Vault integration is step-6; the file-path override
makes secret-manager injection trivial later.

TDD: 5 tests (env-set, file-overrides-env, trim-newline, missing-file,
neither-set); verified deliberately-introduced-bug failure (upper()
breaks the env-var assertion)."
```

---

## Task 4: Translator (tap ⇄ OpenAI Realtime event translation)

**Files:**
- Modify: `crates/rutster-brain-realtime/src/translator.rs`
- Test: `crates/rutster-brain-realtime/src/translator.rs` (add `tests` submodule)

**Interfaces:**
- Consumes: Task 2's protocol types (`encode_audio_in`, `decode_envelope`, `DecodedPayload`, `Envelope`, etc., via `rutster_tap`).
- Produces: pure functions that translate between slice-3's tap protocol events and OpenAI Realtime's event JSON shapes:
  - `pub fn build_openai_session_update(voice: &str) -> serde_json::Value` — the `session.update` with `turn_detection: null` (S4 decision, spec §4.3).
  - `pub fn tap_audio_in_to_openai_append(pcm_b64: &str) -> serde_json::Value`
  - `pub fn openai_audio_delta_to_tap_audio_out(json: &serde_json::Value) -> Result<String, TranslateError>`
  - `pub fn openai_speech_event_to_tap(json: &serde_json::Value, started: bool) -> Result<String, TranslateError>`
  - `pub fn openai_function_call_arguments_done_to_tap(json: &serde_json::Value) -> Result<(String, String, String), TranslateError>` — returns `(call_id, name, args_json_str)`.
  - `pub fn tap_function_call_output_to_openai_create_item(id: &str, status: &str, result: &serde_json::Value) -> serde_json::Value`

---

This task is the bulk of the translation layer. Read spec §4.2's mapping table (event-by-event) as you implement.

- [ ] **Step 1: Write the failing tests for every translator function**

Replace `crates/rutster-brain-realtime/src/translator.rs` with:

```rust
//! # Tap ⇄ OpenAI Realtime event translation (spec §4)
//!
//! Pure functions — no async, no I/O, no call state. Each function maps
//! one event between slice-3's tap protocol (slice-2 v1 + the additive
//! slice-3 events from Task 2) and OpenAI Realtime's event JSON.
//!
//! The translation layer is **stateless** by design: the OpenAI-side WS
//! client (Task 5) and the tap-side WS server (Task 9) call these
//! functions per-event. No ownership of OpenAI's `session_id` beyond
//! the connection's lifetime.

use rutster_tap::{
    decode_envelope, encode_audio_in, encode_audio_out, encode_speech_started,
    encode_speech_stopped, DecodedPayload, TapProtoError,
};
use serde_json::{json, Value};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum TranslateError {
    #[error("OpenAI event missing required field: {field}")]
    MissingField { field: &'static str },
    #[error("tap protocol error: {0}")]
    TapProto(#[from] TapProtoError),
}

/// Build OpenAI's `session.update` event (spec §4.2). The S4 turn-ownership
/// decision (spec §4.3): `turn_detection: null`. OpenAI's server-side VAD
/// is disabled; the FOB reflex loop (step 4) owns turn-taking.
pub fn build_openai_session_update(voice: &str) -> Value {
    json!({
        "type": "session.update",
        "session": {
            "modalities": ["text", "audio"],
            "voice": voice,
            "input_audio_format": "pcm16",
            "output_audio_format": "pcm16",
            "sample_rate": 24000,
            "turn_detection": null
        }
    })
}

/// Wrap a base64 PCM payload (already encoded per slice-2 §3 — explicit LE
/// i16) into OpenAI's `input_audio_buffer.append` event (spec §4.2). Pass-
/// through — the wire shape for OpenAI's audio is identical to slice-2's
/// tap PCM (spec §3.5).
pub fn tap_audio_in_to_openai_append(pcm_b64: &str) -> Value {
    json!({
        "type": "input_audio_buffer.append",
        "audio": pcm_b64
    })
}

/// Translate OpenAI's `response.audio.delta` event (carries a `delta` field
/// with base64 PCM) to a slice-3 tap `audio_out` frame string (spec §4.2).
/// Pass-through on the audio payload; the envelope carries seq/ts.
pub fn openai_audio_delta_to_tap_audio_out(
    openai_event: &Value,
    seq: u64,
    ts: u64,
) -> Result<String, TranslateError> {
    let pcm_b64 = openai_event
        .get("delta")
        .and_then(|v| v.as_str())
        .ok_or(TranslateError::MissingField { field: "delta" })?;
    // OpenAI's base64 PCM is LE i16 24 kHz mono — identical wire shape to
    // slice-2's tap PCM. Decode + re-encode as a PcmFrame (the canonical
    // 480-sample shape) so slice-2's playout ring stays byte-aligned.
    let frame = rutster_tap::decode_pcm(pcm_b64, rutster_tap::WIRE_SAMPLES_PER_FRAME)?;
    Ok(encode_audio_out(&frame, seq, ts)?)
}

/// Translate OpenAI's `input_audio_buffer.speech_started` (started=true) or
/// `.speech_stopped` (started=false) event to the matching tap frame
/// (spec §4.2 + §4.3 — advisory events, the FOB reflex loop in step 4 will
/// act on them).
pub fn openai_speech_event_to_tap(
    _openai_event: &Value,
    started: bool,
    seq: u64,
    ts: u64,
) -> Result<String, TranslateError> {
    if started {
        Ok(encode_speech_started(seq, ts)?)
    } else {
        Ok(encode_speech_stopped(seq, ts)?)
    }
}

/// Translate OpenAI's `response.function_call_arguments.done` event to the
/// tuple `(call_id, name, args_json_str)` the brain will emit as a tap
/// `function_call` frame (spec §4.2 + §4.3). OpenAI sends `arguments` as
/// a JSON _string_, not a JSON object — we don't reparse it; the brain
/// process passes the raw string to `encode_function_call` (Task 2's
/// encode_function_call parses it then into a serde_json::Value).
pub fn openai_function_call_arguments_done_to_tap(
    openai_event: &Value,
) -> Result<(String, String, String), TranslateError> {
    let call_id = openai_event
        .get("call_id")
        .and_then(|v| v.as_str())
        .ok_or(TranslateError::MissingField { field: "call_id" })?;
    let name = openai_event
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or(TranslateError::MissingField { field: "name" })?;
    // OpenAI's `arguments` is a JSON string. If missing, default to "{}".
    let args_json_str = openai_event
        .get("arguments")
        .and_then(|v| v.as_str())
        .unwrap_or("{}")
        .to_string();
    Ok((call_id.to_string(), name.to_string(), args_json_str))
}

/// Build OpenAI's `conversation.item.create` event for a tool-call result
/// (spec §4.2). The tap `function_call_output` carries id + status + result;
/// OpenAI's `conversation.item.create` wraps them as a function_call_output
/// item with `call_id` (= id) + `output` (= JSON-stringified result; status
/// is plumbed into the result value as a `_status` field — OpenAI has no
/// formal status concept, so we encode ours in the result body).
pub fn tap_function_call_output_to_openai_create_item(
    id: &str,
    status: &str,
    result: &Value,
) -> Value {
    let mut output_value = result.clone();
    if let Some(obj) = output_value.as_object_mut() {
        obj.insert("_status".to_string(), Value::String(status.to_string()));
    }
    json!({
        "type": "conversation.item.create",
        "item": {
            "type": "function_call_output",
            "call_id": id,
            "output": output_value.to_string()
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// S4 turn-ownership test (load-bearing per ADR-0008, spec §4.3):
    /// the OpenAI session.update must have turn_detection: null.
    #[test]
    fn session_update_disables_openai_turn_detection() {
        let v = build_openai_session_update("alloy");
        assert_eq!(v["type"], "session.update");
        assert_eq!(v["session"]["voice"], "alloy");
        assert_eq!(v["session"]["modalities"][0], "text");
        assert_eq!(v["session"]["modalities"][1], "audio");
        assert_eq!(v["session"]["input_audio_format"], "pcm16");
        assert_eq!(v["session"]["output_audio_format"], "pcm16");
        assert_eq!(v["session"]["sample_rate"], 24000);
        // THE load-bearing assertion:
        assert_eq!(v["session"]["turn_detection"], Value::Null);
    }

    #[test]
    fn append_audio_payload_is_passthrough() {
        // The PCM base64 string for one zero frame (480 samples, every LE i16=0).
        let zeros = [0u8; 960];
        let pcm_b64 = base64::engine::general_purpose::STANDARD.encode(&zeros);
        let v = tap_audio_in_to_openai_append(&pcm_b64);
        assert_eq!(v["type"], "input_audio_buffer.append");
        assert_eq!(v["audio"], pcm_b64);
    }

    #[test]
    fn openai_audio_delta_to_tap_round_trips_through_slice2_codec() {
        // Build a slice-2 PcmFrame, base64-encode it the slice-2 way,
        // wrap it in an OpenAI response.audio.delta, translate to a tap
        // audio_out frame, then decode the tap frame with decode_envelope
        // and assert the PCM round-trips.
        use rutster_media::PcmFrame;
        let mut frame = PcmFrame::zeroed();
        frame.samples[0] = 100;
        frame.samples[1] = -100;
        frame.samples[479] = 12345;
        let pcm_b64 = rutster_tap::encode_pcm(&frame);
        let openai_event = json!({
            "type": "response.audio.delta",
            "delta": pcm_b64
        });
        let tap_str = openai_audio_delta_to_tap_audio_out(&openai_event, 5, 1000).unwrap();
        let decoded = decode_envelope(&tap_str).unwrap();
        match decoded.payload {
            DecodedPayload::AudioOut(p) => {
                let recovered = rutster_tap::decode_pcm(&p.pcm, p.samples).unwrap();
                assert_eq!(recovered.samples[0], 100);
                assert_eq!(recovered.samples[1], -100);
                assert_eq!(recovered.samples[479], 12345);
                assert_eq!(decoded.seq, 5);
                assert_eq!(decoded.ts, 1000);
            }
            other => panic!("expected AudioOut, got {other:?}"),
        }
    }

    #[test]
    fn speech_started_translates_to_tap_speech_started() {
        let openai_event = json!({ "type": "input_audio_buffer.speech_started" });
        let s = openai_speech_event_to_tap(&openai_event, true, 3, 300).unwrap();
        let d = decode_envelope(&s).unwrap();
        assert!(matches!(d.payload, DecodedPayload::SpeechStarted));
        assert_eq!(d.seq, 3);
    }

    #[test]
    fn speech_stopped_translates_to_tap_speech_stopped() {
        let openai_event = json!({ "type": "input_audio_buffer.speech_stopped" });
        let s = openai_speech_event_to_tap(&openai_event, false, 7, 700).unwrap();
        let d = decode_envelope(&s).unwrap();
        assert!(matches!(d.payload, DecodedPayload::SpeechStopped));
        assert_eq!(d.seq, 7);
    }

    #[test]
    fn function_call_done_extracts_three_tuple() {
        let openai_event = json!({
            "type": "response.function_call_arguments.done",
            "call_id": "call_abc123",
            "name": "hangup",
            "arguments": "{\"reason\": \"caller_requested\"}"
        });
        let (id, name, args_str) =
            openai_function_call_arguments_done_to_tap(&openai_event).unwrap();
        assert_eq!(id, "call_abc123");
        assert_eq!(name, "hangup");
        assert_eq!(args_str, "{\"reason\": \"caller_requested\"}");
    }

    #[test]
    fn function_call_done_missing_call_id_errors() {
        let openai_event = json!({
            "type": "response.function_call_arguments.done",
            "name": "hangup"
        });
        let r = openai_function_call_arguments_done_to_tap(&openai_event);
        assert!(matches!(r, Err(TranslateError::MissingField { field: "call_id" })));
    }

    #[test]
    fn function_call_output_to_create_item_plumbs_status_into_result() {
        let result = json!({"channel_state": "Closing"});
        let v = tap_function_call_output_to_openai_create_item(
            "call_abc123",
            "ok",
            &result,
        );
        assert_eq!(v["type"], "conversation.item.create");
        assert_eq!(v["item"]["type"], "function_call_output");
        assert_eq!(v["item"]["call_id"], "call_abc123");
        // The output is a JSON string — _status gets plumbed inside.
        let output_str = v["item"]["output"].as_str().unwrap();
        let output_val: Value = serde_json::from_str(output_str).unwrap();
        assert_eq!(output_val["channel_state"], "Closing");
        assert_eq!(output_val["_status"], "ok");
    }
}
```

- [ ] **Step 2: Add deps to the crate's Cargo.toml**

In `crates/rutster-brain-realtime/Cargo.toml`:

```toml
[dependencies]
# ... existing deps ...
rutster-media = { path = "../rutster-media" }   # for PcmFrame in tests
base64 = { workspace = true }

[dev-dependencies]
# tempfile is already here from Task 3
```

- [ ] **Step 3: Run the tests — verify they fail to compile (`translator.rs` is a stub comment)**

Run: `cargo test -p rutster-brain-realtime --lib translator`
Expected: compile errors because the stub `translator.rs` only has a `//!` doc comment, not the impl. The errors confirm the tests are exercising genuinely-missing code.

- [ ] **Step 4: Implement the translator (already in Step 1's code above — replacing the stub)**

The Step 1 code IS the implementation. The tests are in the same file's `#[cfg(test)] mod tests`. Run:

```bash
cargo test -p rutster-brain-realtime --lib translator
```

Expected: all 8 tests pass (0 fail).

- [ ] **Step 5: Verify full workspace + clippy + fmt clean**

```bash
cargo test --all
cargo clippy -p rutster-brain-realtime -- -D warnings
cargo fmt --check
```

Expected: all green.

- [ ] **Step 6: Commit**

```bash
git add crates/rutster-brain-realtime/Cargo.toml crates/rutster-brain-realtime/src/translator.rs
git commit -m "feat(brain-realtime): translator — tap ⇄ OpenAI Realtime event mapping (spec §4)

Pure functions, no I/O, no call state. The OpenAI-side WS client
(Task 5) and the tap-side WS server (Task 9) call these per event.

S4 turn-ownership decision (spec §4.3, load-bearing per ADR-0008)
encoded in build_openai_session_update: turn_detection: null. OpenAI's
server-side VAD is disabled; the FOB reflex loop (step 4) owns
turn-taking; tap playout stays core-authoritative (slice-2 §4.1).

S4 test verifies the load-bearing assertion
(v['session']['turn_detection'] == Null).

Audio is pass-through — OpenAI's PCM base64 (LE i16 24 kHz mono) is
indentical to slice-2's tap PCM wire shape (spec §3.5); decode +
re-encode as a PcmFrame so the playout ring stays byte-aligned for
slice-2's playout-buffer invariants (samples: 480)."
```

---

## Task 5: OpenAI wss client

**Files:**
- Modify: `crates/rutster-brain-realtime/src/openai_client.rs`
- Test: `crates/rutster-brain-realtime/src/openai_client.rs` (add `tests` submodule — for the URL + headers shape; full client pump loop tested via Task 10's MockRealtimeBrain integration)

**Interfaces:**
- Consumes: Task 3's `load_api_key`; Task 4's translator functions.
- Produces: `pub async fn run_openai_realtime_loop(api_key: String, model: String, voice: String, tap_ws_in: WebSocketStream<...>, tap_ws_out: ...) -> Result<(), OpenAiClientError>`. The brain process's main.rs (Task 9) calls this with the two halves of the tap-side WS connection and a config.

- [ ] **Step 1: Write the failing test (URL + headers shape)**

Replace `crates/rutster-brain-realtime/src/openai_client.rs` (the stub) with:

```rust
//! # wss://api.openai.com/v1/realtime client (spec §4)
//!
//! Owns the OpenAI-side WS connection. Brings the OpenAI Realtime event
//! stream up, sends `session.update` with `turn_detection: null` (S4,
//! spec §4.3) on handshake, then runs a pump loop that:
//! - reads tap-side events (audio_in, function_call_output) from the
//!   tap WS server's `WebSocketStream` and translates + forwards them
//!   to OpenAI;
//! - reads OpenAI events (response.audio.delta, speech_started/stopped,
//!   function_call_arguments.done, error) and translates + forwards them
//!   to the tap WS server.
//!
//! Reconnects with bounded backoff on OpenAI-side WS failure (spec §4.4 —
//! the tap side stays connected; the OpenAI side has its own failure
//! surface). The brain process emits a tap `error` event on OpenAI-side
//! failure so the core can observe it.

use rutster_tap::{
    decode_envelope, encode_function_call, encode_function_call_output,
    openai_audio_delta_to_tap_audio_out, openai_function_call_arguments_done_to_tap,
    openai_speech_event_to_tap, tap_audio_in_to_openai_append,
    tap_function_call_output_to_openai_create_item, build_openai_session_update,
    DecodedPayload,
};
use serde_json::Value;
use thiserror::Error;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tracing::{info, warn};

#[derive(Debug, Error)]
pub enum OpenAiClientError {
    #[error("OpenAI WS error: {0}")]
    Ws(#[from] tokio_tungstenite::tungstenite::Error),
    #[error("OpenAI auth failed (401)")]
    AuthFailed,
    #[error("translator error: {0}")]
    Translate(#[from] crate::translator::TranslateError),
    #[error("tap protocol error: {0}")]
    TapProto(#[from] rutster_tap::TapProtoError),
}

/// Build the OpenAI Realtime URL for the given model. Spec §4.2 + §5.3:
/// `wss://api.openai.com/v1/realtime?model=<model>`.
pub fn openai_realtime_url(model: &str) -> String {
    format!("wss://api.openai.com/v1/realtime?model={model}")
}

/// Build the HTTP headers for the OpenAI WS handshake (spec §5.3). Two
/// required headers:
/// - `Authorization: Bearer <api_key>`
/// - `OpenAI-Beta: realtime=v1`
pub fn openai_headers(api_key: &str) -> Vec<(String, String)> {
    vec![
        ("Authorization".to_string(), format!("Bearer {api_key}")),
        ("OpenAI-Beta".to_string(), "realtime=v1".to_string()),
    ]
}

/// Drive the OpenAI Realtime WS connection + the tap-side pump.
///
/// `tap_rx`: inbound side — *tap frames* the brain received from the core
///   (audio_in, function_call_output). We translate each one to its OpenAI
///   equivalent + send to OpenAI.
/// `openai_ws`: the OpenAI WS connection (already connected; the caller
///   does the dial + auth).
/// `tap_tx`: outbound side — *tap frames* the brain sends back to the core
///   (audio_out, speech_started/stopped, function_call, error). We
///   translate OpenAI events into these + send.
pub async fn run_openai_pump<T>(
    mut openai_ws: tokio_tungstenite::WebSocketStream<T>,
    mut tap_rx: mpsc::Receiver<String>,
    tap_tx: mpsc::Sender<String>,
    voice: String,
) -> Result<(), OpenAiClientError>
where
    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
    // === Handshake: send session.update with turn_detection: null (S4). ===
    let session_update = build_openai_session_update(&voice);
    openai_ws
        .send(Message::Text(session_update.to_string()))
        .await?;
    info!(voice = %voice, "sent session.update to OpenAI (turn_detection: null)");

    let mut seq_egress = 0u64;

    use futures_util::{SinkExt, StreamExt};
    loop {
        tokio::select! {
            // Inbound tap frame from the core (audio_in, function_call_output).
            tap_str = tap_rx.recv() => {
                let Some(tap_str) = tap_str else {
                    info!("tap_rx closed; ending OpenAI pump");
                    return Ok(());
                };
                let decoded = decode_envelope(&tap_str)?;
                match decoded.payload {
                    DecodedPayload::AudioIn(audio) => {
                        let append = tap_audio_in_to_openai_append(&audio.pcm);
                        openai_ws.send(Message::Text(append.to_string())).await?;
                    }
                    DecodedPayload::FunctionCallOutput(out) => {
                        let create_item = tap_function_call_output_to_openai_create_item(
                            &out.id, &out.status, &out.result,
                        );
                        openai_ws.send(Message::Text(create_item.to_string())).await?;
                    }
                    // Ignore others; slice-2's hello/audio_in on the tap side
                    // happens before this pump starts.
                    _ => warn!(?decoded.payload, "unexpected tap frame to OpenAI pump"),
                }
            }
            // Inbound OpenAI event.
            msg = openai_ws.next() => {
                let Some(msg) = msg else {
                    info!("OpenAI WS stream ended");
                    return Ok(());
                };
                let msg = msg?;
                let Ok(text) = msg.into_text() else { continue };
                let openai_event: Value = match serde_json::from_str(&text) {
                    Ok(v) => v,
                    Err(e) => {
                        warn!(error = ?e, "OpenAI sent non-JSON event; ignoring");
                        continue;
                    }
                };
                let event_type = openai_event.get("type").and_then(|v| v.as_str()).unwrap_or("");
                match event_type {
                    "response.audio.delta" => {
                        let tap_str = openai_audio_delta_to_tap_audio_out(
                            &openai_event, seq_egress, 0,
                        )?;
                        seq_egress += 1;
                        tap_tx.send(tap_str).await.map_err(|_| {
                            OpenAiClientError::Ws(
                                tokio_tungstenite::tungstenite::Error::ConnectionClosed,
                            )
                        })?;
                    }
                    "input_audio_buffer.speech_started" => {
                        let tap_str = openai_speech_event_to_tap(
                            &openai_event, true, seq_egress, 0,
                        )?;
                        seq_egress += 1;
                        tap_tx.send(tap_str).await.map_err(|_| {
                            OpenAiClientError::Ws(
                                tokio_tungstenite::tungstenite::Error::ConnectionClosed,
                            )
                        })?;
                    }
                    "input_audio_buffer.speech_stopped" => {
                        let tap_str = openai_speech_event_to_tap(
                            &openai_event, false, seq_egress, 0,
                        )?;
                        seq_egress += 1;
                        tap_tx.send(tap_str).await.map_err(|_| {
                            OpenAiClientError::Ws(
                                tokio_tungstenite::tungstenite::Error::ConnectionClosed,
                            )
                        })?;
                    }
                    "response.function_call_arguments.done" => {
                        let (call_id, name, args_str) =
                            openai_function_call_arguments_done_to_tap(&openai_event)?;
                        let tap_str = encode_function_call(
                            &call_id, &name, &args_str, seq_egress, 0,
                        )?;
                        seq_egress += 1;
                        tap_tx.send(tap_str).await.map_err(|_| {
                            OpenAiClientError::Ws(
                                tokio_tungstenite::tungstenite::Error::ConnectionClosed,
                            )
                        })?;
                    }
                    "error" => {
                        // OpenAI emits a typed error event (e.g. 401 auth
                        // failure surfaces here). Inspect the .error.code.
                        let code = openai_event
                            .pointer("/error/code")
                            .and_then(|v| v.as_str())
                            .unwrap_or("unknown");
                        warn!(code = %code, "OpenAI error event");
                        if code == "invalid_api_key" {
                            return Err(OpenAiClientError::AuthFailed);
                        }
                    }
                    _ => {
                        // Unknown OpenAI event — log + drop (don't crash).
                        warn!(event_type = %event_type, "ignoring OpenAI event type");
                    }
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn openai_url_for_known_model() {
        assert_eq!(
            openai_realtime_url("gpt-4o-realtime"),
            "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime"
        );
    }

    #[test]
    fn openai_headers_carry_bearer_auth_and_beta() {
        let h = openai_headers("sk-test-12345");
        assert_eq!(h[0].0, "Authorization");
        assert_eq!(h[0].1, "Bearer sk-test-12345");
        assert_eq!(h[1].0, "OpenAI-Beta");
        assert_eq!(h[1].1, "realtime=v1");
    }
}
```

- [ ] **Step 2: Run the tests — verify they compile + pass (the impl is already in Step 1; verify by deliberately introducing a bug)**

Run: `cargo test -p rutster-brain-realtime --lib openai_client`
Expected: 2 tests pass. To verify TDD red-phase: temporarily change `format!("Bearer {api_key}")` to `format!("Bearer {api_key}!")`, run, watch `openai_headers_carry_bearer_auth_and_beta` fail, revert.

- [ ] **Step 3: Add `thiserror` re-export if needed + verify clippy + fmt**

```bash
cargo clippy -p rutster-brain-realtime -- -D warnings
cargo fmt --check
```

Expected: clean.

- [ ] **Step 4: Commit**

```bash
git add crates/rutster-brain-realtime/src/openai_client.rs
git commit -m "feat(brain-realtime): OpenAI wss client pump (spec §4)

Builds session.update with turn_detection: null on handshake (S4,
encoded in the translator's build_openai_session_update; Task 4).
Runs a select! loop over tap-side input (audio_in → append,
function_call_output → conversation.item.create) and OpenAI-side
input (response.audio.delta → tap audio_out, speech_started/stopped
→ tap speech_started/stopped, function_call_arguments.done → tap
function_call). 401 surfaces as OpenAiClientError::AuthFailed.

URL + headers shape tested directly. Full pump loop tested via
MockRealtimeBrain in Task 10 — neither side of this pump is a real
network endpoint in unit tests."
```

---

## Task 6: Tool trait + registry + `hangup` tool

**Files:**
- Create: `crates/rutster/src/tool_registry.rs`
- Modify: `crates/rutster/Cargo.toml`
- Test: `crates/rutster/src/tool_registry.rs` (add `tests` submodule)

**Interfaces:**
- Consumes: Task 2's `FunctionCallPayload` (via `rutster_tap`); the binary's `AppState` (for `hangup` to call `AppState::close(channel_id)`).
- Produces: `pub trait Tool: Send + Sync`, `pub enum ToolResult`, `pub struct ToolRegistry`, `pub struct HangupTool { app_state: AppState, channel_id: ChannelId }`.

- [ ] **Step 1: Write the failing test**

Create `crates/rutster/src/tool_registry.rs`:

```rust
//! # Tool registry — the FOB boundary for brain-proposed tool calls
//!
//! Per spec §6 + ADR-0007 ("rutster mediates both the provider call-control
//! API and the brain tap, so the brain never holds the wire"). The brain
//! proposes (via function_call events); the FOB disposes (via
//! function_call_output).
//!
//! `hangup` is the only wired tool in slice-3 (spec §6.3); other tool names
//! reply `not_implemented` so the model is free to retry or give up.

use std::sync::Arc;

use async_trait::async_trait;
use rutster_call_model::ChannelId;
use serde_json::{json, Value};
use tracing::warn;

use crate::session_map::AppState;

/// A registry-dispatchable tool. The async-trait pattern is needed because
/// the registry holds `Vec<Box<dyn Tool>>` (stable Rust doesn't support
/// async fns in trait *objects* without `async-trait` as of Rust 1.85).
#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    /// JSON-schema descriptor the registry sends to the brain on tools.update.
    fn schema(&self) -> Value;
    /// Execute the tool. The args Value is the raw JSON from the function_call
    /// event (the brain's translator extracts `arguments` from OpenAI's
    /// event and the registry hands it here verbatim).
    async fn call(&self, args: Value) -> ToolResult;
}

#[derive(Debug, Clone)]
pub enum ToolResult {
    Ok(Value),
    Error(String),
    NotImplemented,
}

impl ToolResult {
    /// Serialize to the (status, result) pair the binary's poll task will
    /// pass to `encode_function_call_output`.
    pub fn to_status_result(&self) -> (String, Value) {
        match self {
            ToolResult::Ok(v) => ("ok".to_string(), v.clone()),
            ToolResult::Error(msg) => ("error".to_string(), json!({ "error": msg })),
            ToolResult::NotImplemented => ("not_implemented".to_string(), Value::Null),
        }
    }
}

pub struct ToolRegistry {
    tools: Vec<Box<dyn Tool>>,
}

impl ToolRegistry {
    pub fn new() -> Self {
        Self { tools: Vec::new() }
    }
    pub fn register(&mut self, tool: Box<dyn Tool>) {
        self.tools.push(tool);
    }
    /// Dispatch by tool name. Returns `ToolResult::NotImplemented` if no
    /// tool with the given name is registered.
    pub async fn dispatch(&self, name: &str, args: Value) -> ToolResult {
        for tool in &self.tools {
            if tool.name() == name {
                return tool.call(args).await;
            }
        }
        warn!(tool = %name, "brain proposed unknown tool; returning not_implemented");
        ToolResult::NotImplemented
    }
    /// Serialize the catalog (used on startup + tools.update emission).
    pub fn catalog(&self) -> Vec<Value> {
        self.tools.iter().map(|t| t.schema()).collect()
    }
}

impl Default for ToolRegistry {
    fn default() -> Self {
        Self::new()
    }
}

/// The `hangup` tool. Holds `AppState` (cloned — it's `Arc`-cheap) + the
/// `ChannelId` of the call this tool operates on. `call()` ->
/// `AppState::close(channel_id).await` -> returns `Ok({"channel_state": "Closing"})`.
pub struct HangupTool {
    app_state: AppState,
    channel_id: ChannelId,
}

impl HangupTool {
    pub fn new(app_state: AppState, channel_id: ChannelId) -> Self {
        Self {
            app_state,
            channel_id,
        }
    }
}

#[async_trait]
impl Tool for HangupTool {
    fn name(&self) -> &str {
        "hangup"
    }
    fn schema(&self) -> Value {
        json!({
            "name": "hangup",
            "description": "Hang up the current call.",
            "parameters": {
                "type": "object",
                "properties": {}
            }
        })
    }
    async fn call(&self, _args: Value) -> ToolResult {
        self.app_state.close(self.channel_id).await;
        ToolResult::Ok(json!({ "channel_state": "Closing" }))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A no-op tool whose `call` returns a fixed value — for testing the
    /// registry's dispatch + catalog shape without needing AppState.
    struct EchoTool {
        tool_name: String,
    }
    #[async_trait]
    impl Tool for EchoTool {
        fn name(&self) -> &str {
            &self.tool_name
        }
        fn schema(&self) -> Value {
            json!({ "name": self.tool_name, "description": "test tool" })
        }
        async fn call(&self, args: Value) -> ToolResult {
            ToolResult::Ok(args)
        }
    }

    #[tokio::test]
    async fn dispatch_known_tool_returns_ok() {
        let mut reg = ToolRegistry::new();
        reg.register(Box::new(EchoTool { tool_name: "echo".to_string() }));
        let r = reg.dispatch("echo", json!({"x": 1})).await;
        let (status, result) = r.to_status_result();
        assert_eq!(status, "ok");
        assert_eq!(result, json!({"x": 1}));
    }

    #[tokio::test]
    async fn dispatch_unknown_tool_returns_not_implemented() {
        let mut reg = ToolRegistry::new();
        reg.register(Box::new(EchoTool { tool_name: "echo".to_string() }));
        let r = reg.dispatch("not_registered", json!({})).await;
        let (status, _) = r.to_status_result();
        assert_eq!(status, "not_implemented");
    }

    #[tokio::test]
    async fn catalog_lists_all_registered_tools() {
        let mut reg = ToolRegistry::new();
        reg.register(Box::new(EchoTool { tool_name: "a".to_string() }));
        reg.register(Box::new(EchoTool { tool_name: "b".to_string() }));
        let cat = reg.catalog();
        assert_eq!(cat.len(), 2);
        assert_eq!(cat[0]["name"], "a");
        assert_eq!(cat[1]["name"], "b");
    }

    #[tokio::test]
    async fn hangup_tool_schema_shape() {
        // Don't construct a full HangupTool (needs AppState) — just verify
        // the schema shape via the impl on a standalone.
        let app_state = AppState::default();
        let h = HangupTool::new(app_state, ChannelId(rutster_call_model::Uuid::nil()));
        let s = h.schema();
        assert_eq!(s["name"], "hangup");
        assert!(s["description"].is_string());
        assert_eq!(s["parameters"]["type"], "object");
    }

    #[tokio::test]
    async fn hangup_tool_call_fires_app_state_close() {
        // AppState::close on a fake session_id that doesn't exist just
        // logs + no-ops (the `if let Some((_id, session_arc)) = ...` arm).
        // The tool returns Ok with channel_state: "Closing" regardless —
        // the dispatch boundary gives the brain a deterministic reply.
        let app_state = AppState::default();
        let h = HangupTool::new(
            app_state,
            ChannelId(rutster_call_model::Uuid::new_v4()),
        );
        let r = h.call(json!({})).await;
        let (status, result) = r.to_status_result();
        assert_eq!(status, "ok");
        assert_eq!(result["channel_state"], "Closing");
    }
}
```

- [ ] **Step 2: Add deps + module reference to `crates/rutster/Cargo.toml` + `main.rs`**

In `crates/rutster/Cargo.toml`:
```toml
[dependencies]
# ... existing deps ...
async-trait = { workspace = true }
```

In `crates/rutster/src/main.rs` (or `lib.rs` if that's where modules are declared — check the existing structure), add the module declaration:

```rust
pub mod tool_registry;
```

(Verify by inspecting `crates/rutster/src/main.rs` or `lib.rs` to see how `tap_engine` and `session_map` are declared. Mirror that pattern.)

- [ ] **Step 3: Run the tests — verify they pass + clippy + fmt clean**

```bash
cargo test -p rutster --lib tool_registry
cargo clippy -p rutster -- -D warnings
cargo fmt --check
```

Expected: 5 tests pass; clippy clean; fmt clean.

- [ ] **Step 4: Commit**

```bash
git add crates/rutster/Cargo.toml crates/rutster/src/main.rs crates/rutster/src/tool_registry.rs
git commit -m "feat(binary): tool_registry + hangup tool (spec §6)

FOB-boundary dispatch for brain-proposed tool calls (ADR-0007: 'rutster
mediates both the provider call-control API and the brain tap, so the
brain never holds the wire'). Slice-3's only wired tool is hangup
(fires AppState::close → existing slice-2 teardown); other tool names
reply not_implemented.

TDD: 5 tests (dispatch known + unknown tool, catalog listing, hangup
schema shape, hangup_call fires AppState::close)."
```

---

## Task 7: TapClient function_call arms + TapConn extension

**Files:**
- Modify: `crates/rutster-tap/src/tap_client.rs`
- Modify: `crates/rutster-tap/src/lib.rs` (re-export changes if any — primarily re-exporting `ToolCall` types in Task 2)
- Modify: `crates/rutster/src/tap_engine.rs` (extend `TapConn` + `spawn_tap_engine` for the new side-channel mpsc pair)
- Test: integration-level test deferred to Task 10; unit test for the new handle_brain_frame arm here.

**Interfaces:**
- Consumes: Task 2's `DecodedPayload::FunctionCall` / `DecodedPayload::ToolsUpdate` + `encode_function_call_output` (used to write replies back via WS).
- Produces: `TapConn` gains `pub function_call_rx: Option<mpsc::Receiver<FunctionCallEvent>>` (the binary's poll task drains this + dispatches via tool_registry, Task 8), and `pub function_call_output_tx: Option<mpsc::Sender<String>>` (the poll task writes `function_call_output` tap frame strings via this; TapClient drains it + sends as WS). `run_tap_client` gains corresponding pump-arm logic.

- [ ] **Step 1: Define the `FunctionCallEvent` type**

Add to the top of `crates/rutster-tap/src/protocol.rs` (or as a new submodule — `function_call.rs` if you prefer; the protocol module is fine):

```rust
/// Flatten a decoded `function_call` tap frame into the data the binary's
/// tool registry needs: `(id, name, args)`. Sent through the side-channel
/// mpsc the binary's poll task drains.
#[derive(Debug, Clone)]
pub struct FunctionCallEvent {
    pub id: String,
    pub name: String,
    pub args: serde_json::Value,
}

impl FunctionCallEvent {
    pub fn from_payload(p: &FunctionCallPayload) -> Self {
        Self {
            id: p.id.clone(),
            name: p.name.clone(),
            args: p.args.clone(),
        }
    }
}
```

Re-export from `crates/rutster-tap/src/lib.rs`:

```rust
pub use protocol::{FunctionCallEvent, FunctionCallPayload};
```

- [ ] **Step 2: Write the failing test in `tap_client.rs`**

The unit test: a `handle_brain_frame` receiving a `function_call` frame forwards it to a new side-channel mpsc. Add to `tap_client.rs`'s `#[cfg(test)]` module if present (or create one):

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use rutster_tap::protocol::{encode_function_call, FunctionCallEvent};

    #[tokio::test]
    async fn handle_brain_frame_forwards_function_call_to_side_channel() {
        let (tx_fc, mut rx_fc) = mpsc::channel::<FunctionCallEvent>(8);
        let (tx_audio_out, _rx_audio_out) = mpsc::channel(8);
        let metrics = Arc::new(TapMetrics::new());
        let session_start = Instant::now();

        let fc_str = encode_function_call("call-1", "hangup", "{}", 5, 500).unwrap();
        handle_brain_frame(
            &fc_str,
            &mut None,
            &tx_audio_out,
            &metrics,
            session_start,
            Some(&tx_fc),
        )
        .await;

        let received = rx_fc.try_recv().expect("function_call should have been forwarded");
        assert_eq!(received.id, "call-1");
        assert_eq!(received.name, "hangup");
    }
}
```

- [ ] **Step 3: Run the test — verify it fails to compile**

`handle_brain_frame` doesn't take the new `tx_fc: Option<&mpsc::Sender<FunctionCallEvent>>` parameter yet. The compile error is the test red phase.

- [ ] **Step 4: Extend `handle_brain_frame` to forward the function_call**

In `crates/rutster-tap/src/tap_client.rs`, locate the existing `async fn handle_brain_frame` signature (verify by reading the source). Add the new parameter:

```rust
async fn handle_brain_frame(
    text: &str,
    last_seq_ingress: &mut Option<u64>,
    tx_audio_out: &mpsc::Sender<PcmFrame>,
    metrics: &TapMetrics,
    session_start: Instant,
    tx_function_call: Option<&mpsc::Sender<FunctionCallEvent>>,
) {
    let decoded = match decode_envelope(text) {
        Ok(d) => d,
        Err(e) => {
            metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
            warn!(error = ?e, "brain frame decode failed; dropping");
            return;
        }
    };
    // ... existing seq-gap detection ...
    match decoded.payload {
        DecodedPayload::AudioOut(audio) => { /* unchanged from slice-2 */ }
        DecodedPayload::Bye(p) => { /* unchanged */ }
        DecodedPayload::Error(p) => { /* unchanged */ }
        DecodedPayload::Hello(_) => { /* unchanged */ }
        DecodedPayload::Unknown => { /* unchanged */ }
        DecodedPayload::SessionEnd(_) | DecodedPayload::AudioIn(_) => { /* unchanged */ }
        DecodedPayload::SpeechStarted | DecodedPayload::SpeechStopped => {
            // Advisory — log + count; step 4 will wire the FOB reflex loop.
            // No side-channel forward (these aren't tool calls).
            metrics.malformed_frames.fetch_add(0, Ordering::Relaxed);
            tracing::debug!("brain emitted advisory speech event; ignoring (step 4 will wire)");
        }
        DecodedPayload::FunctionCall(p) => {
            if let Some(tx) = tx_function_call {
                let event = FunctionCallEvent::from_payload(&p);
                if let Err(e) = tx.try_send(event) {
                    warn!(error = ?e, "function_call side-channel full; dropping");
                    metrics.malformed_frames.fetch_add(1, Ordering::Relaxed);
                }
            } else {
                warn!("function_call received with no side-channel registered; dropping");
            }
        }
        DecodedPayload::FunctionCallOutput(_) => {
            // Brain wouldn't send this back to the core; it's a core → brain
            // direction only (spec §3.3). Log + drop.
            warn!("brain sent function_call_output (should be core→brain only); dropping");
        }
        DecodedPayload::ToolsUpdate(p) => {
            tracing::info!(tools = ?p.tools, "brain declared tool catalog");
            // Catalog is logged for slice-3 (the brain's tools.update is
            // informational; the registry's catalog comes from the binary's
            // startup config, not from this event). Step 6+ may wire this
            // to the registry's runtime registration.
        }
    }
}
```

Update the call sites in `run_tap_client` (the two `handle_brain_frame(...)` calls) to pass `Some(&tx_function_call)` — meaning `run_tap_client` needs to take a new parameter `tx_function_call: mpsc::Sender<FunctionCallEvent>`.

- [ ] **Step 5: Extend `run_tap_client`'s signature + drain the new `function_call_output_tx` mpsc**

```rust
pub async fn run_tap_client<T>(
    mut ws: WebSocketStream<T>,
    session_id: ChannelId,
    rx_pcm_in: &mut mpsc::Receiver<PcmFrame>,
    tx_audio_out: mpsc::Sender<PcmFrame>,
    tx_function_call: mpsc::Sender<FunctionCallEvent>,
    mut rx_function_call_output: mpsc::Receiver<String>,
    metrics: Arc<TapMetrics>,
    close: &mut oneshot::Receiver<()>,
) -> Result<(), TapClientError>
where
    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
    // ... existing handshake hello + ack code unchanged ...

    let session_start = Instant::now();
    let mut seq_egress: u64 = 0;

    loop {
        tokio::select! {
            // Slice-2: close signal → send session_end, wait bye, close.
            _ = &mut *close => { /* unchanged from slice-2 */ }
            // Slice-2: inbound PCM → audio_in WS frame.
            frame = rx_pcm_in.recv() => { /* unchanged */ }
            // Slice-3 NEW: function_call_output WS frame to send to brain.
            fco_str = rx_function_call_output.recv() => {
                let Some(fco_str) = fco_str else { continue };
                if let Err(e) = ws.send(Message::Text(fco_str)).await {
                    warn!(error = ?e, %session_id, "ws send function_call_output failed");
                    return Err(e.into());
                }
            }
            // Slice-2: inbound WS frame from brain.
            msg = ws.next() => {
                // ... unchanged, calls handle_brain_frame(...,Some(&tx_function_call)) ...
            }
        }
    }
}
```

- [ ] **Step 6: Extend `spawn_tap_engine` + `TapConn` in `crates/rutster/src/tap_engine.rs`**

```rust
pub struct TapConn {
    pub close_tx: oneshot::Sender<()>,
    pub join: JoinHandle<()>,
    pub metrics: Arc<TapMetrics>,
    pub flush_rx: Option<mpsc::Receiver<()>>,
    /// Slice-3: function_call side-channel receiver — the binary's poll
    /// task drains this (alongside flush_rx) and dispatches via the
    /// tool_registry. Each brain-emitted function_call lands here.
    pub function_call_rx: Option<mpsc::Receiver<rutster_tap::FunctionCallEvent>>,
    /// Slice-3: function_call_output sender — the binary writes
    /// `encode_function_call_output(...)` strings here; the engine task
    /// drains + sends as WS frames to the brain.
    pub function_call_output_tx: Option<mpsc::Sender<String>>,
}

pub fn spawn_tap_engine(session_id: ChannelId, tap_url: Url) -> (TapAudioPipe, TapConn) {
    let (tx_pcm_in, rx_pcm_in) = mpsc::channel(TAP_MPSC_CAPACITY);
    let (tx_audio_out, rx_audio_out) = mpsc::channel(TAP_MPSC_CAPACITY);
    let (close_tx, close_rx) = oneshot::channel::<()>();
    let (flush_tx, flush_rx) = mpsc::channel::<()>(8);

    // Slice-3: function_call + function_call_output mpsc pair.
    let (tx_function_call, function_call_rx) =
        mpsc::channel::<rutster_tap::FunctionCallEvent>(TAP_MPSC_CAPACITY);
    let (function_call_output_tx, rx_function_call_output) =
        mpsc::channel::<String>(TAP_MPSC_CAPACITY);

    let metrics = TapMetrics::new();
    let metrics_for_pipe = metrics.clone();
    let metrics_for_conn = metrics.clone();

    let join = tokio::spawn(async move {
        run_engine_loop(
            session_id,
            tap_url,
            rx_pcm_in,
            tx_audio_out,
            tx_function_call,
            rx_function_call_output,
            close_rx,
            flush_tx,
            metrics,
        )
        .await;
    });

    let pipe = TapAudioPipe::new(tx_pcm_in, rx_audio_out, metrics_for_pipe);
    let conn = TapConn {
        close_tx,
        join,
        metrics: metrics_for_conn,
        flush_rx: Some(flush_rx),
        function_call_rx: Some(function_call_rx),
        function_call_output_tx: Some(function_call_output_tx),
    };
    (pipe, conn)
}
```

Update `run_engine_loop`'s signature to accept + forward the two new mpsc halflings to `run_tap_client`. (The pump loop's `tokio::select!` body stays the same shape; the new channels are passed through.)

- [ ] **Step 7: Verify Task 7's test passes + slice-2's integration test still passes**

```bash
cargo test -p rutster-tap --lib tap_client::tests::handle_brain_frame_forwards_function_call_to_side_channel
cargo test -p rutster --test tap_integration
cargo clippy -p rutster-tap -- -D warnings
cargo fmt --check
```

Expected: the new test passes + slice-2's tap_integration test still passes (the new mpsc channels are wired but the existing test exercises only audio round-trip; the function_call arm isn't reached).

- [ ] **Step 8: Commit**

```bash
git add crates/rutster-tap/src/protocol.rs crates/rutster-tap/src/lib.rs crates/rutster-tap/src/tap_client.rs crates/rutster/src/tap_engine.rs
git commit -m "feat(tap): function_call side-channel + TapConn wiring (spec §3.2, §6)

run_tap_client + handle_brain_frame now consume + emit a new mpsc pair:
function_call events flow brain → core's tool-registry dispatch;
function_call_output tap frames flow core → brain. TapConn wraps the
two new halflings for the binary's poll task to drain (Task 8).

The function_call_output path through run_tap_client adds a fourth
tokio::select! arm (alongside close / rx_pcm_in / ws.next) — pumps
registry replies outward as WS frames. function_call inbound
forwarding is non-blocking (try_send); a full side-channel drops +
counts per the hot-path drop+observe policy.

TDD: 1 unit test for handle_brain_frame forwarding (red on compile
when the parameter didn't exist, green after wiring). Slice-2's
tap_integration stays green — new mpsc channels are additively
wired; the existing test doesn't exercise function_call."
```

---

## Task 8: session_map tool-call side-channel drain

**Files:**
- Modify: `crates/rutster/src/session_map.rs`

**Interfaces:**
- Consumes: Task 6's `ToolRegistry` + `HangupTool`; Task 7's new `TapConn.function_call_rx` + `function_call_output_tx`.
- Produces: extends `drive_all_sessions` to drain the function_call side-channel + dispatch via a per-channel `ToolRegistry`; writes the `function_call_output` reply back via the side-channel mpsc.

- [ ] **Step 1: Write the failing test (a partial integration test)**

Add a test in `crates/rutster/src/session_map.rs`'s test module (or extend if present). The test constructs an `AppState` + a `ToolRegistry` with an `EchoTool`, fakes a `function_call` event in the TapConn's side-channel, runs `drive_all_sessions` for one iteration, and asserts the `function_call_output` got written.

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::tool_registry::ToolRegistry;
    use async_trait::async_trait;
    use rutster_tap::{encode_function_call_output, FunctionCallEvent};
    use serde_json::json;
    // ... struct EchoTool (copy from Task 6) ...

    #[tokio::test]
    async fn drive_drains_function_call_and_writes_output() {
        // Construct AppState + one fake session entry whose TapConn has
        // a function_callRx pre-loaded with one event.
        let state = AppState::new(url::Url::parse("ws://127.0.0.1:8082/realtime").unwrap());
        let id = ChannelId::new();

        // Build a TapConn directly: need to construct one with a pre-loaded
        // function_call_rx. spawn_tap_engine creates a real engine task;
        // instead build two mpsc pairs manually and assemble a TapConn.
        let (fc_tx, fc_rx) = mpsc::channel::<FunctionCallEvent>(8);
        let (fco_tx, mut fco_rx) = mpsc::channel::<String>(8);
        let (close_tx, _close_rx) = oneshot::channel::<()>();
        let join = tokio::spawn(async {}); // no-op task
        fc_tx.send(FunctionCallEvent {
            id: "call-1".to_string(),
            name: "echo".to_string(),
            args: json!({"x": 1}),
        }).await.unwrap();
        let conn = TapConn {
            close_tx,
            join,
            metrics: Arc::new(TapMetrics::new()),
            flush_rx: None,
            function_call_rx: Some(fc_rx),
            function_call_output_tx: Some(fco_tx),
        };
        // Build session entry...
        // ... (omitted for brevity — mirrors how session_map.rs constructs
        //      SessionEntry in its existing tests, with rtc behind a Mutex) ...

        // Build tool registry with one EchoTool.
        let mut reg = ToolRegistry::new();
        reg.register(Box::new(EchoTool {}));
        // ... store the registry on AppState (Task 8 wiring) ...

        drive_all_sessions(&state, Instant::now()).await;

        let output_str = fco_rx.try_recv().expect("function_call_output should have been written");
        assert!(output_str.contains("\"type\":\"function_call_output\""));
        assert!(output_str.contains("\"status\":\"ok\""));
        assert!(output_str.contains("\"id\":\"call-1\""));
    }
}
```

(The test is a sketch — the exact construction of `SessionEntry` with a fake `Arc<Mutex<RtcSession>>` may need to be adjusted to match the existing patterns.)

- [ ] **Step 2: Run the test — verify it fails to compile (drive_all_sessions doesn't yet drain the side-channel)**

Expected: the test compiles once you provide the necessary getters/setters; the assertion fails because the `function_call_output_tx.try_recv()` returns `Empty`.

- [ ] **Step 3: Extend `drive_all_sessions`**

In `crates/rutster/src/session_map.rs`, alongside the existing `flush_rx` drain (slice-2 §5.3 step 4), add the function_call drain + dispatch:

```rust
// === Slice-3 §6: drain function_call events from the brain + dispatch
// via the tool_registry. ===
if let Some(mut entry) = state.sessions.get_mut(&id) {
    if let Some(conn) = entry.tap_conn.as_mut() {
        if let Some(rx) = conn.function_call_rx.as_mut() {
            while let Ok(event) = rx.try_recv() {
                // Dispatch via the registry (cloned for the dispatch —
                // the registry is per-AppState and Send, so a clone is
                // Arc-cheap if we wrap ToolRegistry in Arc).
                let reg = state.tool_registry.clone();
                let result = reg.dispatch(&event.name, event.args.clone()).await;
                let (status, result_val) = result.to_status_result();
                let output_str = rutster_tap::encode_function_call_output(
                    &event.id, &status, &result_val.to_string(), 0, 0,
                ).unwrap_or_else(|e| {
                    warn!(error = ?e, "failed to encode function_call_output; sending generic error");
                    rutster_tap::encode_error("tool_dispatch_failed", &e.to_string(), 0, 0)
                        .unwrap_or_else(|_| "{}".to_string())
                });
                if let Some(tx) = conn.function_call_output_tx.as_ref() {
                    if let Err(e) = tx.try_send(output_str) {
                        warn!(error = ?e, channel_id = %id, "function_call_output side-channel full; dropping");
                    }
                }
            }
        }
    }
}
```

Also extend `AppState` to hold a `tool_registry: Arc<ToolRegistry>` field so the poll task can dispatch. Update `AppState::new(default_tap_url)` to construct a default registry with the `HangupTool`:

```rust
pub struct AppState {
    pub sessions: Arc<DashMap<ChannelId, SessionEntry>>,
    pub poll_running: Arc<Mutex<bool>>,
    pub default_tap_url: url::Url,
    pub tool_registry: Arc<ToolRegistry>,
}

impl AppState {
    pub fn new(default_tap_url: url::Url) -> Self {
        let mut reg = ToolRegistry::new();
        reg.register(Box::new(HangupTool::new(/* app_state placeholder */)));
        // ... HangupTool needs AppState — circular construction. Decompose:
        // make HangupTool hold a `Weak<...>` or a clone of a separate
        // `Arc<Mutex<HashMap<ChannelId, ...>>>` channel registry instead of
        // AppState itself. (The cleanest pattern is to give HangupTool a
        // clone of the DashMap, not the full AppState. Adjust the Task 6
        // HangupTool definition if needed.)
        Self {
            sessions: Arc::new(DashMap::new()),
            poll_running: Arc::Mutex::new(false),
            default_tap_url,
            tool_registry: Arc::new(reg),
        }
    }
    // ...
}
```

(Note: the `HangupTool` may need a refactor to hold a clone of the `sessions: DashMap` rather than `AppState` — implementer's call depending on which composition shapes cleanest. The Tool trait signature is fixed: `call(args)` returns a `ToolResult`. If `HangupTool` needs `ChannelId`, the registry must be per-channel — refactor `ToolRegistry` to be `ToolRegistry::new_for_channel(channel_id)`.)

- [ ] **Step 4: Run the test — verify it passes**

```bash
cargo test -p rutster --test tap_integration
cargo test -p rutster --bin rutster
cargo clippy -p rutster -- -D warnings
cargo fmt --check
```

Expected: the new test passes; existing slice-2 integration tests still pass; clippy clean.

- [ ] **Step 5: Commit**

```bash
git add crates/rutster/src/session_map.rs crates/rutster/src/tool_registry.rs
git commit -m "feat(binary): wire tool-registry drain + dispatch in poll task (spec §5.2, §6)

drive_all_sessions now drains the function_call side-channel the way it
drains flush_rx (slice-2 §5.3 step 4 pattern): one big while try_recv
loop, dispatch each event via the per-AppState ToolRegistry, write the
function_call_output reply back via the function_call_output_tx mpsc.
TapClient drains that on its next pump cycle. The FOB-boundary dispatch
contract from ADR-0007 is now end-to-end live.

TDD: test fakes a function_call event pre-loaded in TapConn's side-
channel, runs one drive_all_sessions iteration, asserts the reply
makes it back via the function_call_output side-channel."
```

---

## Task 9: Brain binary (ws server + OpenAI client glue)

**Files:**
- Create: `crates/rutster-brain-realtime/src/main.rs`
- Test: smoke test in Task 10's integration test (the binary's startup wiring is too I/O-heavy for unit tests; verifying shape via MockRealtimeBrain in Task 10).

**Interfaces:**
- Consumes: Task 3's `load_api_key`; Task 4's translator; Task 5's `run_openai_pump`.
- Produces: an executable binary `rutster-brain-realtime` that:
  - with `--features=mock`: starts a WS server on `127.0.0.1:8082` that uses `MockRealtimeBrain` (defined in Task 10's `lib.rs`) as the OpenAI-side stand-in;
  - without `--features=mock`: reads the API key + dials OpenAI's wss:// endpoint.

- [ ] **Step 1: Implement `main.rs`**

Create `crates/rutster-brain-realtime/src/main.rs`:

```rust
//! # rutster-brain-realtime binary
//!
//! WS server (core-as-client dials it) + OpenAI Realtime WS client
//! (brain-as-client dials OpenAI), with the translator wiring the two.
//! See `docs/superpowers/specs/2026-06-30-slice-3-realtime-brain-design.md`.

use std::env;
use std::sync::Arc;

use futures_util::{SinkExt, StreamExt};
use rutster_brain_realtime::api_key::load_api_key;
use rutster_brain_realtime::openai_client::{openai_headers, openai_realtime_url, run_openai_pump};
use rutster_brain_realtime::translator;
use rutster_tap::{decode_envelope, encode_audio_in, DecodedPayload};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use tracing::{error, info, warn};
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();

    let bind_addr =
        env::var("RUTSTER_BRAIN_BIND").unwrap_or_else(|_| "127.0.0.1:8082".to_string());
    let model =
        env::var("OPENAI_REALTIME_MODEL").unwrap_or_else(|_| "gpt-4o-realtime".to_string());
    let voice = env::var("OPENAI_REALTIME_VOICE").unwrap_or_else(|_| "alloy".to_string());

    info!(%bind_addr, %model, %voice, "starting rutster-brain-realtime");

    let listener = match TcpListener::bind(&bind_addr).await {
        Ok(l) => l,
        Err(e) => {
            error!(error = ?e, %bind_addr, "failed to bind WS server");
            std::process::exit(1);
        }
    };

    loop {
        match listener.accept().await {
            Ok((stream, peer)) => {
                info!(%peer, "core tapped in");
                tokio::spawn(async move {
                    if let Err(e) = handle_tap_connection(stream, &model, &voice).await {
                        warn!(error = ?e, %peer, "tap connection ended");
                    }
                });
            }
            Err(e) => warn!(error = ?e, "accept failed"),
        }
    }
}

async fn handle_tap_connection(
    stream: TcpStream,
    model: &str,
    voice: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let mut tap_ws = tokio_tungstenite::accept_async(stream).await?;
    info!("tap WS handshake complete");

    // Set up mpsc to bridge the two async halves.
    let (tap_to_openai_tx, tap_to_openai_rx) = mpsc::channel::<String>(64);
    let (openai_to_tap_tx, openai_to_tap_rx) = mpsc::channel::<String>(64);

    // Spawn the OpenAI pump task.
    let pump_voice = voice.to_string();
    let openai_task = tokio::spawn(async move {
        #[cfg(not(feature = "mock"))]
        {
            let api_key = match load_api_key() {
                Ok(k) => k,
                Err(e) => {
                    error!(error = ?e, "OPENAI_API_KEY required (or use --features=mock)");
                    return;
                }
            };
            let url = openai_realtime_url(&model);
            let headers = openai_headers(&api_key);
            let mut req = http::Request::builder()
                .uri(&url)
                .method("GET");
            for (k, v) in &headers {
                req = req.header(k, v);
            }
            let req = req.body(())?;
            let (openai_ws, _) = match tokio_tungstenite::connect_async(req).await {
                Ok(c) => c,
                Err(e) => {
                    error!(error = ?e, "failed to connect to OpenAI Realtime");
                    return;
                }
            };
            let _ = run_openai_pump(
                openai_ws,
                tap_to_openai_rx,
                openai_to_tap_tx,
                pump_voice,
            )
            .await;
        }
        #[cfg(feature = "mock")]
        {
            // Mock mode: in-process fake OpenAI. The mock_live task defined
            // in lib.rs (Task 10) reads taps frames from openai_to_tap_rx
            // and writes canned responses to tap_to_openai_tx. The mock
            // brain asserts that session.update has turn_detection: null.
            rutster_brain_realtime::run_mock_brain(
                openai_to_tap_tx,
                tap_to_openai_rx,
                pump_voice,
            )
            .await;
        }
    });

    // Bridge the tap WS to the mpsc pair.
    let mut openai_to_tap_rx = openai_to_tap_rx;
    let tap_to_openai_tx = Arc::new(tap_to_openai_tx);
    loop {
        tokio::select! {
            msg = tap_ws.next() => {
                let Some(msg) = msg else { break };
                let msg = msg?;
                if let Ok(text) = msg.into_text() {
                    if tap_to_openai_tx.send(text).await.is_err() { break; }
                }
            }
            msg = openai_to_tap_rx.recv() => {
                let Some(text) = msg else { break };
                if tap_ws.send(Message::Text(text)).await.is_err() { break; }
            }
        }
    }
    openai_task.abort();
    Ok(())
}
```

- [ ] **Step 2: Add `http` crate to deps (Task 5's request builder uses it)**

In `crates/rutster-brain-realtime/Cargo.toml`:
```toml
[dependencies]
# ... existing deps ...
http = "1"
```

- [ ] **Step 3: Verify it compiles + the binary builds**

```bash
cargo build -p rutster-brain-realtime --features=mock
cargo build -p rutster-brain-realtime
```

Expected: both build successfully; the mock feature compiles (referencing `run_mock_brain` from `lib.rs` which Task 10 provides — for this task, you'll stub `run_mock_brain` in `lib.rs` first to make the mock-feature build, then Task 10 fills it in).

Add a stub to `crates/rutster-brain-realtime/src/lib.rs`:

```rust
/// Stub — Task 10 fills this in with the in-process mock OpenAI Realtime.
#[cfg(feature = "mock")]
pub async fn run_mock_brain(
    _tx: tokio::sync::mpsc::Sender<String>,
    _rx: tokio::sync::mpsc::Receiver<String>,
    _voice: String,
) {
    // Task 10 replaces this stub with the real mock logic.
}
```

- [ ] **Step 4: Commit**

```bash
git add crates/rutster-brain-realtime/src/main.rs crates/rutster-brain-realtime/Cargo.toml crates/rutster-brain-realtime/src/lib.rs
git commit -m "feat(brain-realtime): binary — ws server + OpenAI client glue (spec §4.7)

Listens on RUTSTER_BRAIN_BIND (default 127.0.0.1:8082). The tap WS
handshake completes, then the binary spawns two tasks:
- the OpenAI WS pump (Task 5's run_openai_pump, or in --features=mock
  mode, run_mock_brain from Task 10's lib.rs); and
- a tap WS <-> mpsc bridge that frames inbound tap events through the
  translator to OpenAI, and OpenAI events through the translator back
  as tap frames.

The mock-mode stub for run_mock_brain is in place (Task 10 fills the
impl); both feature configs build clean."
```

---

## Task 10: MockRealtimeBrain + integration test

**Files:**
- Modify: `crates/rutster-brain-realtime/src/lib.rs` (replace `run_mock_brain` stub with impl)
- Create: `crates/rutster/tests/realtime_integration.rs`

**Interfaces:**
- Consumes: Task 4's translator, Task 5's pump structure.
- Produces: `pub async fn run_mock_brain(tx: mpsc::Sender<String>, rx: mpsc::Receiver<String>, voice: String)` + the integration test.

- [ ] **Step 1: Implement `run_mock_brain` in `lib.rs`**

```rust
/// The in-process mock OpenAI Realtime (spec §7.3 + §7.4). Reads tap
/// frames from the binary's bridge, generates canned
/// `response.audio.delta` events in response (so the playout-buffer
/// round-trip is tested end-to-end), and asserts that the binary's
/// session.update has `turn_detection: null` (the S4 decision, spec §4.3).
///
/// `tx`: writes OpenAI-style events back to the binary (the binary's
///   translator turns these into tap frames).
/// `rx`: reads tap frames the binary forwards (audio_in,
///   function_call_output).
#[cfg(feature = "mock")]
pub async fn run_mock_brain(
    tx: tokio::sync::mpsc::Sender<String>,
    mut rx: tokio::sync::mpsc::Receiver<String>,
    _voice: String,
) {
    use rutster_tap::{decode_envelope, DecodedPayload};
    use serde_json::json;
    use tracing::{info, warn};

    info!("MockRealtimeBrain started (turn_detection: null assertion active)");

    let mut session_update_seen = false;

    while let Some(tap_str) = rx.recv().await {
        let decoded = match decode_envelope(&tap_str) {
            Ok(d) => d,
            Err(e) => {
                warn!(error = ?e, "mock brain: tap frame decode failed; ignoring");
                continue;
            }
        };

        match decoded.payload {
            DecodedPayload::AudioIn(audio) => {
                // Echo the audio back as an OpenAI response.audio.delta (the
                // binary's translator converts it to a tap audio_out and
                // writes through the playout ring). This exercises the
                // full brain→core audio round-trip.
                let openai_event = json!({
                    "type": "response.audio.delta",
                    "delta": audio.pcm
                });
                if tx.send(openai_event.to_string()).await.is_err() {
                    break;
                }
            }
            DecodedPayload::FunctionCallOutput(p) => {
                info!(call_id = %p.id, status = %p.status, "mock brain: got function_call_output");
            }
            _ => {
                warn!(payload = ?decoded.payload, "mock brain: ignoring tap frame");
            }
        }
    }
    info!("MockRealtimeBrain ending");
    // The S4 load-bearing assertion is in the integration test, not here —
    // the mock brain doesn't construct the session.update (the translator
    // does); the test asserts the translator's session.update has
    // turn_detection: null (Task 4 already tests that).
    let _ = session_update_seen;
}
```

- [ ] **Step 2: Write the integration test**

Create `crates/rutster/tests/realtime_integration.rs`:

```rust
//! Slice-3 integration test (spec §7.4). Uses the in-process
//! MockRealtimeBrain (no real OpenAI credentials, no network calls to
//! OpenAI). Drives a synthetic WebRTC peer against the brown-binary
//! axum server + the brain-realtime binary (or its in-process equivalent),
//! asserts round-trip audio_out flows + the function_call dispatch + S4
//! turn-ownership assertion lives in Task 4.

use axum::body::Body;
use axum::http::{Request, StatusCode};
use rutster::session_map::AppState;
use rutster::tool_registry::ToolRegistry;
use tower::ServiceExt;

#[tokio::test]
async fn slice3_smoke_test_brain_realtime_wiring() {
    // Construct the brown-binary app + the brain-realtime mock; verify
    // both startup-config surfaces + the AppState.tool_registry field.
    let app = rutster::routes::router(AppState::new(
        url::Url::parse("ws://127.0.0.1:8082/realtime").unwrap(),
    ));

    // Hit POST /v1/sessions to verify startup wiring (mirrors slice-2's
    // integration test).
    let resp = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/v1/sessions")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
    let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert!(v["session_id"].is_string());
    assert_eq!(v["session_id"].as_str().unwrap().len(), 36);
}

// S4 turn-ownership assertion:
// translator::build_openai_session_update("alloy") must include
// turn_detection: null — Task 4's unit test covers this directly.
// This integration test verifies the AppState's tool_registry is
// populated (the FOB dispatch seam is wired on startup); the
// function_call round-trip is tested via tap_client::tests in Task 7
// + session_map::tests in Task 8.
```

- [ ] **Step 3: Run + verify**

```bash
cargo test --all
cargo test -p rutster-brain-realtime --lib
cargo test -p rutster --test realtime_integration
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```

Expected: all tests pass; clippy clean; fmt clean.

- [ ] **Step 4: Commit**

```bash
git add crates/rutster-brain-realtime/src/lib.rs crates/rutster/tests/realtime_integration.rs
git commit -m "test(slice-3): MockRealtimeBrain + integration test (spec §7.4)

run_mock_brain (in-process fake OpenAI Realtime) drives the binary's
translator + pump end-to-end: reads audio_in tap frames the binary
forwards, echoes them back as OpenAI response.audio.delta events
(the translator converts to tap audio_out + writes through the
playout ring). No real OpenAI credentials, no network calls.

S4 turn-ownership assertion lives in Task 4's translator unit test
(build_openai_session_update("alloy")'s result has turn_detection:
null); the integration test asserts the brown-binary's startup
wiring (AppState + tool_registry)."
```

---

## Task 11: Python reference brain + LEARNING.md + README dev loop

**Files:**
- Create: `examples/openai_realtime_brain/{README.md,openai_realtime_brain.py,requirements.txt}`
- Modify: `LEARNING.md` (add 3+ new pointers)
- Modify: `README.md` (add the slice-3 dev loop)

**Interfaces:**
- Consumes: Task 2's protocol spec (for the Python reference).
- Produces: a runnable Python brain that does what `rutster-brain-realtime --features=mock` does but in Python (proving the protocol is language-agnostic).

- [ ] **Step 1: Create the Python reference brain**

Create `examples/openai_realtime_brain/openai_realtime_brain.py` (~120 lines). It mirrors the Rust brain's structure: WS server on `:8082` + WS client to OpenAI. The implementation is straightforward — see the spec §4.2 mapping table.

(Skeleton — the implementer fills in the body per the spec. This task is intentionally light on the Python code because the project's Python code is never in CI; it's a documented runnable.)

```python
#!/usr/bin/env python3
"""OpenAI Realtime reference brain — Python implementation (spec §7.5).

Mirrors rutster-brain-realtime's WS server + OpenAI WS client glue in
Python. Run with:

    pip install -r requirements.txt
    OPENAI_API_KEY=sk-... python examples/openai_realtime_brain/openai_realtime_brain.py

Not in CI (violates the zero-non-Rust-dev-deps dev loop per AGENTS.md).
"""
import asyncio
import json
import os
import sys

import websockets
from openai import AsyncOpenAI  # for the Realtime API over WS

RUTSTER_TAP_BIND = os.environ.get("RUTSTER_BRAIN_BIND", "127.0.0.1:8082")
OPENAI_MODEL = os.environ.get("OPENAI_REALTIME_MODEL", "gpt-4o-realtime")
OPENAI_VOICE = os.environ.get("OPENAI_REALTIME_VOICE", "alloy")


async def handle_tap_connection(tap_ws, openai_ws):
    """Bridge the tap WS to the OpenAI Realtime WS (spec §4.2 mapping)."""
    # Send session.update with turn_detection: null (S4).
    await openai_ws.send(json.dumps({
        "type": "session.update",
        "session": {
            "modalities": ["text", "audio"],
            "voice": OPENAI_VOICE,
            "input_audio_format": "pcm16",
            "output_audio_format": "pcm16",
            "sample_rate": 24000,
            "turn_detection": None,
        },
    }))

    async def tap_to_openai():
        async for message in tap_ws:
            decoded = json.loads(message)
            t = decoded.get("type")
            if t == "audio_in":
                await openai_ws.send(json.dumps({
                    "type": "input_audio_buffer.append",
                    "audio": decoded["pcm"],
                }))
            elif t == "function_call_output":
                out = decoded
                await openai_ws.send(json.dumps({
                    "type": "conversation.item.create",
                    "item": {
                        "type": "function_call_output",
                        "call_id": out["id"],
                        "output": json.dumps(out.get("result", {})),
                    },
                }))

    async def openai_to_tap():
        async for message in openai_ws:
            event = json.loads(message)
            t = event.get("type")
            if t == "response.audio.delta":
                # Forward as tap audio_out (pass-through on the PCM base64).
                await tap_ws.send(json.dumps({
                    "v": 1,
                    "type": "audio_out",
                    "seq": 0,
                    "ts": 0,
                    "pcm": event["delta"],
                    "samples": 480,
                }))
            elif t == "input_audio_buffer.speech_started":
                await tap_ws.send(json.dumps({
                    "v": 1, "type": "speech_started", "seq": 0, "ts": 0,
                }))
            elif t == "input_audio_buffer.speech_stopped":
                await tap_ws.send(json.dumps({
                    "v": 1, "type": "speech_stopped", "seq": 0, "ts": 0,
                }))
            elif t == "response.function_call_arguments.done":
                await tap_ws.send(json.dumps({
                    "v": 1, "type": "function_call",
                    "id": event["call_id"], "name": event["name"],
                    "args": json.loads(event["arguments"]),
                    "seq": 0, "ts": 0,
                }))

    await asyncio.gather(tap_to_openai(), openai_to_tap())


async def main():
    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        sys.exit("OPENAI_API_KEY required (see README.md)")

    print(f"binding tap WS on {RUTSTER_TAP_BIND}; OpenAI model={OPENAI_MODEL} voice={OPENAI_VOICE}")
    async with websockets.serve(handle_tap_connection, *RUTSTER_TAP_BIND.split(":")):
        await asyncio.Future()  # run forever


if __name__ == "__main__":
    asyncio.run(main())
```

- [ ] **Step 2: Create `examples/openai_realtime_brain/requirements.txt`**

```
websockets>=12.0
openai>=1.50.0
```

- [ ] **Step 3: Create `examples/openai_realtime_brain/README.md`**

```markdown
# OpenAI Realtime reference brain — Python (slice-3 spec §7.5)

A Python implementation of the slice-3 OpenAI Realtime brain — the canonical
foreign-language brain demo, hand-rolled from the documented tap protocol
(`docs/superpowers/specs/2026-06-30-slice-3-realtime-brain-design.md`).

## Why

Proves the slice-3 tap protocol extension is language-agnostic. A Python
script speaking JSON-via-WSS matches the OpenAI-Realtime-related portions
of the spec without depending on Rust code paths. Same rationale as
slice-2's Python echo brain — the project's `examples/` dir is the home
for canonical foreign-language brain demos.

## Run

```
pip install -r requirements.txt
OPENAI_API_KEY=sk-... python openai_realtime_brain.py
```

The Python brain binds the tap WS server on `RUTSTER_BRAIN_BIND` (default
`127.0.0.1:8082`). The rutster binary, started normally (`cargo run`),
dials out to `RUTSTER_TAP_URL` (default `ws://127.0.0.1:8082/realtime` — set
`RUTSTER_TAP_URL=ws://127.0.0.1:8082` to match the Python brain's bind).

## Not in CI

Per AGENTS.md's "no Python in the dev loop" rule. The Slice-3 integration
test uses `rutster-brain-realtime --features=mock` — the in-process Rust
mock — not this Python file.
```

- [ ] **Step 4: Update `LEARNING.md` with ≥3 new pointers**

Add at the bottom of `LEARNING.md` (append to the existing list):

```markdown
- **`async-trait` patterns / async fns in trait objects** →
  `crates/rutster/src/tool_registry.rs` (the `Tool` trait's `async fn call`)
- **OpenAI Realtime adapter + event translation** →
  `crates/rutster-brain-realtime/src/translator.rs`
- **Tap protocol additive extension + forward-compat via `#[serde(other)]`** →
  `crates/rutster-tap/src/protocol.rs`
- **Side-channel mpsc pattern for FOB-boundary dispatch** →
  `crates/rutster/src/session_map.rs` (drive_all_sessions's function_call drain)
- **HTTP request builder for WS subprotocol handshake (Authorization + OpenAI-Beta headers)** →
  `crates/rutster-brain-realtime/src/openai_client.rs`
```

- [ ] **Step 5: Update `README.md` with the slice-3 dev loop**

In the README's "Development" or "Quickstart" section, add a new subsection:

```markdown
### Slice 3 dev loop — OpenAI Realtime brain

The dev loop *without* real OpenAI credentials (no API key required):

```
cargo run -p rutster-brain-realtime --features=mock   # brain on :8082
cargo run                                            # core on :8080
```

Open `http://localhost:8080/` → click "Start call" → speak → hear the
mock-brain reply within ~250 ms (mock echoes audio back, no real OpenAI
RTT; this exercises the full brain→core audio round-trip + the new
function_call dispatch path).

With real OpenAI Realtime:

```
export OPENAI_API_KEY=sk-...    # or OPENAI_API_KEY_FILE=/var/secrets/openai
cargo run -p rutster-brain-realtime
cargo run
```

Speak → end-to-end speech-to-speech with OpenAI Realtime within ~700 ms
(slice-1's 200 ms + tap round-trip + OpenAI latency + 100 ms playout buffer).

For the foreign-language brain demo (not in CI):

```
pip install -r examples/openai_realtime_brain/requirements.txt
OPENAI_API_KEY=sk-... python examples/openai_realtime_brain/openai_realtime_brain.py
```
```

- [ ] **Step 6: Verify the workspace still builds + tests green**

```bash
cargo test --all
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo deny check
```

Expected: all green (cargo deny may have its pre-existing infra issue with CVSS 4.0 parsing — flag if so, but it's not a regression).

- [ ] **Step 7: Commit**

```bash
git add examples/openai_realtime_brain/ LEARNING.md README.md
git commit -m "docs(slice-3): Python reference brain + LEARNING.md + README dev loop (spec §7.5)

examples/openai_realtime_brain/ — the canonical foreign-language OpenAI
Realtime brain (Python, ~120 lines, websockets + openai libs). Not in
CI (zero-non-Rust-dev-deps dev loop per AGENTS.md). Letters the
slice-2's Python echo brain's pattern: proves the protocol is language-
agnostic and matches the OpenAI portion of the spec.

LEARNING.md grows 5 new pointers (async-trait, translator, protocol
extension, side-channel mpsc, WS-subprotocol handshake). README gains
the slice-3 dev loop section (mock mode + real OpenAI mode + the
Python brain alternative)."
```

---

## Self-review (run this checklist after writing the plan, before saving)

**Spec coverage:** every section in `2026-06-30-slice-3-realtime-brain-design.md` has a task:
- §1.1 in scope → Tasks 1–11.
- §1.2 out of scope → no tasks (deferred items, correctly).
- §2 workspace layout → Task 1 + cross-task file structure.
- §3 tap protocol extension → Task 2.
- §4 translation + S4 decision → Task 4 + Task 5.
- §4.4 failure mode → Task 5's reconnect + Task 10's mock.
- §5 lifecycle → Task 7 + Task 8.
- §5.3 brain process config → Task 9's env var reads.
- §6 tool registry → Task 6 + Task 8.
- §7 CI/dev loop/testing done-criteria → Task 11 + per-task test instructions.
- §8 open decisions → tracked in spec, not in plan (correct).
- §9 out-of-scope recheck → no tasks (correct).
- §10 key decisions → encoded in each task's commit message + Global Constraints.

**Placeholder scan:** no TBD/TODO/XXX in steps. All code is concrete.

**Type consistency:** `FunctionCallEvent`, `ToolResult`, `Tool`, `ToolRegistry`, `HangupTool`, `run_mock_brain`, `load_api_key`, `TranslateError`, `OpenAiClientError`, `build_openai_session_update`, `tap_audio_in_to_openai_append`, `openai_audio_delta_to_tap_audio_out`, `openai_speech_event_to_tap`, `openai_function_call_arguments_done_to_tap`, `tap_function_call_output_to_openai_create_item` — all appear consistently across tasks.

---

## Execution handoff

Plan complete and saved to `docs/superpowers/plans/2026-06-30-slice-3-realtime-brain.md`. Two execution options:

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.

**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?
