# Rutster slice 3 — Swap echo for OpenAI Realtime: the brain lands

- **Status:** Draft (pending review)
- **Date:** 2026-06-30
- **Spearhead step:** 3 of 6 (vision-revision §10 / PORT_PLAN "Phasing")
- **Origin:** brainstorming session 2026-06-30, resumed after the 2026-06-29
  strategic pivot (ADR-0007 + ADR-0008).
- **Depends on:** [slice 2 — the agent tap](2026-06-28-slice-2-agent-tap-design.md)
  (must be implemented + green; the tap protocol and `TapAudioPipe` seam are
  the foundation this slice swaps content into).
- **Related:**
  [ADR-0002](../../adr/0002-north-star-and-fused-core.md) (fused vertical),
  [ADR-0004](../../adr/0004-license.md) (GPL-3.0-or-later),
  [ADR-0008](../../adr/0008-fob-and-green-zone.md) (the brain is **green-zone**;
  the reflex loop is **FOB** — load-bearing for the S4 turn-ownership
  decision below),
  [ARCHITECTURE.md §"Agent tap"](../../ARCHITECTURE.md).

---

## TL;DR

Stand up spearhead step 3: swap slice-2's echo brain for a real
**speech-to-speech brain — OpenAI Realtime** — reached through slice-2's
existing tap. The core dials out (core-as-client; brain-as-server; still
**no inbound tap port on the core**), slice-2's tap protocol carries
audio + new event types (additive, v1, forwards-compatible), and the
brain process translates between our protocol and OpenAI Realtime's event
taxonomy.

Slice 3 proves **agent integration**: the same WSS plumbing that today
echoes will, in step 4, carry VAD/barge-in signals back from the brain to
the core's reflex loop (the FOB). It deliberately defers real barge-in /
VAD-driven playout kill (step 4), the PSTN trunk (step 5), and spend
control (step 6) — but it *pre-paves* the `speech_started` /
`speech_stopped` event seam so step 4 lands cleanly.

The seam slice-2 pre-paved (`AudioSource` / `AudioSink` traits + the
`TapAudioPipe` shape + the `TapEngine` task) is the **test of this
slice**: `RtcSession`'s media-loop path changes by zero lines; the
TapEngine's spawn / reconnect / teardown logic is untouched; only the
*tap protocol module* (`rutster-tap/src/protocol.rs`) grows new event
types and a new tool-registry module (`crates/rutster/src/tool_registry.rs`)
lands in the binary.

---

## 1. Scope

### 1.1 In scope

- Implementation of spearhead step 3: WebRTC peer → core terminates
  DTLS-SRTP, decodes Opus → canonical PCM @ 24 kHz mono, ships PCM
  **over WSS** to an external OpenAI Realtime brain process, receives PCM
  back, encodes + plays out via str0m. The user speaks and hears the AI
  reply through an end-to-end speech-to-speech loop within ~700 ms
  (slice-1's 200 ms + tap round-trip + OpenAI latency + 100 ms playout
  buffer headroom).
- **`crates/rutster-brain-realtime`** — a new workspace member; library +
  binary. Dual purpose like slice-2's `rutster-tap-echo`: a standalone
  dev-loop binary (`cargo run -p rutster-brain-realtime`) and an in-process
  `MockRealtimeBrain` for integration tests (no network calls to OpenAI).
  Default port `ws://127.0.0.1:8082/realtime` (the slice-2 echo brain
  defaults to `:8081/echo`; the two coexist).
- **Tap protocol extension (additive, v1, forwards-compatible):**
  - `speech_started`, `speech_stopped` (brain → core, advisory).
  - `function_call` (brain → core: tool name + args).
  - `function_call_output` (core → brain: status + result).
  - `tools.update` (brain → core: tool catalog so the core can validate).
  Old echo brains ignore the new types per slice-2's "unknown type → log +
  count + drop" rule (§3.4 of the slice-2 spec).
- **In-boundary tool registry** in the brown binary
  (`crates/rutster/src/tool_registry.rs`). `hangup` is the only wired tool
  — fires the existing `Channel: Connected → Closing` path. Other tool
  names reply `status: "not_implemented"`. The brain's `tools.update`
  event declares the catalog; the registry validates function_call events
  against it before dispatch.
- **OpenAI Realtime translation layer** in `rutster-brain-realtime`: tap
  events ↔ OpenAI Realtime events. Audio is 24 kHz mono PCM inside base64
  LE i16 — **matching slice-1's canonical tap format exactly**, no resample.
- **S4 turn-ownership decision (load-bearing per ADR-0008):** OpenAI
  Realtime's `session.update` is sent with `turn_detection: null`
  (disabled). The core drives turn-taking through its own (FOB) reflex
  loop in step 4; OpenAI is treated as a speech-to-speech transducer.
  The `speech_started` / `speech_stopped` events are caught and forwarded
  through the tap protocol as advisory signals so step 4 can use them,
  but OpenAI does **not** auto-bar the brain's `audio_out` — the
  core-authoritative playout buffer (slice 2 §4.1) is the only thing
  that gates playout.
- **Two-source config** for the brain process: `OPENAI_API_KEY` env
  default + `OPENAI_API_KEY_FILE` path override. `OPENAI_REALTIME_MODEL`
  env (default: `gpt-4o-realtime` or current equivalent; documented).
- **`--features=mock` dev mode** on the brain binary: runs the brain
  process with an in-process mocked Realtime (no API key, no network
  calls to OpenAI) — for offline dev loop + integration tests.
- New workspace deps: `tokio-tungstenite` (already pulled by slice-2),
  `reqwest` or `serde_json` (already pulled), and **no new** workspace
  member-deps beyond what slice-2 already pinned. Reuse slice-2's
  protocol types from `rutster-tap`.

