Skip to content

Coherence API Playbook


Assumptions

The following were inferred from code, not from explicit configuration:

  • API_TOKEN is a static shared secret stored in Django settings. No token rotation or per-client tokens observed.
  • ConversationOut.journey_slug / journey_name / step_slug / step_title are 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 infos and config. Browse endpoints return publishable metadata only and do not expose internal config.
  • Browse endpoints return a separate public conversation shape and do not expose raw conversation.infos or internal conversation.config.
  • The list endpoint (GET /api/coherence/conversations) returns all matching results in one response — there is no limit/offset param. Scope queries with person_id/journey/time to keep result sets small.
  • 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.

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 OPTIONS requests include CORS headers for allowed origins.

Example:

curl "https://app.the-gathering.earth/api/coherence/conversations?person_id=42&journey=coherence-check&time=2026-03-01T14%3A30%3A00Z" \
  -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", "person_id"], "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.event is this holon or one of its descendants;
  • connected_holon_slug: participation—the conversation is explicitly linked to this holon through Conversation.connected;
  • person_id: the Person is in Conversation.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. 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 for this journey?"

The GET /api/coherence/conversations endpoint determines whether a conversation is "active" at a given point in time using this filter:

CoherenceConversation.objects.filter(
    participants__id=person_id,
    journey__slug=journey,
    start__lte=time + timedelta(minutes=5),
).filter(Q(finish__gte=time) | Q(finish__isnull=True))

Three conditions must all be true:

  1. participants__id=person_id — the given person is a participant in the conversation.
  2. journey__slug=journey — the conversation belongs to the requested journey slug.
  3. start__lte=time + timedelta(minutes=5) — the conversation starts no later than 5 minutes after time. This means a caller querying up to 5 minutes before the scheduled start will still get a match.
  4. finish__gte=time OR finish IS NULL — the conversation has not yet finished at time, or it is open-ended.

In plain terms: a conversation is returned if the person is a participant, the conversation belongs to the requested journey, it has not ended, and it either has already started or starts within the next 5 minutes.

In simple terms: Imagine a meeting on a calendar. You walk up to the room and ask "is my coherence-check conversation happening right now?" The answer is yes if: (1) your name is on the invite, (2) it is for that journey, (3) the meeting hasn't finished yet, and (4) it either already started or starts in the next 5 minutes. We allow the 5-minute early window so an agent connecting just before the scheduled time still finds the conversation.

Edge cases:

  • If start is null, that conversation will never match.
  • If finish is null, that conversation can still match because the endpoint treats it as open-ended.
  • The 5-minute early window is hardcoded; it cannot be overridden by the caller.
  • The time parameter is supplied by the caller — it is not server-side "now".
  • If no conversation matches, the endpoint creates and returns a new unscheduled conversation for that journey.

When nothing matched and nothing could be created, the endpoint names the reason rather than returning a bare 404 or a misleading empty list:

Status error Meaning
404 person_not_found No Person with that person_id on this METIS instance. Person ids are per-instance — the commonest cause is provisioning a person on one instance and reading it back from another.
404 journey_not_found No conversation journey with that slug on this instance (the slug may belong to a non-conversation journey).
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.
// 404
{
  "error": "person_not_found",
  "message": "No Person with id 2065 exists on this METIS instance. Person ids are per-instance — check the request is going to the instance that person was created on."
}

A lookup that succeeds but simply has no conversations is never an error: it is 200 with the created unscheduled conversation.


Golden Path: Find the current conversation for a person

curl "https://app.the-gathering.earth/api/coherence/conversations?person_id=42&journey=coherence-check&time=2026-03-01T14%3A30%3A00Z" \
  -H "Authorization: Bearer mysecrettoken"
