What is a Published Agent?
A Published Agent is a tenant-owned route that exposes one Agent Profile over a public HTTPS surface. Publishing does not change the profile itself — it adds a row that maps(tenant, slug) to the profile and tracks the public knobs that the Chat Gateway enforces on every request.
Published agents are:
- Slug-addressed — the public URL is
https://<gateway>/v1/chat/{tenant}/{slug} - Profile-backed — every chat turn runs against the same profile that conductor users invoke privately
- API-key authenticated — bearers are HMAC-hashed with a pepper; plaintext is shown exactly once
- Soft-deleted — unpublishing keeps the row for history, conditional unique indexes on
unpublished_at IS NULL - Tenant-scoped — Postgres row-level security partitions every read and write
Two-Listener Architecture
The conductor is never exposed to the public internet. Public chat traffic terminates on a separatechat-gateway binary that signs every internal request to the conductor’s private listener.
The conductor refuses to start when
INTERNAL_PORT is enabled but CHAT_GATEWAY_SIGNING_KEY_CURRENT is unset, and the gateway refuses to start when CONDUCTOR_INTERNAL_URL resolves to a *.railway.app host. See Operations for the env reference.
Data Model
Five Postgres tables back every public chat turn. All five are RLS-scoped bycurrent_setting('app.tenant_id').
Soft-Delete and Uniqueness
published_agents uses partial unique indexes on (tenant_id, slug) WHERE unpublished_at IS NULL and (tenant_id, profile_name) WHERE unpublished_at IS NULL. Unpublishing stamps unpublished_at so the slug can be re-used immediately while history stays intact.
LISTEN / NOTIFY
published_agents and agent_api_keys carry triggers that emit published_agents_changed and agent_api_keys_changed notifications. The chat gateway currently reads on every request; the route cache wired through these channels is a planned follow-up.
API Key Identity
The gateway never sees plaintext keys after issuance. The full lifecycle: Tokens follow the formatao_<env>_<base32-22> where <env> is dev, staging, or prod. The base32 tail is 22 chars of random entropy.
Reaction surface:
- Revoke —
DELETE /api/profiles/{name}/keys/{id}stampsrevoked_at; the gateway returns401 unauthorizedon the next request without leaking the key id - Rotation — issue a new key, hand it to the client, then revoke the old one
- Lost pepper — every existing key becomes unverifiable; treat this as a full rotation event
Conversations and Public Runs
Aconversation_id is the only continuity handle on the public API. The first request mints one; subsequent requests pass it back to reuse the runtime session.
Why Conversation, Not Session?
The public surface usesconversation_id instead of exposing session_id for three reasons:
- Encapsulation — Conversations are the public concept; sessions are a runtime detail that may be recycled
- Cross-agent isolation —
published_idis stamped on the conversation row at creation, and a stolenconversation_idcannot be reused against a different published agent - One identity surface — Surfacing both ids would let callers desync them; the gateway resolves session reuse via the conversation row, not via wire input
Watcher and Public Run IDs
Every chat turn opens one in-processwatcher goroutine inside the gateway. The watcher is the single source of truth for terminal conversation_messages writes — sync and stream handlers subscribe to it via an in-process channel, but never write the terminal row themselves.
Public run ids (prun_...) are durable: even if the client disconnects, the watcher finishes the run and persists the terminal message. Callers re-attach with GET .../runs/{publicRunId} for the final answer or GET .../runs/{publicRunId}/stream to subscribe to a still-running watcher.
Ingress Metering
After a public chat request is accepted and dispatched to the conductor, the gateway increments an in-memory counter for that(tenant, published agent). The counter flushes to usage_records once per interval as kind='ingress', with published_id and the number of accepted requests since the previous flush.
Ingress rows are append-only increments, not cumulative upserts. Tenant-wide usage sums ingress_requests over the selected window, while per-agent metrics filter by published_id. Phase 1 records ingress for stats only, so these rows have cost_usd = 0.
Crash Recovery Sweep
On boot, the chat gateway runs a one-shot sweep:internal_run_id get a re-attached watcher so the terminal write happens even after a gateway crash. Rows that never made it past dispatch do not have an internal run to resume; the sweep finalizes them as lost. The sweep is logged and continues; a sweep failure never blocks boot.
Public Knobs
Every published row carries the request-shaping knobs the gateway enforces.
The rate-limit key is the API key id, not the tenant — an abusive bearer cannot poison the whole tenant.
Internal Listener Allowlist
The conductor’sINTERNAL_PORT allowlists only the routes the chat gateway needs:
chatsig signature trio plus a fresh nonce. Nonces are tracked in Redis (SET NX EX 60) so replays inside the window return 401 nonce_replayed. Memory-backed nonce storage is provided for local development.
Control-plane routes (profiles, pools, secrets, published agents, runners) are rejected on the internal listener — those belong to the dashboard surface only.
Tenant Boundary
Every gateway request derives the tenant from the URL path. The gateway then:- Sets
app.tenant_idon the pg session, scoping every read through RLS - Looks up
agent_api_keysby hash and checkstenant_idmatches the URL tenant — defense in depth against a stolen key being used cross-tenant - Loads the published row by
(tenant, slug)and validateskey.published_id == published.id
X-Chat-Gateway-Tenant header only after signature verification. Inbound X-Tenant-ID is ignored on the internal listener.
Both the conductor and chat gateway check the active Postgres role on boot. If the role is a SUPERUSER or has BYPASSRLS, startup fails because Postgres would skip the RLS policies that enforce this tenant boundary.
Failure Modes
See Publishing an Agent for the operator walkthrough, or the Chat Gateway API and Conductor publish endpoints for wire-level reference.