conversation-api¶
Status: Current | Last reviewed: 2026-10-15
Customer Authentication owns the shared customer-page state and recipient-bound connection reuse for real and simulated SMS. Browser presence is per share code, and page lifetime never extends the verification challenge. See the shared-page flow.
The single source of truth for live conversation state. Holds the session, transcripts, frame stream, and lifecycle event broker that fan out to async-agent and analytics.
Copilot test streams use the copilot's two-channel STT as their transcript
source. When merging related streams, sse_stream excludes TRANSCRIPT events
from appended -standin streams to prevent duplicate speech with independent
message IDs. Partner UI/tool/status events remain, and requesting the partner
stream directly still shows its transcript. Both SSE and WebSocket adapters use
this generator.
SSE/WebSocket auto-activation skips rows already stamped with the orchestrator's
audio_in_sample_rate metadata, even before their first Redis event. Otherwise
a Salesforce iframe opened during pipeline startup can create a duplicate text
session and replace the phone session's metadata. Workflow-created conversation
rows without that runtime stamp still start normally. Regression:
apps/conversation-api/test_live_session_startup.py.
Source¶
Runtime¶
| Aspect | Value |
|---|---|
| Language | Python 3.12 |
| Framework | FastAPI + gRPC |
| Ports | :8080 HTTP/SSE, :1234 gRPC |
What it does¶
- Creates a
conversationsrow when a session starts. - Receives streamed frames from ai-orchestrator (transcripts, tool calls, A2UI descriptors, events).
- Persists transcripts in
conversation_messages, including lifecycle lineage (message_key,session_seq,variant,revision) so interim/final/enhanced transcript variants upsert idempotently and replay in stable order. - Persists
TOOL_CALL,PIPELINE_EVENT,CHAT_EVENT,UI_RENDER,ENABLE_MULTIMODAL, andUI_STATEentries inconversation_eventsso saved transcript playback can replay tool results, subagent activity, user-facing timeline events (verification, UI actions, payment outcomes), and the caller's A2UI surfaces with their filled-in state after the live Redis stream expires. - Produces
chat_eventrows itself for payment outcomes (payments/service.py record_outcome) and verification success (customer_auth/service.py _grant) via thechat_events.pyhelper (persist + alias-fanned live XADD); UI-action chat events arrive from the orchestrator over gRPC like any other entry. See a2ui-rendering.md. - Maintains
conversation_search_documents, one indexed search row per conversation for global and per-agent history pages. - Pushes assistant frames to surface-app via SSE.
- Emits lifecycle events into the workflow trigger queue.
- Forwards transcript events to event-publisher for downstream analytics.
- Runs the optional
conversation-archive-worker, which writes deterministic transcript/recording bundles to S3-compatible storage for enabled tenant archive policies.
Dependencies¶
| Service | Purpose |
|---|---|
| Postgres | conversations, conversation_messages, events |
| Redis | SSE channel pubsub, ephemeral state |
| MinIO / S3-compatible object storage | Conversation recordings, file uploads, archived conversation bundles |
| async-agent | Lifecycle event fan-out |
| event-publisher | Multi-cloud event forwarding |
The object-storage layer is storage.py over the shared trueserve_storage seam (boto3). AWS S3 is the default; MinIO and GCS S3 interoperability are configuration (STORAGE_ENDPOINT + STORAGE_FORCE_PATH_STYLE); see object-storage.md.
Authentication¶
Two valid auth paths into the HTTP API (apps/conversation-api/auth.py):
- Bearer API token (
Authorization: tsf_*) — server-to-server callers, validated againstapi_tokens. Conversation routes requireagents:manage; legacy call scopes are accepted as aliases. - Surface-internal HMAC stamp (
X-Surface-Internal+-Sig) — surface-app proves the call originates from a surface-app pod holding a verified embed JWT. The shared secretSURFACE_INTERNAL_HMAC_SECRETis mounted into both services; stamps older than 60 s are rejected.
Versioned agent ids. The embed stamp's
agentand the request'sagent_idcarry a version suffix (3023:draft,3023:v9) because the orchestrator needs it to resolve the right pipeline version. Theagentstable, however, is keyed on the bare id. Any auth/tenant-scope check that touchesagents(_assert_agent_in_auth_scope) must strip the suffix (agent_id.split(":", 1)[0]) before comparing the stamp or querying the row — otherwiseSELECT 1 FROM agents WHERE id = '3023:draft'matches nothing and a valid embed gets a spurious403 "Agent is outside the authenticated tenant". Same suffix handling asdb_queries.get_surface_config_for_agent. The ai-manager in-app chat tester (/api/text-chat/create) must FORWARD the versioned id (3026:draft) toPOST /conversations— it bares the id only for the localgetPipelinetenant check. (It used to bare the id everywhere, which made the chat tester always run the published version, so draft edits silently never took effect.) conversation-api'sOrchestratorGRPCClient.create_sessionauto-setsmetadata["call_source"]="ai-manager-test"when":draft" in agent_id, which is what unlocksallow_draftin the orchestrator — so forwarding the suffix is both necessary and sufficient for draft chat testing.The same rule applies to
_authorize_conversation's agent-scope check (the one that compares the stamp'sauth.agent_idto the conversation row'sagent_id, not theagentstable): a conversation row stores the bare id (3026) while the embed stamp carries the suffix (3026:draft), so it must compare onsplit(":", 1)[0]of both. A rawconversation["agent_id"] != auth.agent_idspuriously 403s a valid draft session — and because both the SSE stream (GET /sse/conversations/{id}) and the message send (POST /conversations/{id}/responses) route through_authorize_conversation, the symptom is a chat that greets server-side but shows no messages (SSE 403) andFailed to send message: 403on send.
Joining agent_versions on a conversation row¶
A conversation row's agent_id can carry the suffix (locally: 1872 of 3220 rows, of which 1776 are :draft), and agent_versions.agent_id never does — so a query that reads a running session's config needs both halves, and the two halves fail differently:
JOIN agent_versions av ON av.agent_id = split_part(c.agent_id, ':', 1)
WHERE …
AND (av.version_number = c.config_version
OR av.is_current_published = true
OR av.status = 'draft')
ORDER BY (av.version_number IS NOT DISTINCT FROM c.config_version) DESC,
av.is_current_published DESC NULLS LAST,
av.version_number DESC
split_parton the join. Without it the join matches zero rows for every suffixed conversation — which is every emulator/Test-button session, i.e. exactly the path an author uses to check a capability they just enabled.- Pin to
conversations.config_version. That integer column is stamped by the orchestrator from the resolved config's_version_number(db_pipeline_store→grpc_provider/livekit_provider→conversation_store), so it names the exactagent_versionsrow this session is running. Re-deriving the version fromis_current_published DESC, version_number DESCanswers a different question, and the answers diverge in the commonest authoring case: a capability enabled on the draft of an agent that also has a published version. Published-first stays as the fallback for the ~5 % of rows with a NULLconfig_version, and it lives in theWHEREas a widened candidate set rather than anELSEbranch, so aconfig_versionnaming a deleted version degrades to the current version instead of returning nothing.IS NOT DISTINCT FROM(not=) in theORDER BYkeeps a NULLconfig_versionsorting deterministically.
Three sites do this, and each one's failure mode is why the bug survived: customer_auth/config.py NODE_POLICY_SQL fails closed (no row → NodeAuthPolicy() → every auth tool returns 409 capability_disabled, indistinguishable from a capability the author never enabled); http_api.py _load_injection_policy fails open (no row → {} → injection permitted, so the author's allow_targets/disabled flag silently never applies — a control that reads as enforced and isn't); http_api.py _resolve_agent_type returns None and routes to the LOCAL branch (harmless for local agents, wrong for Agent Assist). catalog_manifest.py is the existing precedent — it has always done both halves. Regression coverage: test_customer_auth_node_policy.py executes config.NODE_POLICY_SQL itself against Postgres rather than a paraphrase, because a paraphrase is what lets the query and the test drift.
Note the two conversation-lookup forms are not interchangeable: NODE_POLICY_SQL uses the tolerant (c.id::text = $1 OR c.session_id = $1) because telephony/SCV legs arrive by a non-uuid session_id, where the strict c.id = $1::uuid form (_load_injection_policy) raises a DataError instead of missing — and a surrounding except turns that into the same silent fail-safe.
The two addresses, and which one a WRITE uses¶
The tolerant lookup form above exists because a conversation has two addresses, and the reason it needs stating separately is that the tables disagree about which one they hold:
| Tables | Key | Reached by |
|---|---|---|
conversation_share_codes, conversation_messages, conversation_events, conversation_recordings |
conversation_id uuid FK |
the row's conversations.id |
customer_auth_challenges/_events, payment_transactions/_events |
conversation_id free text, matched with = |
whatever the caller passed |
conversations.session_id is free text and is the runtime key: an Asterisk leg's is a channel id (1788188503.4), an Amazon Connect leg's likewise. Browser sessions mint one uuid and use it for both addresses — which is why every bug in this class is telephony-only and invisible in Preview.
Two helpers in db_queries.py, pointing opposite ways, and picking the wrong one is silent both times:
resolve_conversation_row_id→ the uuid, for anything writing a uuid column.create_share_codecalls it, which covers all three minters (auth links,POST /share-codes, multimodal SMS) at one seam; before that they passed the caller's id straight into the uuid FK, asyncpg refused the argument before the statement ran, and each minter reported its own flavour of "could not send the link" — Customer Authentication answereddelivery_failedon a call whose ANI had resolved perfectly, and multimodal A2UI could not attach a surface at all. An id matching nothing now raisesValueErrornaming it, rather than aDataErrorabout UUID lengths that reads exactly like a shape bug.derive_conversation_row_id→ the uuid a row would get, for the paths that CREATE one (multimodal_sms_endpoint's ensure-row fallback). It mirrors ai-orchestrator'spersistence/conversation_store.derive_conversation_row_idbyte-for-byte (uuid5(NAMESPACE_URL, "trueserve:conversation:{session_id}")) so the row ensured here is the row the runtime later upsertsON CONFLICT (id), not a second conversation holding half the call's evidence. The fallback used touuid.UUID()the session id and swallow the raise, so the row was never created andcreate_share_codethen had nothing to resolve.canonical_conversation_id→ thesession_id, for the free-text capability tables. Both capability_guards canonicalize at the request boundary because the two ends of one ceremony arrive addressed differently: the orchestrator issues by session id, surface-app's proxy forwards the uuid its share code resolved to. Normalizing to the uuid instead would also silently stop out-of-band completions reaching the live agent — the injection publishes to Redisinject:{conversation_id}and the orchestrator subscribes under its session id.
The share-code reuse check in multimodal_sms_endpoint accepts either address for the same reason: a strict != against the stored uuid was always true on a phone call, so every turn minted a new code and texted the caller a second link for the surface they already had open. Regression: test_share_code_conversation_address.py (real Postgres — the defect was the parameter the driver refused, so a mocked pool proves nothing).
The same split in REDIS — every key is <prefix>:{conversation_id}¶
Postgres addressing was only half of it. Every Redis key in this service is built as <prefix>:{conversation_id}, and the two sides of the multimodal round trip build it from different addresses:
| Writes under the session id | Reads/writes under the uuid |
|---|---|
ai-orchestrator: entries: (the frame stream), a2ui:proxy:{id}:{surface}, and its a2ui:action:/inject:/multimodal:connected: subscriptions; its gRPC stream registration |
every HTTP caller — a share code resolves to the uuid, so surface-app's SSE proxy, the UI-action route, and the proxy-capability route all arrive holding it |
agent-host: multimodal:live:, mcp:session_policy:, queued-render drain |
So on a phone call the surface subscribed to entries:<uuid> (which did not exist) while the render sat in entries:1788193570.12 with 58 entries in it — the page opened, reported healthy, and sat on "Waiting for content from your session…" forever. SSE replays from "0", so this was never a timing race; the stream was simply the wrong one. The same split silently broke the return path: a button tap on that surface was published to a2ui:action:<uuid> and answered no delivery channel available.
conversation_aliases.py closes it here, because this is the only service that knows both addresses. conversation_alias_ids(id) resolves the pair with the tolerant lookup — caller's own id first, since callers that stop at the first hit (send_to_grpc_client, the proxy-capability read) must prefer the address they were asked about — and read paths fan out over the result while publish_to_aliases publishes to all of it. Four things about it are load-bearing:
- A fault degrades to ONE address, never to zero. A resolver that returned
[]on a DB blip would stop publishing entirely; the floor is today's single-address behaviour. - A mapping payload is STAMPED per alias. Fanning out the channel is not enough — ai-orchestrator's
a2ui_toollistener psubscribesa2ui:action:*and routes on theconversation_idin the body, looking it up in a callback registry keyed by its own session id, and agent-host's queued-render flusher does the same withmultimodal:connected:*. Publishing the uuid on the session-id channel is a message both of them drop. Channels whose subscribers key on the channel name alone (inject:) take astrpayload and are published verbatim. - Only a settled answer is cached. A missing row, or a row whose
session_idis still null, is re-read every time; remembering "this conversation has one address" for the 60 s TTL would recreate the bug on a live call. deliveredstays honest.POST /conversations/{id}/injectreturns the subscriber count so theconversation_actionsworkflow node can tell a dead session from a live one — that only means anything if the publish reached the address the pipeline subscribed under.
A secondary defect in the same class was found and fixed alongside: sse()'s duplicate-text-session guard counted entries:{uuid}, so on a live phone call it would have started a second agent the moment the caller opened their multimodal link.
Not converted, and the same class if they ever need to be: form_context: (keyed by whatever the variables caller passed), agui:, avatar:/avatar_mode:/avatar_mode_state:, and A2A's entries: drain in a2a_server.py — all browser- or A2A-only paths today, where the two addresses coincide.
Regression: test_conversation_aliases.py, whose last test is a text read asserting no call site in http_api.py addresses a conversation by a single id any more.
WebSocket event streaming¶
Every browser-facing SSE stream has a WebSocket sibling, selected per city by
TSF_EVENT_TRANSPORT (read at runtime by surface-app/ai-manager and delivered
to the browser in existing payloads — never NEXT_PUBLIC_*, which is baked at
build time). The motivation: some *.trueserve.ai networks sit behind
middleboxes that buffer SSE, blanking every streaming surface.
One generator, two transports. The four event generators (sse_stream,
agui_sse_stream, historical_sse_stream, notifications) yield STRUCTURED
events — {"id", "event", "data"} / {"comment"} — and
stream_events.py encodes
them: sse_encode reproduces the historical SSE wire byte-for-byte, and
drive_ws_event_stream sends one JSON text frame per event over an accepted
socket. data stays the exact string an EventSource event.data would carry,
so both transports share one browser dispatch path and one event-name
allow-list. Because the generators are shared, WS inherits every side effect —
the multimodal:live presence key, the multimodal:disconnected publish, the
connection gauges — for free; a WS-only reimplementation would silently split
them.
Disconnect cleanup is detached on purpose. anyio cancel scopes are
level-triggered: once a client disconnect cancels the task iterating a
generator, every subsequent await in its finally raises immediately — which
was silently skipping the presence delete / disconnect publish / pubsub close
on EVERY disconnect (SSE included, a latent production bug). All four
generators now delegate cleanup to run_stream_cleanup, which detaches it onto
its own shielded task.
Endpoints (beside the existing /video + /avatar sockets, which also
gained /ws/… ALIASES so ingress path-routing reaches them):
WS /ws/conversations/{id}/events— paramsticket/access_token,mode,format(trueserve|agui),last_event_id.WS /ws/share/{code}/events— ticketless; the share code is the capability (a deliberate widening of "everything post-resolve goes through surface-app"; rate-limited like resolve, conversation id still never reaches the browser).WS /ws/notifications?session_id=— unauthenticated demo parity.
Close codes: 4401 auth, 4403 forbidden, 4404 not found, 1000 clean end
(client must NOT reconnect). Comments double as heartbeats. WS handlers check
the handshake Origin themselves (_ws_origin_allowed) because
OriginValidationMiddleware is a BaseHTTPMiddleware and never sees websocket
scopes.
Stream tickets. A browser can't put the HMAC stamp on a WS handshake and a
long-lived bearer doesn't belong in a URL, so POST /stream-tickets exchanges
existing auth for a single-use 90 s ticket
(stream_tickets.py,
GETDEL consumption; reconnects re-mint). surface-app mints via
POST /api/events/ticket, ai-manager via POST /api/phone/events/ticket
(which is also the phone emulator's transport flag delivery).
No new base URLs. The browser's WS URL is always a same-origin PATH:
/ws/* on the surface-app and ai-manager hostnames is path-routed to
conversation-api at the ingress (HTTPRoute rules in
charts/trueserve-stack/templates/httproute-{surface-app,ai-manager}.yaml; dev
nginx location /ws/ blocks in the vanguard template). Direct MFE embeds use
the conversation-api base they already know, https→wss, with
?access_token=.
Tests: test_stream_events.py
(encoder byte-parity + detached cleanup),
test_ws_events_endpoint.py.
Sending an SMS: one seam, and the bearer an HMAC-stamped caller doesn't have¶
Two routes put a real SMS on the wire — the Customer Authentication link (customer_auth/delivery.py) and the multimodal A2UI link (multimodal_sms_endpoint in http_api.py) — and both go through async-agent's POST /api/v1/sms/send. They shared every piece of configuration and none of the code, which is how one telephony call delivered the auth link and silently failed the multimodal one.
async_agent_sms.py is now the single seam. async_agent_bearer(header) prefers the caller's own Authorization and falls back to ASYNC_AGENT_API_TOKEN — that fallback is not a convenience, it is the only path by which an internal-stamp caller reaches real SMS at all, because the orchestrator authenticates with the X-Surface-Internal HMAC stamp and has no bearer to forward. With neither credential the seam sends nothing and returns an error that names ASYNC_AGENT_API_TOKEN; letting async-agent answer instead produces "Tenant-scoped token required", which reads as a tenant misconfiguration rather than as this service having nothing to present. send_sms_via_async_agent then reports the real reason on failure — error → FastAPI's detail → HTTP {status} — because the old body-only read saw no status/error key in a 401 {"detail": …} and told the model "Unknown SMS error", which it answered by retrying the same failing call. Transport faults come back as an outcome, never as a raise. Regression: test_async_agent_sms.py, including a text read that both call sites import the seam and neither builds the URL itself.
POST /conversations/{id}/responses: the type dispatch is string-vs-enum¶
post_response routes the request body's type string ("TEXT" | "SUGGESTION" | "ACTION" | "FEEDBACK") by comparing it against CLIENT_RESPONSE_TYPES members before mapping to the gRPC TYPE_* int the orchestrator's publisher switches on. That enum must keep its str mixin (Enum(..., type=str) in http_api.py): a plain Enum member is never equal to its own value, so with the mixin dropped the FEEDBACK and ACTION arms are both unreachable and every such request falls through to the else → TYPE_TEXT (1). The failure is fully silent end to end — the route answers 200 with a healthy-looking via: grpc_callback (the message really did reach the publisher's bidi stream), and the publisher's TYPE_TEXT arm JSON-parses the payload looking for text, finds none, and returns without logging. Observed as: a copilot on_transfer summary request that produced no card and no log line anywhere.
Two rules fall out, guarded by test_post_response_types.py:
- Compare
.valueor keep the mixin; the test asserts the members compare/hash/serialize as their wire strings and that the PydanticLiteralunion and the enum agree. It deliberately asserts on the enum itself rather than paraphrasing the route'sifchain — the defect was the comparison, so a test that re-implements it passes on the broken code. - Never interpolate a member into a payload. A
str-mixinEnumis notStrEnum:==/isinstance/hashing/json.dumpsbehave as the string, butstr()and f-strings still renderClientResponseTypes.ACTIONon 3.11+.
A2A (Agent2Agent)¶
conversation-api exposes Chat Agents flagged agents.a2a_enabled = true as A2A agents, hosted here because it's already the external entrypoint to a running agent. Implementation in a2a_server.py; routes in http_api.py; lookup via db_queries.get_agent_for_a2a (strips the version suffix, same rule as above).
GET /agents/{agent_id}/.well-known/agent-card.json— public A2A Agent Card (built with the officiala2a-sdkAgentCard). Skills are derived from the agent's published-version tools; identity fromagents.name/description, with optional overrides inagents.a2a_metadata. The card advertisessecuritySchemes.trueServeApiToken(Authorization: Bearer <token>,agents:run). Returns 404 unlessa2a_enabledand the agent is Text In | Text Out. Origin validation is skipped for any/.well-known/path (it's a public discovery document).POST /agents/{agent_id}— the JSON-RPC endpoint advertised by the Agent Card;POST /agents/{agent_id}/a2aremains an equivalent alias.message/sendreturns JSON andmessage/streamreturns SSE. Requires a TrueServe Bearer API token viarequire_calls_initiate(agents:runscope; legacycalls:initiatestill aliases to it), and the token tenant must match the agent tenant. Bridges into the live runtime: creates a gRPC session (same path asPOST /conversations), posts the user text (same path as.../responses), then drains the Redisentries:{conversation_id}stream collecting finalROLE_AGENTtranscripts until the turn goes idle (STATUS_IDLE) orA2A_TURN_TIMEOUT_SEC(default 45) elapses, and returns the reply as a completed A2ATask(or a failed Task if no text was collected).
The runtime's protobuf TranscriptEntry stores text in content. grpc_api.write_sample_stream preserves that field name when serializing entries to Redis; the A2A collector must read transcript.content, not transcript.text (the latter belongs to A2A message parts). Only final agent transcripts become buffered reply text; user echoes and reasoning variants are excluded. Streaming additionally exposes interim agent transcripts. Final revisions of the same message replace its previous text. Both numeric STATUS_IDLE (2) and STATUS_ENDED (4) end collection after a reply, with a quiet-period fallback when no terminal status arrives.
If the collection window ends without agent text, the HTTP JSON-RPC response carries a failed Task with the diagnostic in status.message and no response artifacts. HTTP 200 alone does not indicate a successful agent turn. For message/stream, the route returns text/event-stream immediately after authentication and request validation. Each data: payload is an SDK-validated A2A 0.3 JSON-RPC response: an initial working Task, zero or more artifact-update events, and exactly one final status-update (completed or failed). Task, context, artifact, and request IDs stay consistent within the stream; timestamps are ISO 8601 strings. The final successful artifact has lastChunk: true.
The adapter sends full response snapshots with append: false, since runtime content is cumulative. It groups by message_key/turn_id, ignores stale revisions and late interims after a final, and excludes reasoning. Appending each interim would duplicate words. Providers that batch their output still produce valid SSE but may deliver one text snapshot; the bridge cannot stream tokens the provider has not emitted.
The producer queue is bounded to 32 events, and a socket write that stalls for 15 seconds closes the request. Ten-second SSE comments keep idle streams alive, and Cache-Control: no-cache, no-transform plus X-Accel-Buffering: no prevent proxy buffering. Disconnects cancel the producer and close its gRPC session using the existing cancellation-safe run_stream_cleanup; the response wrapper also closes the generator on socket-write failures. Runtime errors after headers become failed status events, without exposing internal exception text. The existing 30-second session-start limit and 45-second collection window still apply.
The FastAPI auth/tenant gates remain common to both endpoint URLs and methods. Invalid params and unsupported methods return JSON-RPC errors before starting a turn. Each request still creates a fresh context; conversation-history reuse, persistent task lookup/cancel, and tasks/resubscribe are not implemented. An unsupported method is rejected rather than treated as a new user message.
Regression: cd apps/conversation-api && .venv/bin/python test_a2a_server.py and .venv/bin/python test_a2a_stream.py serializes real protobuf envelopes and exercises the HTTP handler with mocked runtime boundaries. AS-138 was reproduced on an isolated Rome bookstore agent: ordinary chat produced text in 4.94 s, while A2A returned a placeholder after 45.61 s despite a final agent transcript already existing in Redis. With the collector corrected, the same A2A prompt returned the recommendation in 5.87 s. SSE was then checked end-to-end through Rome's public proxy with the installed A2A SDK: first Task at 19 ms, first text at 1.12 s, thirteen text updates, and terminal completion at 8.17 s. The separate Responses API fixture also passed with batched text output. Live checks additionally verified immediate session release after disconnect, separate request/transcript identities for concurrent streams, and a failed terminal event after 46.35 s for a fixture with transcript output disabled.
The card's url comes from CONVERSATION_API_PUBLIC_URL (else reconstructed from X-Forwarded-*). a2a-sdk is an explicit dependency in pyproject.toml — keep it pinned. The matching outbound side (our agents calling a remote A2A agent) is the a2a tool type — see universal-pipeline-builder.md.
Supervisor sessions (Human Supervision)¶
POST /conversations/supervisor-session idempotently resolves THE supervisor chat conversation for a (supervisor agent, related conversation) pair — the Human Supervision transcript relay (agent-host) and the CRM copilot embed (surface-app BFF proxy) both call it so they converge on one thread. The id is deterministic (derive_supervisor_conversation_id in db_queries.py: uuid5 of the BARE agent id + the related conversation's ROW uuid, resolved with the tolerant two-addresses lookup). Idempotency: a live pipeline (pubsub_numsub on the inject: alias channels) → live: true, nothing created; an existing conversation whose session ended → re-opened on the same id with resume_history: "true" session metadata (the orchestrator seeds stored conversation_messages back into the LLM context and suppresses the greeting); otherwise created fresh. Auth: bearer with agents:manage, same as POST /conversations.
Related: the inject endpoint's kind values now include transcript (a relayed live-call line — the orchestrator wraps it with the stay-silent contract) and call_status (a pre-formatted lifecycle notice, e.g. call ended). Full loop: human-supervision.md.
Customer Authentication¶
conversation-api is the authentication authority for the per-agent Customer Authentication capability: the customer_auth/ package owns challenges, OTP hashing, WebAuthn ceremonies, cross-conversation lockout, the append-only customer_auth_events audit trail, and the provider seam. Routes live under /conversations/{id}/auth/* and accept the same two auth paths as multimodal SMS (bearer with agents:manage, or the surface-internal stamp bound to the conversation). Wiring is init_customer_auth(...) in http_api's startup — the session-variable writer, injector, and authorization guards are dependency-injected because http_api imports the package's router (the dependency runs one way only); a missing wiring fails closed. Full flow, state model, and gotchas: customer-authentication.md.
Payments (PCI)¶
conversation-api is the payments authority for the per-agent Payments capability: the payments/ package owns policy composition (tenant ceilings ∧ node config), the payment_transactions records, refund limits, and the append-only payment_events audit trail. Routes live under /conversations/{id}/payments/* (collect, cancel, outcome, refund, list, events) and take the same two auth paths as Customer Authentication. Wiring is init_payments(...) in http_api's startup; a missing wiring is a 503, never a skipped check.
No route here accepts cardholder data, and none ever should. Digits go from the caller's keypad to the orchestrator's payment-mode controller and from the browser's hosted-fields iframe to payment-vault — in both cases direct. A field that carried a PAN would put this service in PCI scope, which is the thing the vault exists to prevent. find_payments_config uses the same NODE_POLICY_SQL shape as customer_auth (see Joining agent_versions on a conversation row). Full flow: payments.md.
Conversation search¶
POST /conversations/search is the shared search surface for global conversation search and per-agent Conversation History. It reads conversation_search_documents, not the hot transcript tables, so list pages stay fast at 100k+ conversations. The request supports cursor pagination plus transcript text, date range, agent id, version, status, channel, caller, outcome, recording, archive-state, and test-traffic filters. Responses use:
{
"items": [],
"nextCursor": null,
"hasMore": false,
"total": 0
}
The cursor is opaque. Clients must send it back unchanged and should keep their own back-stack for previous-page navigation. GET /api/conversations in ai-manager delegates to the same search helper for compatibility with older UI callers.
Conversation archiving¶
Archiving is disabled until a tenant creates an enabled conversation_archive_policy. The recommended starting policy is: archive terminal non-test conversations after 30 days, purge hot detail rows after 90 days, and retain conversation_search_documents indefinitely unless a later retention policy says otherwise.
conversation-archive-worker uses Redis leader locking, claim/retry state in conversation_archive_items, and idempotent object keys. Each archived conversation lands under:
<prefix>/tenant=<tenant_id>/year=<yyyy>/month=<mm>/day=<dd>/<conversation_id>/
The bundle contains manifest.json, conversation.json, transcript.jsonl, events.jsonl, recordings/<track>.<format>, and attachments/... when the conversation has attachments. Archive sinks reference tenant integrations for S3-compatible credentials; sink config should contain non-secret values such as bucket, prefix, endpoint, region, and source_bucket. Detail reads fall back to the archived bundle when hot transcript/event/recording rows have been purged.
Vanguard¶
overmind restart conversation-api
The dev Procfile includes conversation-archive-worker. It is safe for it to run locally because it only enqueues work for enabled tenant policies.
Production¶
Helm chart at charts/conversation-api. The archive worker is packaged as an optional Deployment via archiveWorker.enabled; the worker remains policy-driven even when deployed. HTTP/gRPC session affinity is via Redis pubsub, not sticky sessions.
The Docker build context is the repo root (for libs/http-client, consumed by integration_resolver.py and customer_auth/providers/oidc.py via http_client_bootstrap.py), so the Dockerfile's COPY sources are apps/conversation-api/-prefixed — don't revert the CI/compose context. Same pattern as ai-orchestrator's libs/telemetry context.
See also¶
Agent evaluation¶
Performance tests use durable jobs, pinned runtime configurations and shared mock state. See agent evaluation and worker operations for this service’s role, admission limits, authenticated stream ownership and recovery.
Studio roles and runtime tokens¶
Conversation execution, transport/session actions and conversation-bound customer authentication/payment actions require agents:run. agents:manage implies
run access for authors; workflows have no separate permission gate. Personal
tokens derive current access from the active tenant membership: Admin gets full
tenant access, Builder gets agent/tool authoring and run access, Viewer gets
browse/run access. Platform Admin receives every capability in the token's
tenant. Removed memberships and inactive users or tenants are rejected.
Legacy roles resolve as Developer → Builder, Operator → Admin and Analyst →
Viewer. Legacy pipeline/call read scopes map to agents:run, while workflow
management and pipeline write scopes map to agents:manage. Normalizing a
read scope repeatedly never grants write access. See API authentication.
Regression check from the repository root:
apps/async-agent/.venv/bin/python scripts/audit/test_runtime_access.py.
Named tenant roles are authoritative. Retired direct permission grants, including wildcards, do not elevate Viewer or Builder.