hci-atrium 0.4.0 → 0.6.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.
package/GUIDE.md ADDED
@@ -0,0 +1,1215 @@
1
+ # hci-atrium — Student & AI-assistant guide
2
+
3
+ A complete, task-oriented reference for the `hci_atrium` Python SDK — the client for the Atrium
4
+ multimodal asset library. It's written to be pasted into a coding assistant verbatim: every
5
+ signature below is checked against `clients/python/src/hci_atrium/`. If you're new to the SDK,
6
+ start with [`README.md`](README.md) for the 60-second overview; this file is the reference you
7
+ come back to while building.
8
+
9
+ Everything here works with either client:
10
+
11
+ ```python
12
+ from hci_atrium import Atrium # sync — the default for scripts
13
+ from hci_atrium import AsyncAtrium # asyncio — same surface, `await` everything
14
+ ```
15
+
16
+ The two mirror each other method-for-method. Examples below use the sync client unless a feature
17
+ (rooms driven by `on`/`serve`, the Home Assistant bridge) needs asyncio — those are marked **async**.
18
+
19
+ ## Reading this from TypeScript
20
+
21
+ The same SDK is published on npm as `hci-atrium` (`pnpm add hci-atrium`), at parity with the
22
+ Python surface below apart from the deliberate gaps noted at the end of this section. This guide
23
+ stays the reference for both — translate it mechanically:
24
+
25
+ - **Names**: `snake_case` → `camelCase`. `library.search_by_examples` → `library.searchByExamples`,
26
+ `add_direct_model` → `addDirectModel`, `content_kind` → `contentKind`.
27
+ - **Keyword arguments** become one trailing `params` object:
28
+ `client.library.search("rain", modality="audio", limit=10)` →
29
+ `client.library.search("rain", { modality: "audio", limit: 10 })`.
30
+ - **`None` → `null`**, `True`/`False` → `true`/`false`, dicts → plain objects.
31
+ - **Async everywhere.** There is no sync/async split — every call returns a `Promise`, so the
32
+ `AsyncAtrium` examples are the ones to follow (`await` the sync ones too).
33
+
34
+ Deliberate gaps: the full `atrium` CLI (`login`, `whoami`, `search`, …) and the Home Assistant
35
+ bridge are **Python-only**. The npm package ships exactly one command, `atrium guide`, which prints
36
+ this file (`pnpm exec atrium guide`). Everything else is ported.
37
+
38
+ For the per-surface specifics — the parity ledger, the browser-side differences (API keys, capture),
39
+ and the React bindings — see `PARITY.md` and `README.md`, which ship alongside this file in the npm
40
+ package (`clients/typescript/` in the repo).
41
+
42
+ ---
43
+
44
+ ## 1. Setup + auth
45
+
46
+ ### Install
47
+
48
+ **Fresh project with uv (recommended):**
49
+
50
+ ```sh
51
+ uv init my-study-app
52
+ cd my-study-app
53
+ uv add hci-atrium
54
+ ```
55
+
56
+ Run scripts with `uv run python main.py` (or `uv run <script.py>`) — uv creates the venv and syncs
57
+ automatically, no activation. The CLI comes along: `uv run atrium --help`. The SDK needs Python
58
+ >=3.10; if the pinned version is older, `uv python pin 3.12`.
59
+
60
+ **Existing project:** `uv add hci-atrium` in a uv-managed project; for a plain venv/pip project:
61
+
62
+ ```sh
63
+ pip install hci-atrium
64
+ ```
65
+
66
+ **Throwaway try-out:** `uv run --with hci-atrium python` opens a REPL with the SDK importable, no
67
+ project needed.
68
+
69
+ SDK developers working against a checkout: `uv add --editable path/to/clients/python` overrides the
70
+ published package with your local source.
71
+
72
+ ### Connect
73
+
74
+ ```python
75
+ from hci_atrium import Atrium
76
+
77
+ client = Atrium() # defaults to the deployed instance (or ATRIUM_BASE_URL)
78
+ ```
79
+
80
+ | What | Argument | Environment variable | Default |
81
+ |---|---|---|---|
82
+ | Instance URL | `Atrium(base_url=...)` | `ATRIUM_BASE_URL` | the deployed instance |
83
+ | Credential | `Atrium(api_key="atr_...")` | `ATRIUM_API_KEY` | cached OAuth → browser sign-in |
84
+
85
+ **Zero-config sign-in.** With no `api_key`, the first authenticated call opens a browser
86
+ (Authorization Code + PKCE, out-of-band code). Tokens are cached in `~/.atrium/credentials.json`
87
+ (mode `0600`) and refreshed silently — later runs and later scripts don't prompt again.
88
+ `client.login()` forces this now instead of lazily; `client.logout()` forgets the cached session;
89
+ `client.token()` returns a live bearer token (for pointing another OpenAI-compatible tool at the
90
+ same instance).
91
+
92
+ **API keys (CI / headless / a fixed identity).** A key looks like `atr_...` and is minted in the
93
+ Atrium web app under a project's *Settings → API keys*. Two shapes:
94
+
95
+ - **Project-scoped key** — acts as that project; every call is scoped to it automatically, no
96
+ `project=` needed. The right shape for a study app that only ever writes to one project.
97
+ - **Account-wide key** — acts as your user across every project you can see.
98
+
99
+ Pass it as `Atrium(api_key="atr_...")` or set `ATRIUM_API_KEY` — either skips the browser flow
100
+ entirely.
101
+
102
+ ```python
103
+ me = client.me() # GET /v1/users/me
104
+ me.email, me.role
105
+ me.key_project_id # set when a project-scoped key is in use
106
+ me.effective_project_id # the project data ops target (scoped key, else your Personal project)
107
+ ```
108
+
109
+ ### Projects & scope
110
+
111
+ A **project** is identified by a `p_...` short id (e.g. `p_4mD2qX`) — a base58, URL-safe id that
112
+ doubles as the API "connect target" for that project. Scope any call to a project (and optionally
113
+ a collection within it) three ways:
114
+
115
+ ```python
116
+ client.library.search("rain", project="p_4mD2qX") # per call
117
+ client = Atrium(project="p_4mD2qX") # default for a whole client
118
+ photos = client.collection("c_7Bq3Xk") # a reusable scoped view
119
+ photos.library.search("rain") # every call on `photos` is scoped
120
+ ```
121
+
122
+ `client.project(id)` / `client.collection(id)` return a scoped view that shares the same
123
+ authenticated session (cheap — no re-auth). A project-scoped API key scopes everything
124
+ automatically; you never need `project=` with one.
125
+
126
+ ### The CLI
127
+
128
+ Installing the package puts an `atrium` command on `PATH`. From inside the monorepo:
129
+
130
+ ```sh
131
+ uv run --project clients/python atrium login
132
+ uv run --project clients/python atrium whoami
133
+ uv run --project clients/python atrium search "calm rainy afternoon" --modality audio
134
+ ```
135
+
136
+ Once installed into your own project, just `atrium ...` (or `python -m hci_atrium ...`). Full
137
+ command list: `login`, `logout`, `whoami`, `search`, `assets`, `upload`, `enrich`, `chat`,
138
+ `survey pull`, `survey push`, `ha-bridge` — every command takes `--base-url`;
139
+ `search`/`assets`/`upload` also take `--project`/`--collection`. Run `atrium <command> --help` for
140
+ flags.
141
+
142
+ Survey templates round-trip through files, so they can live in git next to your study code:
143
+
144
+ ```sh
145
+ atrium survey pull onboarding --project p_4mD2qX -o onboarding.survey.json
146
+ atrium survey push onboarding.survey.json --project p_4mD2qX
147
+ ```
148
+
149
+ `pull` writes the same export envelope the SPA's "Export to file…" produces
150
+ (`{"atrium_survey_template": 1, "name", "description", "body"}`) — to stdout without `-o`. It omits
151
+ the SPA's `exported_at`/`source` stamps, so re-pulling an unchanged template gives an unchanged file.
152
+ Unlike the SPA export (which inlines images so the file survives the source project's deletion),
153
+ `pull` keeps bare project-file refs — an `image` in the body stays bound to the source project.
154
+
155
+ `push` first checks whether the template is published (a template that isn't there yet counts as
156
+ unpublished); pushing to a PUBLISHED one prints a warning, because the write changes the live survey.
157
+ It then checks the body against the dialect (`survey-templates:validate`): any issue is printed to
158
+ stderr and the push is refused (non-zero exit) unless you pass `--force`, which downgrades them to
159
+ warnings — but on a PUBLISHED template dialect issues are refused even with `--force`. It then runs
160
+ the same idempotent ensure as `client.surveys.template(...)` — a write only when the body actually
161
+ changed, reported as `version N written`, otherwise `already at version N, no change`. N may be the
162
+ same number either way: the server is copy-on-write and overwrites the current version in place while
163
+ no response is pinned to it. An envelope's `description` is applied only when the push *creates* the
164
+ template (a notice goes to stderr when it's ignored). Publication and the default flag are never
165
+ touched. The file may also be a *bare* template body (no envelope), in which case `--name` is
166
+ required; `--name` overrides an envelope's own name.
167
+
168
+ ### Errors
169
+
170
+ Every exception the SDK raises derives from `hci_atrium.AtriumError`:
171
+
172
+ ```python
173
+ from hci_atrium import AtriumError, AuthError, APIError
174
+
175
+ try:
176
+ client.library.search("rain")
177
+ except APIError as exc: # a non-2xx HTTP response
178
+ exc.status_code, exc.body # int, str (truncated)
179
+ except AuthError: # sign-in / token exchange / refresh failed
180
+ ...
181
+ except AtriumError: # catch-all for everything the SDK raises
182
+ ...
183
+ ```
184
+
185
+ ---
186
+
187
+ ## 2. Core concepts, in one screen
188
+
189
+ - **Project** — the top-level scope for data (`p_...` short id). Every asset, room, and session
190
+ lives in exactly one project. A study is usually one project.
191
+ - **Collection** — a node in a project's recursive collection tree (`c_...` short id); `Library` =
192
+ the project's root. Used to scope search/listing to a subtree. The SDK only *scopes to* an
193
+ existing collection id (`client.collection(id)`) — creating/managing the tree is a web-app task,
194
+ not exposed here.
195
+ - **Asset** — one library item. Three independent things it can carry:
196
+ - **bytes** (`sha256` set) — decodable content, served at `asset.content_url`.
197
+ - **link** (`source_url` set) — an external/attribution URL (e.g. a Spotify playlist).
198
+ An asset can have *both* (uploaded audio with a source link) or *neither* (a description-only
199
+ asset, e.g. "the smell of rain").
200
+ - **description** — always present as metadata; it *is* the content when there are no bytes.
201
+ - `asset.facts` — a server-owned JSON bag (analysis results, the `link` discriminator, etc.);
202
+ never write to it yourself.
203
+ - `asset.data` — an app-owned JSON bag the server never touches — your study's own structured
204
+ metadata on the asset.
205
+ - **Rooms vs sessions** — deliberately decoupled:
206
+ - A **room** is pure live transport: `room.send(...)` fans a message to everyone connected,
207
+ nothing is stored. Anyone can join by a short **connect code**, no account needed
208
+ (`join_room(code)`). No replay — if you weren't listening, you missed it.
209
+ - A **session** is a durable, append-only, gapless log (`session.event(...)`). No running/ended
210
+ lifecycle — it's simply the record, always appendable. A session doesn't need a room.
211
+ - To keep what happens live, a host **bridges** the two: `room.record(into=session)`.
212
+ - **Surveys** — a durable *data object* on a session, guarded by a one-time capability token
213
+ (`session.token`); a project-scoped, versioned template drives what it asks.
214
+
215
+ ---
216
+
217
+ ## 3. Library
218
+
219
+ ### Search
220
+
221
+ ```python
222
+ client.library.search(
223
+ query: str = "",
224
+ *,
225
+ like=None, # rank "more like this" — an Asset, SearchHit, or id
226
+ like_by: str | None = None, # which of the liked asset's signals seeds it (default: native)
227
+ modality: str | None = None, # desired result kind, e.g. "audio", "image"
228
+ space: str | None = None, # force a specific embedding space (id or name)
229
+ limit: int = 20,
230
+ tags: list[str] | None = None, # results must carry ALL of these tags
231
+ any_tags: list[str] | None = None, # …and at least one of these
232
+ exclude_tags: list[str] | None = None, # …and none of these
233
+ provider: str | list[str] | None = None, # who provides the content — see below
234
+ filters: Filters | None = None, # the long tail: content_kind, statuses, ranges, exclude_ids, …
235
+ image=None, audio=None, video=None, # search BY a media file instead of text (cross-modal)
236
+ vector: list[float] | None = None, # rank by a raw vector (pass space= with it)
237
+ include_vectors: bool | str = False, # inline one vector per hit onto hit.vector
238
+ project: str | None = None,
239
+ collection: str | None = None,
240
+ ) -> list[SearchHit]
241
+ ```
242
+
243
+ ```python
244
+ for hit in client.library.search("calm rainy afternoon", modality="audio", limit=5):
245
+ print(round(hit.score, 3), hit.asset.title or hit.asset.filename)
246
+ print(" ", hit.asset.content_url)
247
+
248
+ playlists = client.library.search("lofi beats", modality="audio", provider="spotify:playlist")
249
+
250
+ # "More like this" — pass an asset (or a SearchHit straight from a previous search) as like=
251
+ similar = client.library.search(like=hit, modality="audio")
252
+ ```
253
+
254
+ - Pass **exactly one** query input: a text `query`, an example asset (`like=`), a media source
255
+ (`image=`/`audio=`/`video=`), or a raw `vector=`. `modality` means "what kind of result I want":
256
+ results OF that kind, **provided by the library's own storage and matched by their native
257
+ content** (the server's media-intent defaults — text-about-content and byteless links don't
258
+ compete unless you opt in). For a text query it also picks that modality's space; for a media
259
+ query the query embeds in the cross-modal joint space.
260
+ - `like=` ranks "more like this": an `Asset`, a `SearchHit`, or a bare id. `modality` still filters
261
+ the results (cross-modal: an image can fetch its fitting audio, ranked in the cross-modal default
262
+ space); the source asset must be embedded in the ranking `space`. `like_by=` picks which of the
263
+ asset's stored signals seeds the query (`"native"`, `"manual"`, `"bridge:<modality>"`,
264
+ `"view:N"`/`"seg:<id>"`) — default is the native-first auto pick.
265
+ - `provider` selects where results come from: `"library"` (stored bytes — the implied default
266
+ for a media `modality`), a streaming provider (`"spotify"`, entity-qualified
267
+ `"spotify:playlist"` / `"spotify:track"` / `"spotify:album"`), `"link"` (anything with a URL,
268
+ recognized or not), `"none"` (neither bytes nor URL — e.g. a fileless asset you hand-tagged
269
+ "audio"), `"any"` (everything), or a list to mix. Non-library classes match by their
270
+ description text — mixing classes ranks text-matches against content-matches, which is only
271
+ score-comparable on a fully calibrated space; and a provider link with no description holds no
272
+ vectors at all, so ranked search can't surface it (keyword search can). Unknown values → 422
273
+ listing the vocabulary.
274
+ - `hit.asset` is an `Asset`; `hit.score`/`hit.raw_score`/`hit.calibrated`/`hit.matched_by`/
275
+ `hit.match_label` describe the match.
276
+ - `include_vectors=True` inlines onto `hit.vector` the one vector that scored each hit (its identity
277
+ is `hit.matched_by`); a space id/name instead returns that space's vector per hit, which can
278
+ differ from the one that ranked it. Off by default — vectors are large and never leave Postgres in
279
+ the search path; `hit.vector` is `None` unless you asked.
280
+ - `filters=Filters(...)` carries full library-filter power: `content_kind` (the immutable kind
281
+ sniffed from the file's bytes — `"image"`, `"audio"`, …, unlike `modality` which matches the
282
+ editable label, so it finds a JPEG even after it's retagged `"product"`), `statuses`, numeric
283
+ fact `ranges` (`{"bpm": (90, 120), "duration_s": (None, 30)}`), `uploader`, extra kind/label
284
+ facets (`all_kinds`/`any_kinds`/`none_kinds`/`all_labels`/`any_labels`/`none_labels`),
285
+ `max_seconds`, `match_by` (the Match-by override: an explicit list — e.g. `["native", "manual"]`,
286
+ or `[]` for every signal — applies globally, replacing the per-class default that comes with a
287
+ `provider` filter), `data_match={"kind": "field"}` (a JSONB containment filter on the app-owned
288
+ `asset.data`, `data @> data_match` — retrieval of what you wrote, not derivation), and
289
+ `exclude_ids` (drop these asset ids — dedup for a prefetch loop; capped at 512).
290
+
291
+ Other `Library` methods, same scoping conventions (`project=`/`collection=`, `space=`):
292
+
293
+ | Method | Purpose |
294
+ |---|---|
295
+ | `library.add(file=None, *, modality=, description=, collection=)` | add an asset to the library — a file, or (omit `file`) a fileless asset whose `description` *is* its content. See [Add to the library](#add-to-the-library) |
296
+ | `library.compare(a, b, *, space=, breakdown=False)` | pairwise similarity of two operands — `Asset`, `str`, `list[float]`, or `by.file(...)`/`by.asset(id)`/`by.concept(name_or_id, collection=)`; `breakdown=True` adds the per-feature terms (see below) |
297
+ | `library.search_by_examples(positives, negatives=, *, method="cav"/"centroid"/"probe", match_by=, filters=, ...)` | "mood board" — rank by a set of example assets (default `method="cav"`). `match_by=["native"]` picks the signals by class — same meaning as `search`'s `filters.match_by`. `match_by={"native": 1.0, "manual": 0.3}` also weights them (only a set query can: search picks one best signal, this blends every one). An allowlist either way; no space ids. `probe` fits its own weights, so pass `method="cav"` to set them by hand. `filters=` narrows by kind/label facets + `content_kind`; a Filters field with no set-query slot (`statuses`/`ranges`/`uploader`/`max_seconds`/`data_match`/`exclude_ids`) raises |
298
+ | `library.enrich(query, modality, *, tags=, total=, preview=, wait=)` | pull external candidates (Freesound/Openverse/…) into the library |
299
+ | `library.vectors(ids, *, space, match_by=None, strict=False)` | bulk-read stored vectors for many assets — see [Reading an asset back](#reading-an-asset-back) |
300
+ | `library.spaces()` | list the searchable embedding spaces |
301
+ | `library.default_space(modality)` | the deployment's default space for that modality (a `spaces()` dict, or `None`) |
302
+
303
+ `by.concept(...)` can only be one side of a `compare` call — it scores the *other* operand's vector
304
+ against that tag's materialized concept snapshot (calibrated to the concept's own boundary, not the
305
+ usual search calibration), 404 if the concept doesn't resolve, 422 if it has none. `breakdown=True`
306
+ then fills `result.features` with the per-`(space, matched_by)` terms behind that score (it stays
307
+ `None` for a plain asset/vector compare, which has no per-feature structure):
308
+
309
+ ```python
310
+ result = client.library.compare(asset, by.concept("cozy"), breakdown=True)
311
+ for f in result.features or []:
312
+ print(f.space, f.matched_by, f.weight, f.raw_score, f.score) # which signals carried the match
313
+ ```
314
+
315
+ Read those as **raw per-feature terms, not additive contributions** — they cover only the scored
316
+ space's features and do not sum to `result.score`.
317
+
318
+ `default_space` keeps deployment space ids out of your code — the deployment declares its defaults,
319
+ the app just asks:
320
+
321
+ ```python
322
+ space = client.library.default_space("audio") # e.g. {"id": "clap", "name": "CLAP", ...} or None
323
+ rows = client.library.vectors(ids, space=space["id"])
324
+ ```
325
+
326
+ ### Asset views
327
+
328
+ ```python
329
+ asset = client.assets.get("a_123")
330
+
331
+ asset.capabilities # set[str] ⊆ {"bytes", "link"} — independent flags, both can hold
332
+ asset.link # AssetLink(provider, entity, uri) | None — present iff "link" in capabilities
333
+ asset.playback # PlaybackIntent — ALWAYS present
334
+ asset.content_url # public URL for the bytes (fingerprinted ?v=sha when known)
335
+ asset.thumbnail_url # public URL for an image/video poster (404 if none)
336
+ ```
337
+
338
+ `asset.playback` normalizes the asset into one play intent, mirroring the room `play` signal's
339
+ `{source, ref, mode?}` shape 1:1 — a host can forward it as-is:
340
+
341
+ ```python
342
+ intent = asset.playback # PlaybackIntent(source=..., ref=..., mode=...)
343
+ intent.as_signal() # {"source": ..., "ref": ..., "mode": ...} — mode omitted if unset
344
+ room.send("play", {"playback": intent.as_signal()})
345
+ ```
346
+
347
+ Priority: `source="bytes"` whenever the asset has decodable content (even if it also has a link);
348
+ else the recognized link provider's name (currently only `"spotify"`) with `ref` a
349
+ `provider:entity:id` URI, `mode="shuffle"` for a playlist; else `source="link"` with the raw URL
350
+ for an unrecognized provider; else `source="description"` with `ref` = the asset id (nothing to
351
+ fetch, but a host can still look it up).
352
+
353
+ <a name="reading-an-asset-back"></a>
354
+ ### Reading an asset back: vectors, segments, index
355
+
356
+ What the library stored *for* an asset — its embeddings, its cut sound events, and the processing
357
+ state of each signal. All three are bound on the asset itself:
358
+
359
+ ```python
360
+ asset = client.assets.get("a_123")
361
+
362
+ for v in asset.vectors(): # every stored vector; list[AssetVector]
363
+ print(v.space, v.matched_by, len(v.vector)) # matched_by: "native" / "manual" / "bridge:text" / "seg:<id>"
364
+ asset.vectors(space="CLAP", match_by="native") # narrow: space (id or name, one or a list) + signal
365
+
366
+ for seg in asset.segments(): # the sound events cut out of an audio asset
367
+ print(seg.ordinal, seg.start_s, seg.end_s, seg.content_url) # just that slice's pre-cut bytes
368
+
369
+ for entry in asset.index(): # one row per stored signal — the processing state
370
+ print(entry.space, entry.matched_by, entry.active, entry.status, entry.error)
371
+ ```
372
+
373
+ `client.assets.vectors(asset, space=, match_by=)` / `.segments(asset)` / `.index(asset)` are the
374
+ unbound forms — each takes an `Asset`, a `SearchHit`, or a bare id. Vectors and segments are public
375
+ by id (like `content_url`); the index map is authenticated. On the async client the bound methods are
376
+ awaitable (`await asset.vectors()`).
377
+
378
+ For many assets at once, `library.vectors(ids, *, space, match_by=None, strict=False)` is the bulk
379
+ read — `space` is **required** here (one id/name, or a list):
380
+
381
+ ```python
382
+ rows = client.library.vectors([h.asset for h in hits], space="CLAP")
383
+ {r.asset: r.vector for r in rows} # each AssetVector carries its owning asset id
384
+ ```
385
+
386
+ With `match_by=None` the signal is resolved per asset, so one call can return **mixed** `matched_by`
387
+ values — read `r.matched_by` to see which each row is. Ids that don't exist or aren't visible to your
388
+ key land in the server's `missing` list: `strict=False` (default) omits them, `strict=True` raises
389
+ listing them.
390
+
391
+ ### Browse, add to the library
392
+
393
+ ```python
394
+ page = client.assets.list(
395
+ modality="audio", # editable label
396
+ content_kind=None, # or ["image", "audio"] — the bytes-sniffed kind instead
397
+ limit=48, cursor=None, project=None, collection=None,
398
+ )
399
+ page.items # list[Asset]
400
+ page.next_cursor # pass back as cursor= for the next page, or None
401
+ ```
402
+
403
+ <a name="add-to-the-library"></a>
404
+ `library.add(...)` is the front door for putting an asset into the library — with a file, or fileless:
405
+
406
+ ```python
407
+ asset = client.library.add(
408
+ "path/to/rain.wav", # path, Path, bytes, open file, or an http(s) URL
409
+ modality=None, # override the modality *label* (a search/display facet;
410
+ # embedding follows the bytes, never this)
411
+ description="rain on a tin roof",
412
+ collection=None, # home it in a collection, else the default scope
413
+ )
414
+
415
+ # Fileless — no file, the description *is* the content (the web UI's "Add other"):
416
+ note = client.library.add(description="a warm, relaxed evening mood")
417
+ ```
418
+
419
+ A positional string is always a file path/URL, never a description — pass `description=` as a
420
+ keyword; a fileless add requires it. `client.assets.upload(file, ...)` is the same call for a file.
421
+
422
+ Every media argument across the SDK (`image=`, `audio=`, `video=`, `library.add(file)`) accepts a
423
+ path, `Path`, raw `bytes`, an open binary file, or an `http(s)` URL — normalized by
424
+ `hci_atrium._files`.
425
+
426
+ ### Bulk import
427
+
428
+ For importing many assets at once from a manifest (JSON/CSV) instead of one-by-one uploads,
429
+ `POST /v1/library/import` is a raw multipart endpoint, not (yet) wrapped by the SDK; call it with
430
+ `httpx` directly or `client._request("POST", "/v1/library/import", ...)`.
431
+
432
+ ### Searching external sources
433
+
434
+ Search external providers (Freesound, Openverse, …) for media the library doesn't have yet, then
435
+ import the ones you want. The search is **read-only** — nothing is imported until you call
436
+ `import_`, and every result carries the licence + attribution you need to honour.
437
+
438
+ ```python
439
+ sources = client.sources.list() # which providers are registered
440
+
441
+ result = client.sources.search(
442
+ "rain on a tin roof",
443
+ providers=None, # None = every source; or "freesound" / ["freesound", …]
444
+ k=12, # per-provider cap
445
+ modality="audio", # narrow to sources serving this kind
446
+ )
447
+
448
+ for c in result.results: # provider-tagged Candidates (not yet imported)
449
+ print(c.provider, c.title, c.license, c.preview_url) # c.duration / c.size / c.content_type too
450
+
451
+ for skip in result.skipped: # a source that couldn't answer, and why
452
+ print("skipped", skip.provider, "—", skip.reason) # e.g. missing credentials
453
+
454
+ asset = client.sources.import_(result.results[0]) # pull that candidate into the library
455
+ # or name it yourself: client.sources.import_(source_id="freesound", external_id="12345")
456
+ ```
457
+
458
+ - `search` fans out over every registered source by default; a source that's missing credentials,
459
+ is down, or doesn't serve the ask lands in `result.skipped` (with a reason) instead of failing the
460
+ whole call — so you always get the sources that *did* answer.
461
+ - Each `Candidate` is external-only until imported: `provider` + `external_id` are what
462
+ `import_(candidate)` re-resolves to fetch the bytes. `import_` returns the library `Asset`;
463
+ re-importing the same candidate is a no-op that returns the existing one.
464
+ - `import_` takes an optional `modality=` / `description=` to override what the source reports.
465
+
466
+ ### Chat & agents
467
+
468
+ ```python
469
+ reply = client.chat("what's a good rainy-day soundscape?", model="gpt-4o-mini", temperature=0.7)
470
+ print(reply) # a str with .model / .raw
471
+ for chunk in client.chat("hi", stream=True):
472
+ print(chunk, end="")
473
+
474
+ reply = client.agent("find a calm track and tell me its title", tools=[my_search_tool])
475
+ ```
476
+
477
+ `chat(messages, *, model=, stream=False, strict_params=False, **params)` posts to
478
+ `/v1/chat/completions`; `**params` are extra OpenAI parameters (`temperature`, `max_tokens`,
479
+ `tools`, …) forwarded verbatim. `agent(prompt, *, tools, model=, max_turns=6, **params)` runs a
480
+ tool-using loop, forwarding `**params` to every `chat` call it makes.
481
+
482
+ > **Provider-unsupported params are silently dropped by default.** Atrium routes chat through
483
+ > LiteLLM, which drops a sampling param a model doesn't support (e.g. a non-default `temperature`
484
+ > on an OpenAI "gpt-5"-family model) rather than erroring — matched by **model-name pattern**, so a
485
+ > self-hosted model named like a known family inherits that family's quirks. Pass
486
+ > `strict_params=True` to error instead, with the provider's own message. This only applies to
487
+ > models routed through Atrium/LiteLLM — a model registered via `add_direct_model` bypasses all of
488
+ > it: its request body goes verbatim to the endpoint.
489
+
490
+ ---
491
+
492
+ ## 4. Rooms & sessions
493
+
494
+ **Two kinds of traffic flow through a room.** *Messages* are the named things on the wire — react to
495
+ them by name with `room.on("play")`, `room.on("presence.join")`, `room.on("mood.switch")`; the
496
+ vocabulary is open, anyone can `room.send` any type. *Audio* is the live sound in the room — process
497
+ it with `async for` over the `audio_*` family (`audio_vectors` / `audio_scores` / `audio_transcripts`
498
+ / `audio_clips`), streams the SDK assembles for you from the room's raw `audio.snippet` windows, with
499
+ any refinement (embedding, transcription) running on *your* credentials. Transcripts show the duality
500
+ directly: `room.on("transcript.final")` **consumes** a caption someone else is already broadcasting
501
+ (free — you read the wire), while `room.audio_transcripts()` **computes** your own from the audio.
502
+
503
+ ### Sync: host a room
504
+
505
+ ```python
506
+ from hci_atrium import Atrium
507
+
508
+ client = Atrium()
509
+ room = client.rooms.open(name="living room") # named → stable connect code across runs
510
+ print(room.connect_code)
511
+
512
+ with client, room: # on exit: delete anonymous / leave a named room standing
513
+ for event in room.receive(): # blocks; SSE stream
514
+ if event.source != "host":
515
+ print(event.type, event.payload)
516
+ ```
517
+
518
+ `rooms.open(*, name=None, project=None, embedding_space=None)` — give it a `name` to reuse the same
519
+ room (and connect code) across runs; the `name → room id` mapping is cached in
520
+ `~/.atrium/rooms.json`. Anonymous (`name=None`) opens a fresh room every call. `rooms.get(id)` /
521
+ `rooms.list()` fetch existing rooms.
522
+
523
+ `room.send(type, payload=None, *, to=None, include_self=False, source=None, ephemeral=False,
524
+ ack_id=None, reply_to=None)` — fire-and-forget, never stored. `to` (a connection id, an
525
+ `identity`, or a list) unicasts/multicasts; default fan-out is everyone else. `ephemeral=True`
526
+ marks throwaway noise (beats, cursors) that a recorder skips.
527
+
528
+ `room.receive(*, identity=None, announce=None)` — blocking SSE iterator of `Event`s; sets
529
+ `room.connection_id` from the first `connection.ready` frame. Reconnect yourself on drop (rooms
530
+ have no replay).
531
+
532
+ ### Join without an account
533
+
534
+ ```python
535
+ from hci_atrium import join_room
536
+
537
+ with join_room("ABC123") as room: # connect code, no account, no full client
538
+ room.send("message", {"text": "hi"})
539
+ for event in room.receive():
540
+ ...
541
+ ```
542
+
543
+ ### Sessions (durable log)
544
+
545
+ ```python
546
+ session = client.sessions.start(name="P07 · pilot", participant_ref="P07")
547
+ session.event("note", {"text": "participant arrived"})
548
+
549
+ session = client.sessions.resume(session_id) # continue an existing session by id — raises if unknown
550
+ print(session.last_seq) # last_seq reflects the session as of the fetch — re-`resume()` to refresh it
551
+ ```
552
+
553
+ ### Recording a room into a session
554
+
555
+ ```python
556
+ session = client.sessions.start(name="studio take")
557
+ room = client.rooms.open(name="studio")
558
+
559
+ with room.record(into=session): # starts a background capture thread immediately
560
+ room.send("note", {"text": "session started"})
561
+ room.send("beat", {"bpm": 120}, ephemeral=True) # skipped — ephemeral
562
+ ...
563
+ ```
564
+
565
+ `ephemeral=True` events, presence, and non-`signal` frames are skipped; everything else is
566
+ appended with `via={"room_id", "room_name", "from", "to"}`. A dropped stream reconnects with
567
+ backoff (gaps are lost — rooms have no replay); check `rec.alive` / `rec.error`.
568
+
569
+ ### Reading sessions back (analysis)
570
+
571
+ The write side (`sessions.start`/`resume`, `session.event`) has a read-model twin for analysis:
572
+
573
+ ```python
574
+ for info in client.sessions.list(phase="pilot"): # newest first; phase: a string or a list
575
+ print(info.id, info.participant_ref, info.condition, info.last_seq)
576
+
577
+ info = client.sessions.get(session_id) # one session — has_transcript populated here
578
+ for row in client.sessions.events(session_id, after=0, limit=500):
579
+ print(row.seq, row.type, row.payload, row.source, row.via)
580
+
581
+ client.sessions.export(session_id, "csv", path="out/") # "jsonl"|"csv"|"transcript"|"bundle"
582
+ client.sessions.export_bundle([id_a, id_b], path="out/study.zip") # several sessions, one zip
583
+ ```
584
+
585
+ - `sessions.list(*, project=None, phase=None)` returns `SessionInfo` rows for your default project
586
+ (a scoped key's project, else Personal); `""` as a phase matches sessions with no phase set.
587
+ `has_transcript` is `None` in list results — only `sessions.get(id)` populates it. There is **no
588
+ participant filter server-side**: filter the rows on `participant_ref` yourself.
589
+ - `sessions.get(id)` is the read model; `sessions.resume(id)` hits the same endpoint but returns the
590
+ live `Session` handle for continued logging.
591
+ - `sessions.events(id, *, after=None, limit=None)` pages the durable log as flat, pandas-friendly
592
+ `SessionEvent` rows. `after` is a **sequence number**, not a timestamp (there is no time filter
593
+ server-side); `limit` is 1..500 (server default 100).
594
+ - `export(id, format="jsonl", *, path=None)` returns the raw `bytes` — or, with `path=` (a file, or a
595
+ directory to derive the filename in), writes them and returns the written `Path`. Each format is a
596
+ distinct server route, not a `?format=` param; `"transcript"` 404s when the session has none.
597
+ - `export_bundle(ids, *, path=None)` zips several sessions. `ids` is required (no "all sessions"
598
+ mode) and capped at 100 **distinct** ids — more raises `ValueError` rather than truncating.
599
+
600
+ ### Mirror your app's own activity: `room.trace()`
601
+
602
+ One call mirrors what the SDK is doing on this client into the room as `trace.*` signals — so an
603
+ observation dashboard, or any code-joined peer, can watch the app think:
604
+
605
+ ```python
606
+ with room.trace(): # or: t = room.trace(); …; t.stop()
607
+ client.library.search("calm rain", modality="audio") # -> trace.library.search
608
+ room.play(hit, layer="music", mode="queue") # -> trace.play
609
+ ```
610
+
611
+ - A traced call sends `trace.<label>` (`trace.library.search` / `trace.chat` / `trace.play`) carrying
612
+ a one-line summary plus a capped detail; a recorder starting/stopping sends `trace.state.<label>`.
613
+ - **Default off** — it broadcasts app internals to anyone holding the connect code. Tracing stops on
614
+ `t.stop()` or when the room's `with` exits, and a trace failure never breaks the traced call.
615
+ - Ephemerality is per-signal: `trace.library.*` / `trace.chat` are **durable** (they happen outside
616
+ the room, so the trace is their only record — a recorder captures them into the session), while
617
+ `trace.play` and `trace.state.*` are ephemeral (the underlying `play` already rides the room).
618
+ - Async: `room.trace()` is still a plain call, but scope it with `async with room.trace(): …`.
619
+
620
+ ### Async: `on`/`serve`, presence, targeted request/reply
621
+
622
+ The async room adds an event-dispatch style on top of the same transport — register a handler per
623
+ signal type, then `serve()` to run until cancelled:
624
+
625
+ ```python
626
+ import asyncio
627
+ from hci_atrium import AsyncAtrium
628
+
629
+ async def main() -> None:
630
+ async with AsyncAtrium() as client:
631
+ room = await client.rooms.open(name="lab")
632
+ print(room.connect_code)
633
+
634
+ @room.on("presence.join")
635
+ def on_join(event):
636
+ print("joined:", event.payload.get("identity"))
637
+
638
+ @room.on("ping")
639
+ async def on_ping(event):
640
+ return {"pong": True} # only sent back if the ping was a `request(...)`
641
+
642
+ await room.connect(identity="host")
643
+ await room.serve() # blocks; reconnects transient drops with backoff
644
+
645
+ asyncio.run(main())
646
+ ```
647
+
648
+ - `await room.connect(*, identity=None, announce=None)` joins live and starts the dispatcher,
649
+ returning the connection id. `presence.join`/`presence.leave` arrive as ordinary signals — just
650
+ register handlers for them.
651
+ - `await room.request(type, payload=None, *, to, timeout=None)` sends to one peer and awaits its
652
+ single reply (the peer's handler return value) — `to` is required. Raises `TimeoutError` on no
653
+ reply within `timeout` (default: the room's request timeout).
654
+ - A handler's return value becomes the auto-reply when the *incoming* signal itself was a
655
+ `request(...)` (carried an `ack_id`) — a responder never touches correlation plumbing.
656
+ - Don't mix `on`/`serve` with `receive()` on the same room — each opens its own stream (a second
657
+ connection, a second presence join).
658
+
659
+ ### Live audio vectors (async)
660
+
661
+ If a Player in the room has its **"Share audio clips"** toggle on (default off — it shares actual
662
+ audio, so consent lives on the device making sound), you can get a live semantic embedding of what
663
+ the room currently sounds like — ~10 s windows, embedded in the deployment's default audio search
664
+ space, so each vector is directly comparable against library-derived vectors:
665
+
666
+ ```python
667
+ async for v in room.audio_vectors(): # zero-args: default audio space, that space's model
668
+ score = cosine(v.vector, tense_anchor) # v: AudioVector(vector, space, sequence,
669
+ await room.send("mood.update", {"score": score}) # window_seconds, timestamp)
670
+ ```
671
+
672
+ - Subscribing **is** the demand signal: while no consumer is subscribed, an armed Player captures
673
+ nothing. Break out of the loop to end the subscription.
674
+ - **The loop blocks until a Player shares audio.** If no participant is already announcing the
675
+ snippet-producer role at subscribe time, one warning is logged and the loop keeps waiting (no
676
+ timeout) — nothing yields until someone turns their Player's "Share audio clips" toggle on.
677
+ - `space=` pins a different space; `model=` overrides the embedder (rarely needed — by default the
678
+ space's own model is resolved once at subscribe). Embedding runs on *your* credentials/compute.
679
+ - Vectors are ephemeral room signals, never stored. Record the room into a session if a study
680
+ needs a durable trace.
681
+ - `relay=True` also relays each vector into the room as an `audio.vector` signal
682
+ (`ephemeral=True`, recorder-skipped) — so a loginless, code-joined peer (e.g. a Unity scene) can
683
+ read the room's live mood vector without credentials of its own
684
+ (`docs/reference/rooms-sessions-protocol.md`).
685
+
686
+ `room.audio_scores(anchors, *, space=None, model=None, top_n=1, relay=False)` fuses
687
+ `audio_vectors()` with per-anchor `library.compare` scoring — a live "which anchor does the room
688
+ sound like right now" signal, no manual cosine math:
689
+
690
+ ```python
691
+ async for frame in room.audio_scores({"tense": "a tense, anxious atmosphere", "calm": "calm and peaceful"}):
692
+ print(frame.scores) # {"tense": 0.71, "calm": 0.22} — calibrated per-anchor compare scores
693
+ print(frame.mix) # scores normalized to sum 1 (uniform when every score is 0 — gate on
694
+ # max(frame.scores) to tell "no match" from "anchors tied")
695
+ ```
696
+
697
+ - `anchors` maps a name to anything the `by.*` grammar accepts (bare `str`/`Asset`/`list[float]`,
698
+ `by.file(...)`/`by.asset(...)`/`by.concept(...)`) — **or a list** of those, a few-shot category
699
+ (e.g. `"tense": ["a tense, anxious atmosphere", "a horror movie soundtrack"]`) scored as the mean
700
+ of its **top-`top_n`** members (default `top_n=1` — the nearest member wins, so spread-out
701
+ examples don't blur into an average; pass a larger `top_n` to widen back toward the plain mean —
702
+ `top_n >= len(members)` averages every member).
703
+ - Every window scores its anchors concurrently (`asyncio.gather`); a handful of `compare` calls per
704
+ ~10 s window is cheap.
705
+ - Every anchor is validated once, before waiting on any frame: a typo'd/unresolvable `by.concept(…)`
706
+ anchor raises `AtriumError` naming it immediately, rather than only failing mid-stream.
707
+ - `relay=True` forwards straight to `audio_vectors` — it relays each window's raw
708
+ `audio.vector` signal (see above), **not** the computed `scores`/`mix` (peers compare via the
709
+ embed broker instead).
710
+ - No smoothing: every `ScoredAudio` frame is independent. Ease/debounce the stream yourself —
711
+ `hci_atrium.reactive.Decider` is a small, framework-free helper for turning a live score dict into
712
+ a stable "current winner":
713
+
714
+ ```python
715
+ from hci_atrium import Decider
716
+
717
+ decider = Decider(margin=0.1, dwell=2.0) # a new winner must beat the current one by 0.1,
718
+ # and stay switched for at least 2s
719
+ async for frame in room.audio_scores(anchors):
720
+ winner = decider.update(frame.scores) # the new winner's name, or None if unchanged
721
+ if winner is not None:
722
+ await room.send("mood.switch", {"to": winner})
723
+ ```
724
+
725
+ `decider.current` holds the standing winner between updates; with two anchors this is a plain
726
+ binary hysteresis switch.
727
+
728
+ ### Live transcripts (async)
729
+
730
+ `room.audio_transcripts()` is the speech-to-text counterpart of `audio_vectors()` — it consumes the
731
+ same Player-shared `audio.snippet` stream, but transcribes the audio into **live captions** instead
732
+ of embedding it, yielding a `Transcript` (a `str` subclass) per **finalized sentence**:
733
+
734
+ ```python
735
+ async for t in room.audio_transcripts(): # zero-args: deployment default model
736
+ print(str(t)) # a settled sentence; t.raw has start/end
737
+ ```
738
+
739
+ - Same demand gate as `audio_vectors`: subscribing announces the `audio-snippet-consumer` role, so
740
+ an armed Player only captures while a consumer is subscribed; break out of the loop to end it (the
741
+ stream tears down deterministically on `break`). The same one-time "no Player is sharing audio"
742
+ warning fires if nobody is producing yet.
743
+ - **Streaming by default.** When the deployment offers the live transcription route, snippets flow
744
+ through it and you get per-sentence finals as the speech settles — each with `.final == True` and
745
+ `t.raw` carrying the sentence's `start`/`end`. Pass `partials=True` to *also* get the revisable
746
+ live hypothesis (`.final == False`); a partial **replaces** the previous one wholesale, so render
747
+ the latest, don't append.
748
+ - `model=` / `language=` pick the transcription model and language hint (omit `language` to
749
+ autodetect; omit `model` for the deployment default). Transcription runs on *your*
750
+ credentials/compute. An empty-text sentence is dropped unless `skip_empty=False`; a snippet the
751
+ server can't transcribe is skipped so one bad frame can't kill the stream, but a rejected
752
+ credential (401/4401) raises `AuthError`.
753
+ - **Degradation ladder.** If the streaming route can't be reached — an old deploy, a proxy that
754
+ strips the WebSocket upgrade, a refused connection — this degrades to per-clip batch
755
+ transcription: one independent transcript per ~10 s window, **no partials**, the clip's format
756
+ taken from each frame's own MIME type (the Player ships Opus-in-WebM). On *this* fallback path
757
+ each transcript carries its source frame's `sequence` and `window_seconds` under
758
+ `t.raw["snippet"]` — a cheap ordering handle that has no equivalent on the streaming path (there's
759
+ no 1:1 snippet↔sentence correlation). The fallback is **connect-time only**: once streaming is
760
+ live a mid-session drop *raises* rather than silently degrading.
761
+
762
+ **Rooms-free — `client.transcribe_live(chunks, ...)`.** Not everything lives in a room. This runs
763
+ the same streaming engine over any async iterable of audio byte-chunks (a mic, a file, a socket),
764
+ yielding the same finals (and `partials=True` partials):
765
+
766
+ ```python
767
+ async for t in client.transcribe_live(mic_chunks(), format="pcm16", sample_rate=16000):
768
+ print(str(t))
769
+ ```
770
+
771
+ - **Container clips** (`format="wav"` default, or `"webm"`/`"ogg"`/…): each chunk is a
772
+ self-contained, decodable clip — the browser `MediaRecorder` / room-snippet shape.
773
+ - **Raw PCM** (`format="pcm16"`): a continuous PCM16LE **mono** byte stream, the shape native mic
774
+ libraries (`sounddevice`/`pyaudio`) yield. This **requires** `sample_rate=` — raw PCM is
775
+ headerless, so the SDK can't sniff the capture rate. Passing `sample_rate=` with a container
776
+ format is an error (containers carry their own rate).
777
+
778
+ **In-room broker — `room.transcribe()`.** The broker form consumes the room's snippet stream on
779
+ **your** credentials and publishes the transcript back into the room as signals, so any consumer —
780
+ including a loginless, code-joined tool — reads captions without transcription access of its own:
781
+
782
+ ```python
783
+ async with room.transcribe(): # a side effect: starts immediately, stops on room exit
784
+ ...
785
+
786
+ # any consumer, even a code-joined peer with no credentials of its own:
787
+ @room.on("transcript.final")
788
+ def on_line(event):
789
+ print(event.payload["seq"], event.payload["text"])
790
+ ```
791
+
792
+ - `transcript.partial` — `{"text": …}`, **ephemeral** (a live caption the recorder skips).
793
+ Wholesale-replaced — render the latest, don't append.
794
+ - `transcript.final` — `{"text": …, "start": …, "end": …, "seq": n}`, durable. `seq` is a per-handle
795
+ counter from 0; the signal's `source` is `"transcriber"`. Bridge the room into a session
796
+ (`room.record(into=session)`) and each final is persisted — no new recording API, the `ephemeral`
797
+ flag alone drives the recorder split.
798
+ - Like `record`, it's a side effect: started in the background, stopped when the room's `async with`
799
+ exits — or `await handle.stop()` / scope it with `async with room.transcribe(): …`. See
800
+ `examples/live_transcript.py` for the manual live-test recipe.
801
+
802
+ ### Live audio clips (async)
803
+
804
+ `room.audio_clips()` is the rawest member of the family — it hands you each ~10 s window decoded and
805
+ nothing else (no embedding, no transcription — cost: none), for custom processing:
806
+
807
+ ```python
808
+ async for clip in room.audio_clips(): # zero-args
809
+ do_something(clip.data, clip.mime) # AudioClip(data, mime, sequence,
810
+ # window_seconds, source, source_name)
811
+ ```
812
+
813
+ Same demand gate and one-time warning as `audio_vectors`. Producer-agnostic: a Player (v1 frames,
814
+ `clip.source is None`) or a Capture tool (v2 frames — `clip.source` is the sender's connection id,
815
+ `clip.source_name` its announced name, resolved from the subscribe-time roster).
816
+
817
+ ### Share your embedding / chat access: `offer_embeds` / `offer_chat` (async)
818
+
819
+ Let other room participants use your embedding/chat access — they ask, your client answers. A
820
+ loginless, code-joined peer (e.g. a Unity scene) has no Atrium credentials of its own. Register one
821
+ of these on an already-authed room **before** `connect()`/`serve()` — the offer is only advertised
822
+ when the connection opens, so calling either after connecting raises `AtriumError`. Once registered,
823
+ it answers that peer's requests over the room — riding the ordinary `on`/`request` machinery, no new
824
+ transport:
825
+
826
+ ```python
827
+ room.offer_embeds(spaces=["clip"]) # spaces=None (default): unrestricted
828
+ room.offer_chat(model="gpt-4o-mini", max_tokens=200) # model=None: the deployment/client default
829
+ await room.connect(identity="broker")
830
+ await room.serve()
831
+ ```
832
+
833
+ - `offer_embeds` handles `embed.resolve`: `{anchors: {name: <descriptor>}, space?}` →
834
+ `{space, vectors: {name: [floats]}, errors?}`. A descriptor is `{"text": "…"}` /
835
+ `{"concept": "name", "collection"?: "…"}` / `{"vector": [...]}` — the same shapes the `by.*`
836
+ operand grammar builds for `compare`. **The requester chooses `space`** (omitted → the
837
+ deployment's default text space); `spaces=` optionally allowlists what this broker serves. A
838
+ failing anchor (unknown concept, an unsupported `asset` descriptor) lands in `errors` without
839
+ failing the rest; a malformed/oversized request or a disallowed space gets a whole-request
840
+ `{"error": "…"}` reply. Text is embedded with *this* client's own credentials; repeated
841
+ text/concept anchors are cached.
842
+ - `offer_chat` handles `chat.generate`: `{messages: [{role, content}, …]}` → `{text, model}`.
843
+ Stateless (no history kept). The **broker** pins `model` (cost control — the requester can't
844
+ choose it) and caps `max_tokens`; malformed/empty/over-sized `messages` get `{"error": "…"}`.
845
+ **Billing**: any peer holding the connect code can trigger a reply on your model billing (there's
846
+ a per-call `max_tokens` cap, but no aggregate limit across calls) — rotate the code for an open
847
+ study, or prefer a local model, if that's a concern.
848
+ - Both announce a discoverable role in the presence roster (`announce.roles`) —
849
+ `"embed-broker"` / `"chat-broker"` — so a code-joined peer can find them. The full wire dialect
850
+ (including the `audio.vector` info signal from `relay=True` above) is in
851
+ `docs/reference/rooms-sessions-protocol.md`.
852
+
853
+ ### Offer live captions + recording to a Capture tool: `offer_transcription` / `offer_audio_capture` (async)
854
+
855
+ The Capture tool (`/tools/capture/?code=…`) is a loginless voice surface. The host publishes two
856
+ standing-service offers; the tool's toggles turn them on/off over the room (`<service>.start` /
857
+ `<service>.stop`, each replied `{"ok": True}` / `{"error": …}`). Register both **before**
858
+ `connect()`/`serve()` (like the brokers above):
859
+
860
+ ```python
861
+ async with room.record(into=session): # the single durable binding — see below
862
+ room.offer_transcription() # "Live captions" toggle → transcript.* signals
863
+ room.offer_audio_capture() # "Save recording" toggle → audio into the session
864
+ await room.serve()
865
+ ```
866
+
867
+ - `offer_transcription(model=None, language=None)` runs the same engine as `room.transcribe()` on
868
+ *your* credentials while on, publishing `transcript.partial` (ephemeral) / `transcript.final`
869
+ (durable) — each stamped with the producer's `source` (connection id) and roster-resolved
870
+ `source_name`. Bare call is the happy path (deployment default model, language autodetect).
871
+ - `offer_audio_capture()` is **session-less**: `room.record(into=session)` is the single durable
872
+ binding, and the capture bridge writes recordings into **the active recorder's** session. So the
873
+ "Save recording" toggle is meaningful only while the room is being recorded, and availability
874
+ rides the wire so the tool can show it accordingly:
875
+ - a `capture.status` request replies `{"ok": True, "available": <bool>}` — available ⇔ ≥1 recorder
876
+ is active on this room handle;
877
+ - every recorder start/stop broadcasts an ephemeral `capture.available` `{"available": <bool>}`;
878
+ - `capture.start` with **no** active recorder replies
879
+ `{"error": "no active recorder — call room.record(into=session) first"}`.
880
+ On `capture.start` it binds the **first** active recorder's session and bridges each `audio.snippet`
881
+ frame into one recording per source (keyed by announced `name`, else connection id — a same-name
882
+ reconnect continues the same recording). When that recorder stops (explicit stop, the room's
883
+ `async with` exit, or room close) every open recording is finalized, the bridge disengages, and
884
+ `capture.available: false` is broadcast. **Exactly one recorder feeds audio** — a second concurrent
885
+ recorder warns once and is not double-written.
886
+ - See `examples/capture_session.py` for the whole shape (recording off until you flip the toggle).
887
+
888
+ ### Driving the Player's audio layers
889
+
890
+ The hosted Player (`/tools/player/?code=…`) plays sustained, mixable **layers** — background music,
891
+ ambient beds, SFX. A layer is just a named, independently-mixed channel (its own volume + effects);
892
+ every layer can hold both a single-track **queue** and overlapping **one-shots** at once — the
893
+ difference is `mode`, not the layer. `Room` and `RoomParticipant` carry the app side of the
894
+ `play` / `layer.*` dialect (`docs/reference/audio-layers-dialect.md`):
895
+
896
+ ```python
897
+ room.configure_layers([ # optional — layers auto-vivify on first use
898
+ {"id": "music", "label": "Music", "volume": 0.5},
899
+ {"id": "ambient", "crossfade_ms": 3000},
900
+ {"id": "sfx"},
901
+ ])
902
+ # play() takes an Asset / SearchHit, a content-URL or asset-id string, a track dict, or a list:
903
+ room.play(tavern_song, layer="music", mode="queue", transition="now") # crossfade a song in
904
+ room.play({"asset_id": rain.id, "loop": True}, layer="ambient", mode="queue") # looping bed
905
+ room.play(beds, layer="ambient", mode="queue", repeat=True, shuffle=True) # endless shuffled ambience
906
+ room.play(clang, layer="sfx") # one-shot (mode defaults to oneshot)
907
+ room.play([hit_a, hit_b], layer="music", mode="queue", transition="queued") # queue several
908
+ room.play({"asset_id": clang.id, "delay_seconds": 0.2, "spatial": {"x": -1, "y": 0, "z": 0}}, layer="sfx")
909
+
910
+ room.clear_layer(layer="ambient", fade_out_ms=2000) # one layer (queue + one-shots)
911
+ room.clear_all(fade_out_ms=1000) # stop all audio
912
+ room.set_layer_effects("music", {"lowpass": {"hz": 700}}, fade_ms=600) # backroom muffle
913
+ room.set_layer_effects("ambient", {"reverb": {"mix": 0.4, "ir": "cave"}, # wet convolution send
914
+ "echo": {"time_ms": 330, "feedback": 0.35}}) # + a slapback
915
+ room.set_layer_effects("*", {}, fade_ms=800) # every existing layer back to neutral
916
+ room.set_layer_position("music", 0, 0, -2, fade_ms=800) # place the whole layer 2 m in front (HRTF)
917
+ state = room.query_layers(timeout=10.0) # {"layers": {"music": {...}, ...}} — blocking pull
918
+
919
+ for snap in room.watch_layers(): # subscribe: a fresh snapshot on every change
920
+ music = snap["layers"].get("music", {})
921
+ if music.get("remaining_ms", 0) < 60_000 and music.get("queued_count", 0) < 2:
922
+ room.play(next_track, layer="music", mode="queue", transition="queued") # refill ahead of silence
923
+ ```
924
+
925
+ `play(source, *, layer=None, mode="oneshot", transition="now", repeat=False, shuffle=False)`.
926
+ `repeat`/`shuffle` are queue-mode modes — `repeat` loops the whole queue, `shuffle` randomizes its
927
+ order (combinable). A track dict carries per-sound options — `loop` (a single sustained track,
928
+ distinct from queue `repeat`), `segment` (a stored sound-event id),
929
+ `clip_start_seconds`/`clip_end_seconds`, `gain`, `spatial`, and (one-shot)
930
+ `delay_seconds`/`max_duration_seconds`/`fade_in_ms`/`fade_out_ms`.
931
+
932
+ `set_layer_effects(layer, effects, *, fade_ms=None)` sets the layer's whole effect bus at once —
933
+ `lowpass`/`highpass` (filters), `reverb` (a **wet send**: `{"mix": 0.4, "ir": "hall"}`, `ir` one of
934
+ `room`/`hall`/`cave`/`outdoor`, or `ir_asset` an asset id to convolve with your own impulse
935
+ response), and `echo` (`{"time_ms": 330, "feedback": 0.35, "mix": 0.25}`). It's the state, not a
936
+ patch: an **absent** implemented key ramps that effect back to neutral, so `{}` clears the bus.
937
+ `layer="*"` applies the same state to **every existing** layer (it doesn't auto-vivify new ones), the
938
+ same wildcard `clear_all` uses.
939
+
940
+ `set_layer_position(layer, x, y, z=0.0, *, fade_ms=None, distance=None)` places a whole layer in 3D
941
+ (HRTF panning of everything on it) — the listener sits at the origin, `+x` is right, `+y` up, and
942
+ **negative `z` is in front**. The panner is created lazily on the first call, so a layer you never
943
+ position keeps the plain, unpanned graph. `distance=` opts into distance attenuation
944
+ (`{"model": "inverse", "ref": 1, "max": 40, "rolloff": 1}`; `model` one of
945
+ `linear`/`inverse`/`exponential`) and is **sticky** — a later move that omits it keeps the model, and
946
+ `{"rolloff": 0}` goes back to direction-only. There is no `"*"` wildcard: position is per-layer. A
947
+ per-sound `spatial` (`{"x", "y", "z", "distance"?}` on a track dict) composes *within* a positioned
948
+ layer — a placed sound inside a placed group — and a per-sound `effects` dict stacks in front of the
949
+ layer's bus rather than exempting the sound from it (for a sound that must stay dry, play it on its
950
+ own clean layer). Full dialect: `docs/reference/audio-layers-dialect.md`.
951
+
952
+ The Player **loudness-normalizes** library audio to a consistent level by default (a per-track gain
953
+ from the asset's measured loudness toward −18 LUFS, plus a master limiter). Playing an `Asset` /
954
+ `SearchHit` attaches the measured loudness automatically, so it just works; the listener can turn it
955
+ off (Settings / `?normalize=off`).
956
+ `query_layers` opens a short-lived room connection (peers see a presence join/leave) and returns the
957
+ Player's real snapshot — current track, queue, one-shot count, elapsed/remaining per layer.
958
+ `watch_layers` subscribes and yields that same snapshot on every change (plus a ~1 s heartbeat while
959
+ playing) until you break out — so a soundscape loop refills a low queue before it runs dry. The
960
+ low-water threshold is yours to pick off the snapshot; the Player pushes raw state. The **async**
961
+ client has identical signatures — `await room.play(...)`, `await room.clear_all()`,
962
+ `await room.query_layers()` (a broadcast request, no `to=` needed; first Player to reply wins), and
963
+ `async for snap in room.watch_layers():`.
964
+
965
+ Need a Player to drive? Launch the hosted one and only open a tab if none is connected:
966
+
967
+ ```python
968
+ if not room.has(identity_prefix="player:"): # per-worker roster check (best-effort)
969
+ room.open_player() # opens the hosted Player tab
970
+ print(room.player_url()) # the plain launch URL — print / hand out / QR-encode
971
+ room.open_player(analysis=False, clips=True) # bake the room-sharing opt-ins into the URL
972
+ url = room.player_url(clips=True) # …or just build the URL with those params
973
+ ```
974
+
975
+ `room.participants()` reads the room's current presence roster (a short observe stream) as typed
976
+ `Participant`s — `connection_id`, `identity`, `name`, `roles` (the announce's `role`/`roles` shapes
977
+ merged), `connected_at`, and the original `raw` dict. `room.has(role=None, *, name=None,
978
+ identity_prefix=None)` is the boolean sugar over it: give at least one predicate (several are ANDed)
979
+ — `role` matches the merged roles, `name` the announced name, `identity_prefix` the identity's
980
+ leading segment. A hosted Player announces identity `player:web` (no role), so
981
+ `room.has(identity_prefix="player:")` is the launch-once guard. Both are best-effort per web worker.
982
+
983
+ `player_url(*, analysis=None, clips=None)` and `open_player(*, analysis=None, clips=None)` take the
984
+ Player's URL settings as typed kwargs (`bool` → `on`/`off`; `None` omits, keeping the Player default).
985
+ (async: `await room.participants()` / `if not await room.has(identity_prefix="player:"):
986
+ room.open_player()` — `open_player`/`player_url` stay sync.)
987
+
988
+ The observation dashboard has the symmetric pair: `room.dashboard_url()` builds
989
+ `<base>/tools/dashboard?code=<code>` (the dashboard takes only the connect code) and
990
+ `room.open_dashboard()` opens it in a tab — both sync, on the sync and async clients alike.
991
+
992
+ The hosted Viewer is the visual sibling — put an image (or video) on a shared screen over the room:
993
+
994
+ ```python
995
+ room.show(asset_id="…", caption="Rainy market") # or url="https://…", projection="360"
996
+ room.clear_view() # blank the stage
997
+ room.open_viewer() # launch a viewer tab (or print room.viewer_url())
998
+ ```
999
+
1000
+ `show` takes exactly one of `asset_id` / `url`; `viewer_url()` / `open_viewer()` mirror the Player
1001
+ pair (both sync).
1002
+
1003
+ ---
1004
+
1005
+ ## 5. Surveys
1006
+
1007
+ A survey is a durable data object on a session, guarded by a one-time capability token:
1008
+
1009
+ ```python
1010
+ survey = client.surveys.start(into=session) # uses the project's DEFAULT template
1011
+ # or: client.surveys.start("onboarding-v2", into=session, name="intro survey", return_to="https://...")
1012
+
1013
+ survey.token # once-only capability: read+write exactly this object
1014
+ survey.url # hosted survey tool URL, token in the fragment — hand to the participant
1015
+ survey.open() # open `survey.url` in the local browser (same-machine delivery)
1016
+ ```
1017
+
1018
+ `Surveys.start(template=None, *, into, name=None, return_to=None, link_params=None)` —
1019
+ `template=None` pins the project's default template; naming one pins *that* template's current
1020
+ version, so later edits to the template never change an already-started survey. `return_to` seeds an
1021
+ `app`-controlled return button on the survey's end page. `link_params` (a dict) is appended to
1022
+ `survey.url` as query parameters — inserted before its `#token=` fragment — for the template's page
1023
+ modules (`{"modules": "build,test"}`) or any question's `param` binding.
1024
+
1025
+ Manage templates (project-scoped, numbered versions) — safe to rerun every script start:
1026
+
1027
+ ```python
1028
+ client.surveys.template(
1029
+ "onboarding-v2",
1030
+ {"pages": [...]}, # dialect: docs/reference/survey-template-dialect.md
1031
+ project="p_4mD2qX", # or rely on the client's default project scope
1032
+ default=False,
1033
+ description="the intro survey", # applied on CREATE only, like the SPA's import
1034
+ ) # -> {"name", "version", "is_default", "changed", "created"} —
1035
+ # idempotent, writes only if the body actually changed. The
1036
+ # server is copy-on-write (it overwrites the current version in
1037
+ # place while no response is pinned to it), so read `changed`,
1038
+ # not the version number, to tell a write from a no-op
1039
+
1040
+ client.surveys.templates(project="p_4mD2qX") # list: name, current_version, is_default
1041
+ client.surveys.upload_file("logo.png", project="p_4mD2qX", role="attachment")
1042
+ # -> {"id", "url", "filename", "content_type"} — reference `id` from
1043
+ # a template body's `image` field
1044
+ ```
1045
+
1046
+ Read the answers back for analysis, by template **name** (not a started `Survey`), across the
1047
+ project's sessions:
1048
+
1049
+ ```python
1050
+ rows = client.surveys.responses("onboarding-v2", phase="post", submitted_only=True, limit=500)
1051
+ for r in rows:
1052
+ print(r.participant_ref, r.session_id, r.status, r.version, r.contents) # contents = raw answers
1053
+
1054
+ csv = client.surveys.responses("onboarding-v2", format="csv") # bytes: one row per response
1055
+ ```
1056
+
1057
+ `responses(template, *, project=None, phase=None, submitted_only=False, limit=200, format="rows")` —
1058
+ newest first, resolved with `project=` exactly like `template`/`templates`. `phase` (a string or a
1059
+ list; `""` matches unset) filters by the response's session phase; `submitted_only` drops drafts;
1060
+ `limit`'s server ceiling is 500. `format="rows"` gives `SurveyResponse` rows (`contents` stays a free
1061
+ dict — the survey dialect is your app's); `format="csv"` gives the CSV export as `bytes` with
1062
+ question-id columns.
1063
+
1064
+ `start_survey(template=None, *, into, name=None, return_to=None, link_params=None)` (module-level) is sugar for
1065
+ `into._client.surveys.start(...)` — handy when you only have the session in hand.
1066
+
1067
+ **Async** mirrors every method 1:1 (`await client.surveys.start(...)`, `await async_start_survey(...)`).
1068
+
1069
+ ---
1070
+
1071
+ ## 6. Home Assistant bridge
1072
+
1073
+ `hci_atrium.ha.HomeAssistantBridge` runs near your HA instance, joins an already-open room, and
1074
+ declaratively wires room signals to HA services (and HA state back into the room). It's
1075
+ **async-only**. `websockets` ships as a core dependency — nothing extra to install.
1076
+
1077
+ ```python
1078
+ import asyncio
1079
+ from hci_atrium import async_join_room
1080
+ from hci_atrium.ha import HomeAssistantBridge
1081
+
1082
+ async def main() -> None:
1083
+ room = await async_join_room("ABC123")
1084
+ bridge = (
1085
+ HomeAssistantBridge("ws://homeassistant.local:8123/api/websocket", "***token***")
1086
+ .media_sink("media_player.living_room", "media_player.kitchen") # play `play` intents
1087
+ .on_signal( # any other signal -> any HA service
1088
+ "plate.pulse", domain="number", service="set_value",
1089
+ entity_id="number.plate_intensity", data=lambda p: {"value": p["strength"]},
1090
+ )
1091
+ )
1092
+ try:
1093
+ await bridge.run(room) # blocks until cancelled
1094
+ finally:
1095
+ await room.aclose()
1096
+
1097
+ asyncio.run(main())
1098
+ ```
1099
+
1100
+ - **`media_sink(*entity_ids, forward_state=True)`** — plays the room's `play` intent (the typed
1101
+ `{playback: {source, ref, mode?}}` shape, or the legacy `content_url`/`asset_id` shapes) via
1102
+ `media_player.play_media` (+ `.shuffle_set` for a shuffled playlist) on each entity. Also
1103
+ forwards those entities' state back as `media.state` unless `forward_state=False`.
1104
+ - **`on_signal(type, *, domain, service, entity_id=None, data=None)`** — maps one room signal type
1105
+ to one HA service call; `data` is a static dict or `callable(payload) -> dict`. Several
1106
+ registrations for the same `type` all fire.
1107
+ - **`forward_state(entity_ids, *, as_type="media.state", debounce=True)`** — forwards HA
1108
+ `state_changed` on these entities into the room. Call it directly for entities outside
1109
+ `media_sink`, or to change `as_type`/`debounce`.
1110
+ - **`await bridge.run(room)`** — wires everything, announces the bridge's identity, and runs both
1111
+ legs (room dispatcher + HA connection) until cancelled. Raises only on a terminal room refusal
1112
+ (a 4xx); the HA leg reconnects with backoff on its own.
1113
+
1114
+ **Zero-code CLI**, once the room exists:
1115
+
1116
+ ```sh
1117
+ atrium ha-bridge --code ABC123 \
1118
+ --ha-url ws://homeassistant.local:8123/api/websocket --ha-token *** \
1119
+ --media-player media_player.living_room media_player.kitchen
1120
+ ```
1121
+
1122
+ Every flag also reads an env var fallback: `ATRIUM_ROOM_CODE`, `HA_URL`, `HA_TOKEN`,
1123
+ `HA_MEDIA_PLAYERS` (comma-separated).
1124
+
1125
+ ---
1126
+
1127
+ ## 7. Recipes
1128
+
1129
+ ### Search the library and play the top hit into a room (sync)
1130
+
1131
+ ```python
1132
+ from hci_atrium import Atrium
1133
+
1134
+ client = Atrium()
1135
+
1136
+ with client, client.rooms.open(name="living room") as room:
1137
+ print(f"connect code: {room.connect_code}")
1138
+ hits = client.library.search("calm rainy afternoon", modality="audio", limit=1)
1139
+ if hits:
1140
+ asset = hits[0].asset
1141
+ room.send("play", {"playback": asset.playback.as_signal()})
1142
+ print(f"playing: {asset.title or asset.filename}")
1143
+ # on exit: leaves the named room standing (code kept for next run), then closes the client
1144
+ ```
1145
+
1146
+ ### Record a study session with a survey
1147
+
1148
+ ```python
1149
+ from hci_atrium import Atrium
1150
+
1151
+ client = Atrium()
1152
+ session = client.sessions.start(name="P07 · pilot", participant_ref="P07")
1153
+ session.event("phase", {"name": "intro"})
1154
+
1155
+ survey = client.surveys.start(into=session) # the project's default template
1156
+ print(f"send this to the participant:\n {survey.url}")
1157
+ survey.open() # or: hand survey.token / survey.url to a participant-facing device
1158
+
1159
+ session.event("phase", {"name": "survey_sent"})
1160
+ print(f"session {session.id} — {client.sessions.resume(session.id).last_seq} events so far")
1161
+ ```
1162
+
1163
+ ### A room consumer that reacts to signals and replies to requests (async)
1164
+
1165
+ ```python
1166
+ import asyncio
1167
+ from hci_atrium import async_join_room
1168
+
1169
+ async def main() -> None:
1170
+ async with await async_join_room("ABC123") as room:
1171
+
1172
+ @room.on("play")
1173
+ async def on_play(event):
1174
+ intent = event.payload.get("playback")
1175
+ print("play:", intent)
1176
+
1177
+ @room.on("ping")
1178
+ async def on_ping(event):
1179
+ return {"pong": True} # answers a room.request("ping", to=...)
1180
+
1181
+ await room.connect(identity="visuals")
1182
+ await room.serve() # blocks; reconnects transient drops
1183
+
1184
+ asyncio.run(main())
1185
+ ```
1186
+
1187
+ ### Record a room into a session, skipping the noise
1188
+
1189
+ ```python
1190
+ import time
1191
+ from hci_atrium import Atrium
1192
+
1193
+ client = Atrium()
1194
+ room = client.rooms.open(name="studio")
1195
+ session = client.sessions.start(name="studio take")
1196
+ print(f"connect code: {room.connect_code} session: {session.id}")
1197
+
1198
+ with room.record(into=session):
1199
+ room.send("note", {"text": "session started"})
1200
+ room.send("beat", {"bpm": 120}, ephemeral=True) # not recorded — ephemeral
1201
+ time.sleep(30) # capture whatever participants send
1202
+
1203
+ print(f"recorded {client.sessions.resume(session.id).last_seq} events")
1204
+ ```
1205
+
1206
+ ---
1207
+
1208
+ ## Left out / not verified here
1209
+
1210
+ - Bulk import (`POST /v1/library/import`) has no SDK wrapper; only the raw endpoint is documented.
1211
+ - Collection *creation/management* (the tree itself) is not an SDK operation — only scoping to an
1212
+ existing collection id.
1213
+ - `hci_atrium.dash.Dashboard` (the observation-dashboard helper) and the SFX/agent-tooling pieces
1214
+ (`client.tool`, `client.agent`, direct-model routing) exist in the SDK but are out of scope for
1215
+ this guide — see the source docstrings and `README.md` for those.