hci-atrium 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/GUIDE.md +1182 -0
  2. package/PARITY.md +385 -0
  3. package/README.md +96 -24
  4. package/bin/atrium.mjs +35 -0
  5. package/dist/atrium.d.ts +126 -42
  6. package/dist/atrium.d.ts.map +1 -1
  7. package/dist/atrium.js +192 -87
  8. package/dist/atrium.js.map +1 -1
  9. package/dist/dash.d.ts +3 -3
  10. package/dist/dash.d.ts.map +1 -1
  11. package/dist/dash.js +1 -2
  12. package/dist/dash.js.map +1 -1
  13. package/dist/env.d.ts +14 -0
  14. package/dist/env.d.ts.map +1 -1
  15. package/dist/env.js +22 -0
  16. package/dist/env.js.map +1 -1
  17. package/dist/index.d.ts +4 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +11 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/models.d.ts +84 -31
  22. package/dist/models.d.ts.map +1 -1
  23. package/dist/models.js +97 -25
  24. package/dist/models.js.map +1 -1
  25. package/dist/player/play-command.d.ts +6 -6
  26. package/dist/player/play-command.d.ts.map +1 -1
  27. package/dist/player/play-intent.d.ts.map +1 -1
  28. package/dist/player/play-intent.js +2 -1
  29. package/dist/player/play-intent.js.map +1 -1
  30. package/dist/player/runtime.d.ts +7 -3
  31. package/dist/player/runtime.d.ts.map +1 -1
  32. package/dist/player/runtime.js.map +1 -1
  33. package/dist/react/use-room-connection.d.ts +4 -1
  34. package/dist/react/use-room-connection.d.ts.map +1 -1
  35. package/dist/react/use-room-connection.js +5 -1
  36. package/dist/react/use-room-connection.js.map +1 -1
  37. package/dist/rooms/index.d.ts +3 -1
  38. package/dist/rooms/index.d.ts.map +1 -1
  39. package/dist/rooms/index.js +4 -3
  40. package/dist/rooms/index.js.map +1 -1
  41. package/dist/rooms/offer-transcription.d.ts +1 -1
  42. package/dist/rooms/offer-transcription.js +1 -1
  43. package/dist/rooms/owned-room.d.ts +251 -0
  44. package/dist/rooms/owned-room.d.ts.map +1 -0
  45. package/dist/rooms/owned-room.js +844 -0
  46. package/dist/rooms/owned-room.js.map +1 -0
  47. package/dist/rooms/recorder.d.ts +1 -1
  48. package/dist/rooms/recorder.js +1 -1
  49. package/dist/rooms/room.d.ts +82 -278
  50. package/dist/rooms/room.d.ts.map +1 -1
  51. package/dist/rooms/room.js +60 -854
  52. package/dist/rooms/room.js.map +1 -1
  53. package/dist/rooms/standing-source.d.ts +1 -1
  54. package/dist/rooms/standing-source.js +1 -1
  55. package/dist/rooms/transcriber.d.ts +3 -3
  56. package/dist/rooms/transcriber.d.ts.map +1 -1
  57. package/dist/rooms/transcriber.js +2 -2
  58. package/dist/rooms/transport.d.ts +1 -1
  59. package/dist/rooms/transport.js +2 -2
  60. package/dist/shaping/assets.d.ts +13 -0
  61. package/dist/shaping/assets.d.ts.map +1 -1
  62. package/dist/shaping/assets.js +27 -0
  63. package/dist/shaping/assets.js.map +1 -1
  64. package/dist/shaping/chat.d.ts +3 -1
  65. package/dist/shaping/chat.d.ts.map +1 -1
  66. package/dist/shaping/chat.js +4 -2
  67. package/dist/shaping/chat.js.map +1 -1
  68. package/dist/shaping/rooms.d.ts +78 -1
  69. package/dist/shaping/rooms.d.ts.map +1 -1
  70. package/dist/shaping/rooms.js +58 -6
  71. package/dist/shaping/rooms.js.map +1 -1
  72. package/dist/shaping/sources.d.ts +7 -3
  73. package/dist/shaping/sources.d.ts.map +1 -1
  74. package/dist/shaping/sources.js +9 -10
  75. package/dist/shaping/sources.js.map +1 -1
  76. package/dist/transcribe-stream.d.ts +1 -1
  77. package/dist/transcribe-stream.js +1 -1
  78. package/dist/transcribe.d.ts +2 -2
  79. package/dist/transcribe.js +2 -2
  80. package/package.json +18 -3