### 1.2 Out of scope (with scheduled return)

| Deferred item | Returns in | Why deferred |
|---|---|---|
| Real barge-in / VAD-driven playout kill | Step 4 | Slice-3 pre-paves the `speech_started` / `speech_stopped` advisory event seam; step 4 wires the FOB reflex loop to act on them. The core-authoritative playout buffer from slice-2 §4.1 is already in place. |
| PSTN trunk / rented-transport integration | Step 5 | ADR-0007's rented CPaaS raw-media fork lands the phone number; the brain is unaffected. |
| Spend cap / abuse gate | Step 6 | The brain has no spend surface yet — OpenAI bills the operator directly. In-boundary spend pacing lands with `rutster-spend` in step 6. |
| Multi-brain routing / per-tenant brain selection | Step 6 | One tap URL per call in slice-3 (env + per-call override). The slice-3 brain process is OpenAI-Realtime-only. A Deepgram+LLM+TTS composite adapter or a self-hosted open-weights brain is a future-rung concern; the tap protocol is brain-agnostic by design. |
| API-key rotation / KMS integration | Step 6 + later | `OPENAI_API_KEY` (env) + `OPENAI_API_KEY_FILE` (path) is the dev posture. KMS / Vault integration lands with the real trust boundary (step 6). |
| TLS on the tap and on the OpenAI leg | Step 6 | Slice-2 rejects `wss://` URLs at session-create (deferred to step 6); the OpenAI Realtime leg is `wss://` and uses rustls-native roots (no cert pinning in slice-3 — defer to step 6). |
| Authentication / authorization on the `tap_url` override | Step 6 | Inherits slice-2's "no auth yet" posture. |
| Re-INVITE / session migration / resumability | Later | Refresh the page → new session, same as slice-1/2. OpenAI Realtime session is per-call; no in-flight call preservation across server restart. |
| CDR / event bus / OTel beyond per-Channel `tracing` spans | Step 5 | Single peer + single brain; no fanout yet. Tool-call events go to logs + counters only. |
| Quality dashboard (containment, escalation reasons) | Capability ladder rung 3 | The current build target proves agent integration, not analytics. |
| Audio resampling for brains using 16 kHz | Future | OpenAI Realtime uses 24 kHz mono, matching slice-1's canonical tap format. Will be needed if a future brain uses 16 kHz; the translator resamples at that time. |
| `response.audio.delta` batching optimization | Future | OpenAI sends many small delta events; the translator MAY batch into the slice-2 `audio_out` 20 ms frame. Optimization optional for v1; tracked. |

### 1.3 What this slice does NOT prove

It does **not** prove: a real barge-in reflex (only the event seam),
latency determinism under reflex timing, PSTN trunking, spending controls,
multi-tenancy on the tap URL, API-key rotation, or wss:// TLS posture in
production. It proves **only** agent integration: the OpenAI Realtime
adapter as green-zone (per ADR-0008), the tap protocol extension carrying
audio + interruption signals + function-call plumbing, the in-boundary
tool registry dispatching `hangup` cleanly, and the seam test (slice-2's
`RtcSession` accepts the new brain with zero internal change).

---

## 2. Workspace layout (delta on slice-2)

One new workspace member; one new module in the binary; additive
extension to `rutster-tap`'s protocol module.

```
rutster/
├── Cargo.toml                          # +rutster-brain-realtime in members
├── crates/
│   ├── rutster/                         # binary: +tool_registry module
│   │   ├── src/main.rs
│   │   ├── src/session_map.rs          # +tool-call side-channel drain
│   │   ├── src/routes.rs               # unchanged shape
│   │   ├── src/tap_engine.rs           # UNCHANGED — the seam test
│   │   ├── src/tool_registry.rs        # NEW: Tool trait, hangup tool
│   │   └── static/index.html           # minor: surface brain connection status
│   ├── rutster-media/                   # UNCHANGED — the seam test
│   │   └── src/loop_driver.rs          # UNCHANGED
│   ├── rutster-call-model/              # UNCHANGED
│   ├── rutster-tap/                     # +additive protocol event types
│   │   └── src/protocol.rs             # +speech_started/stopped, function_call/output, tools.update
│   ├── rutster-tap-echo/                # UNCHANGED (still works against extended protocol)
│   ├── rutster-brain-realtime/          # NEW crate
│   │   ├── Cargo.toml                  # deps: rutster-tap, tokio, tokio-tungstenite, serde_json, tracing, url
│   │   ├── src/lib.rs                  # lib + MockRealtimeBrain for tests
│   │   ├── src/main.rs                 # standalone binary: ws://127.0.0.1:8082/realtime + OpenAI WS client
│   │   ├── src/translator.rs           # tap ⇄ OpenAI event translation
│   │   └── src/openai_client.rs         # wss://api.openai.com/v1/realtime client
│   ├── rutster-trunk/                   # STUB (unchanged)
│   └── rutster-spend/                   # STUB (unchanged)
└── examples/
    └── echo_brain/                      # unchanged (Python reference; ignores new events)
```

### 2.1 Dependency direction

- `rutster-brain-realtime` → `rutster-tap` (for protocol types — same
  re-export pattern slice-2 established for `rutster-tap-echo`).
- `rutster-brain-realtime` is its own workspace member that's both a
  binary (dev loop) and a library (test fixture). The library re-exports
  a `MockRealtimeBrain` for use by integration tests in the binary crate.
- `rutster` (binary) gains `tool_registry.rs` as a sibling of
  `tap_engine.rs` — the new module dispatches tool-call events the
  TapClient observes via the new side-channel mpsc.
- `rutster-tap`'s protocol module is the contract both the brown core
  and the new brain process share; new event types added here are re-used
  by `rutster-brain-realtime` (the contract test that the wire types are
  reusable from outside the core, exactly as `rutster-tap-echo` did).
