Skip to main content

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 separate chat-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 by current_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 format ao_<env>_<base32-22> where <env> is dev, staging, or prod. The base32 tail is 22 chars of random entropy. Reaction surface:
  • RevokeDELETE /api/profiles/{name}/keys/{id} stamps revoked_at; the gateway returns 401 unauthorized on 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

A conversation_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 uses conversation_id instead of exposing session_id for three reasons:
  1. Encapsulation — Conversations are the public concept; sessions are a runtime detail that may be recycled
  2. Cross-agent isolationpublished_id is stamped on the conversation row at creation, and a stolen conversation_id cannot be reused against a different published agent
  3. 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-process watcher 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:
Rows that already have a conductor 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’s INTERNAL_PORT allowlists only the routes the chat gateway needs:
Every request must carry a valid 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:
  1. Sets app.tenant_id on the pg session, scoping every read through RLS
  2. Looks up agent_api_keys by hash and checks tenant_id matches the URL tenant — defense in depth against a stolen key being used cross-tenant
  3. Loads the published row by (tenant, slug) and validates key.published_id == published.id
The conductor’s internal listener trusts the 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.