> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orcapods.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Publishing

> Public chat-gateway routes for tenant-owned agent profiles: data model, identity, conversations, and the gateway-to-conductor split.

## What is a Published Agent?

A **Published Agent** is a tenant-owned route that exposes one [Agent Profile](/concepts/profiles) 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](/api-reference/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](/concepts/api-keys) 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.

```mermaid theme={null}
flowchart LR
  Client[Public Client]
  Gateway[chat-gateway<br/>PORT 8090<br/>public]
  Conductor[conductor<br/>PUBLIC PORT<br/>dashboard only]
  Internal[conductor<br/>INTERNAL_PORT<br/>signed requests only]
  Worker[agent-worker sidecar]
  Pg[(Postgres)]
  Redis[(Redis)]

  Client -- Bearer ao_... --> Gateway
  Gateway -- HMAC chatsig headers --> Internal
  Internal --> Worker
  Worker --> Pg
  Gateway <--> Pg
  Gateway <--> Redis
  Conductor <--> Pg
```

| Listener                 | Reachable from               | Required headers                                               |
| ------------------------ | ---------------------------- | -------------------------------------------------------------- |
| chat-gateway (PORT)      | Public internet              | `Authorization: Bearer ao_<env>_<22>`                          |
| conductor PUBLIC PORT    | Tenant operators / dashboard | Clerk session (dashboard) or `Authorization: Bearer ao_...`    |
| conductor INTERNAL\_PORT | Private mesh only            | `X-Chat-Gateway-{Timestamp, Nonce, Key-ID, Tenant, Signature}` |

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](/guides/publishing) 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')`.

| Table                   | Purpose                                                                        |
| ----------------------- | ------------------------------------------------------------------------------ |
| `published_agents`      | One row per (tenant, slug); maps to a profile, holds public knobs              |
| `agent_api_keys`        | One row per minted key, stores HMAC hash and metadata                          |
| `conversations`         | One row per chat thread; pins a stable conversation id to a runtime session id |
| `conversation_messages` | Append-only user and assistant turns plus optional stored errors               |
| `gateway_runs`          | Run-level audit trail with `dispatching`, `running`, `ok`, or `error` status   |
| `usage_records`         | Append-only ingress meter rows for accepted public chat requests               |

```mermaid theme={null}
erDiagram
  published_agents ||--o{ agent_api_keys : "scopes"
  published_agents ||--o{ conversations : "owns"
  conversations ||--o{ conversation_messages : "contains"
  conversations ||--o{ gateway_runs : "drives"
```

### 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:

```mermaid theme={null}
sequenceDiagram
  participant Op as Operator
  participant Cond as Conductor
  participant Pg as Postgres
  participant Client
  participant GW as Chat Gateway

  Op->>Cond: POST /api/profiles/{name}/keys
  Cond->>Cond: token = ao_<env>_<22 base32>
  Cond->>Cond: hash = HMAC-SHA256(pepper, token)
  Cond->>Pg: INSERT agent_api_keys (hash, metadata)
  Cond-->>Op: { id, token }  # one-time plaintext
  Op->>Client: deliver bearer
  Client->>GW: Authorization: Bearer ao_...
  GW->>GW: HMAC-SHA256(pepper, token)
  GW->>Pg: SELECT agent_api_keys WHERE key_hash = ...
  Pg-->>GW: row + revoked_at NULL check
  GW->>GW: enforce tenant_id, published_id, rate limit
```

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:

