Coherence API Playbook¶
Assumptions¶
The following were inferred from code, not from explicit configuration:
API_TOKENis a static shared secret stored in Django settings. No token rotation or per-client tokens observed.ConversationOut.journey_slug / journey_name / step_slug / step_titleare resolved from the ORM relation at serialisation time. They may be empty strings if the conversation has no journey/step set.- Operational conversation endpoints return both
infosandconfig. Browse endpoints return publishable metadata only and do not expose internalconfig. - Browse endpoints return a separate public conversation shape and do not expose raw
conversation.infosor internalconversation.config. - Error format for Django Ninja endpoints follows Ninja's default:
{"detail": "..."}for 404s and{"detail": [...]}for 422 validation errors.
Base URL¶
https://app.the-gathering.earth/ — all endpoints live under /api/coherence/.
Machine-readable spec / interactive docs¶
Coherence routes are mounted on the same shared NinjaAPI instance as the rest of the
Core API (/api/coherence/... alongside /api/...), so they're covered by the same
live, always-current OpenAPI document — there is no separate hand-maintained Coherence
spec file.
| What | URL |
|---|---|
| Interactive Swagger UI (try endpoints in the browser) | https://app.the-gathering.earth/api/docs |
| Raw OpenAPI JSON | https://app.the-gathering.earth/api/openapi.json |
This PLAYBOOK.md stays as the narrative companion (golden paths, algorithms, field
reference) for things that don't fit cleanly in a schema — the OpenAPI doc is the
source of truth for exact request/response shapes.
Authentication¶
Most endpoints accept either: Authorization: Bearer <API_TOKEN> (a static shared secret set
by settings.API_TOKEN, no expiry) or a per-user API token (see below) — whichever the caller
has. Browser session cookies do not authenticate this surface, except the two
entry app sign-in requests.
The discovery and attributed-write endpoints — /conversation-events, /conversations/search,
and audio upload — are the exception: they reject API_TOKEN and require a per-user API
token, because writes on these endpoints are attributed to a real person. Audio download,
transcript download, transcript import evidence, and the video download URL are read-only
and have nothing to attribute, so they accept API_TOKEN like the rest of the surface. The journey-step endpoints (…/journeys, …/steps,
…/steps/{step_slug}/config) additionally require that the token's account has Coherence
access (superuser or the coherence_users group) — identity alone gets a 403 there. Obtain a
per-user token via the shared /api/v1/ auth flow:
curl -X POST "https://app.the-gathering.earth/api/v1/auth/login" \
-H "X-Metis-Api-Key: <API_LOGIN_SECRET>" \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "password": "..."}'
# => {"token": "metis_agentic_<id>_<secret>", "token_type": "Bearer", "expires_in_seconds": 86400, ...}
Then send that token as the Bearer credential on Coherence requests:
curl "https://app.the-gathering.earth/api/coherence/conversation-events" \
-H "Authorization: Bearer metis_agentic_<id>_<secret>"
The token is valid for 24 hours and can be revoked early via POST /api/v1/auth/logout. See
api/agentic/router/auth.py for the full login/logout contract.
CORS¶
Browser CORS is enabled for API routes only (/api/*).
- Allowed origins come from
CORS_ALLOWED_ORIGINS(comma-separated env var). - Allowed methods:
GET, POST, PUT, PATCH, OPTIONS. - Allowed request headers:
Authorization, Content-Type. - Preflight
OPTIONSrequests include CORS headers for allowed origins.
Example:
curl "https://app.the-gathering.earth/api/coherence/rooms/conversation?journey=coherence-check&room=bob" \
-H "Authorization: Bearer mysecrettoken"
Global Rules¶
Error format¶
Django Ninja errors follow this envelope:
// 404
{"detail": "Not Found"}
// 400 – invalid query combination
{"detail": "Provide exactly one of journey_id or journey_slug."}
// 422 – validation failure
{
"detail": [
{"loc": ["query", "journey"], "msg": "field required", "type": "missing"}
]
}
Application-level errors (version conflicts, invalid batch input, RealtimeKit metadata
issues, namespace placement) use a separate {"error": "...", "message": "..."} envelope
instead of Ninja's default {"detail": ...}. See each endpoint's Golden Path section below
for the exact error codes it can return.
Datetime format¶
All datetimes are ISO 8601 with UTC timezone:
2026-03-01T14:00:00Z
ID format¶
All IDs are positive integers (PostgreSQL bigint PKs). Slugs (e.g. journey slug) are URL-safe strings generated by Django's slugify.
Discover Conversation Events and conversations¶
GET /api/coherence/conversation-events
GET /api/coherence/conversation-events?holon_slug={holon_slug}
GET /api/coherence/conversations/search
GET /api/coherence/conversations/search?holon_slug={holon_slug}
GET /api/coherence/conversations/search?connected_holon_slug={holon_slug}
GET /api/coherence/conversations/search?person_id={person_id}
GET /api/coherence/conversations/search?sort=date_asc
These discovery endpoints require a per-user API token (see Authentication above); the shared API_TOKEN is not accepted.
/conversation-events lists every Coherence Conversation Event. Supplying
holon_slug narrows it to that holon when it is an event plus every Conversation
Event in its descendant tree.
/conversations/search is the side-effect-free operational list endpoint: no
filters returns every conversation, including scheduled, unscheduled, active,
and completed rows. It never creates a conversation.
Its optional filters combine with AND:
holon_slug: conversation ownership—Conversation.eventis this holon or one of its descendants;connected_holon_slug: participation—the conversation is explicitly linked to this holon throughConversation.connected;person_id: the Person is inConversation.participants.
sort orders by start: date_desc is the default (newest first), and
date_asc is oldest first. Conversations without a start date are always last;
ID is the stable tie-breaker.
Ownership and participation are intentionally separate. An Event owns a
conversation through Conversation.event; connected is supplementary
involvement and does not change that owner.
Each search item includes its event, journey/step, time bounds, Persons, and
connected Holons, but deliberately excludes raw infos, config, and
transcript content. Missing filter targets return 404; valid filters with no
matches return [].
search_conversations is also the answer for "list conversations owned by one
event" (filter by holon_slug) and "list one Person's conversations" (filter
by person_id) — there is no separate nested-envelope endpoint for either.
Get a conversation summary¶
GET /api/coherence/conversations/{conversation_id}/summary
Authorization: Bearer <per-user token from POST /api/v1/auth/login>
Returns one item in the same shape as /conversations/search — event,
journey/step, time bounds, Persons, connected Holons — for a single known
conversation ID. It deliberately omits infos, config, and transcript
content, unlike GET /conversations/{id}. 404 when the conversation does
not exist.
Download a conversation transcript¶
GET /api/coherence/conversations/{conversation_id}/transcript
GET /api/coherence/conversations/{conversation_id}/transcript?person_id={person_id}
Authorization: Bearer <per-user token from POST /api/v1/auth/login>
Returns the conversation transcript as a UTF-8 Markdown file attachment
(Content-Type: text/markdown; charset=utf-8, Content-Disposition: attachment;
filename="<conversation>.md"), using the same renderer as the web app's
transcript download so the two never drift. The Markdown includes conversation
metadata, all speakers in chronological order with display names and MM:SS
timestamps relative to the conversation, and the transcript text. Consecutive
utterances from the same speaker are combined into one run, keeping the
timestamp of the run's first utterance.
The optional person_id filters to one attributed speaker's utterances —
either a regular METIS Person ID or a negative unresolved-speaker placeholder
ID — matching the transcript's stored person_id, not the conversation's
participant list. A negative id is matched against METIS's own placeholder ids
(-1, -2, …). An enter-coherence guest personId is not a supported filter:
it usually matches nothing (404), but it can coincide with an unrelated
placeholder id, so never send one. Once an operator assigns the guest to a
Person, that Person's id works. Timestamps
remain relative to the complete conversation, not the filtered excerpt. Returns 404 when the conversation does not exist, or
when the requested person_id has no transcript utterances in that
conversation.
This endpoint is read-only. Normalized transcript rows are written by the Cloudflare/Chirp provider import pipeline, not via this API.
Read the transcript import evidence¶
GET /api/coherence/conversations/{conversation_id}/transcript/import-evidence
Authorization: Bearer <API_TOKEN or per-user token>
Returns what the transcript importer recorded about this conversation's import: the outcome, the fingerprint of the provider artifact it read, the approved window it cut against, and the row counts with a count per exclusion reason.
{
"conversationId": 456,
"status": "imported",
"failureReason": null,
"contractImport": true,
"artifact": {"sessionId": "6f1c…", "sha256": "9ab3…", "bytes": 48213},
"window": {
"conversationStartedAt": "2026-06-10T10:12:04.000+00:00",
"captureEndedAt": "2026-06-10T10:47:31.000+00:00"
},
"counts": {
"raw": 41,
"accepted": 22,
"installedSegments": 22,
"excludedByReason": {
"before_window_start": 9,
"after_window_end": 8,
"crosses_window_start": 1,
"crosses_window_end": 1,
"transcript_timestamp_ambiguous": 0,
"transcript_timestamp_malformed": 0,
"empty_sentence": 0
}
}
}
Aggregates and digests only. There are no rows, no text, no row indices, and
nothing saying who spoke an excluded sentence. METIS never keeps a record of the
speech it refused — the provider artifact is fetched into memory, filtered, and
dropped — so this endpoint reports how many rows were excluded and why, never
which ones. The transcript itself stays at GET /conversations/{id}/transcript.
accepted plus every excludedByReason count sums to raw; all seven reasons
are always present, zeros included. installedSegments is the number of stored
transcript rows the import produced, and is null until the install returned —
which is what distinguishes "rows were admitted" from "rows are stored".
status is one of imported, install_refused_or_incomplete,
transcript_window_empty, transcript_window_missing,
invalid_transcript_window, legacy_unbounded, or never_ran when the importer
has recorded nothing for this conversation yet. failureReason is null exactly
when status is imported, and otherwise repeats the status code, so a client
can branch on one nullable field. contractImport is true once the
conversation has been cut under the window contract; it is sticky, so a later
failure never clears it.
artifact, window and counts are null when the import did not get that
far — no artifact fetched, no usable window, or the filter never ran.
Reconciliation. This exists so a caller holding its own copy of the provider
artifact can prove METIS filtered the same bytes: hash your download and
compare it against artifact.sha256, then check the rendered transcript against
these counts. Without the digest, the two reads are independent fetches of the
same session with nothing tying them together. Note that Cloudflare retains a
session's transcript artifact for roughly seven days, so the raw-side download
has to happen close to the conversation; the digest comparison has no such limit
once both sides are recorded.
This endpoint is a read: it never runs, retries, or changes an import. 404 when
the conversation does not exist.
Upload or download conversation audio¶
POST /api/coherence/conversations/{conversation_id}/audio
Authorization: Bearer <per-user token from POST /api/v1/auth/login>
Content-Type: multipart/form-data
audio=@conversation.wav
Uploads and replaces the conversation's canonical sound file. Supported extensions
are aac, flac, m4a, mp3, oga, ogg, opus, wav, and webm; the maximum
size is 500 MiB. A successful upload returns 201 with the infos["audio"]
metadata (path, name, content_type, size_bytes, source, created_at).
Invalid extensions, empty files, and oversized files return
400 {"error":"invalid_conversation_audio","message":"…"} and leave the
previous audio metadata/file intact.
Each successful upload adds a Conversation note with the acting user, filename, and byte count.
GET /api/coherence/conversations/{conversation_id}/audio
Authorization: Bearer <API_TOKEN or per-user token>
Streams the stored canonical audio file through the authenticated API — read-only, so
(unlike upload) it accepts the shared API_TOKEN too. It returns 404 when the
conversation has no audio metadata or the referenced managed file is missing. This
avoids exposing an unauthenticated /media/ URL.
When a conversation has no uploaded audio, GoogleTranscribe extracts and stores
a FLAC from the downloaded recording before sending it to Chirp. A later upload
becomes the canonical audio used by a future Chirp run; it does not rewrite the
existing transcript automatically.
Get a conversation video's download URL¶
GET /api/coherence/conversations/{conversation_id}/video
Authorization: Bearer <per-user token from POST /api/v1/auth/login>
Returns {"url": "...", "name": "..."} pointing at the same video recording
file the web app's player uses (an un-finalized front-trim wins over the full
recording, matching the player). Unlike the audio endpoint above, this does
not stream the file through Django: url is the nginx-served /media/...
path (with a cache-busting ?v= marker), and the caller downloads directly
from nginx. This is a deliberate difference from audio — the video file is
large enough that proxying it through Django/gunicorn is wasteful, and the
/media/ path is already unauthenticated-but-unguessable in production
(deploy/sites-available/*.conf), the same trust model the web UI's own
"Download" link in the Recording section already relies on. Returns 404 when the conversation
has no stored recording.
Algorithm: "Is a conversation active now in this room?"¶
GET /api/coherence/rooms/conversation and POST /api/coherence/rooms/conversation/start
answer this question the same way. All times are UTC; t is the server's now (neither
request takes a time).
A conversation matches when all of these are true:
- It runs in the requested room: for a named room, it was started in that room; for the journey's default room, it has no room name (booked conversations included).
- It belongs to the requested journey slug.
startis set andstart ≤ t + 5 minutes— a caller arriving up to 5 minutes before the scheduled start still finds it.- At least one of:
finishis null (open-ended);finish ≥ t— it has not reached its booked finish;- its live session is still open — the room is running, however long past its booked finish.
Order: conversations whose live session is open come first, then the oldest
(lowest id) first. The order is fixed, so two tabs asking the same question get
the same answer.
In simple terms: Imagine a meeting room with a calendar on the door. You walk up and ask "is a coherence-check conversation happening in this room right now?" The answer is yes if one is booked or was started in that room, it either already started or starts in the next 5 minutes, and it has not finished — where a meeting still going on in the room has not finished, whatever the calendar says.
Edge cases:
- If
startisnull, that conversation never matches. - Both boundaries are inclusive:
finishexactly attmatches, one second earlier does not;startexactly 5 minutes aftertmatches, one second later does not. - The 5-minute early window is fixed; the caller cannot change it.
- There is no grace after the booked finish: a conversation running past it stays joinable because its room is running, not because of the time.
There is no lookup or start by person: everyone who opens a room link is handed the same conversation, whoever they are.
Golden Path: Open a room link¶
A room link is a room: everyone who opens it is handed the same conversation. Each
conversation journey has a default room (no name), and may have named rooms
(bob, crystal-springs): lowercase letters, digits and single hyphens, up to 50
characters. Capitals in a request are lowered (Bob is bob).
Ask which conversation is running in the room:
curl "https://app.the-gathering.earth/api/coherence/rooms/conversation?journey=coherence-check&room=bob" \
-H "Authorization: Bearer mysecrettoken"
Leave room out (or empty) for the journey's default room. The lookup asks about the
server's now and takes no time. It has three answers:
| Status | Body | Meaning |
|---|---|---|
200 |
ConversationOut |
The conversation running in that room. |
204 |
none | The room exists and nothing is running in it. |
404 |
{"error", "message"} |
journey_not_found (no conversation journey with that slug), or room_not_found (no room link with that name on the journey; its link may have been deleted). |
It never creates a conversation. When the answer is 204, start one deliberately:
curl -X POST "https://app.the-gathering.earth/api/coherence/rooms/conversation/start" \
-H "Authorization: Bearer mysecrettoken" \
-H "Content-Type: application/json" \
-d '{"journey": "coherence-check", "room": "bob"}'
| Status | Body | Meaning |
|---|---|---|
201 |
ConversationOut |
A new unscheduled conversation: start now, finish now + 1 day, the journey's first step, no participants, a named room's name in config.room, and the note UnScheduled Conversation created (room link: <journey>/<room>) ((room link: <journey>) for the default room). |
200 |
ConversationOut |
A conversation already running in that room (the rule above) is returned instead. Nothing is created. |
404 / 409 |
{"error", "message"} |
The named refusals below. |
Safe to repeat. The request re-checks for a running conversation under a lock
before creating one, so a retry, a reload, or two tabs sending it at once all get the
same conversation — the first 201, the rest 200.
When nothing could be found or started, the reason is named rather than a bare
404. The first two are checked before anything else; the last two only when nothing
is running to return:
| Status | error |
Meaning |
|---|---|---|
404 |
journey_not_found |
No conversation journey with that slug on this instance (the slug may belong to a non-conversation journey, or to another instance). |
404 |
room_not_found |
No room link with that name on the journey; its link may have been deleted. |
409 |
journey_ownership_ambiguous |
The journey exists but is not owned by exactly one Event holon. A server configuration error — the conversation cannot be created until it is fixed. |
409 |
event_has_no_experience |
The journey's owning Event holon has no live experience for conversations to run in. A server configuration error — the conversation cannot be created until the event is given one. |
// 404
{
"error": "room_not_found",
"message": "No room link named 'bob' exists on conversation journey 'coherence-check'. Its link may have been deleted."
}
After a conversation ends the room stays: the lookup answers 204 and the next start
makes a new conversation in the same room. A removed link answers room_not_found,
even while a conversation is still running in it.
Two things to know:
- The default room holds every conversation on the journey with no room name, booked ones included, and conversations started before room links existed. When two are running, the one with an open live session comes first, then the oldest.
- A conversation with a start and no finish is running until a finish is set. In the default room, every visitor is handed it until then.
Entry app sign-in¶
Two requests exist for METIS's own entry app, and only for it:
GET /api/coherence/entry/session (who is signed in, and a CSRF token) and
POST /api/coherence/entry/join (record the signed-in person as a participant of the
conversation running in a room link). They are authenticated by the METIS session
cookie, which the browser sends from a METIS front end on the same site. They cannot
be called with an API token, and the person recorded is always the signed-in one.
Signing in happens on METIS's own sign-in page. The live schema has their exact shapes.
Golden Path: Fetch a single conversation by ID¶
curl "https://app.the-gathering.earth/api/coherence/conversations/7" \
-H "Authorization: Bearer mysecrettoken"
Returns a single ConversationOut object (same shape as items in the list endpoint), or 404 if not found.
Golden Path: Browse child events and available journeys for a holon¶
curl "https://app.the-gathering.earth/api/coherence/browse/holons/gathering-2026" \
-H "Authorization: Bearer mysecrettoken"
{
"holon": {
"id": 1,
"slug": "gathering-2026",
"name": "Gathering 2026",
"type": "domain"
},
"events": [
{
"id": 10,
"slug": "opening-weekend",
"name": "Opening Weekend",
"type": "event",
"journeys": {
"scope": "direct",
"items": [
{
"id": 5,
"slug": "speaker-interview",
"name": "Speaker Interview",
"description": "Recorded speaker interview",
"source": "direct"
}
]
}
},
{
"id": 11,
"slug": "story-lab",
"name": "Story Lab",
"type": "event",
"journeys": {
"scope": "direct",
"items": []
}
}
]
}
Rules:
- The endpoint uses the requested holon's immediate child events only
- It returns only conversation journeys directly attached to each event
- No parent inheritance or fallback lookup is applied here
Golden Path: Browse conversations for a journey¶
curl "https://app.the-gathering.earth/api/coherence/browse/conversations?journey_slug=speaker-interview" \
-H "Authorization: Bearer mysecrettoken"
{
"journey": {
"id": 5,
"slug": "speaker-interview",
"name": "Speaker Interview",
"description": "Recorded speaker interview",
"texts": {
"intro": "Welcome"
}
},
"conversations": [
{
"id": 101,
"title": "Interview with Alice",
"start": "2026-04-03T09:00:00Z",
"finish": "2026-04-03T10:00:00Z",
"step": {
"slug": "recorded",
"title": "Recorded"
},
"participants": [
{"id": 42, "name": "Alice Ferreira", "photo": "/media/people/alice.jpg"}
],
"connected": [
{"id": 10, "slug": "opening-weekend", "name": "Opening Weekend", "type": "event"}
],
"publishing": {
"summary": {"text": "Short public summary"},
"thumbnails": [{"url": "/media/coherence/thumbs/direct.jpg"}],
"youtube": {"description": "Long external description"}
},
"public": {
"summary": "Short public summary",
"thumbnail": "/media/coherence/thumbs/direct.jpg",
"youtube_description": "Long external description"
}
}
]
}
Rules:
- Provide exactly one of
journey_idorjourney_slug - Without
holon_slug, the endpoint returns all conversations for that journey holon_slugis optional; when provided, it filters to conversations connected to that event holon- Conversations not connected to any holon are included only in the unfiltered journey-wide call
- Results are ordered newest first by
start, then byid - Raw
infosis intentionally not returned
Example with event filter:
curl "https://app.the-gathering.earth/api/coherence/browse/conversations?journey_slug=speaker-interview&holon_slug=opening-weekend" \
-H "Authorization: Bearer mysecrettoken"
Golden Path: Browse all child-event journey conversations for a holon¶
curl "https://app.the-gathering.earth/api/coherence/browse/holons/gathering-2026/conversations" \
-H "Authorization: Bearer mysecrettoken"
{
"holon": {
"id": 1,
"slug": "gathering-2026",
"name": "Gathering 2026",
"type": "domain"
},
"events": [
{
"id": 10,
"slug": "opening-weekend",
"name": "Opening Weekend",
"type": "event",
"journeys": [
{
"journey": {
"id": 5,
"slug": "speaker-interview",
"name": "Speaker Interview",
"description": "Recorded speaker interview",
"texts": {"intro": "Welcome"}
},
"conversations": [
{
"id": 101,
"title": "Interview with Alice",
"start": "2026-04-03T09:00:00Z",
"finish": "2026-04-03T10:00:00Z",
"step": {"slug": "recorded", "title": "Recorded"},
"participants": [
{"id": 42, "name": "Alice Ferreira", "photo": "/media/people/alice.jpg"}
],
"connected": [
{"id": 10, "slug": "opening-weekend", "name": "Opening Weekend", "type": "event"}
],
"publishing": {
"summary": {"text": "Short public summary"},
"thumbnails": [{"url": "/media/coherence/thumbs/direct.jpg"}],
"youtube": {"description": "Long external description"}
},
"public": {
"summary": "Short public summary",
"thumbnail": "/media/coherence/thumbs/direct.jpg",
"youtube_description": "Long external description"
}
}
]
}
]
},
{
"id": 11,
"slug": "story-lab",
"name": "Story Lab",
"type": "event",
"journeys": []
}
]
}
Rules:
- The endpoint uses the requested holon's immediate child events only
- For each child event, it includes only conversation journeys directly attached to that event
- A conversation appears in a group only if it is connected to that event and its journey is attached to that event
- Conversations connected to a child event but using some other journey are excluded from that event's groups
- Inherited journeys are not included here, and the overview endpoint also stays direct-only
Golden Path: Batch lookup persons by IDs¶
curl "https://app.the-gathering.earth/api/coherence/browse/persons?ids=42,18,7" \
-H "Authorization: Bearer mysecrettoken"
[
{"id": 42, "name": "Alice Ferreira", "photo": "/media/people/alice.jpg", "contact": {"email": "alice@example.com"}},
{"id": 18, "name": "Bob Silva", "photo": null, "contact": {}},
{"id": 7, "name": "Carol Dias", "photo": "/media/people/carol.jpg", "contact": {"telegram": "@carol"}}
]
Rules:
- Pass a comma-separated list of integer IDs via the
idsquery parameter - Maximum 50 IDs per request
- Unknown IDs are silently skipped — no 404 for missing persons
- Order of results is not guaranteed
Golden Path: Batch lookup holons by IDs¶
curl "https://app.the-gathering.earth/api/coherence/browse/holons?ids=10,1" \
-H "Authorization: Bearer mysecrettoken"
[
{
"id": 10,
"slug": "opening-weekend",
"name": "Opening Weekend",
"type": "event",
"description": "The opening weekend of the gathering",
"logo": "/media/holons/logos/opening.jpg",
"links": {"website": "https://example.com"},
"infos": {"locations": [1, 2]}
},
{
"id": 1,
"slug": "gathering-2026",
"name": "Gathering 2026",
"type": "domain",
"description": "",
"logo": null,
"links": {},
"infos": {}
}
]
Rules:
- Pass a comma-separated list of integer IDs via the
idsquery parameter - Maximum 50 IDs per request
- Unknown IDs are silently skipped — no 404 for missing holons
- Returns full public holon data: description, logo, links, infos
- Order of results is not guaranteed
Golden Path: Update a conversation's infos¶
Once an agent has found the conversation via the list endpoint, it can attach data to infos:
curl -X PATCH "https://app.the-gathering.earth/api/coherence/conversations/7" \
-H "Authorization: Bearer mysecrettoken" \
-H "Content-Type: application/json" \
-d '{
"infos": {"agent_notes": "User seemed engaged"}
}'
Returns the full updated ConversationOut. infos and config are both shallow-merged when provided.
Some namespaces are forbidden here entirely¶
infos and config are separate stores by convention:
config = operational/pipeline state, infos = output content. Namespaces that have their own
dedicated, correct-by-construction endpoint — or are written internally only — are rejected
regardless of which field you address them under, not just when misplaced:
curl -X PATCH "https://app.the-gathering.earth/api/coherence/conversations/7" \
-H "Authorization: Bearer mysecrettoken" \
-H "Content-Type: application/json" \
-d '{
"config": {"enter-coherence": {"phase": "exit-lounge"}}
}'
# 400 {"error": "invalid_namespace_placement", "message": "'\''enter-coherence'\'' cannot be written via this endpoint — use PATCH /conversations/{id}/enter-coherence instead"}
| Namespace | Rule |
|---|---|
enter-coherence |
Forbidden here in either field. Use POST/GET /conversations/{id}/enter-coherence (below) — the only endpoint that may write it. |
cal.com, iris.*, idempotency |
Forbidden here in either field. Written internally only (webhook / Iris pipeline / the Idempotency-Key ledger) — no client should ever send these. |
question_changes |
Forbidden here in either field. Use POST /conversations/{id}/question-changes; its backing representation is private. |
room |
Forbidden here in either field. Written once, when a conversation is started in a named room link (Open a room link); no client can move a conversation between rooms. |
publishing, publishing_status, publish_decisions |
Must be written via infos, not config (still just a placement check — no dedicated endpoint yet). The legacy publishing_approval key is also still rejected under config; nothing reads it. |
question_timings |
Forbidden here in either field, and no longer stored: question order, active question and durations are derived from the recorded changes. Use POST /conversations/{id}/question-changes. |
Namespaces not in this table are still accepted in either field. The same rules apply to
POST .../recorded.
This replaced an earlier, weaker version of this check that only validated placement
(reject enter-coherence under infos, still allow it under config) — that version still let
enter-coherence PartyKit silently drift the same room state between infos and config across
requests, since it could still address config['enter-coherence'] directly through this generic
endpoint. Giving it a narrow endpoint that can only ever touch that one sub-object closes the
mistake structurally instead of merely detecting it.
Read and merge a conversation's infos¶
GET /api/coherence/conversations/{conversation_id}/infos
PATCH /api/coherence/conversations/{conversation_id}/infos
Authorization: Bearer <per-user token from POST /api/v1/auth/login>
Content-Type: application/json
A narrower alternative to the generic PATCH /conversations/{id} above, for
callers that only need infos and don't want config, transcript-adjacent
fields, or the full ConversationOut shape in the response.
GET returns {"infos": {...}}, or {"infos": {}} when nothing has been
stored. PATCH requires an object-valued infos member and shallow-merges
its top-level keys — omitted keys are preserved, supplied keys replace whole
values. A successful write returns the resulting {"infos": {...}}.
curl -X PATCH "https://app.the-gathering.earth/api/coherence/conversations/7/infos" \
-H "Authorization: Bearer mysecrettoken" \
-H "Content-Type: application/json" \
-d '{"infos": {"agent_notes": "User seemed engaged"}}'
The same forbidden/misplaced-namespace rules as generic PATCH apply (see
above) — enter-coherence, cal.com, iris.*, and audio are rejected with
400 {"error": "invalid_namespace_placement", "message": "…"}. This endpoint
never reads or writes config. There is deliberately no delete/replace
operation — only shallow merge.
Golden Path: Record a displayed-question change¶
POST /api/coherence/conversations/{conversation_id}/question-changes
Authorization: Bearer <API_TOKEN or per-user token>
Idempotency-Key: <stable unique key for this change>
Send only the domain event: which question the shared room changed to and when the room accepted that change. Do not send question order, active index, or accumulated durations — METIS records the change and derives all of those from the recorded history, so there is no second copy for a client to keep in step.
curl -X POST "https://app.the-gathering.earth/api/coherence/conversations/456/question-changes" \
-H "Authorization: Bearer mysecrettoken" \
-H "Idempotency-Key: room-456-question-change-41" \
-H "Content-Type: application/json" \
-d '{
"questionId": 11,
"occurredAt": "2026-07-07T18:12:00Z"
}'
A successful first write returns 201:
{
"ok": true,
"conversationId": 456,
"questionId": 11,
"occurredAt": "2026-07-07T18:12:00Z",
"replayed": false
}
Rules:
questionIdis required and must belong to the conversation's Journey.occurredAtmust be a timezone-aware ISO 8601 datetime.Idempotency-Keyis required. An identical retry returns200withreplayed: true; reuse for different content returns409 idempotency_conflict.- Changes may arrive out of order. A change older than the latest accepted one
is filed at its own
occurredAt, so a retry that overtook its successor still lands correctly — no reordering or replay logic is needed in the client. occurredAtmore than five minutes in the future is rejected with400(a client clock running ahead would otherwise pin the active question).- Payload shape/type errors return
422; semantic errors return the documented400/409application-error envelope.
Golden Path: Upsert enter-coherence room state + RealtimeKit metadata¶
POST/GET /conversations/{id}/enter-coherence is the sole owner of
config['enter-coherence'] — the only endpoint that may write it. enter-coherence PartyKit
uses it for two purposes that used to go through two different paths (generic PATCH for
room state, a since-removed /realtimekit endpoint for meeting metadata) and now share one:
- Room state —
phase,claims,version(withexpectedVersionfor optimistic concurrency). - RealtimeKit metadata —
meetingId,recordingId,recordingEndedAt,participants, so METIS can recover the post-meeting transcript. The canonical session id is derived server-side from Cloudflare (see below) — it is not accepted from the client. - The approved transcript window —
conversationStartedAt,captureEndedAt(see The transcript window below).
sessionIdis ignored. METIS derives the canonical session from Cloudflare (list the meeting's sessions, pick the real recorded one); the old client-suppliedsessionId, read off the browser SDK, was unreliable and caused conv-220's empty-transcript defect. SendingsessionIdis accepted but ignored — the value is never stored, so a legacy client still sending it keeps working. Stop sending it; a future release may remove the field. Any previously storedsessionIdis scrubbed on the next write.
curl -X POST "https://app.the-gathering.earth/api/coherence/conversations/456/enter-coherence" \
-H "Authorization: Bearer mysecrettoken" \
-H "Content-Type: application/json" \
-d '{
"phase": "conversation",
"version": 4,
"expectedVersion": 3,
"claims": {"Alice": {"personId": 123, "claimedAt": "2026-07-07T17:56:00.000Z"}},
"meetingId": "bbb8940e-1b97-402a-97d6-2708b7feca41",
"participants": [
{
"browserSessionId": "browser-session-id",
"personId": 123,
"displayName": "Alice",
"realtimeKitParticipantId": "rtk-participant-id",
"customParticipantId": "coherence:coherence-456:browser-session-id",
"assignedAt": "2026-07-07T17:56:00.000Z",
"lastCredentialIssuedAt": "2026-07-07T17:56:00.000Z"
}
]
}'
Returns {"ok": true, "conversationId": 456, "meetingId": "...", "version": 4, "phase": "conversation", "participantCount": 1}.
Rules:
- Every field is optional and merged individually — send only what changed.
meetingIdmay be absent at first (e.g. writingphasebefore a RealtimeKit meeting exists) and supplied later. - Scalar fields never erase a previously stored non-null value when omitted/null:
recordingId,recordingEndedAt.phase/versionsimply take the latest provided value (they're live state, not durable IDs). - Optimistic concurrency guard (
expectedVersion): if provided, the server checks that the currentconfig['enter-coherence'].versionmatches it before writing. On mismatch, returns 409version_conflictwithcurrentVersion— fetch the latest state and retry. Omit to write unconditionally. First write (noenter-coherenceyet) is accepted regardless ofexpectedVersion. claimsupsert per display-name key (shallow-merged) — writing one claim never erases another.participantsupsert bycustomParticipantId. Resending the samecustomParticipantIdwith a newrealtimeKitParticipantIdupdates the participant id andlastCredentialIssuedAt(covers token-refresh recreation) but keeps the originalassignedAt.- Meeting conflict: if a different
meetingIdis already stored for this conversation, the call returns 409meeting_conflictwithcurrentMeetingIdand writes nothing. - Does not advance the journey step.
One person, several sessions¶
A person whose phone or tab dies comes back in a new browser session. Their identity moves to
the new session, so one person — or one guest — can own several participants entries in one
conversation. These are the rules:
- Send one entry per browser session. When a device switches, add a new entry with the same
personId; do not move an existing entry'scustomParticipantId. Entries are stable across re-pushes, and a null or0personIdnever erases a stored one. - The transcript groups every entry sharing a
personIdinto one speaker. This covers the rendered Markdown, the Participants line and?person_id=. Grouping reads the participant map when the transcript is imported, after the meeting ends. A transcript already imported is never regrouped: a later retry of the import keeps it exactly as it was installed. - Naming. A positive
personIdthat is a participant of the conversation is named by the METIS Person. Otherwise the speaker takes thedisplayNameof the most recently assigned (assignedAt) entry whose name contains a letter or digit — so a placeholder such as'.'never wins — and falls back toSpeaker N. - Guests (negative
personId) group the same way and stay unassigned speakers until an operator assigns them to a Person. A guest id is not a supported?person_id=filter on the transcript endpoint; use the transcript's own placeholder id, or the Person id once assigned. - An entry with no
personId, or0, is its own speaker. - There is no
supersededflag, and none is needed: grouping by person already stops an abandoned session showing up as an extra speaker.
Diagnostics lookup:
curl "https://app.the-gathering.earth/api/coherence/conversations/456/enter-coherence" \
-H "Authorization: Bearer mysecrettoken"
Returns the currently stored meetingId/recordingId/version/phase/claims/participants (null/empty if nothing has been written yet), plus the transcript window. sessionId is not returned — it is derived, not stored.
The transcript window¶
Both people stay in one meeting for the lounge, consent, the conversation and the post-lounge, and the provider's post-meeting transcription is session-scoped — its raw artifact can contain speech from all three phases. So the application tells METIS which interval it approved, and METIS exposes nothing else:
[conversationStartedAt, captureEndedAt]
| Field | Send it when |
|---|---|
conversationStartedAt |
capture is confirmed and the conversation is admitted |
captureEndedAt |
every expected browser is muted or authoritatively absent |
curl -X POST "https://app.the-gathering.earth/api/coherence/conversations/456/enter-coherence" \
-H "Authorization: Bearer mysecrettoken" \
-H "Content-Type: application/json" \
-d '{"conversationStartedAt": "2026-08-02T10:00:00.000Z", "captureEndedAt": "2026-08-02T10:30:00.000Z"}'
# => 200 {"ok": true, ..., "conversationStartedAt": "2026-08-02T10:00:00+00:00",
# "captureEndedAt": "2026-08-02T10:30:00+00:00", "transcriptWindowComplete": true}
Rules — the same on POST and GET, and identical to the /recorded compatibility path:
- Both are optional and may arrive in separate writes. A request that omits them (or sends
null) is accepted and changes nothing — an older client that has never heard of the window keeps working exactly as before. - Each boundary is write-once. The first non-null value is immutable. Replaying the same
instant is a no-op success (any equivalent encoding counts —
+02:00andZforms of one instant are the same value). - A different value returns 409
transcript_boundary_conflictwith the stored value incurrentConversationStartedAt/currentCaptureEndedAt. - A window whose end is not strictly after its start returns 400
invalid_transcript_window. So does a naive timestamp: no offset means no defined instant. - A rejection writes nothing at all — not the boundary, not the
phase, not the participants in the same request. Retry the whole request once the conflict is understood. transcriptWindowCompleteistrueonce both are stored. Until then the transcript importer imports zero rows and reportstranscript_window_missing; it never falls back to the whole provider session.
Do not substitute a timestamp that is merely nearby: recordingStartedAt can precede
admission, endInitiatedAt can leave the other participant still publishing, and
recordingEndedAt can trail the privacy boundary by the provider's stop latency. If a boundary
is genuinely unknown, send nothing — a missing window drops the transcript, a wrong one leaks
private speech.
Only rows lying wholly inside the window are imported. A sentence that straddles either
edge is dropped in full (text cannot be split by a timestamp), and imported offsets are measured
from conversationStartedAt, so a rendered transcript starts at 00:00 when the conversation did.
Golden Path: Mark a conversation as recorded¶
After the conversation recording is complete, call this endpoint to advance the journey to the next step and log a note. You can optionally update infos and/or config with the same shallow-merge semantics as PATCH.
curl -X POST "https://app.the-gathering.earth/api/coherence/conversations/7/recorded" \
-H "Authorization: Bearer mysecrettoken" \
-H "Content-Type: application/json" \
-d '{
"infos": {"recording_url": "https://example.com/rec/abc123"}
}'
Returns the full updated ConversationOut with the step advanced. If the conversation is already on the last step, the note is still created but the step remains unchanged.
capture_ended_at (rollout compatibility)¶
This endpoint also accepts capture_ended_at, which writes the same write-once
captureEndedAt boundary described under
The transcript window — one boundary, two entry points, never two
answers:
curl -X POST "https://app.the-gathering.earth/api/coherence/conversations/7/recorded" \
-H "Authorization: Bearer mysecrettoken" \
-H "Content-Type: application/json" \
-d '{"captureEndedAt": "2026-08-02T10:30:00.000Z"}'
- Either spelling works:
captureEndedAt(as everywhere else on this surface) orcapture_ended_at. - Same value already stored → no-op,
200. - Different value → 409
transcript_boundary_conflict, and the whole request is refused: no step advance, no note, noinfos/configpatch. - Unusable window (naive, or not after a stored
conversationStartedAt) → 400invalid_transcript_window, again writing nothing. - A replayed
Idempotency-Keystill checks the boundary: an identical value is a no-op200, but a different one is a409rather than being silently swallowed by the replay.
It exists only so a client that finalizes through /recorded can still record the boundary
during rollout. Prefer POST /conversations/{id}/enter-coherence.
Retrying safely: Idempotency-Key¶
Without the header the call is not idempotent — calling it twice will advance the step twice (if further steps exist) and create two notes. A caller that must retry an ambiguous network failure should send an Idempotency-Key header:
curl -X POST "https://app.the-gathering.earth/api/coherence/conversations/7/recorded" \
-H "Authorization: Bearer mysecrettoken" \
-H "Idempotency-Key: coherence-recorded-7-<recordingId>" \
-H "Content-Type: application/json" \
-d '{}'
- First request with a key — side effects apply as normal, and the key is stored on the conversation in the same database write.
- Repeat with the same key — nothing is applied (no step advance, no note,
finishuntouched, request body ignored) and the endpoint returns200with the sameConversationOutshape. - Different key — a distinct operation. Derive the key from something that identifies the recording, so a genuine re-recording of the same conversation is not suppressed, and so a retry reuses the same key (a random key per attempt defeats the mechanism).
- Scope — per conversation. Retention — 24 hours.
Concurrent retries are safe: the conversation row is locked for the whole read-decide-write, so the second request sees the first one's key rather than both advancing.
Golden Path: Read and tune a Journey step's config (IRIS prompts)¶
GET /api/coherence/conversation-events/{event_slug}/journeys
GET /api/coherence/journeys/{journey_slug}/steps
GET /api/coherence/journeys/{journey_slug}/steps/{step_slug}/config
PATCH /api/coherence/journeys/{journey_slug}/steps/{step_slug}/config
All four require a per-user API token and Coherence access (superuser or the
coherence_users group); anything else gets 401/403. Step configs drive live pipeline
behaviour — a successful PATCH takes effect for every conversation subsequently processed
(or retried) at that step. There is no draft/publish layer: read, edit carefully, verify.
Navigate from either starting point:
- Event →
GET /conversation-events/{event_slug}/journeys→ pick the journey. An Event can own several Journeys; there is no "default" — choose explicitly. - Conversation → the
journey_slugalready returned by/conversations/searchor/conversations/{id}.
GET /journeys/{journey_slug}/steps returns every step in pipeline order — including
archived ones, so the Journey definition is never silently incomplete — with descriptive
fields and iris_job, but no config bodies:
curl "https://app.the-gathering.earth/api/coherence/journeys/podcast-pipeline/steps" \
-H "Authorization: Bearer metis_agentic_<id>_<secret>"
# => [{"id": 12, "slug": "generate-content", "order": 5, "title": "Generate Content",
# "goal": "", "success_criteria": "", "starter_message": "",
# "is_archived": false, "iris_job": "content_generator",
# "updated_at": "2026-07-17T09:15:00.123Z"}, ...]
Read one step's config (sanitized — see field policy below), keeping updated_at for the
concurrency guard:
curl "https://app.the-gathering.earth/api/coherence/journeys/podcast-pipeline/steps/generate-content/config" \
-H "Authorization: Bearer metis_agentic_<id>_<secret>"
# => {"config": {"iris_job": "content_generator", "model": "gemini-2.5-pro",
# "prompts": {"base": "...", "title": "...", ...}, ...},
# "updated_at": "2026-07-17T09:15:00.123Z"}
PATCH merges your partial config into the stored one recursively: nested dicts merge per
key, scalars/lists replace, and an explicit null deletes the key (an IRIS field then falls
back to its model default on validation). Send expected_updated_at from your last GET to
fail with 409 instead of silently overwriting a concurrent edit made by someone else:
curl -X PATCH "https://app.the-gathering.earth/api/coherence/journeys/podcast-pipeline/steps/generate-content/config" \
-H "Authorization: Bearer metis_agentic_<id>_<secret>" \
-H "Content-Type: application/json" \
-d '{
"config": {"prompts": {"base": "New base prompt wording ..."}},
"expected_updated_at": "2026-07-17T09:15:00.123Z"
}'
# => 200 {"config": {...merged, validated, sanitized...}, "updated_at": "..."}
Every successful PATCH is written to the step's audit history, attributed to the calling account, with the dotted paths of the keys it touched — the same audit trail any other edit to the step uses. Failed PATCHes (400/409) record nothing.
For steps with a registered iris_job, the merged result is validated against that job's
config model before anything is saved — a failure returns
400 {"error": "invalid_step_config", "fields": {"prompts.base": "..."}} and leaves the
stored config untouched. A stale expected_updated_at returns
409 {"error": "stale_config", "updated_at": "<current>"}; re-read and retry. Steps without
a registered iris_job have no config contract and are merged as-is.
One repair affordance: a stored key the job's config model does not declare (a leftover from
an older config shape) makes validation fail on every PATCH — for exactly those keys, an
explicit null in the payload is accepted and deletes the key, so the step can be fixed via
the API instead of requiring an out-of-band edit.
Config field policy (fail-closed). Every IRIS config field has a declared API access
state — read_write (returned, patchable), read (returned, not patchable — e.g.
iris_job: changing a step's job type is Admin work), or hidden (never returned nor
accepted, preserved unchanged through PATCH — e.g. the YouTube youtube_refresh_token,
which only the Connect-YouTube OAuth flow writes). Writing a non-read_write key returns
400 {"error": "config_keys_not_writable"}. Fields with no declared state are neither
returned nor accepted.
cal.com booking webhook¶
Unauthenticated endpoint that receives cal.com booking webhooks for a specific person. Register the URL as the cal.com webhook destination, e.g. https://app.the-gathering.earth/api/coherence/hook/cal.com/42.
curl -X POST "https://app.the-gathering.earth/api/coherence/hook/cal.com/42" \
-H "Content-Type: application/json" \
-d '{ "triggerEvent": "BOOKING_CREATED", ... }'
Always returns {"ok": true} — errors are logged, not surfaced. No auth required.
Handled events (via triggerEvent in the request body):
| Event | Action |
|---|---|
BOOKING_CREATED |
Creates a new conversation, adds participants, attaches a note. |
BOOKING_RESCHEDULED |
Updates start/finish on the existing conversation, attaches a note. |
BOOKING_CANCELLED |
Attaches a cancellation note, then deletes the conversation. |
The journey and the room are read from the videoCallUrl in the payload metadata, which is the booking page's location in cal.com. Its path must be a room link: /<journey> books into the journey's default room, /<journey>/<room> into that named room (capitals are lowered, Bob is bob). Rescheduling and cancelling find the conversation by its bookingId, not by its link.
A new booking is not saved, and an error note on the person says why, when its link:
- is not a room link (no path, or more than two parts);
- is an old personal link (
/<person id>/<journey>), which no longer opens: the note names the room link to put in the booking page's location instead; - names no
CONVERSATION-type journey; - names a room the journey has no room link for.
The answer is still {"ok": true}, with the reason in warning.
A GET on the same path returns a plain-text activation hint and is used when registering the destination in cal.com.
ConversationOut field reference¶
| Field | Type | Nullable | Notes |
|---|---|---|---|
id |
integer | no | |
participants |
list[PersonOut] | no | |
connected |
list[object] | no | Each has id, name, type, slug |
infos |
object | no | Public/display metadata; publishing artefacts live under infos["publishing"] |
config |
object | no | Internal/operational metadata; cal.com, Enter Coherence, and Iris state live here |
start |
datetime | yes | ISO 8601 UTC |
finish |
datetime | yes | ISO 8601 UTC |
journey_slug |
string | no | |
journey_name |
string | no | |
journey_description |
string | yes | |
step_slug |
string | no | |
step_title |
string | no | |
texts |
object | no | Keys: welcome, enter, post, goodbyeURL; empty {} if not configured |
questions |
list[Question] | no | Ordered by journey question sequence; each has id and text |
JourneyConversationBrowse field reference¶
| Field | Type | Nullable | Notes |
|---|---|---|---|
journey.id |
integer | no | |
journey.slug |
string | no | |
journey.name |
string | no | |
journey.description |
string | no | Empty string if not set |
journey.texts |
object | no | From journey.config["texts"]; empty {} if not configured |
conversations[].id |
integer | no | |
conversations[].title |
string | no | The fields.title publishing field when set, else a server-generated fallback (Conversation <date> or Conversation <id>) |
conversations[].start |
datetime | yes | ISO 8601 UTC |
conversations[].finish |
datetime | yes | ISO 8601 UTC |
conversations[].step |
object | no | slug and title of the current step; empty strings if unset |
conversations[].participants |
list[PublicPerson] | no | Name, optional photo, and contact dict |
conversations[].connected |
list[HolonRef] | no | Minimal connected holons |
conversations[].publishing |
object | no | Denormalised public publishing payload (fields / records / artifacts, provenance envelopes stripped) |
conversations[].public.thumbnail |
string | yes | First thumbnail URL from the artifacts.imported_thumbnails publishing field, falling back to records.youtube.thumbnail |
conversations[].public.youtube_description |
string | yes | The fields.youtube_description publishing field |
HolonConversationBrowse field reference¶
| Field | Type | Nullable | Notes |
|---|---|---|---|
holon |
HolonRef |
no | Requested holon |
events[] |
list | no | Immediate child holons of type event |
events[].journeys[] |
list | no | Directly attached conversation journeys for that event |
events[].journeys[].journey |
JourneyBrowse |
no | Journey metadata |
events[].journeys[].conversations |
list[PublicConversationOut] |
no | Only conversations connected to that event and using that journey |
Endpoint Quick Reference¶
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/coherence/browse/holons/{holon_slug} |
Bearer/User token | Browse child events and available conversation journeys |
GET |
/api/coherence/browse/holons/{holon_slug}/conversations |
Bearer/User token | Browse all child-event attached journey conversations for a holon |
GET |
/api/coherence/browse/holons?ids=… |
Bearer/User token | Batch lookup public holon details by IDs (max 50) |
GET |
/api/coherence/browse/persons?ids=… |
Bearer/User token | Batch lookup public person details by IDs (max 50) |
GET |
/api/coherence/browse/conversations |
Bearer/User token | Browse public conversations for one journey |
GET |
/api/coherence/conversation-events |
User token only | List all Conversation Event holons, optionally below a holon |
GET |
/api/coherence/conversation-events/{event_slug}/journeys |
User token + Coherence access | List the conversation Journeys an Event owns |
GET |
/api/coherence/journeys/{journey_slug}/steps |
User token + Coherence access | List a Journey's steps in order (archived included, no config) |
GET |
/api/coherence/journeys/{journey_slug}/steps/{step_slug}/config |
User token + Coherence access | Read one step's sanitized config + updated_at |
PATCH |
/api/coherence/journeys/{journey_slug}/steps/{step_slug}/config |
User token + Coherence access | Recursive-merge + validate a step's config (409 concurrency guard) |
GET |
/api/coherence/rooms/conversation |
Bearer/User token | The conversation running in a room link (200), nothing running (204); never creates |
POST |
/api/coherence/rooms/conversation/start |
Bearer/User token | Start a conversation in a room link, or return the one already running (201 created, 200 existing) |
GET |
/api/coherence/entry/session |
METIS session cookie (entry app only) | Who is signed in on this host, and a CSRF token; never 401 |
POST |
/api/coherence/entry/join |
METIS session cookie + CSRF token (entry app only) | Record the signed-in person as a participant of the conversation running in a room link |
GET |
/api/coherence/conversations/search |
User token only | List conversations globally or by owner holon, connected holon, and Person |
GET |
/api/coherence/conversations/{id} |
Bearer/User token | Fetch a single conversation |
PATCH |
/api/coherence/conversations/{id} |
Bearer/User token | Update infos/config (shallow merge), optional concurrency guard |
GET |
/api/coherence/conversations/{id}/summary |
User token only | Narrow event/journey/step/participants/connected projection; no infos/config/transcript |
GET |
/api/coherence/conversations/{id}/infos |
User token only | Read a conversation's infos JSON |
PATCH |
/api/coherence/conversations/{id}/infos |
User token only | Shallow-merge a conversation's infos JSON only (no config) |
GET |
/api/coherence/conversations/{id}/transcript |
Bearer/User token | Download the transcript as a Markdown file, optional person_id filter |
POST |
/api/coherence/conversations/{id}/audio |
User token only | Upload and replace canonical audio (multipart, max 500 MiB) |
GET |
/api/coherence/conversations/{id}/audio |
Bearer/User token | Download canonical audio |
GET |
/api/coherence/conversations/{id}/video |
Bearer/User token | Get the nginx-served video download URL (not proxied through Django) |
POST |
/api/coherence/conversations/{id}/enter-coherence |
Bearer/User token | Sole owner of config['enter-coherence']: upsert room state (phase/claims/version) + RealtimeKit metadata + the write-once transcript window (idempotent) |
GET |
/api/coherence/conversations/{id}/enter-coherence |
Bearer/User token | Look up stored enter-coherence room state + RealtimeKit metadata + transcript window (diagnostics) |
POST |
/api/coherence/conversations/{id}/question-changes |
Bearer/User token | Idempotently record one displayed-question change; METIS derives state/timings |
POST |
/api/coherence/conversations/{id}/recorded |
Bearer/User token | Advance to next step, add "recorded" note, optionally update infos/config and (rollout compatibility) capture_ended_at (send Idempotency-Key to make a retry a no-op) |
GET |
/api/coherence/hook/cal.com/{person_id} |
None | cal.com webhook activation check (plain-text hint) |
POST |
/api/coherence/hook/cal.com/{person_id} |
None | cal.com booking webhook (BOOKING_CREATED/RESCHEDULED/CANCELLED) |