- `rutster-media` and `rutster-call-model` are untouched. `loop_driver.rs`
  and `rtc_session.rs` are byte-identical to slice-2 (post-review-fix)
  baseline.

### 2.2 Why one new crate for the OpenAI Realtime brain (not an `examples/` file)

Mirrors slice-2's dual-purpose pattern for `rutster-tap-echo`: a real
workspace member that runs `cargo fmt`, `cargo clippy -D warnings`,
`cargo test`, and `cargo deny check` like every other crate. It reuses
`rutster-tap`'s protocol types — the contract test that the wire types
are reusable from **outside the core**, which is what makes the tap a
real extension point and not a closed system.

A Python reference brain (`examples/openai_realtime_brain/openai_realtime_brain.py`)
is the canonical foreign-language brain demo for **OpenAI Realtime
specifically** (slice-2 already shipped the echo brain in Python). This
file is **not in CI** (Python would violate the zero-non-Rust-dev-deps
dev loop). README-documented runnable: `pip install websockets openai`
and run.

### 2.3 Why the brain is green-zone (per ADR-0008)

ADR-0008 classifies "the agent brain" explicitly under green-zone: it's
not hot-path inside the boundary (its round-trip is hundreds of ms; the
timing-critical work happens in the FOB reflex loop), not
security-constitutive (the core-authoritative playout buffer structurally
prevents the brain from flooding the wire), and not differentiating (the
tap-as-open-protocol is the differentiator; any brain speaking it works).
So the OpenAI Realtime adapter lives **outside** the FOB trust boundary —
its own process, its own API key, its own failure domain. The brain is
**reached by** the FOB; it does not live in it. The only FOB-side
additions in slice-3 are:

- `rutster-tap/src/protocol.rs` — additive event types (protocol
  extension, not new behavior — the existing TapClient + TapAudioPipe
  surface is unchanged).