[
  {
    "id": 7,
    "participants": [
      {"id": 42, "name": "Alice Ferreira", "photo": null, "contact": {"email": "alice@example.com"}},
      {"id": 18, "name": "Bob Silva",      "photo": "/media/people/bob.jpg", "contact": {}}
    ],
    "connected": [
      {"id": 3, "name": "⬢ Global", "type": "domain", "slug": "global"}
    ],
    "infos": {"publishing": {"youtube": {"title": "Conversation trailer"}}},
    "config": {"cal.com": {"bookingId": "abc123xyz"}},
    "start":  "2026-03-01T14:00:00Z",
    "finish": "2026-03-01T15:00:00Z",
    "journey_slug": "coherence-check",
    "journey_name": "Coherence Check",
    "step_slug":    "scheduled",
    "step_title":   "Scheduled"
  }
]

If nothing matches, the endpoint creates a new unscheduled conversation for that person and journey instead of returning an empty array.


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_id or journey_slug
  • Without holon_slug, the endpoint returns all conversations for that journey
  • holon_slug is 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 by id
  • Raw infos is 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 ids query 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 ids query 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.
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:

  • questionId is required and must belong to the conversation's Journey.
  • occurredAt must be a timezone-aware ISO 8601 datetime.
  • Idempotency-Key is required. An identical retry returns 200 with replayed: true; reuse for different content returns 409 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.
  • occurredAt more than five minutes in the future is rejected with 400 (a client clock running ahead would otherwise pin the active question).
  • Payload shape/type errors return 422; semantic errors return the documented 400/409 application-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 statephase, claims, version (with expectedVersion for optimistic concurrency).
  • RealtimeKit metadatameetingId, 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 windowconversationStartedAt, captureEndedAt (see The transcript window below).

sessionId is ignored. METIS derives the canonical session from Cloudflare (list the meeting's sessions, pick the real recorded one); the old client-supplied sessionId, read off the browser SDK, was unreliable and caused conv-220's empty-transcript defect. Sending sessionId is 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 stored sessionId is 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. meetingId may be absent at first (e.g. writing phase before a RealtimeKit meeting exists) and supplied later.
  • Scalar fields never erase a previously stored non-null value when omitted/null: recordingId, recordingEndedAt. phase/version simply take the latest provided value (they're live state, not durable IDs).
  • Optimistic concurrency guard (expectedVersion): if provided, the server checks that the current config['enter-coherence'].version matches it before writing. On mismatch, returns 409 version_conflict with currentVersion — fetch the latest state and retry. Omit to write unconditionally. First write (no enter-coherence yet) is accepted regardless of expectedVersion.
  • claims upsert per display-name key (shallow-merged) — writing one claim never erases another.
  • participants upsert by customParticipantId. Resending the same customParticipantId with a new realtimeKitParticipantId updates the participant id and lastCredentialIssuedAt (covers token-refresh recreation) but keeps the original assignedAt.
  • Meeting conflict: if a different meetingId is already stored for this conversation, the call returns 409 meeting_conflict with currentMeetingId and writes nothing.
  • Does not advance the journey step.

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:00 and Z forms of one instant are the same value).
  • A different value returns 409 transcript_boundary_conflict with the stored value in currentConversationStartedAt / 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.
  • transcriptWindowComplete is true once both are stored. Until then the transcript importer imports zero rows and reports transcript_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) or capture_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, no infos/config patch.
  • Unusable window (naive, or not after a stored conversationStartedAt) → 400 invalid_transcript_window, again writing nothing.
  • A replayed Idempotency-Key still checks the boundary: an identical value is a no-op 200, but a different one is a 409 rather 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, finish untouched, request body ignored) and the endpoint returns 200 with the same ConversationOut shape.
  • 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:

  • EventGET /conversation-events/{event_slug}/journeys → pick the journey. An Event can own several Journeys; there is no "default" — choose explicitly.
  • Conversation → the journey_slug already returned by /conversations/search or /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.

Journey is resolved from the videoCallUrl in the payload metadata. If the slug doesn't match a CONVERSATION-type journey, an error note is attached to the person and no conversation is created.

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/conversations Bearer/User token List active conversations for a person at a point in time
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)