* **Revoke** — `DELETE /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.

```mermaid theme={null}
sequenceDiagram
  participant Client
  participant GW as Chat Gateway
  participant Pg as Postgres
  participant Cond as Conductor (INTERNAL)
  participant Worker

  Client->>GW: POST /v1/chat/{t}/{a} { message }
  GW->>Pg: INSERT conversations (session_id mints next)
  GW->>Cond: POST /api/sessions { profile }
  Cond-->>GW: { sessionId }
  GW->>Pg: UPDATE conversations SET session_id
  GW->>Pg: INSERT conversation_messages (user)
  GW->>Cond: POST /api/runs { sessionId, input }
  Cond->>Worker: stream
  Worker-->>Cond: events
  GW->>Pg: INSERT conversation_messages (assistant) + UPDATE gateway_runs
  GW-->>Client: { conversation_id, public_run_id, message }

  Note over Client,GW: subsequent turns
  Client->>GW: POST /v1/chat/{t}/{a} { message, conversation_id }
  GW->>Pg: SELECT conversations WHERE id = conv_...
  GW->>Cond: POST /api/runs { sessionId reused }
```

### 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 isolation** — `published_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.

```mermaid theme={null}
flowchart LR
  Sync[handleSyncChat]
  Stream[handleStreamChat]
  Resume[handleResumeStream]
  W[watcher goroutine]
  Pg[(Postgres)]
  IRun[conductor /api/runs stream]

  IRun --> W
  W -->|done or error| Pg
  Sync -. subscribe .-> W
  Stream -. subscribe .-> W
  Resume -. subscribe .-> W
```

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:

```sql theme={null}
SELECT * FROM gateway_runs
WHERE status IN ('dispatching', 'running')
  AND created_at > now() - interval '24 hours';
```

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.

| Field                    | Default                  | Effect                                                        |
| ------------------------ | ------------------------ | ------------------------------------------------------------- |
| `slug`                   | kebab-cased profile name | URL path; immutable after publish                             |
| `visibility`             | `private`                | Informational today; reserved for future allowlists           |
| `authMode`               | `api_key`                | `api_key` or `jwt` (the latter is reserved)                   |
| `allowedOrigins`         | `[]`                     | When non-empty, browsers must send a matching `Origin` header |
| `rateLimitRpm`           | `60`                     | Per-key sliding-window limit; `0` disables limiting           |
| `syncMaxDurationSeconds` | `60`                     | Upper bound on the sync chat endpoint; bounded `1..270`       |
| `conversationTtlDays`    | `null`                   | Conversation retention; `null` keeps history forever          |
| `exposeToolEvents`       | `false`                  | When true, stream emits `tool` SSE frames                     |
| `enabled`                | `true`                   | Hard kill switch independent of unpublish                     |

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:

```
POST   /api/sessions
POST   /api/runs
GET    /api/runs/{id}
GET    /api/runs/{id}/stream
```

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

| Symptom                     | Likely cause                                                                                          |
| --------------------------- | ----------------------------------------------------------------------------------------------------- |
| `401 unauthorized`          | Token malformed, revoked, wrong tenant, or pepper rotated                                             |
| `404 not_found` (auth path) | Slug never published, unpublished, or `enabled=false`                                                 |
| `403 origin_not_allowed`    | Browser sent an `Origin` not on `allowedOrigins`                                                      |
| `429 rate_limited`          | Per-key sliding window exhausted; `Retry-After: 60`                                                   |
| `502 upstream`              | Conductor internal listener returned non-2xx; check signing key parity                                |
| `504 sync_timeout`          | Run did not finish within `syncMaxDurationSeconds`; resume via `runs/{id}`                            |
| `410 gone`                  | Public run id older than 24 h and watcher never re-attached                                           |
| Gateway refuses to start    | `CONDUCTOR_INTERNAL_URL` is a `*.railway.app` host, or the Postgres role bypasses RLS                 |
| Conductor refuses to start  | `INTERNAL_PORT` set but `CHAT_GATEWAY_SIGNING_KEY_CURRENT` missing, or the Postgres role bypasses RLS |

See [Publishing an Agent](/guides/publishing) for the operator walkthrough, or the [Chat Gateway API](/api-reference/chat-gateway) and [Conductor publish endpoints](/api-reference/conductor#publishing-profiles) for wire-level reference.