- `crates/rutster/src/tool_registry.rs` — in-boundary tool dispatch (a
  security-constitutive capability: the brain proposes tool calls; the
  FOB disposes. Per ADR-0007's spend-gate posture: "rutster mediates both
  the provider call-control API and the brain tap, so the brain never
  holds the wire").

---

## 3. Tap wire protocol extension (`rutster-tap/src/protocol.rs`)

The slice-2 v1 protocol (envelope: `{v, type, seq, ts}`). The new event
types are additive — slice-2's §3.4 "unknown type → log + count + drop"
rule means an old brain (slice-2's `rutster-tap-echo`) ignores them.
Slice-2's envelope invariants (per-direction `seq`, advisory `ts`,
`samples: 480` for audio frames, explicit LE byte order) carry over
unchanged.

### 3.1 Envelope (slice-2's, unchanged)

```jsonc
{
  "v": 1,
  "type": "<event_name>",
  "seq": <uint>,
  "ts": <uint>
}
```

### 3.2 New events — brain → core (advisory)

| `type` | payload fields | when |
|---|---|---|
| `speech_started` | `{}` | brain detected user speech started (translated from OpenAI `input_audio_buffer.speech_started`; advisory — the core’s FOB reflex loop, when wired in step 4, may use it for barge-in) |
| `speech_stopped` | `{}` | brain detected user speech stopped (translated from OpenAI `input_audio_buffer.speech_stopped`; advisory) |
| `tools.update` | `{ "tools": [{ "name": "<slug>", "description": "...", "parameters": {...} }] }` | brain declares its tool catalog (sent on `hello` ack, re-sent on tool catalog changes). The core's tool registry uses this to validate function_call events. |
| `function_call` | `{ "id": "<uuid>", "name": "<slug>", "args": { ... } }` | brain wants the core to execute a tool (translated from OpenAI `response.function_call_arguments.done`). The core dispatches via the tool registry and replies via `function_call_output`. |

### 3.3 New events — core → brain

| `type` | payload fields | when |
|---|---|---|
| `function_call_output` | `{ "id": "<uuid>", "status": "ok"\|"error"\|"not_implemented", "result": { ... } }` | core's tool-registry reply. Translated by the brain process into an OpenAI `conversation.item.create` with `type: function_call_output` so OpenAI continues the conversation appropriately. |

### 3.4 Invariants

- **Tool-call id:** `function_call` and `function_call_output` carry the
  same `id` (a UUID minted by the brain; OpenAI calls it `call_id`, we
  translate verbatim). Mismatches are logged + counted; the tool-registry
  dispatch keys off the id.
- **Tool validation:** the core's tool registry validates `function_call`
  events against the most recent `tools.update` catalog. An unknown tool
  name → `function_call_output` with `status: "not_implemented"` (not
  `error`; distinguishing "the FOB doesn't know this tool" from "the tool
  failed"). The catalog is brain-authoritative — the brain declares it;
  the core merely checks dispatch.
- **Advisory interruption events:** `speech_started` / `speech_stopped`
  are **advisory only** in slice-3. The core logs + counts them; the FOB
  reflex loop lands in step 4 and will use them. No slice-3 code path
  acts on them in the hot media loop — `loop_driver.rs` is byte-identical
  to slice-2 (post-review-fix) baseline.
- **Forward-compat:** unknown `type` values continue to be logged +
  counted + dropped (slice-2 §3.4). The echo brain from slice-2 ignores
  every new type this slice introduces; it echoes audio unchanged.

### 3.5 Byte-order + audio format (unchanged from slice-2)

PCM inside the base64 payload is explicit little-endian `i16` bytes (v1
wire contract, slice-2 §3.4). OpenAI Realtime's audio events also use
24 kHz mono PCM inside base64 LE i16 — **no transcoding, no resample,
no endianness swap.** The translator passes the audio payload through
to the WS frame verbatim. This is a happy convergence that simplifies
the translator; it is not a coincidence (slice-1's choice of 24 kHz mono
was made with this ecosystem in mind).

---

## 4. The OpenAI Realtime translation layer (`rutster-brain-realtime/src/translator.rs`)

Pure (no schema of its own beyond the new tap event types); maps event
names + payload shapes between the two wire protocols. Owns no call
state beyond the OpenAI Realtime session/connection state.

### 4.1 The two-leg brain process

```
                WS server side                        WS client side
        (core-as-client dials here)         (brain-as-client dials OpenAI)
                  ┌──────────────────┐                              ┌──────────┐
   tap protocol   │  rutster-brain-  │     translation layer         │  OpenAI  │
   core ◄────────┤  realtime         ├────────────────────────────►  │ Realtime │
  (audio_in/out,  │                  │   tap event ⇄ OpenAI event   │  API WS  │
  speech_started, │                  │                              │ (wss://api.openai.com)│
  func_call, ...) └──────────────────┘                              └──────────┘
                        │
                        │ brain process also owns:
                        │  • OpenAI session.update (turn_detection: null — S4)
                        │  • interruption-signal forwarding (OpenAI → tap)
                        │  • function_call forwarding (bidirectional)
                        │  • tools.update on startup (catalog to core)
```

The translation layer is pure (no schema of its own beyond the new tap
event types); it maps event names + payload shapes between the two wire
protocols. It owns no call state beyond OpenAI's session/connection state.

### 4.2 Event mapping table

| Tap protocol | OpenAI Realtime | Notes |
|---|---|---|
| `hello` | `session.update` (modalities: ["audio", "text"], audio config, **turn_detection: null**) | Sent on handshake + reconnect; the S4 decision is encoded here |
| `audio_in` (base64 PCM LE i16) | `input_audio_buffer.append` (base64, same wire shape) | Pass-through; no transcoding |
| `audio_out` (from brain → core, advisory) | `response.audio.delta` events | The brain receives, formats, sends to core via tap_audio_out mpsc |
| `speech_started` (brain → core advisory) | `input_audio_buffer.speech_started` | Caught and forwarded as advisory |
| `speech_stopped` (brain → core advisory) | `input_audio_buffer.speech_stopped` | Caught and forwarded as advisory |
| `function_call` (brain → core) | `response.function_call_arguments.done` (OpenAI emits) → translator formats as tap function_call | Brain's translator extracts `call_id`, `name`, `arguments`; the core dispatches via tool registry |
| `function_call_output` (core → brain) | `conversation.item.create` with `item.type: function_call_output`, `call_id: <id>`, `output: <result-json>` | Closes the tool-call loop |
| `bye` / `session_end` | `session.delete` + WS close | Graceful teardown |

### 4.3 The S4 turn-ownership decision (load-bearing per ADR-0008)

> ARCHITECTURE.md is right that the core should be authoritative on
> VAD/barge-in ("the tap carries the *results* of reflexes, not the
> responsibility"; "`AudioOut` advisory / core-authoritative"). But the
> most likely first brain — OpenAI Realtime — does its own server-side
> VAD and turn detection by default. Integrating step 3–4 means either:
> disable the brain's turn detection and feed it clean, locally-detected
> turns (preserves the reflex-authoritative principle, but is more
> integration work and fights the API's grain), or
> accept split-brain turn-taking where local VAD and the brain's VAD can
> disagree (double-triggers, dropped barge-ins).
>
> **Decision implied:** make "who owns turn detection" an explicit decision
> in the step-3 (brain) design doc, defaulting to core-authoritative,
> brain VAD off, with the integration cost budgeted. Don't let it be
> discovered at wiring time.
> — Claude, vision-sanity-check S4

ADR-0008's FOB/green-zone split makes this decidable without ambiguity:

- **The reflex loop is FOB** (hot-path + differentiating — turn-taking,
  VAD-driven barge-in, jitter, pacing).
- **The brain is green-zone** (per ADR-0008's explicit classification).
- **The tap is core-authoritative** (slice-2 §4.1 — the playout buffer
  structurally prevents the brain from gating playout).

The only doctrine-consistent posture is: **OpenAI Realtime's server-side
turn detection is disabled (`session.update` with `turn_detection: null`),
and the FOB owns turn-taking.** The `speech_started` /
`speech_stopped` events are forwarded as advisory signals so step 4 can
use them as one input to the FOB reflex loop — but OpenAI does not gate
playout, ever. The core-authoritative playout buffer (slice-2 §4.1) is
the only thing that gates playout in slice-3 (and continues to be in
step 4).

**Why this is the right call now (rather than deferring to step 4):**
leaving OpenAI's turn detection on would mean slice-3's demo
"works by accident" — the slice-4 barge-in integration would then have to
disable it mid-flight, with unpredictable behavioral changes. By nailing
it down at slice-3's `session.update`, the brain process never wires up
the wrong posture and step 4's barge-in work composes cleanly on top.

### 4.4 Failure mode + reconnect

- **Tap-side WS reconnect (brain unreachable):** slice-2's
  `TapEngine::spawn_tap_engine` already does bounded-backoff reconnect
  (`250 ms → 500 ms → 1 s → 2 s → cap 5 s`, infinite retries,
  re-`hello`s with the same `session_id`). The brain process being
  unreachable looks identical to slice-2's echo brain being unreachable
  — the slice-2 reconnect path is unchanged. The seam test (slice-2
  §8.5 #6) holds: `tap_engine.rs` is byte-identical.
- **OpenAI-side WS failure:** the brain process's own concern — it enters
  its own bounded-backoff reconnect to OpenAI, and forwards a tap `error`
  event (`{ "code": "brain_upstream_disconnect", "message": "..." }`)
  to the core as an FYI. The core logs + counts; the call stays up; the
  tap-side playout goes silent (slice-2's underflow path) until OpenAI
  reconnects. The slice-3 brain's OpenAI-side reconnect is independent
  of the slice-2 tap-side reconnect — two distinct failure surfaces.
- **API-key invalid (OpenAI returns 401):** the brain process emits a tap
  `error` event (`{ "code": "brain_auth_failed", "message": "..." }`)
  and exits the OpenAI leg. The core's response is unchanged from the
  upstream-disconnect case (logs + silence + reconnect path). Operator
  intervention; the call stays up.
- **Tool-registry dispatch failure:** a tool-registry error (e.g.,
  `hangup` fires but the channel state machine rejects the transition
  because the call is already `Closing`) returns a `function_call_output`
  with `status: "error"` and a result body explaining. The brain forwards
  the error to OpenAI; OpenAI may or may not retry depending on its own
  logic. No core-side retry; the model gets one chance.

---

## 5. Lifecycle & integration

### 5.1 Session lifecycle (slice-3 delta on slice-2)

1. `POST /v1/sessions` — body still *optionally* carries `tap_url`. If
   absent, falls back to `RUTSTER_TAP_URL` env. Default now documented
   as either `ws://127.0.0.1:8081/echo` (slice-2 Rust echo brain) or
   `ws://127.0.0.1:8082/realtime` (slice-3 OpenAI Realtime brain); the
   operator picks which brain to run.
2. `POST /v1/sessions/:id/offer` — unchanged from slice-1/2; SDP answer.
3. On `Connected`: the binary's poll task observes the state transition
   + spawns the `TapEngine` task (unchanged from slice-2). The
   `TapEngine` connects to the brain process's WS server.
4. Brain process startup: connects to `wss://api.openai.com/v1/realtime`,
   sends `session.update` with `turn_detection: null`, waits for OpenAI
   `session.created` ack.
5. Brain process WS server accepts the core's tap WS; `hello` exchange;
   brain process sends `tools.update` with the catalog (currently:
   `hangup` only, plus any tool schemas the brain process's startup
   config declares — see §6).
6. Audio flows: core decodes Opus → PCM → `audio_in` to brain → translator
   formats as `input_audio_buffer.append` → OpenAI processes →
   `response.audio.delta` → translator formats as `audio_out` → core's
   playout ring → str0m encode.
7. Interruption signals: OpenAI `input_audio_buffer.speech_started` /
   `.speech_stopped` → translator → tap `speech_started` / `speech_stopped`
   → core logs + counts (advisory). Step 4 will wire these into the FOB
   reflex loop.
8. Tool calls: OpenAI `response.function_call_arguments.done` → translator
   → tap `function_call` → core's `tool_registry` dispatches (via the
   TapClient's new side-channel mpsc) → `function_call_output` reply →
   translator → OpenAI `conversation.item.create`.
9. `DELETE /v1/sessions/:id` or peer-close → `Closing`: slice-2's
   unmodified teardown sequence fires — the TapEngine's `session_end` →
   brain process's `session.delete` to OpenAI → WS close on both legs.

### 5.2 The tool-call side-channel (the only brown-binary wiring)

Slice-2's `TapClient` runs the WSS pump loop with two mpsc channels
(`tx_pcm_in` for inbound audio to brain, `rx_audio_out` for outbound
audio from brain). Slice-3 adds a third:

- `tx_function_call: mpsc::Sender<FunctionCallEvent>` — the TapClient
  emits a `FunctionCallEvent` whenever it observes a tap `function_call`
  message on its inbound stream. The binary's poll task drains this in
  the same cycle it drains the existing `flush_tx` side-channel
  (slice-2 §5.3 step 4) — same pattern, one extra channel.
- `rx_function_call_output: mpsc::Receiver<FunctionCallOutputEvent>` —
  the binary writes `function_call_output` events through this channel;
  the TapClient picks them up and sends them as tap WS frames.

The shape keeps the seam test (§7 #5 of done criteria below): all this
lives in the binary's `tap_engine.rs` callers + the new `tool_registry.rs`
module. `loop_driver.rs` and `rtc_session.rs` are untouched.

### 5.3 Brain process startup configuration

| Env var | Purpose | Default |
|---|---|---|
| `RUTSTER_TAP_BIND` | WS server bind addr for the brain process | `127.0.0.1:8082` |
| `OPENAI_API_KEY` | OpenAI Realtime API key (mutually exclusive with `_FILE`) | required unless `--features=mock` |
| `OPENAI_API_KEY_FILE` | Path to a file containing the API key | optional; overrides `_KEY` |
| `OPENAI_REALTIME_MODEL` | OpenAI Realtime model id | `gpt-4o-realtime` (or current equivalent) |
| `OPENAI_REALTIME_VOICE` | Voice for TTS output | `alloy` (OpenAI's default; documented) |
| `RUTSTER_BRAIN_TOOLS` | Comma-separated tool names the brain should advertise in `tools.update` | `hangup` (only tool the core wires; others cause `not_implemented` replies) |

The dev mode (`--features=mock`) doesn't read `OPENAI_API_KEY` and
instruments an in-process mock OpenAI Realtime WS server that generates
canned `response.audio.delta` events on `input_audio_buffer.append` and
asserts on the `session.update` having `turn_detection: null`. Used by:
the slice-3 integration test (no real OpenAI credentials, no network
calls), and the offline dev loop.

---

## 6. The tool registry (`crates/rutster/src/tool_registry.rs`)

A FOB-internal dispatch over the brain's proposed tool calls. The brain
proposes (via `function_call`); the FOB disposes (via `function_call_output`).
Per ADR-0007's spend-gate posture: the brain never holds the wire,
structurally.

### 6.1 Trait + registry shape

```rust
#[async_trait]
pub trait Tool: Send + Sync {
    /// Tool name as the brain will reference it in function_call events.
    fn name(&self) -> &str;
    /// JSON-schema description the registry sends to the brain on tools.update.
    fn schema(&self) -> serde_json::Value;
    /// Execute the tool; return a result that the registry will serialize
    /// into the function_call_output payload.
    async fn call(&self, args: serde_json::Value) -> ToolResult;
}

pub enum ToolResult {
    Ok(serde_json::Value),
    Error(String),
    NotImplemented,
}

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

impl ToolRegistry {
    pub fn new() -> Self;
    pub fn register(&mut self, tool: Box<dyn Tool>);
    /// Dispatch by tool name; returns ToolResult::NotImplemented if unknown.
    pub async fn dispatch(&self, name: &str, args: serde_json::Value) -> ToolResult;
    /// Used on startup + tools.update: enumerate the registered catalog.
    pub fn catalog(&self) -> Vec<serde_json::Value>;
}
```

### 6.2 The `hangup` tool (the only wired impl in slice-3)

A `Tool` impl that on `call`:
1. Looks up the `ChannelId` (passed via the dispatch context — the
   `tool_registry` is keyed by `ChannelId`, one registry per active
   channel).
2. Fires `AppState::close(channel_id).await` — the existing slice-2
   teardown path (sets `Closing`, sends `session_end`, aborts the engine).
3. Returns `ToolResult::Ok({"channel_state": "Closing"})` so the brain
   gets confirmation.

Other tool names (anything in `RUTSTER_BRAIN_TOOLS` the brain advertises
that isn't `hangup`) → the registry returns `ToolResult::NotImplemented`:
the brain process forwards the reply as an OpenAI
`conversation.item.create` with the not_implemented status, and the model
is free to continue.

### 6.3 What slice-3 does NOT wire

- No `transfer` tool (escalation rung 2 — separate slice).
- No `lookup` / CRM-integration tools (business-logic integrations are
  green-zone + future-rung).
- No spend/abuse-triggered tools (step 6).
- No dynamic tool registration at runtime (the catalog is fixed at
  startup from `RUTSTER_BRAIN_TOOLS`).

If a future brain proposes a tool not in the registry: `not_implemented`
reply; the model is free to retry with different arguments or give up.
The registry's job is to be the auditable dispatch boundary, not to be
extensible mid-call.

---

## 7. CI, dev loop, testing

### 7.1 New `[workspace.dependencies]` (Cargo.toml)

- `async-trait = "0.1"` (for the `Tool` trait's async fn).
- `tokio-tungstenite`, `serde_json`, `tracing`, `url` — already pulled
  by slice-2; reused.

Member crate `rutster-brain-realtime` references these with
`dep.workspace = true`.

### 7.2 CI (`.github/workflows/ci.yml`)

Unchanged structure: `cargo fmt --check`, `cargo clippy -D warnings`,
`cargo test --all`, `cargo deny check`. The new `rutster-brain-realtime`
crate joins `--all`. **No Python in CI** — the OpenAI Realtime Python
reference brain is README-documented only. The Rust
`rutster-brain-realtime`'s in-process `MockRealtimeBrain` powers the
integration test; no real OpenAI credentials needed.

### 7.3 Dev loop

- `cargo run -p rutster-brain-realtime --features=mock` → starts the
  brain process with an in-process mock OpenAI Realtime (on `:8082`):
  no API key required, no network calls to OpenAI.
- `cargo run -p rutster-brain-realtime` (with `OPENAI_API_KEY` set) →
  starts the brain process with the real OpenAI Realtime.
- `cargo run` (or `cargo run -p rutster`) → starts the axum signaling
  server on `0.0.0.0:8080`, dials out to `$RUTSTER_TAP_URL` on each
  session.
- Browser → `http://localhost:8080/` → click "Start call" → grant mic →
  speak → hear AI reply through the brain process.
- `RUST_LOG=rutster=debug cargo run` for verbose tracing including
  tap + tool-call events.
- `--features=echo` on the binary (slice-2 inherited) → bypasses the
  brain entirely, routes audio through `EchoAudioPipe`.

### 7.4 Testing strategy

- **Unit tests in `rutster-brain-realtime`:**
  - Protocol-event translation round-trips (tap `audio_in` ↔ OpenAI
    `input_audio_buffer.append`; tap `function_call` ↔ OpenAI
    `response.function_call_arguments.done`; tap `function_call_output`
    ↔ OpenAI `conversation.item.create`).
  - `session.update` body asserts `turn_detection: null` (S4 decision
    encoded as a test).
  - `tools.update` serialization round-trips.
  - Error path: OpenAI-side WS error → tap `error` event forwarding.
- **Unit tests in `rutster-tap`:**
  - New event types (de)serialization round-trips with golden JSON
    fixtures in `tests/fixtures/`.
  - Slice-2's existing echo-brain tests stay green (the new event types
    are ignored by the echo brain — forwards-compat).
- **Unit tests in `rutster` `tool_registry.rs`:**
  - `hangup` tool fires `AppState::close` correctly.
  - Unknown tool name → `ToolResult::NotImplemented`.
  - Registry catalog serialization.
- **Integration test in `rutster` binary crate:** spin up the axum server
  (ephemeral port) + the in-process `MockRealtimeBrain` (ephemeral port) +
  set `RUTSTER_TAP_URL`. Drive a synthetic WebRTC peer (extending
  slice-2's `tap_integration.rs` harness): push PCM into the core via
  the WebRTC peer → assert `audio_out` flows back through the tap (the
  mock brain generates canned `response.audio.delta` on
  `input_audio_buffer.append`) → assert they're re-encoded + pushed to
  str0m. Plus: function-call round-trip (mock brain emits a
  `function_call` for `hangup` → core's `tool_registry.dispatch("hangup")`
  fires → channel state `Closing` → `session_end` over the tap →
  mock brain's `session.delete` recorded).
- **Manual e2e test plan (README):**
  1. `cargo run -p rutster-brain-realtime --features=mock` (mock brain on `:8082`).
  2. `cargo run` (core on `:8080`).
  3. Browser → speak → hear mock-brain reply within ~250 ms (slice-2's
     round-trip; no real OpenAI).
  4. Repeat with the real brain (`cargo run -p rutster-brain-realtime`
     with `OPENAI_API_KEY` set) → end-to-end speech-to-speech with real
     OpenAI Realtime within ~700 ms.
  5. Repeat with the Python brain (`python
     examples/openai_realtime_brain/openai_realtime_brain.py`) → should
     also work (proves the protocol is language-agnostic, same as
     slice-2's Python echo brain).
  6. Function-call: trigger a model behavior that emits a tool call
     (e.g. a test prompt that says "please hang up") → assert the call
     closes + `session_end` over the tap + `session.delete` to OpenAI.
  7. `cargo test --all` green; `cargo fmt --check` /
     `cargo clippy -D warnings` / `cargo deny check` green.

### 7.5 Slice 3 "done" criteria

The slice is complete when, on a clean checkout (+ open pivot / slice-1
review fixes / slice-2 plan-rebaseline PRs merged):

1. `cargo test --all` passes (unit + integration). The new
   `rutster-brain-realtime` crate tests green alongside slice-1/2's suite.
2. `cargo fmt --check`, `cargo clippy -D warnings`, `cargo deny check`
   all pass.
3. `cargo run -p rutster-brain-realtime --features=mock` + `cargo run` →
   browser, speak, hear mock-brain reply within ~250 ms.
4. With `OPENAI_API_KEY` set: real OpenAI Realtime end-to-end within
   ~700 ms.
5. **Both** `rutster-brain-realtime` (Rust, with `--features=mock`) **and**
   `examples/openai_realtime_brain/openai_realtime_brain.py` (Python)
   successfully interop against the core — proves the extended protocol
   is language-agnostic.
6. **The seam test (load-bearing):** `rutster-media`'s `loop_driver.rs`
   and `rtc_session.rs` keep their media-loop trait-method call sites
   **byte-identical** to slice-2 (post-review-fix) baseline. The only
   brown-binary additions are: `crates/rutster/src/tool_registry.rs`
   (new module) + a new side-channel drain in `session_map.rs`. The
   `tap_engine.rs` and `TapClient` get only additive new mpsc channels
   (one new sender + one new receiver); the WSS pump loop's structure
   is unchanged. A `git diff v<slice-2-post-review-fix-tag> --
   crates/rutster-media/src/loop_driver.rs
   crates/rutster-media/src/rtc_session.rs` shows no behavior-changing
   hunks (doc-comment or import changes permitted).
7. **S4 turn-ownership test:** the integration test asserts that the
   `session.update` sent to OpenAI Realtime (or its mock equivalent)
   contains `turn_detection: null`. The brain never auto-barges; the
   core-authoritative playout buffer is the only playout gate.
8. `LEARNING.md` grows ≥3 new pointers: `async-trait` patterns →
   `crates/rutster/src/tool_registry.rs`; OpenAI Realtime adapter →
   `crates/rutster-brain-realtime/src/translator.rs`; tap protocol
   extension + forwards-compat → `crates/rutster-tap/src/protocol.rs`.

---

## 8. Open decisions (tracked)

- **`response.audio.delta` batching.** OpenAI sends many small delta
  events per response; the translator MAY batch into the slice-2
  `audio_out` 20 ms frame, or pass each delta through immediately as
  its own `audio_out`. Batching reduces WS round-trips but adds latency.
  Track + revisit after latency measurement with the real brain.
- **Tool-registry extensibility.** Slice-3 fixes the catalog at startup
  via `RUTSTER_BRAIN_TOOLS`. Runtime-extensible registration (new tools
  added mid-call) is a future-rung concern — would need a tool-register
  API event. Track.
- **API-key rotation.** Slice-3 reads the key once at startup; a key
  rotation requires a brain-process restart. KMS / Vault integration
  lands with the real trust boundary (step 6). Track.
- **Voice + persona selection.** `OPENAI_REALTIME_VOICE` is the only
  persona knob now; future brain processes may carry full prompt /
  system-message customization (rung 2+). Track.
- **Multi-brain routing.** Slice-3 ships one brain process: OpenAI
  Realtime. A Deepgram+LLM+TTS composite adapter or a self-hosted
  open-weights brain would be its own crate
  (`rutster-brain-deepgram`, `rutster-brain-local`) — the tap protocol
  is brain-agnostic by design. Track + revisit when a second brain
  lands.

---

## 9. Out-of-scope re-check (against AGENTS.md + ADR-0007/0008)

| Item | Status |
|---|---|
| Dedicated timing thread for media loop | Still step 4. |
| TLS on the HTTP signaling surface | Still step 5. |
| Authn / authz / multi-tenancy on `/v1/sessions` | Still step 6. |
| Trickle ICE | Unchanged. |
| Barge-in / VAD-driven playout kill | **Step 4.** Slice-3 pre-paves the `speech_started` / `speech_stopped` advisory event seam so step 4 lands cleanly. |
| PSTN trunk / rented transport | Still step 5. |
| Spend cap / abuse gate | Still step 6. |
| CDR / event bus / OTel beyond per-Channel tracing | Still step 5. |
| Browser automation / Playwright | Still post-slice-1. |
| Docker / compose | Still later-rung. |
| Transfer / park / pickup / barge (call features) | Still escalation rung 2. |

If an agent proposes adding any of these in slice 3, the right answer is
"no, see the slice-3 spec §1.2."

---

## 10. Key design decisions (summary of the brainstorming session)

| Decision | Choice | Rejected alternatives | Why |
|---|---|---|---|
| Brain target | **OpenAI Realtime NOW; design for multi-brain later.** Adapter trait shape is the tap protocol itself, not a Rust trait. | Trait crate `rutster-brain` + impl crate; Deepgram+LLM+TTS now | Slice-2's protocol-as-contract framing already delivers "multi-brain later" without a speculative trait; YAGNI on abstraction. A Deepgram+LLM+TTS composite brain's friction will be in its own internal topology (composing three async WS/HTTP streams), not in `trait BrainAdapter` ergonomics — a trait wouldn't ease that anyway. |
| Cool scope | **Round-trip speech + interruption signals (advisory) + function-calling plumbing** | Speech only; speech + interruption; speech + interruption + function calling | Function calling is the gateway to escalation (rung 2 — `hangup` is the only wired tool; `transfer` lands later). Plumbing the FOB dispatch contract in slice-3 means step-4 barge-in composes on a clean foundation; deferring it would force step-4 to retrofit the seam. |
| Function-call handler | **Built-in tool registry in the core (FOB)** | Outbound tool-server URL (second egress); adapter handles function calls itself; plumbing only | Per ADR-0007: "rutster mediates both the provider call-control API and the brain tap, so the brain never holds the wire." The FOB disposes; the brain proposes. `hangup` is the only wired tool; others reply `not_implemented` honestly. The registry's job is the auditable dispatch boundary, not extensibility. |
| Turn-detection ownership (S4) | **Core-authoritative, brain VAD off** (`session.update` with `turn_detection: null`) | Accept split-brain (OpenAI VAD on; core-authoritative too → disagreement); defer the decision to step 4 | ADR-0008 makes the FOB/green-zone split mechanical: reflex loop is FOB, brain is green-zone, playout is core-authoritative. The only doctrine-consistent choice is to disable OpenAI's server-side VAD and let the FOB own turn-taking. Nailing it down at slice-3's `session.update` means step-4 barge-in composes cleanly. |
| API-key posture | **Env var + file-path override.** `OPENAI_API_KEY` env default; `OPENAI_API_KEY_FILE` overrides. | Env-only; KMS-required-now | Matches slice-2's "no auth yet" stance cleanly. KMS / Vault lands with the real trust boundary (step 6). The file-path override makes secret-manager injection (k8s secrets, Vault agent) trivial when that layer exists. |
| Dev mode | **`--features=mock` in-process mocked OpenAI Realtime** | No dev mock (always require real OpenAI); external mock server | The slice-3 dev loop must be zero-OpenAI-credentials + zero-network-calls-to-OpenAI. The integration test uses the same mock. Same pattern as slice-2's `EchoServer`. |
| Workspace shape | **One new crate `rutster-brain-realtime` (library + binary), mirroring `rutster-tap-echo`** | Put the adapter in `examples/`; trait crate + impl crate | Slices-2's precedent. Real workspace member runs fmt/clippy/test/deny like everyone else. Reuses `rutster-tap`'s protocol types — the contract test that the wire types are reusable from outside the core. A future second brain is its own crate, same shape. |
| Brain process port + protocol | **`ws://127.0.0.1:8082/realtime` (slice-2's echo brain defaults to `:8081/echo`)** | Single brain port with feature toggle | Two brain processes coexist (operator picks which to run via `RUTSTER_TAP_URL`). The default URL stays `ws://127.0.0.1:8081/echo` (slice-2 documented); slice-3 adds `ws://127.0.0.1:8082/realtime` as the documented OpenAI-brain default. |
| Tap protocol extension posture | **Additive v1 events** (not v2) | v2 protocol bump; OpenAI Realtime event-schema adoption | Slice-2 §3.4's "unknown type → log + count + drop" rule makes additive events free — old echo brains ignore them. No wire-format break, no version negotiation needed. A v2 binary mode (raw LE i16 over WS binary frames) remains a future-rung optimization per slice-2 §9. |

---

## 11. References

- [README.md](../../../README.md) — north star, capability ladder
- [ARCHITECTURE.md §"Agent tap"](../../ARCHITECTURE.md) — the presumptive
  tap shape this slice hardens against a real brain
- [PORT_PLAN.md](../../PORT_PLAN.md) — capability checklist + thin-slice
  phasing; §10 "WASM demoted, agent tap is the extension point"
- [ADR-0002](../../adr/0002-north-star-and-fused-core.md) — fused vertical
- [ADR-0007](../../adr/0007-trunk-rented-transport.md) — rent the trunk;
  the brain is unaffected (PSTN reaches the reflex loop via media-leg
  ingress, not via the tap)
- [ADR-0008](../../adr/0008-fob-and-green-zone.md) — FOB / green-zone
  doctrine; the brain is green-zone, the reflex loop is FOB, the tap is
  core-authoritative (the S4 turn-ownership decision follows)
- [Slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md)
  — this slice's foundation
- [Slice 2 — the agent tap](2026-06-28-slice-2-agent-tap-design.md) — the
  tap interface + the `TapAudioPipe` seam this slice composes on
- [Vision-revision spec](2026-06-26-vision-revision-design.md) — the
  pressure-test that produced the architecture
- [Default UI design](2026-06-29-default-ui-design.md) — the operator
  console design record (later-rung, not slice-3's concern)
- [Vision sanity-check review S4](../reviews/2026-06-28-vision-sanity-check.md)
  — the original finding that named the S4 turn-ownership decision;
  slice-3 §4.3 resolves it under ADR-0008