package/PARITY.md ADDED
@@ -0,0 +1,385 @@
1
+ # Python ↔ TypeScript SDK parity ledger
2
+
3
+ The workflow: **Python first, port in validated batches.** New surfaces land in the Python SDK,
4
+ prove themselves in use (an example, a study, a live test), then get ported here in one
5
+ well-scoped batch. This file is what keeps that honest — "port later" without a ledger is how
6
+ drift becomes silent.
7
+
8
+ Rules:
9
+ - When a Python-SDK surface merges that TS lacks, **append a row here** in the same MR/commit.
10
+ - When a batch is ported, check rows off (move to Done or delete — git remembers).
11
+ - A gap that is *deliberate* goes to "Won't port (by design)" with the reason — exceptions are
12
+ decisions, not drift.
13
+ - TS-first surfaces (browser-native capture etc.) get a row in reverse: Python owes the port.
14
+
15
+ Canonical port decisions (Zod, camelCase, fixture corpus, browser API keys): see
16
+ `docs/plans/typescript-sdk-port.md`.
17
+
18
+ ## Open gaps (Python has it, TS doesn't)
19
+
20
+ None. Every Python-SDK surface is either ported (below) or a documented "won't port".
21
+
22
+ > **Dated correction (2026-07-26).** This section read "None" while `library.add` was in fact
23
+ > missing — TS had only `assets.upload` (files), so the **fileless** *description-as-content* asset
24
+ > Python has offered since the description-as-content decision had no TS equivalent, and the guide's
25
+ > TS tab carried a workaround comment saying so. The gap is closed below (ergonomics pass), but the
26
+ > ledger was wrong for the whole window and that is worth recording: a "None" nobody re-derives is
27
+ > how drift hides. The rule stands — a Python surface that lands without a TS twin gets a row here in
28
+ > the same MR.
29
+
30
+ ## Ported (was open, now in TS)
31
+
32
+ - **Ergonomics pass (breaking, 2026-07-26):** an API-shape round over the whole TS surface, driven by
33
+ an audit rather than a new Python surface. **`library.add({ file?, description?, modality?,
34
+ collection? })`** — the twin of Python's `library.add`, closing the fileless gap above (a plain form
35
+ body, no file part; `add()` with neither a file nor a description raises `InvalidArgumentError`).
36
+ New fixtures `assets/add_fileless` + `assets/add_fileless_full`, call-sited in **both** suites.
37
+ Everything else in the pass is TS-side shape, not new capability: `search` gained an options-only
38
+ overload (`search({ like })` replaces `search("", { like })`); `surveys.start` is options-only
39
+ (`start({ template, into })`); `chat` lost its `stream` flag to a separate `chatStream`;
40
+ `ChatOptions`/`AgentOptions` swapped their index signatures for an explicit `params` bag;
41
+ `sources.import(target, opts?)` and `sessions.event(id, type, payload?, opts?)` took their
42
+ load-bearing argument positional; `Session` became a bound class (see the micro-divergence note);
43
+ `Room` split into `Room` + `OwnedRoom`; `room.play`'s source is typed (`PlayTrack` / `PlaySource`);
44
+ `room.send` gained `replyTo` / `ackId`; `assetCapabilities` / `assetPlayback` became
45
+ `asset.capabilities` / `asset.playback` getters; `WebSocketCtor` → `webSocketCtor`; the root barrel
46
+ dropped its protocol internals. Wire-identical throughout — the shared fixture corpus passes
47
+ unchanged apart from the two deliberate new registrations. Type-level tests
48
+ (`tests/types.test-d.ts`, run by `pnpm test:types`) pin the soundness wins as compile errors.
49
+
50
+ - **Wave 6 (the last phase-2 bundle: surveys, direct models, chat streaming, `agent`, 2026-07-26):**
51
+ closes the open-gaps table. **`atrium.surveys`** (`Surveys` in `src/atrium.ts` + `src/shaping/surveys.ts`,
52
+ the `surveys.py` twin): `start(template?, { into, name?, returnTo? })` → a `Survey` (`token` / `url` /
53
+ the pinned `{name, version}` template, plus `open()` — `window.open`, throwing `InvalidArgumentError`
54
+ outside a browser rather than pretending); the idempotent `template(name, body, { project?, default? })`
55
+ ensure flow (POST → 409 → GET → `/versions` → PATCH `is_default`), whose "did it change?" test is a
56
+ key-order-insensitive compare of the **JSON-encoded** body (`sameJsonBody`) so a rerun never appends an
57
+ identical version; `templates()`; `responses(name, opts)` → `SurveyResponse[]` **and** `responsesCsv(name, opts)`
58
+ → `Uint8Array`; `uploadFile(file, { project?, role?, filename? })`. Project refs pass through **verbatim**
59
+ (a short_id must not trigger a projects read a scoped key is denied) and template names are
60
+ percent-encoded in every path. **Direct models** (`src/direct.ts`): `DirectEndpoint` / `DirectRegistry`,
61
+ `addDirectModel(s)` / `removeDirectModel` / `listDirectModels` / `discoverModels`, plus the
62
+ `AtriumOptions.directModels` constructor form; a registered name shadows an Atrium model and its chat
63
+ goes out on the **raw `fetch`** with only that endpoint's key — the Atrium credential never reaches a
64
+ third-party endpoint — while `embed` and everything else stay on Atrium. **`chat(messages, { stream: true })`**
65
+ → `AsyncIterable<string>` (returned synchronously, no `await` before `for await`) over the SSE
66
+ `[DONE]`-terminated delta wire, on the Atrium route and direct endpoints alike, reusing the room
67
+ transport's `sseFrames`. **`atrium.agent(prompt, { tools, maxTurns?, responseFormat? })`** with
68
+ explicit `ToolDescriptor`s (`src/tools.ts`) — see the micro-divergence below. `HttpMethod` gained
69
+ `PATCH`. Fixtures: `clients/wire-fixtures/surveys.json` (11 requests + 3 responses), call-sited in
70
+ **both** suites. Tests: `tests/surveys.test.ts` / `tests/direct.test.ts` / `tests/chat-stream.test.ts` /
71
+ `tests/agent.test.ts` (mirrors of `test_surveys.py` / `test_direct.py` / the Python `agent` cases).
72
+
73
+ - **Wave 5 (the magical-dashboard baseline: `Dashboard` + `room.trace()` + `audioTranscripts`,
74
+ 2026-07-26):** the three surfaces a hosted `/tools/dashboard` needs from a TS host.
75
+ **Host-side `Dashboard`** (`src/dash.ts`, the `dash.py` twin on the **v2 tile wire**): tiles keyed
76
+ by `kind` (`status`/`text`/`image`/`sound`/`decision`) with `col_span`/`row_span` (1..3, validated
77
+ → `InvalidArgumentError`) + `group`; first-class `decision(candidates, { picked, reasoning })`
78
+ (bare-label or `{label, detail?, score?}` candidates); **`components` is THE snapshot carrier**
79
+ (no `tiles` alias); `error(message, detail?)` broadcasts the sticky-strip `dash.error` signal
80
+ (`{message, detail?, at}`) and is **not** a tile; controls are `button(id, label, handler, opts)` /
81
+ `textInput(id, handler, opts)` (the `text` tile owns `text()`) + `onNote(handler)` — no decorator
82
+ form (TS takes the handler as an argument, chainable), no sidebar-sections API. Host-answers-join
83
+ (`presence.join` → a targeted ephemeral `dash.snapshot`), a `dash.delta` per mutation once
84
+ connected, `dash.action` replies through the room dispatcher. **`room.dashboard(opts)`** is the
85
+ recommended constructor: it publishes over the REST `/signal` POST so the dialect's "all `dash.*`
86
+ is ephemeral" actually holds (the WS inbound path forces `ephemeral: false` — same reason the
87
+ transcriber publishes over REST). `new Dashboard(roomLike, opts)` still works for a decoupled/test
88
+ room; `Room` gained a public `identity` getter (the snapshot's `host` fallback, Python parity).
89
+ **`room.trace()`** (`src/trace.ts`): the same envelope + type folding — `trace.<label>` for
90
+ `library.search` / `library.search_by_examples` / `library.enrich` / `chat` (client-wide, durable)
91
+ and `play` (room-scoped, ephemeral), `trace.state.<label>` for `recorder` / `transcriber` /
92
+ `offer.<name>` (ephemeral), identical summary strings + capped top-5 detail. Zero-cost when off
93
+ (the sink list is empty → the summarizer never runs), a failing emit never breaks the traced call,
94
+ a call that throws emits nothing, `stop()` unregisters from both the client and the room, and the
95
+ room's teardown stops every handle. **`room.audioTranscripts({model, language, partials, skipEmpty,
96
+ signal})`** — the raw per-snippet iterator on wave 3's shared engine, including Python's
97
+ per-snippet correlation on the degraded path (`transcript.raw.snippet = {sequence,
98
+ window_seconds}`, the clip's format taken from each frame's own MIME type), via a new
99
+ `TranscribeStreamOptions.fallback` seam (the twin of Python's `_transcribe_engine(fallback=…)`).
100
+ Tests: `tests/dash.test.ts` / `tests/trace.test.ts` / `tests/audio-transcripts.test.ts` (mirrors of
101
+ `test_dash.py` / `test_trace.py` / `test_audio_transcripts.py`), pinning the wire JSON a
102
+ `/tools/dashboard` parser reads rather than importing the tool's types.
103
+ - **SDK surface expansion Rounds 1–2 (introspection + read-back, 2026-07-20):** the port of
104
+ `docs/plans/sdk-surface-expansion.md`. **Round 1:** per-asset vectors (`atrium.assets.vectors(assetOrId,
105
+ {space?, matchBy?})` — public by-id GET, space name-or-id resolution, mixed `matchedBy` tolerated) +
106
+ the bound-object migration — **`Asset` is now a class** (data fields stay own/enumerable/JSON-safe,
107
+ a truly-private `#binder`; `asset.vectors()`/`.segments()`/`.index()` on the prototype, threaded in
108
+ at parse via a new `ClientInternal.assetBinder`; an unbound asset rejects, naming the id-form call);
109
+ bulk `atrium.library.vectors(ids, {space, matchBy?, strict?})`; `atrium.assets.segments()` (reuses
110
+ `SegmentRef`) + `.index()` (server `via` → `matchedBy`); `search({includeVectors})` (`hit.vector`,
111
+ `include_vectors` omitted when falsy); `compare(a, b, {breakdown})` → `CompareResult.features`; tag
112
+ reads `atrium.library.tags()` / `.tag(nameOrId, {includeSnapshot?})`; the Round-1.6 `Asset` fields
113
+ (labels/rejectedLabels/proposedLabels/autoTagged/createdAt/updatedAt/status/descriptionSource/
114
+ homeCollectionId/externalId/origin/sourceId). Broker guard lifted: an `embed.resolve` **asset**
115
+ anchor now resolves through the per-asset vectors endpoint (`brokers.ts` + `room.ts`).
116
+ **Round 2:** `atrium.sessions.list({project?, phase?})` / `.get(id)` (read-model `SessionInfo`, twin
117
+ of `resume`'s live handle — same route, different shape; `Session` handle gained the identity
118
+ fields) / `.events(id, {after?, limit?})` / `.export(id, format?)` / `.exportBundle(ids)`. **Node/
119
+ browser choice:** exports return `Uint8Array` — no `path=` file-writing (the core never touches the
120
+ filesystem, `files.ts`); a Node caller writes the bytes itself. **Deliberate gap kept:** surveys
121
+ (see Open gaps). Tests: `tests/introspection.test.ts` + `tests/readback.test.ts` (mirror the Python
122
+ `test_introspection.py`/`test_readback.py` contract points), brokers test updated for asset anchors.
123
+ - **Wave 4 (standing-service offers + capability brokers, 2026-07-18):** the third broker kind on the
124
+ **v2.1 armed-set wire**. `ArmedSet` state machine (`src/rooms/armed-set.ts`): explicit ∪ (all-arm −
125
+ exclusions-since), fresh all-arm resets exclusions, `isArmed(null)` only under all-arm, migrate on
126
+ same-name reconnect. `StandingSource` shared base (`src/rooms/standing-source.ts`) — armed set + cap
127
+ admission, the `active` roster built on the **armed set** (immediate-on-arm; all-arm enumerates every
128
+ connected `capture-source`; v1 unsourced → `{source: null}`), ephemeral `<service>.available`
129
+ broadcast (deduped, over the REST `/signal` POST), an **own point-in-time roster** folded in loop
130
+ order (seeded from the ready snapshot), presence/migration, one grace sweeper (not a timer per
131
+ departure), the scoped `start`/`stopRequest` shells. `room.offerTranscription({model?, language?, maxSources?})`
132
+ → `OfferedTranscription` (`src/rooms/offer-transcription.ts`): per-armed-source engines over wave 3's
133
+ `transcribeStream` (one bounded drop-oldest queue per source), publishing `source`/`source_name`-attributed
134
+ `transcript.*`. `room.offerAudioCapture({maxSources?})` → `OfferedCapture` (`src/rooms/offer-capture.ts`):
135
+ **session-less** — binds the FIRST active recorder's session via wave 2's `activeRecorders()` /
136
+ `onRecordersChanged` seam (disengage when it dies; one-recorder rule warns, no double-write), writes
137
+ via the chunked recording API (`POST …/recordings` → `PUT …/chunks` → `POST …/finalize`), collision-safe
138
+ keys (name-else-cid; same-name grace reconnect resumes the file; simultaneous same-name → full-cid-suffixed
139
+ files), grace finalize via the single sweeper, create-failure circuit breaker, 401/403 ends the bridge.
140
+ `offerService` wires sender-scoped `<service>.start`/`.stop`/`.status` with `{ok}`/`{error}` replies.
141
+ `room.offerEmbeds({spaces?, maxAnchors?})` / `room.offerChat({model?, maxTokens?})` brokers
142
+ (`src/rooms/brokers.ts` validators): the loginless-Unity requester pattern — requester picks the embed
143
+ space (per-anchor errors don't fail the batch; text embedded on the host's credential + cached), broker
144
+ pins the chat model. All four announce their discoverable role, merged into the presence announce
145
+ (`mergedAnnounce`), registered **before** `connect()`; stopped on room close. `ShapedRequest` gained a
146
+ raw-`content`/`contentType` channel for the binary chunk PUT.
147
+ - **Wave 3 (live transcription, 2026-07-18):** `Atrium.transcribeLive(chunks, opts)` — the streaming
148
+ upgrade over the shared engine (`src/transcribe.ts`): a thin WS client to `WS
149
+ /v1/audio/transcriptions/stream` (`src/transcribe-stream.ts`, `TranscriptionStream`), container-clip
150
+ **or** `pcm16 + sampleRate` input, `partials` opt-in (finals-only default), and the **connect-time-only**
151
+ degradation ladder to per-clip batch POSTs (a mid-session drop / clean-close-without-`done` / 4401
152
+ **raises** — `AtriumError` / `AuthError` — never a silent success; PCM is re-windowed into ~5s WAV
153
+ clips for the fallback). `Transcript.final` added to the transcript model (`true` for every batch
154
+ transcript + finalized sentence, `false` for a live partial). `room.transcribe({model?, language?})`
155
+ → `Transcriber` (`src/rooms/transcriber.ts`) — the in-room broker: consumes the room's snippet stream
156
+ (wave-1 `snippetStream`) through the same engine and publishes `transcript.partial` (ephemeral) /
157
+ `transcript.final` (`{text, start, end, seq}`) as `source: "transcriber"`, so **wave 2's recorder
158
+ records the finals for free**. Handle mirrors Python's `_AsyncTranscriber`: `stop()` / `alive` /
159
+ `error`, stopped when the room closes, `await using`. Needs the authed client slice
160
+ (`RoomAudioClient.transcribeStream`); a loginless `joinRoom` handle throws.
161
+ - **Wave 2 (durable & reactive surfaces, 2026-07-18):** `room.record(into)` → `Recorder`
162
+ (`src/rooms/recorder.ts`) — the single durable binding, over an SSE-**observe** `RoomEventStream`
163
+ (no presence connection): skips `ephemeral` + `presence.*` + the `room.code_rotated` advance-signal,
164
+ stamps `via` = `{room_id, room_name, from, to}`, reconnects on a drop, stops cleanly on a rotation
165
+ (`stoppedReason`) or terminal room-gone/bad-code (`stoppedReason` **and** `.error`); handle is
166
+ `stop()` + `alive` / `error` / `stoppedReason` + `await using`. The **recorder-registration seam** —
167
+ `room.activeRecorders()` (membership + liveness) and `room.onRecordersChanged(listener)` (mirror of
168
+ Python's `_notify_recorders_changed`) — is ported now so wave 4's `offerAudioCapture` binds to the
169
+ active recorder. `room.audioVectors({space?, model?, relay?})` — the zero-args embedding-vector
170
+ consumer (dedicated snippet-consumer socket, embeds each ~10s window against the resolved default
171
+ audio space/model, `relay` re-broadcasts each vector as an ephemeral `audio.vector`). `room.audioScores(anchors, {space?, model?, topN?, relay?})` — per-anchor `library.compare` fusion
172
+ (few-shot list anchors via `toAnchorMembers`/`by.*`, top-`N` mean, mix normalized to sum 1, concept
173
+ anchors preflighted), wired to the existing `Decider`. `audioVectors`/`audioScores` need the authed
174
+ client (injected as `RoomDeps.client`; a loginless `joinRoom` handle throws). `Space.model` now
175
+ surfaced. The recorder matches Python's write semantics exactly: each `session.event` POST is
176
+ awaited **inline** in the observe-stream read loop (backpressure — one write in flight, no unbounded
177
+ queue), a write refused with status < 500 (≠ 429) is **terminal** (`.error` set, the recorder-changed
178
+ seam fires so a bound capture offer disengages), and a transient 5xx/429/network write blip drops +
179
+ reconnects the stream (the gap is lost, capture continues).
180
+ - **Wave 1 (consumers & transport, 2026-07-18):** SSE receive + POST-send room transport with the
181
+ WS→SSE fallback ladder (`transport: "auto" | "ws" | "sse"`, `src/rooms/sse.ts` — Python's
182
+ `_RoomDispatcher`/`_events` parity: code-auth, reconnect/backoff, `room.code_rotated` in-band
183
+ terminal, 403/404 terminal; **signals-only**, binary stays WS-only, and rooms have no replay so no
184
+ `Last-Event-ID` cursor); `audio.snippet` **v2 decode** (`decodeAudioSnippetFrame`, source-attributed,
185
+ v1 still decodes); `room.audioClips()` raw-clip async iterator (source/sourceName from the
186
+ subscribe-time roster snapshot); `room.participants()` / `room.has()` typed `Participant` roster
187
+ (merged `role`+`roles`, `connectedAt` as `Date`) over one short observe stream; `dashboardUrl()` /
188
+ `openDashboard()` / `captureUrl()` / `openCapture()` URL helpers.
189
+
190
+ ## Public surface notes (pre-release)
191
+
192
+ - The default `hci-atrium` barrel exports only the two roster *types* an app touches: the parsed
193
+ `Participant` (merged roles / `name` / `connectedAt`, from `rooms/participant.ts`) and the raw
194
+ transport item `RosterEntry` (from `rooms/transport.ts`). Internal helpers — `mergeRoles`,
195
+ `parseParticipant`, `participantMatches`, `readObserveRoster`, `parseAnchorDescriptor` /
196
+ `AnchorDescriptor` — are **no longer re-exported** from the top-level entry (they stay importable
197
+ internally via `rooms/index`). Also on the record: the `Participant` export was re-pointed to the
198
+ parsed view and the raw roster item renamed to `RosterEntry`. Pre-release, no consumers.
199
+ - **Barrel trim, 2026-07-26 (ergonomics pass).** The root `hci-atrium` entry now exports the two room
200
+ handles (`Room`, `OwnedRoom`), the values their methods hand back (`Recorder`, `Transcriber`,
201
+ `OfferedCapture`, `OfferedTranscription`, `RoomTrace`, `Dashboard`, `Decider`, …), the models, the
202
+ errors, and the option/event types an app names. **Demoted** (still importable from their modules,
203
+ just off the barrel): the audio/snippet frame codec and its `AUDIO_SNIPPET_MAGIC` / `*_VERSION`
204
+ constants, every role string (`AUDIO_ROLE`, `*_BROKER_ROLE`, `CAPTURE_*`, `TRANSCRIBER_SOURCE`, …),
205
+ the transport internals (`RoomEventStream`, `RoomTransport`, `RoomSignal`), the offers' machinery
206
+ (`ArmedSet`, `StandingSource`, `OFFER_MAX_SOURCES`), the dependency-injection bags (`RoomDeps`,
207
+ `OwnedRoomDeps`, `RoomAudioClient`, `OfferDeps`, `CaptureOfferDeps`, `TranscriberDeps`, …),
208
+ `validateChatMessages`, and the trace dialect helpers (`traceEnvelope`, `traceCallType`,
209
+ `traceStateType`, `TRACE_STATE`, `isTraceEphemeral`). These describe the wire, not the app; the
210
+ hosted tools import none of them.
211
+
212
+ **Demoted is not removed.** 0.3.x shipped these *on the barrel*, so this is a breaking change for
213
+ anyone who named one — the package is on npm and its download count is not zero. The migration path
214
+ is the `./internal/*` subpath export, which mirrors the source tree:
215
+
216
+ ```ts
217
+ import { decodeAudioFrame } from "hci-atrium/internal/rooms/codec.js" // extension optional
218
+ ```
219
+
220
+ It carries **no semver guarantee** — internals move without a major bump, by definition. It exists
221
+ so a 0.3 consumer has a one-line edit instead of a fork, not as a second public API.
222
+
223
+ ## Won't port (by design)
224
+
225
+ | Surface | Reason |
226
+ | --- | --- |
227
+ | CLI (`atrium …`) | Documented parity exception — except `atrium guide`, which the npm package ships to print GUIDE.md. |
228
+ | Home Assistant bridge | Documented parity exception. |
229
+ | Sync client surface | JS is async-native; single-surface SDK is the idiom. |
230
+ | Raw-PCM mic input à la `sounddevice` | Browser equivalent is `MediaRecorder`/AudioWorklet clips — same `transcribeLive` contract, different capture idiom. |
231
+
232
+ ## Known micro-divergences (documented, acceptable)
233
+
234
+ - **Agent tools are descriptors, not decorated functions.** Python's `@client.tool` derives a tool's
235
+ OpenAI schema from the function's signature + docstring; TypeScript erases types and has no
236
+ docstrings, so a tool is an explicit `{ name, description, parameters, execute }` descriptor.
237
+ `parameters` is plain JSON Schema, or `{ jsonSchema, validate? }` where `validate` is any
238
+ [Standard Schema](https://standardschema.dev) object (Zod ≥3.24, Valibot, ArkType — the `~standard`
239
+ property) used to parse the model's arguments before `execute` runs. A **bare** schema object is
240
+ rejected with a message naming the fix: deriving JSON Schema from it would mean taking the library
241
+ as a runtime dependency, and the package is zero-dependency by design (Zod stays an *optional* peer,
242
+ loaded lazily only by structured output). One more small deviation inside the loop: a tool's return
243
+ value is JSON-encoded when it's an object (JS's `String({})` is the useless `"[object Object]"`,
244
+ where Python's `str(dict)` is readable).
245
+ - **No `DirectTransports`.** Python caches one `httpx.Client` per direct endpoint and closes them with
246
+ the client; `fetch` is stateless, so TS has nothing to pool or close. The per-endpoint timeout
247
+ becomes an `AbortSignal.timeout` folded into each call's signal — and it is in **milliseconds**
248
+ (the JS unit), not Python's seconds.
249
+ - **`surveys.responsesCsv` is its own method**, where Python has `responses(format="rows"|"csv")`.
250
+ The two return types have nothing in common (parsed rows vs raw bytes), so a union return would be
251
+ a type-level lie the caller has to narrow — the same call the `sessions.export` port already made.
252
+ Also, as there: no `path=` file-writing, since the core never touches the filesystem.
253
+ - `surveys.start({ into })` takes a `Session` handle **or** a bare session id — unchanged. What
254
+ changed (2026-07-26, **overturning** the note that used to live here): TS's `Session` is no longer
255
+ an inert read model. It is a class with a truly-private `#binder`, exactly like `Asset`, carrying
256
+ `session.event(type, payload?, opts?)` bound to the client that produced it — the twin of Python's
257
+ `Session.event`. Python's instance offers *only* `event`, so that is all TS attaches; the id-based
258
+ `sessions.*` namespace (`sessions.event(id, …)`, `sessions.export(id)`, …) stays the complete
259
+ surface and is what an unbound session's error message names. A `Session` built by hand is now a
260
+ `parseSession({...})` call rather than an object literal — a class with a private field cannot be
261
+ satisfied structurally.
262
+ - **`chatStream` is a separate method**, where Python has `chat(..., stream=True)`. A flag that swaps
263
+ the return type between `Promise<ChatReply>` and `AsyncIterable<string>` forces every call site to
264
+ narrow a union it already knows the answer to, and Python's dynamic `stream=True` has no such cost.
265
+ The behaviour is identical either way (the iterable is returned **synchronously** — nothing to
266
+ `await` before the `for await`, same as Python's generator), so this is a type-soundness divergence
267
+ only. It also retires a runtime error: "stream: true can't be combined with a schema
268
+ `responseFormat`" is now unrepresentable, since structured output lives on `chat` alone.
269
+ - A **streamed** chat is traced at hand-off (one `trace.chat`, no reply preview, ~0 duration) —
270
+ mirroring Python, whose decorator emits when the generator is returned, before anything is consumed.
271
+ - **`Room` / `OwnedRoom` split.** Python's `Room` carries its client, so its host-side verbs are
272
+ always callable and the loginless `join_room` handle is a different class already. TS used to hand
273
+ back one `Room` for both and refuse at runtime (`requireClient` → "no account credential"). The
274
+ ~14 credential-bound members now live on `OwnedRoom extends Room`, which `client.rooms.open/get/list`
275
+ return; `joinRoom(code)` **and** `client.rooms.join(code)` return the participant `Room`. Same
276
+ capability boundary, moved from the runtime to the type — `room.record(...)` on a code-joined handle
277
+ is a compile error, not a thrown one. `rooms.join` stays a plain `Room` deliberately: it is built
278
+ without the REST caller and capability slice (a code grants live transport, not the account), so
279
+ typing it `OwnedRoom` would advertise members that throw.
280
+ - **`chat` / `agent` passthrough is an explicit `params` bag**, where Python takes `**kwargs`. TS's
281
+ equivalent was an index signature (`[key: string]: unknown`) on the options interface, which made
282
+ every misspelled SDK option — `responseFormatt`, `modle` — a silently-forwarded OpenAI param. The
283
+ bag keeps the capability (`params: { temperature: 0.3 }`) and gets the typo checking back; SDK-owned
284
+ keys are applied last and win a collision. Same wire.
285
+ - **`room.send` carries `to` + `replyTo` only**, where Python's `Room.send` also takes `ephemeral`.
286
+ Python sends over the REST `/v1/rooms/{id}/signal` POST, which carries the flag; TS sends over the
287
+ room WebSocket, whose inbound path forces `ephemeral: false` server-side. An `ephemeral=` parameter
288
+ on the TS `send` would be a field the transport silently drops, so it isn't offered — the surfaces
289
+ that need ephemerality (`room.dashboard()`, `room.trace()`, `room.transcribe()`, the standing
290
+ offers, `audioVectors({relay: true})`) all publish through the REST POST instead, which is why they
291
+ are `OwnedRoom` members. `ackId` is likewise absent: a reply needs a registered pending entry, and
292
+ only `room.request(...)` creates one.
293
+ - **Base-URL resolution is one helper** (`resolveBaseUrl`, `src/env.ts`) shared by the client,
294
+ `joinRoom`, and the React `useRoomConnection` hook: explicit `baseUrl` → `ATRIUM_BASE_URL` →
295
+ (hook only) the page's own origin → the deployed default. The hook's origin rung is the one
296
+ difference, deliberate — it is browser-only, and a hosted tool page served *by* an instance should
297
+ talk to that instance. **Behavior change (2026-07-26):** the hook previously hardcoded
298
+ `location.origin`, so a tool pointed elsewhere at build time via `ATRIUM_BASE_URL` silently ignored
299
+ it; env is now consulted first. A page that relied on origin-wins while also setting the env var
300
+ resolves differently.
301
+ - `room.play` for byte-backed assets: TS sends compact `{asset_id}`, Python sends a playback
302
+ intent. Both shapes work at the Player.
303
+ - **Player dialect arguments are typed in both spellings.** `room.play(track)` and
304
+ `room.configureLayers(layers)` accept the SDK's camelCase (`PlayTrack` / `LayerDeclaration`) *or*
305
+ the dialect's own snake_case (`WireTrack` / `LayerSpec`, from the `hci-atrium/player` subpath),
306
+ translating the former at the shaping seam. Python takes the wire dict directly and has no second
307
+ spelling. A key belonging to neither is a compile error, which is the point: the runtime always
308
+ accepted a hand-rolled wire descriptor, and before this the type didn't. The read-back
309
+ (`queryLayers` / `watchLayers`) still hands back the raw wire snapshot, snake_case — it is the
310
+ Player's payload, not an SDK model.
311
+ - **One `PlaybackIntent`.** The `{source, ref, mode?}` intent is defined once in `models.ts` and
312
+ re-exported by `hci-atrium/player`'s `play-command`, so the type an app reads off `asset.playback`
313
+ is the exact type the Player runtime dispatches on. `ref` is therefore nullable there too (a
314
+ description-only asset has no target): `routePlayIntent` reads a missing ref as "nothing to play",
315
+ and narrows `mode` to the `"shuffle"` the Spotify resolver understands.
316
+ - `room.play`'s `asset` track key (landed both sides 2026-07-26, together with the `gain` per-call
317
+ option: `play({asset: hit, gain: 2})` / `play(source, {gain: 2})`, per-track gain winning): what
318
+ counts as "an asset under `asset`" is decided differently. Python expands anything that isn't a
319
+ `dict` (an `Asset`/`SearchHit`/id string — anything else raises the usual `TypeError`) and leaves a
320
+ `dict` alone, since that's the wire's inline-asset descriptor; TS has no dict/object split, so it
321
+ expands only a value that *looks* like a parsed `Asset` (`isAssetLike`), a `SearchHit`
322
+ (`score` + `matchedBy`), or a string, and passes anything else through untouched. Same rule for
323
+ every SDK-object call, same wire; only a hand-rolled oddity under `asset` behaves differently
324
+ (Python throws where TS ships it as-is) — the same heuristic trade-off as the `isAssetLike` note above.
325
+ - Live-transcription WS auth: Python rides an `Authorization: Bearer` header on the handshake; the TS
326
+ client can't (browsers forbid custom WS headers), so it carries the credential in-band as the
327
+ `start` frame's `api_key` field — the backend's browser-safe first-frame path (accept → read
328
+ `start` → pop `api_key` → validate). Same credential, different carrier; and unlike the legacy
329
+ `?api_key=` query the backend still tolerates, it never rides the URL into proxy/access logs. A
330
+ rejected key closes `4401` after the socket opened, still surfaced as `AuthError` (never a silent
331
+ degrade to the per-clip batch fallback).
332
+ - `room.dashboard()` and `room.trace()` publish over the REST `/v1/rooms/{id}/signal` POST for the
333
+ same reason `room.transcribe()` does (below): the WS inbound path forces `ephemeral: false`, and
334
+ both dialects are ephemerality-sensitive (all `dash.*` is ephemeral; a trace is durable *unless* it
335
+ mirrors something already durable). Both therefore need an **owned** room — a loginless `joinRoom`
336
+ handle throws, where Python's `Dashboard(room)`/`room.trace()` ride its REST `send` unconditionally.
337
+ A bare `new Dashboard(roomLike)` (the decoupled/test path) still uses the room-like's own `send`.
338
+ - Trace re-entrancy: Python needs a `contextvars` guard because its structured-output `chat` recurses
339
+ through the *traced* `chat`. The TS client's structured path calls the **untraced** private
340
+ `chatOnce` instead, so one logical call emits exactly one `trace.chat` with no task-local state.
341
+ - `room.audioTranscripts()` teardown: a consumer's `break` can't cancel the engine's pending feeder
342
+ `await` the way Python cancels its feeder task, so the iterator holds its **own** `AbortController`
343
+ (chained to an optional `signal`), aborts it in a `finally` **before** winding the engine down —
344
+ closing the snippet socket is what unblocks the feeder — and pulls the engine by hand rather than
345
+ `yield*` (which would forward `return()` into a still-parked engine and deadlock). Same documented
346
+ limitation family as the transcription teardown note below.
347
+ - `room.transcribe()` publishes its `transcript.*` signals over the REST `/v1/rooms/{id}/signal` POST
348
+ (like Python's `room.send`), **not** the room WebSocket — because the WS inbound path forces
349
+ `ephemeral: false`, and the recorder's finals-vs-partials split needs the partial's `ephemeral: true`
350
+ flag to survive on the wire. So the broker's ephemeral hint is honored identically to Python's.
351
+ - Transcription teardown: JS can't hard-cancel a pending `await` the way Python cancels the feeder
352
+ task, so `stop()` aborts via an `AbortSignal` (closing the snippet socket + the stream WS) and the
353
+ feeder stops at its next resumption point / on the input iterator's `return()`. A well-behaved input
354
+ ends promptly; a pathological input blocked forever on its own `await` is the one case that can't be
355
+ force-unblocked (documented limitation, not reachable from the room snippet path, which ends on
356
+ socket close).
357
+ - Offer source-name resolution: Python's `RoomWebSocket.frames(on_presence=…)` folds presence into a
358
+ roster on the *same* loop that yields binary frames, so each frame resolves its source against the
359
+ roster as it was when that frame was processed. The TS transport folds its live roster synchronously
360
+ on receipt (decoupled from the offer's async consumption), so a burst of presence + binary would race
361
+ the roster ahead. The offer therefore keeps its **own** point-in-time roster (`StandingSource` — name
362
+ + roles), seeded from the ready snapshot and folded in loop order, and resolves `source_name` /
363
+ all-arm enumeration off *that*, not the transport's live roster. Same observable behaviour, folded on
364
+ the offer's clock.
365
+ - Offer engine teardown: a standing transcription offer holds a **per-source** `AbortController` (not
366
+ one shared signal). A per-source `transcription.stop` / a source's `presence.leave` — and the whole
367
+ `stop()` — abort that source's signal AND drop its buffered clips (`ClipQueue.clear()` + a `null`
368
+ end-sentinel), matching Python's `engine.cancel()`: buffered clips are discarded and nothing is
369
+ published past the disarm boundary (consent-relevant — no captions after a source stops/leaves). The
370
+ per-source `stopSource` runs under the offer's `Mutex`, like capture's.
371
+ - The offer-capture chunked recording wire (`POST …/recordings` / `PUT …/chunks` / `POST …/finalize`)
372
+ is pinned by byte-exact assertions in `tests/offers.test.ts` (paths, `{source_name, content_type}`
373
+ body, chunk `Content-Type` + bytes) — mirroring Python's `test_offers.py` — rather than added to the
374
+ shared `wire-fixtures` golden corpus, which has no binary-body channel and whose Python side builds
375
+ these calls inline (no shaping-builder call-site to register). Same wire, pinned in both suites.
376
+
377
+ ## TS-first (Python owes the port)
378
+
379
+ | Surface | Notes / porting trigger |
380
+ | --- | --- |
381
+ | `room.onEnd(cb)` — typed terminal-end hook | Fans the transport's already-classified terminal reason (`RoomEndReason`: `code-rotated` / `bad-code` / `room-gone`) out to a public listener, **exactly once** per handle, for a connect-time reject and a mid-session end alike, on the WS and SSE transports both. Returns an unsubscribe; a deliberate `close()` is not an end and never fires it. Added so a driver (the observation dashboard) maps a typed reason to its own UI state instead of string-matching a reject message — and so a mid-session `room-gone` (the room deleted out from under a live consumer) is caught rather than leaving the UI stuck on "live". Python's `Room` / `AsyncRoom` classify the same terminal reasons internally (e.g. the recorder's `stopped_reason`) but expose no public `on_end`. Trigger: the next Python-host consumer that needs the typed end; port as `room.on_end(callback)`. |
382
+ | `atrium.data` — the participant-token tier under a survey (`GET /v1/data/resolve` · `GET`/`PUT /v1/data/content` · `POST /v1/data/submit`: read/write **one** data object with only its `atd_…` capability, no account) | The hosted survey tool speaks this tier over plain `fetch` today; nothing in either SDK wraps it. It belongs in TS **first**: the consumer is a browser page holding a per-object token, which is exactly the loginless shape TS already serves (`joinRoom`). Trigger: the `tools/` survey-runner migration onto the SDK — the same trigger the surveys row carried while it was open, and it was **wrong there**: `surveys.*` is the *researcher-side* surface (an API key, a project), which the SDK now has, and it never depended on that migration. Port to Python afterwards only if a Python host ever needs to fill a survey out. |
383
+ | `hci-atrium/player` — audio-layers engine + `play` / `layer.*` dialect helpers | **TS-only by design — no port owed.** A browser WebAudio surface (named, independently-mixed layers with crossfades, reverb/echo/distance effects, and 3D positioning) behind the hosted Player tool, so SoundMuse 3 and other embedders can drive it. Python hosts drive the Player *over the room dialect*; they don't run the engine, so no Python equivalent is planned. See `docs/reference/audio-layers-dialect.md`. |
384
+
385
+ *(browser mic-capture helpers for the recorder tool may also land here first.)*
package/README.md CHANGED
@@ -18,6 +18,27 @@ pnpm add hci-atrium
18
18
 
19
19
  Or `npm install hci-atrium`.
20
20
 
21
+ ### For coding agents
22
+
23
+ `pnpm exec atrium guide` prints the complete, task-oriented SDK guide —
24
+ pipe it into your agent's context (`pnpm exec atrium guide > atrium-guide.md`, or just tell the
25
+ agent to run it). Reading `node_modules/hci-atrium/GUIDE.md` directly works too. The deployed
26
+ instance serves the same docs at `/llms.txt` for agents that browse.
27
+
28
+ To make it automatic, add these lines to your project's `CLAUDE.md` / `AGENTS.md`:
29
+
30
+ ```markdown
31
+ This project uses the hci-atrium SDK. Before writing Atrium code, run
32
+ `atrium guide` and follow it — it is the authoritative API reference
33
+ (`uv run atrium guide` in Python projects, `pnpm exec atrium guide` in
34
+ TypeScript projects).
35
+ ```
36
+
37
+ The guide is written against the Python SDK; its "Reading this from TypeScript" section covers the
38
+ mechanical mapping, and [`PARITY.md`](PARITY.md) tracks the (few) deliberate gaps. `atrium guide` is
39
+ the *only* subcommand this package ships — the full CLI (`login`, `whoami`, `search`, …) comes with
40
+ the Python package.
41
+
21
42
  ## Register an app
22
43
 
23
44
  An app is registered on an Atrium **project**, next to its API keys (Capabilities → Apps). You
@@ -64,10 +85,12 @@ component that consumes a [room](#live-rooms) by connect code — loginless, so
64
85
  and no sign-in. It joins, classifies failures, and tears the handle down on unmount, returning
65
86
  `{ status, room }`: `status` is `"connecting"` → `"connected"`, `"unreachable"` for a join/connect
66
87
  failure, or a typed terminal end (`"bad-code"` / `"code-rotated"` / `"room-gone"`); `room` is the
67
- `Room` handle, `null` before it exists and again after a terminal end. Register signal handlers in
68
- `onRoom` — it runs the moment the handle exists, **before** `connect()`, so a replayed durable signal
69
- isn't missed — and return a cleanup from it if you need one. `identity` is the presence name (e.g.
70
- `"viewer:web"`); `baseUrl` defaults to `location.origin`.
88
+ participant `Room` handle (loginless — no host-side surface), `null` before it exists and again after
89
+ a terminal end. Register signal handlers in `onRoom` — it runs the moment the handle exists,
90
+ **before** `connect()`, so a replayed durable signal isn't missed — and return a cleanup from it if
91
+ you need one. `identity` is the presence name (e.g. `"viewer:web"`); `baseUrl` defaults to
92
+ `ATRIUM_BASE_URL`, then the page's own origin, then the deployed instance — the same ladder the
93
+ `Atrium` client walks.
71
94
 
72
95
  ```tsx
73
96
  import { useState } from "react"
@@ -130,16 +153,18 @@ apps).
130
153
 
131
154
  | Member | Description |
132
155
  | --- | --- |
133
- | `library.search(query?, opts?)` | Semantic search — text, `like` (an asset/hit/id), `image`/`audio`/`video`, or `vector`. `matchBy` (weighted map), tag facets, and the long tail ride `filters`. |
156
+ | `library.search(query, opts?)` / `library.search(opts)` | Semantic search — text, `like` (an asset/hit/id), `image`/`audio`/`video`, or `vector`. Two forms, no filler positional: pass the text first, or go options-only when there is none. `matchBy` (weighted map), tag facets, and the long tail ride `filters`. |
134
157
  | `library.searchByExamples(positives, opts?)` | Mood-board search over a set of example assets (`method` defaults to `"cav"`). Seeds are `Asset` or `by.asset(id)`. |
135
158
  | `library.compare(a, b, opts?)` | Similarity of two operands (`Asset` / text / `by.*`), raw + calibrated. `opts.space` forces the embedding space. |
159
+ | `library.add(opts)` | The front door into the library: `{ file }` uploads, `{ description }` alone creates a **fileless** asset whose description *is* the content. |
136
160
  | `library.enrich(query, opts)` | Pull external candidates into the library; `opts.preview` is a dry-run. Returns the raw payload. |
137
161
  | `assets.list(opts?)` / `assets.get(id, opts?)` / `assets.upload(file, opts?)` | Browse, fetch, and upload assets. `file` is a `Blob`/`File` or a URL string. |
138
- | `sources.list()` / `sources.search(query, opts?)` / `sources.import(opts)` | External-provider gateway — read-only fan-out search, then import a `Candidate`. |
139
- | `sessions.start(opts?)` / `sessions.resume(id)` / `sessions.event(id, type, opts?)` | Durable, append-only session logs. |
140
- | `rooms.open(opts?)` / `rooms.get(id)` / `rooms.list()` / `rooms.join(code)` | Live [`Room`](#live-rooms) handles — `open`/`get`/`list` are authenticated, `join(code)` is a loginless participant. |
141
- | `surveys.start(template?, opts)` / `surveys.template(name, body, opts?)` / … | [Surveys](#surveys) on a session, and the project's survey templates. |
142
- | `chat(messages, opts?)` | Chat completion. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep), or `stream: true` for an [`AsyncIterable<string>`](#streaming-chat) of deltas. |
162
+ | `sources.list()` / `sources.search(query, opts?)` / `sources.import(target, opts?)` | External-provider gateway — read-only fan-out search, then import a `Candidate` (or an explicit `{ sourceId, externalId }`). |
163
+ | `sessions.start(opts?)` / `sessions.resume(id)` / `sessions.event(id, type, payload?, opts?)` | Durable, append-only session logs. A `Session` handle also carries the bound `session.event(type, payload?)`. |
164
+ | `rooms.open(opts?)` / `rooms.get(id)` / `rooms.list()` / `rooms.join(code)` | Live room handles — `open`/`get`/`list` return an [`OwnedRoom`](#live-rooms), `join(code)` a participant [`Room`](#live-rooms). |
165
+ | `surveys.start(opts)` / `surveys.template(name, body, opts?)` / … | [Surveys](#surveys) on a session, and the project's survey templates. |
166
+ | `chat(messages, opts?)` | Chat completion — always a promise of one reply. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep). |
167
+ | `chatStream(messages, opts?)` | The same completion as an [`AsyncIterable<string>`](#streaming-chat) of deltas, returned synchronously. |
143
168
  | `agent(prompt, { tools, … })` | A [tool-calling loop](#agents-and-tools) over explicit tool descriptors. |
144
169
  | `addDirectModel(name, { baseUrl, apiKey? })` | Route one model name straight to an OpenAI-compatible endpoint ([direct models](#direct-models)) instead of Atrium. |
145
170
  | `embed(opts)` | Embed exactly one of `text` / `image` / `audio` / `video` into a vector; `model` is required. |
@@ -152,13 +177,33 @@ apps).
152
177
 
153
178
  A `Room` is pure live transport over one WebSocket — text signals *and* the ephemeral binary
154
179
  audio-analysis stream. `room.on(type, handler)` registers a handler (its return value auto-replies to
155
- a matching `room.request(...)`); `room.send(type, payload, { to })` fans out; player controls
180
+ a matching `room.request(...)`); `room.send(type, payload, { to, replyTo })` fans out; player controls
156
181
  (`play` / `configureLayers` / `clearLayer` / `clearAll` / `setLayerEffects` / `queryLayers` /
157
182
  `watchLayers` / `playerUrl`) drive the hosted Player; `room.audioSignals(cb)` decodes the
158
183
  loudness/spectrum/beat stream. `connect()` opens the socket (every wire method calls it for you);
159
184
  `close()` / `await using` / a passed `AbortSignal` tears it down.
160
185
 
161
- Host-side surfaces on an **owned** room (`atrium.rooms.open/get/join`, not the loginless `joinRoom`):
186
+ `room.play` takes an `Asset`, a `SearchHit`, an id/URL string, a typed `PlayTrack` for per-sound
187
+ options, or a list of any of those — the track fields are camelCase (`assetId`, `contentUrl`,
188
+ `delaySeconds`, `clipStartSeconds`, `fadeInMs`, …) and the shaping layer translates them to the
189
+ dialect's snake_case wire spelling. The wire spelling is typed too, so a descriptor written straight
190
+ against [`audio-layers-dialect.md`](../../docs/reference/audio-layers-dialect.md) compiles and
191
+ reaches the wire untouched; a key belonging to *neither* spelling is a compile error:
192
+
193
+ ```ts
194
+ await room.play(hit, { layer: "ambient" })
195
+ await room.play({ asset: hit, gain: 2, loop: true, delaySeconds: 0.5 }, { layer: "sfx" })
196
+ await room.play({ asset_id: "a1", fade_in_ms: 250 }) // the dialect's own spelling
197
+ ```
198
+
199
+ `room.configureLayers` follows the same rule: `{ id, label?, volume?, crossfadeMs?, effects? }` or
200
+ the wire's `crossfade_ms`. (`queryLayers` / `watchLayers` hand back the Player's raw snapshot —
201
+ snake_case, as it arrives.)
202
+
203
+ **`Room` vs `OwnedRoom`.** `atrium.rooms.open/get/list` return an `OwnedRoom`: everything above plus
204
+ the host-side surfaces the account credential unlocks. `joinRoom(code)` and `atrium.rooms.join(code)`
205
+ return a plain `Room` — a connect code grants live transport, not the account, so those members are
206
+ **absent from its type** rather than throwing at runtime:
162
207
 
163
208
  | Member | Description |
164
209
  | --- | --- |
@@ -168,6 +213,7 @@ Host-side surfaces on an **owned** room (`atrium.rooms.open/get/join`, not the l
168
213
  | `room.dashboard(opts?)` → [`Dashboard`](#observation-dashboard) | Host-side state for the hosted observation dashboard (`/tools/dashboard`). |
169
214
  | `room.trace()` → `RoomTrace` | Mirror the SDK's own activity into the room as `trace.*` signals (default off). |
170
215
  | `room.offerTranscription()` / `offerAudioCapture()` / `offerEmbeds()` / `offerChat()` | Standing services + capability brokers for code-joined peers. |
216
+ | `room.rotateCode()` / `room.delete()` | Room management (owner only). |
171
217
 
172
218
  This SDK now covers every Python-SDK surface except the documented parity exceptions — the CLI, the
173
219
  Home Assistant bridge, a sync surface, and raw-PCM mic input — each with its reason in
@@ -231,7 +277,10 @@ changed, and flips `default` when asked — so your templates live in your repo,
231
277
  await atrium.surveys.template("post-study", postStudyBody, { default: true })
232
278
 
233
279
  const session = await atrium.sessions.start({ participantRef: "p-07" })
234
- const survey = await atrium.surveys.start("post-study", {
280
+ await session.event("phase.start", { phase: "post-study" }) // bound to the session it came from
281
+
282
+ const survey = await atrium.surveys.start({
283
+ template: "post-study",
235
284
  into: session,
236
285
  returnTo: "https://study.example/next",
237
286
  })
@@ -247,16 +296,26 @@ file (survey imagery) and returns the `id` a template body's `image` field refer
247
296
 
248
297
  ### Streaming chat
249
298
 
250
- `chat(messages, { stream: true })` returns an `AsyncIterable<string>` of content deltas — **not** a
251
- promise, so there is nothing to `await` before the loop. It works the same on a direct endpoint, and
252
- can't be combined with a schema `responseFormat` (structured output validates a complete reply).
299
+ `chatStream(messages, opts?)` returns an `AsyncIterable<string>` of content deltas — **not** a
300
+ promise, so there is nothing to `await` before the loop. It works the same on a direct endpoint. It
301
+ is its own method rather than a `stream: true` flag on `chat`: a flag that swaps the return type
302
+ between a promise and an iterable makes every call site narrow a union it already knows the answer
303
+ to. Structured output stays on `chat`, which always resolves one complete reply.
253
304
 
254
305
  ```ts
255
- for await (const piece of atrium.chat("describe a rainy street", { stream: true })) {
306
+ for await (const piece of atrium.chatStream("describe a rainy street")) {
256
307
  process.stdout.write(piece)
257
308
  }
258
309
  ```
259
310
 
311
+ Anything OpenAI-shaped that the SDK doesn't own itself rides `params` — an explicit passthrough bag
312
+ on both `chat` and `agent`, so a misspelled SDK option is a compile error instead of a silently
313
+ forwarded param:
314
+
315
+ ```ts
316
+ await atrium.chat("hi", { model: "gpt-4o", params: { temperature: 0.3, top_p: 0.9 } })
317
+ ```
318
+
260
319
  ### Agents and tools
261
320
 
262
321
  `agent(prompt, { tools })` runs the tool-calling loop: the model may call your tools until it
@@ -386,16 +445,27 @@ through the `unhandled` hook.
386
445
 
387
446
  ```ts
388
447
  import { joinRoom } from "hci-atrium"
389
- import { PlayerRuntime } from "hci-atrium/player"
448
+ import { buildSignalEnvelope, PlayerRuntime } from "hci-atrium/player"
390
449
 
391
450
  const baseUrl = "https://atrium.example.edu"
392
- const room = await joinRoom("ABC12345", { baseUrl })
451
+ const code = "ABC12345"
452
+ const room = await joinRoom(code, { baseUrl })
393
453
  const context = new AudioContext() // create it on the user's Start gesture — autoplay policy
394
454
 
395
455
  const runtime = new PlayerRuntime({
396
456
  context,
397
457
  baseUrl, // asset-resolution root for the `asset_id`-only play shape
398
- send: (type, payload, opts) => void room.send(type, payload as Record<string, unknown>, opts),
458
+ // The sink rides the REST `/signal` POST, not `room.send`: the runtime's traffic is ephemeral
459
+ // state chatter, and the room transport's inbound path forces `ephemeral: false` — a `room.send`
460
+ // sink would write a `layer.state` heartbeat into every recording session.
461
+ send: (type, payload, opts) => {
462
+ const body = buildSignalEnvelope(type, payload, room.connectionId, opts)
463
+ void fetch(`${baseUrl}/v1/rooms/${room.id}/signal`, {
464
+ method: "POST",
465
+ headers: { "content-type": "application/json", "x-session-code": code },
466
+ body: JSON.stringify(body),
467
+ })
468
+ },
399
469
  onLog: (line) => console.log(line),
400
470
  onSnapshot: (layers) => renderLayers(layers), // engine snapshot → your UI
401
471
  })
@@ -415,10 +485,12 @@ await room.connect()
415
485
  `LayerEngine` is the mixer; `PlayerRuntime` is the mixer *plus* the dialect. It exposes
416
486
  `runtime.engine` for listener-side controls (per-layer / master volume, normalization, device-change
417
487
  resume) and `runtime.masterGain` as an analyzer tap (pre-limiter); `dispose()` tears down the engine,
418
- its timers, and the master chain it built, leaving the host's `AudioContext` alone. One caveat on the
419
- `send` sink: `Room.send` carries `to` and `ephemeral` but not `replyTo`, so a host that must answer a
420
- peer's `layer.query` builds the envelope itself `buildSignalEnvelope` from the same subpath does
421
- exactly that for a `POST /v1/rooms/{id}/signal` transport, which is what the hosted Player runs on.
488
+ its timers, and the master chain it built, leaving the host's `AudioContext` alone. On the `send`
489
+ sink: `room.send` carries `to` and `replyTo` only enough to *route* a `layer.query` reply, but the
490
+ frame lands durable (the room's inbound path forces `ephemeral: false`), so `layer.state` chatter
491
+ would be recorded by any `room.record(...)` bridge. `buildSignalEnvelope` + the `POST
492
+ /v1/rooms/{id}/signal` route, as above, is the sink that keeps the dialect's ephemerality; an
493
+ owned-room host can equally publish through its own authenticated REST caller.
422
494
 
423
495
  ## Honest caveat: the guard is UX, not secrecy
424
496