---
title: "Plan: the session API behind the window"
---

> Documentation Index
> Fetch the complete documentation index at: https://jasonm4130-labs.github.io/ambient/llms.txt
> Use this file to discover all available pages before exploring further.

# Plan: the session API behind the window

# Plan: the session API behind the window

**Goal:** every verb the new window needs exists in Rust first, with a CLI
form and a JSON form, so the web page (next plan) is a view over functions
that are already tested.
**Architecture:** `src/api.rs` holds one dispatcher, `call(method, params, root)`,
that the MCP verb and the window bridge both route through
([ADR 0017](../adr/0017-one-web-page-for-the-window.md)). New capabilities
(search, session metadata, delete, export formats) are `session::` functions
with unit tests, then one `match` arm each in `api.rs`, then a CLI verb.
**Tech Stack:** Rust 1.85 floor, serde/serde_json, no new crates.

## Global Constraints

- This plan's branch is cut from the MCP plan's branch and lands after the session-tools and
  MCP plans (`src/mcp.rs`, `mcp::handle`, ADR 0016 and
  `session::{summaries, transcript_appended, live_session_in, undo_last_naming}` are on that
  branch, not yet on `main`). A task that finds one missing stops and reports it; it does not
  reimplement it.
- Sessions are shared with the transcriber, the window and `ambient mcp` in another process. One
  lock protects a session directory: `session::claim_transcription(dir)` (`src/session.rs:456`,
  the hard-link lock the transcriber takes for the whole of `transcribe_inner`). The protocol: the
  session functions that write (`diarize_session`, `name_speaker`, `undo_last_naming`, and this
  plan's `update_meta`, `delete`) stay lock-free themselves, because the transcriber calls
  `diarize_session` while already holding the lock; every *entry point* that calls one of them
  (each `api::call` method, each CLI arm, the window's diarize worker) claims the lock around the
  call and fails with the lock's message when a live transcriber holds it. Task 4 brings the
  existing `diarize`, `name` and `undo` CLI arms under this rule. A check followed by an unlocked
  write is not enough.
- `raw.jsonl` is never rewritten. Metadata edits rewrite `session.json` only;
  speaker edits append to `edits.jsonl`.
- Every `api::call` method has a JSON example in `docs/developing/api.md`
  (created by Task 1) and a test in `src/api.rs` that feeds that exact JSON.
- Tests use a directory unique to the test:
  `std::env::temp_dir().join(format!("ambient-{}-{}", <test fn name>, std::process::id()))`.
- `scripts/check` does not look at docs; after any docs edit run
  `node .github/scripts/check-links.mjs` and quote `… 0 broken link(s)`.
- No `.github/workflows/`, `.claude/`, or `loop/` changes; no editing existing tests.

## Task 1: One dispatcher for the window and MCP

**Files:**
- Create: `src/api.rs`; register `pub mod api;` in `src/lib.rs` before writing the test
- Modify: `src/mcp.rs` (the `tools/call` arm routes to `api::call`; the tool descriptors move to `api::methods()`)
- Create: `docs/developing/api.md` (frontmatter `title: "The session API"`, `sidebar.order: 29`)

**Interfaces:**
- Produces: `pub struct Method { pub name: &'static str, pub description: &'static str, pub input_schema: serde_json::Value }`;
  `pub fn methods() -> Vec<Method>`; `pub struct Paths { pub config_file: PathBuf, pub roster_file: PathBuf }`
  with `Paths::root(&self) -> PathBuf`, which reads `sessions_dir` from `Config::load_from(&self.config_file)`
  on every call (so a `config.set` of `sessions_dir` moves the next `sessions`/`search`/`delete`
  with it; the window bridge and `ambient mcp` hold a `Paths` for their whole life);
  `pub fn call(method: &str, params: &serde_json::Value, paths: &Paths) -> Result<serde_json::Value, ApiError>`
  (the signature is fixed here; later tasks add methods, never arguments)
  with `pub enum ApiError { InvalidParams(String), Failed(String) }` (`InvalidParams` is what MCP maps to
  `-32602`, `Failed` to `isError: true`). Methods at the end of this task: `sessions`, `transcript`
  (`session`, `since`, `verbatim`), `status`, exactly the shapes the MCP plan defines; restated for the
  window's sake: `transcript` replies `{session, state: "live"|"transcribing"|"pending"|"done", next: <append-order line count>, lines: [Line…]}`
  and a `live` session without `raw.jsonl` yet is `state: "live"`, `lines: []`, not an error.

- [ ] **Step 1:** write the failing tests in `src/api.rs`: `methods()` has three entries named
  `sessions`, `transcript`, `status`; `call("status", {}, &paths)` with a temp config file whose `sessions_dir` is a temp root with one session dir
  holding `session.json` and `transcript.md` returns `{"live":null,"awaiting_transcript":0,"sessions":1}`;
  `call("transcript", {"session":"x","since":"1"}, &paths)` is `Err(InvalidParams)`; `call("nope", {}, &paths)`
  is `Err(InvalidParams)` mentioning `nope`.
- [ ] **Step 2:** run `cargo test api::`, expect FAIL "cannot find function".
- [ ] **Step 3:** move the tool bodies out of `src/mcp.rs` into `api.rs`, leaving `mcp.rs` to parse
  JSON-RPC and map `ApiError`; `mcp::serve` builds `Paths` from `config::path()`, `roster::path()`
  and the sessions root. `cargo test mcp::` must still pass unchanged.
- [ ] **Step 4:** write `docs/developing/api.md`: one section per method with the request and reply
  JSON; say that `ambient mcp` and the window both call it. Run `scripts/check`, expect `CHECK OK`;
  run `node .github/scripts/check-links.mjs`, expect `0 broken link(s)` (ADR 0017 links ADR 0016,
  which this branch carries).
- [ ] **Step 5:** commit: `git add src/api.rs src/lib.rs src/mcp.rs docs/developing/api.md && git commit -m "api: one dispatcher behind mcp and the window"`.

## Task 2: Search across sessions

**Files:**
- Modify: `src/session.rs` (new `pub struct Hit`, `pub fn search(root: &Path, query: &str, limit: usize) -> Result<Vec<Hit>>` after `transcript_appended`)
- Modify: `src/api.rs` (method `search` with `query` string required, `limit` integer default 50), `docs/developing/api.md`
- Modify: `src/main.rs` (new `search` arm: `ambient search <query> [--json]`), `USAGE`; `docs/using/commands.md` (new `## search`)

**Interfaces:**
- Produces: `#[derive(Serialize)] pub struct Hit { pub session: String, pub index: usize, pub track: Track, pub start_ms: u64, pub speaker: Option<String>, pub text: String }`
  where `index` is the line's position in `transcript_appended` order, which is the order the `transcript`
  API method returns and the window renders without re-sorting (the ui-shell plan's Task 3 states
  the same); `session::transcript`'s `start_ms` sort is not used anywhere the index is; matching is case-insensitive substring over the folded (non-verbatim) text; sessions
  are searched newest first and the result is truncated at `limit`.

- [ ] **Step 1:** write the failing test: temp root with two sessions whose `raw.jsonl` lines contain
  "Budget review" (newest) and "budget" (older) and one unrelated line; `search(root, "BUDGET", 50)`
  returns two hits, newest session first, each with the right `index`, checked by indexing
  `transcript_appended(dir, false)` with it and getting the same text (with a room line appended after
  a call line that starts earlier, so append order and time order differ); `search(root, "zzz", 50)` is
  empty; `limit: 1` returns one; an empty query is an error "empty query".
- [ ] **Step 2:** run `cargo test session::search`, expect FAIL "cannot find function".
- [ ] **Step 3:** implement; add the `api` method (a `query` that is not a string → `InvalidParams`)
  and its test; the CLI prints `session  mm:ss  speaker  text` per hit or the JSON array.
- [ ] **Step 4:** run `scripts/check`, expect `CHECK OK`; `cargo run -- search the` and quote the first
  line; `node .github/scripts/check-links.mjs`, expect `0 broken link(s)`.
- [ ] **Step 5:** commit: `git add src/session.rs src/api.rs src/main.rs docs/developing/api.md docs/using/commands.md && git commit -m "search: substring over every transcript"`.

## Task 3: Session metadata: name, tags, notes, pinned

**Files:**
- Modify: `src/session.rs:91-121` (`SessionMeta` gains `#[serde(default)] pub tags: Vec<String>`, `#[serde(default)] pub notes: String`, `#[serde(default)] pub pinned: bool`)
- Modify: `src/session.rs` (new `pub fn update_meta(dir: &Path, patch: &MetaPatch) -> Result<SessionMeta>`; `summaries` gains `tags`, `pinned` fields and sorts pinned sessions first)
- Modify: `src/api.rs` (method `session.update` with `session` required and optional `name`, `notes`, `pinned`, `add_tag`, `remove_tag`), `docs/developing/api.md`
- Modify: `src/main.rs` (new `meta` arm: `ambient meta <dir> name|notes|pinned|tag|untag <value>`), `USAGE`; `docs/using/commands.md`

**Interfaces:**
- Produces: `#[derive(Deserialize, Default)] pub struct MetaPatch { pub name: Option<String>, pub notes: Option<String>, pub pinned: Option<bool>, pub add_tag: Option<String>, pub remove_tag: Option<String> }`
  (tags change one at a time under the lock, so two clients adding different tags both keep theirs;
  `add_tag` of a tag already present and `remove_tag` of one absent are no-ops);
  `update_meta` reads `session.json`, applies the `Some` fields, writes it back atomically (write to
  `session.json.tmp`, rename) and returns the new meta; the `api` method and the CLI arm wrap it in
  `claim_transcription(dir)` (so two callers serialise and a running transcriber refuses the edit
  with "being transcribed"); a session without `session.json` (a live capture) is an error
  "session is still recording". `SessionSummary` gains
  `pub tags: Vec<String>, pub pinned: bool`.

- [ ] **Step 1:** write the failing tests: `update_meta` on a temp session sets `name` and leaves
  `duration_s` untouched; a second call with `add_tag: Some("1:1")` keeps the name, a third with
  `add_tag: Some("budget")` keeps both, `remove_tag: Some("1:1")` leaves `["budget"]`; `pinned: true`
  makes that session first in `summaries` even when older; a dir without `session.json` errors
  "still recording"; the `api` method with a `transcribing.lock` naming this test's own pid errors
  "being transcribed" and `session.json` is unchanged; `session.json` loaded from before this change (no `tags` key) parses with
  defaults.
- [ ] **Step 2:** run `cargo test session::meta`, expect FAIL "cannot find".
- [ ] **Step 3:** implement, add the `api` method and CLI verb (`tag`/`untag` take one tag,
  `pinned` takes `true`/`false` via the same `flag` parser `config` uses).
- [ ] **Step 4:** run `scripts/check`, expect `CHECK OK`; `node .github/scripts/check-links.mjs`,
  expect `0 broken link(s)`.
- [ ] **Step 5:** commit: `git add src/session.rs src/api.rs src/main.rs docs/developing/api.md docs/using/commands.md && git commit -m "session: name, tags, notes, pinned"`.

## Task 4: Delete a session

**Files:**
- Modify: `src/session.rs` (new `pub fn delete(root: &Path, id: &str) -> Result<()>` next to `sweep_audio_at`, `src/session.rs:992`)
- Modify: `src/api.rs` (method `session.delete` with `session` required), `docs/developing/api.md`
- Modify: `src/main.rs` (new `delete` arm: `ambient delete <dir> --yes`; the existing `diarize`, `name` and `undo` arms claim `claim_transcription(dir)` around their session call), `USAGE`; `docs/using/commands.md`; `docs/using/what-is-kept.md` (one sentence: delete removes the directory, retention only removes audio)

**Interfaces:**
- Produces: `delete(root, id, lock: &TranscribeLock)` validates the id (a single non-symlink
  directory under `root`, the MCP plan's rule) and takes proof of the lock rather than claiming it,
  so it follows the protocol above: the `api` method and the CLI arm claim `claim_transcription(dir)`
  once (a live transcriber, diarizer or namer makes that fail "is being transcribed by another
  process", and none can start while it is held) and pass the guard in. It then checks for a live
  capture: `live_session_in(root)` names this dir, or either native track
  (`audio/room.native.wav`, `audio/call.native.wav`) was modified in the last 5 s ("session is
  still recording"; a quiet microphone stops the room file growing while the call file still does).
  A directory with no `session.json` and no fresh audio on either track is a failed capture and is
  deleted like any other. Then `std::fs::remove_dir_all` while the caller still holds the lock (the
  lock file goes with the directory; `TranscribeLock::drop` tolerates a missing file). The CLI
  refuses without `--yes`.

- [ ] **Step 1:** write the failing tests: `delete` removes a finished temp session; removes a
  directory with no `session.json` and both native tracks last modified an hour ago (a failed
  capture); refuses one with a fresh `audio/room.native.wav`; refuses one whose room file is an hour
  old but whose `audio/call.native.wav` is fresh (a live call with a quiet room); the `api` method
  against a session whose `transcribing.lock` names this test's pid errors "being transcribed" and
  leaves the directory intact, and against a finished session succeeds (one claim, no self-conflict); refuses `"../x"`; refuses a symlinked entry; the CLI arm without
  `--yes` prints "add --yes" and exits non-zero (test the pure argument check as a function).
- [ ] **Step 2:** run `cargo test session::delete`, expect FAIL "cannot find function".
- [ ] **Step 3:** implement, add the `api` method (the window shows its own confirm dialog; the API
  does not ask) and the CLI verb.
- [ ] **Step 4:** run `scripts/check`, expect `CHECK OK`; `node .github/scripts/check-links.mjs`,
  expect `0 broken link(s)`.
- [ ] **Step 5:** commit: `git add src/session.rs src/api.rs src/main.rs docs/developing/api.md docs/using/commands.md docs/using/what-is-kept.md && git commit -m "delete: remove a session, never a live one; writers take the lock"`.

## Task 5: Export formats

**Files:**
- Create: `src/export.rs`; register `pub mod export;` in `src/lib.rs` before writing the test
- Modify: `src/api.rs` (method `export` with `session` required, `format` one of `markdown|text|json|srt|vtt|assistant`), `docs/developing/api.md`
- Modify: `src/main.rs:119-140` (`export` gains `--format <f>`; default stays markdown to `transcript.md`), `docs/using/commands.md` (the `export` section)

**Interfaces:**
- Consumes: `session::transcript(dir, false) -> Vec<Line>`, `session::markdown(dir)` (`src/session.rs:1503`), `SessionMeta`.
- Produces: `pub enum Format { Markdown, Text, Json, Srt, Vtt, Assistant }` with `FromStr`;
  `pub fn render(dir: &Path, format: Format) -> Result<String>`: `Markdown` is `session::markdown`
  unchanged; `Text` is `speaker: text` per line, blank line between tracks changes; `Json` is the
  `Line` array; `Srt`/`Vtt` number the lines and use `start_ms`/`end_ms` as `HH:MM:SS,mmm` /
  `HH:MM:SS.mmm` with the speaker as a prefix; `Assistant` is the markdown with a first line
  `# <name or id> (<started_at>, <duration>)` and a last line
  `Read live with the ambient MCP server: session id <id>`.

- [ ] **Step 1:** write the failing tests: a temp session with two lines (`room` 0–1500 "hello" by
  "Ana", `call` 1500–4000 "hi") renders `Srt` as
  `1\n00:00:00,000 --> 00:00:01,500\nAna: hello\n\n2\n00:00:01,500 --> 00:00:04,000\nhi\n`; `Vtt`
  starts with `WEBVTT\n\n` and uses `.`; `Json` round-trips through `serde_json` to two objects;
  `"xml".parse::<Format>()` is an error listing the six names.
- [ ] **Step 2:** run `cargo test export::`, expect FAIL "unresolved import".
- [ ] **Step 3:** implement, add the `api` method and the `--format` flag (`--out` keeps working;
  with a non-markdown format the default output path swaps the extension).
- [ ] **Step 4:** run `scripts/check`, expect `CHECK OK`; `node .github/scripts/check-links.mjs`,
  expect `0 broken link(s)`.
- [ ] **Step 5:** commit: `git add src/export.rs src/lib.rs src/api.rs src/main.rs docs/developing/api.md docs/using/commands.md && git commit -m "export: text, json, srt, vtt and a copy for an assistant"`.

## Task 6: Config, roster and naming through the API

**Files:**
- Modify: `src/api.rs` (methods `config.get`, `config.set` (`key`, `value` strings, via `Config::set` and `config::refuse_while_live`), `roster.list`, `roster.add`, `roster.remove`, `speakers.name` (`session`, `label`, `name`), `speakers.undo` (`session`), `devices` (input devices as `config` prints them)), `docs/developing/api.md`

**Interfaces:**
- Consumes: `config::{Config, path, refuse_while_live}`, `roster::{load_from, save_to, add, remove}`,
  `session::{name_speaker, undo_last_naming, unnamed_labels, live_session_in}`, and the device listing
  `src/main.rs:403-431` prints (extract it into `pub fn input_devices() -> Vec<String>` in the module
  that owns it if it is inline).
- Produces: every method reads and writes only through `Paths`: `Config::load_from(&paths.config_file)`
  / `save_to` (`src/config.rs:74,103`) and `roster::load_from(&paths.roster_file)` / `save_to`
  (`src/roster.rs:30,55`), never `Config::load`/`save` or `roster::load`/`save`, which touch the
  user's real files; `config.get` returns the
  same JSON `settings.rs` pushes today (`src/settings.rs:295-314`, minus `unnamed`, which becomes
  `speakers.unnamed` with a `session` argument; `latest_session` stays in `config.get`, the id of
  the newest finished session or null, so the Settings page can ask about that one without a
  selection).

- [ ] **Step 1:** write the failing tests with temp config and roster files: `config.set`
  `{"key":"diarize","value":"off"}` then `config.get` shows `diarize: false`; `config.set` of
  `sessions_dir` while a temp live session exists is `Err(Failed)` mentioning "recording";
  `config.set` `{"key":"sessions_dir","value":<other temp root>}` (no live session) makes the next
  `sessions` list that root's sessions; `roster.add` `{"name":"Ana"}` then `roster.list` is `["Ana"]`; `speakers.name` on a session with
  `SPEAKER_00` lines returns `{"renamed": 2}` and `speakers.undo` returns `{"reverted": 2}`;
  an unknown `key` in `config.set` is `Err(InvalidParams)`; after the whole test the real
  `config::path()` and `roster::path()` files have the same bytes (or absence) as before it.
- [ ] **Step 2:** run `cargo test api::`, expect FAIL on the new methods.
- [ ] **Step 3:** implement; document every method in `docs/developing/api.md`.
- [ ] **Step 4:** run `scripts/check`, expect `CHECK OK`; `node .github/scripts/check-links.mjs`,
  expect `0 broken link(s)`.
- [ ] **Step 5:** commit: `git add src/api.rs src/mcp.rs src/main.rs docs/developing/api.md && git commit -m "api: config, roster and naming"`
  (add any module Step 3 touched).

## Open Questions

Source: https://jasonm4130-labs.github.io/ambient/plans/2026-09-05-ui-api/index.mdx
