> ## 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 an Agent

> End-to-end walkthrough: configure the chat-gateway, publish a profile, mint an API key, hit the public endpoint, and operate the surface.

## Overview

Publishing exposes one conductor profile over a public HTTPS surface served by the separate `chat-gateway` binary. This guide walks operators through the full lifecycle: configuration, publish, key issuance, calling the surface, rotation, and revoke. See [Publishing](/concepts/publishing) for the conceptual model.

***

## Prerequisites

* A running conductor with Postgres and Redis wired up
* A non-superuser Postgres application role with `NOBYPASSRLS`
* A profile that already runs cleanly under conductor (`POST /api/runs` works)
* DNS for the public chat host pointed at the chat-gateway service (for example `agents.example.com`)
* Two base64 secrets minted ahead of time:
  * **Signing key** — shared HMAC secret between gateway and conductor
  * **API key pepper** — HMAC pepper used to hash minted bearers

Generate both with:

```bash theme={null}
openssl rand -base64 32   # signing key
openssl rand -base64 32   # API key pepper
```

***

## Step 1 — Configure Conductor for the Internal Listener

The conductor now serves two listeners: the public dashboard port and a private `INTERNAL_PORT` that only the chat gateway should reach.

| Variable                            | Required    | Description                                                        |
| ----------------------------------- | ----------- | ------------------------------------------------------------------ |
| `INTERNAL_PORT`                     | Yes         | Private listener port; only routes used by the gateway are mounted |
| `CHAT_GATEWAY_SIGNING_KEY_CURRENT`  | Yes         | Base64 HMAC secret; conductor verifies every internal request      |
| `CHAT_GATEWAY_SIGNING_KEY_PREVIOUS` | No          | Previous key during rotation; accepted in parallel                 |
| `AGENT_API_KEY_PEPPER`              | Yes         | Base64 HMAC pepper; hashes minted public tokens                    |
| `CHAT_GATEWAY_PUBLIC_HOST`          | Yes         | Externally-advertised gateway host; appears in `publicUrl`         |
| `REDIS_URL`                         | Recommended | Backs the nonce-replay store; falls back to in-memory when unset   |

The conductor refuses to start when `INTERNAL_PORT` is set but `CHAT_GATEWAY_SIGNING_KEY_CURRENT` is missing — that combination would silently accept unsigned internal traffic.

### Postgres Role Requirement

When Postgres is configured, the conductor refuses to boot if the connecting role is a `SUPERUSER` or has `BYPASSRLS`. Tenanted tables rely on row-level security policies scoped by the per-transaction `app.tenant_id` setting, so a privileged role would silently read rows across tenants. Railway's default `postgres` user is a superuser; create and use an application role instead.

```sql theme={null}
create role app_conductor login password '<strong-random>' nobypassrls;
grant connect on database <dbname> to app_conductor;
grant usage, create on schema public to app_conductor;
grant select, insert, update, delete on all tables in schema public to app_conductor;
grant usage, select on all sequences in schema public to app_conductor;
alter default privileges in schema public
  grant select, insert, update, delete on tables to app_conductor;
alter default privileges in schema public
  grant usage, select on sequences to app_conductor;
```

Point `POSTGRES_DSN` or the `POSTGRES_USER` / `POSTGRES_PASSWORD` component variables at that role. The `CREATE` privilege is required because the conductor applies embedded schema migrations on boot.

***

## Step 2 — Deploy the Chat Gateway

The gateway is a standalone binary (`cmd/chat-gateway`). The reference Dockerfile is at `docker/Dockerfile.chat-gateway`; the Railway service config is at `docker/railway-chat-gateway.toml`.

| Variable                            | Required    | Description                                                                              |
| ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------- |
| `PORT`                              | No          | Public listen port; defaults to `8090`                                                   |
| `CONDUCTOR_INTERNAL_URL`            | Yes         | Private conductor URL; `*.railway.app` public URLs are rejected at boot                  |
| `POSTGRES_URL` (or `PG*`)           | Yes         | Same Postgres cluster as conductor, using a non-superuser `NOBYPASSRLS` application role |
| `REDIS_URL`                         | Recommended | Nonce store and rate-limit counters                                                      |
| `CHAT_GATEWAY_SIGNING_KEY_CURRENT`  | Yes         | Must match the conductor's current key                                                   |
| `CHAT_GATEWAY_SIGNING_KEY_PREVIOUS` | No          | Match the conductor's previous key during rotation                                       |
| `AGENT_API_KEY_PEPPER`              | Yes         | Same pepper as the conductor                                                             |
| `CHAT_GATEWAY_PUBLIC_HOST`          | No          | Externally-advertised host; logged on boot                                               |
| `GATEWAY_RATE_LIMIT_BACKEND`        | No          | `redis` (default) or `memory`                                                            |
| `GATEWAY_SHUTDOWN_GRACE`            | No          | Graceful shutdown timeout; defaults to `25s`                                             |

The gateway also refuses a Postgres role that is a `SUPERUSER` or has `BYPASSRLS`, for the same tenant-isolation reason as the conductor. The boot log line `chat-gateway.ready` confirms `conductor_internal_url`, `rate_limit_backend`, and whether a previous signing key is present.

***

## Step 3 — Publish a Profile

Publishing is a conductor API call against the **public** port. The dashboard "Publish" surface wraps the same endpoint; the curl examples below match it byte-for-byte.

```bash theme={null}
curl -X POST https://conductor.example.com/api/profiles/general/publish \
  -H 'Authorization: Bearer ao_...' \
  -H 'Content-Type: application/json' \
  -d '{
        "slug": "general",
        "allowedOrigins": ["https://example.com"],
        "rateLimitRpm": 60,
        "syncMaxDurationSeconds": 90,
        "exposeToolEvents": false
      }'
```

The response includes the new `pub_...` row plus the `publicUrl` derived from `CHAT_GATEWAY_PUBLIC_HOST`:

```json theme={null}
{
  "id": "pub_abcd1234",
  "tenantId": "default",
  "profileName": "general",
  "slug": "general",
  "visibility": "private",
  "authMode": "api_key",
  "allowedOrigins": ["https://example.com"],
  "rateLimitRpm": 60,
  "syncMaxDurationSeconds": 90,
  "exposeToolEvents": false,
  "enabled": true,
  "publicUrl": "https://agents.example.com/v1/chat/default/general"
}
```

<Note>
  Slugs are immutable. To change a slug, unpublish and republish.
</Note>

***

## Step 4 — Mint an API Key

API keys are HMAC-hashed with the pepper; the plaintext token is returned **exactly once** and never recoverable. See [API Keys](/concepts/api-keys) for the auth model.

```bash theme={null}
curl -X POST https://conductor.example.com/api/profiles/general/keys \
  -H 'Authorization: Bearer ao_...' \
  -H 'Content-Type: application/json' \
  -d '{"label": "production website"}'
```

```json theme={null}
{
  "id": "key_abcd1234",
  "publishedId": "pub_abcd1234",
  "label": "production website",
  "createdAt": "2026-05-15T10:00:00Z",
  "token": "ao_prod_abcd...22chars..."
}
```

Persist the `token` immediately. Re-fetching the keys list returns metadata only.

***

## Step 5 — Call the Public Endpoint

### Sync Chat

```bash theme={null}
curl -X POST https://agents.example.com/v1/chat/default/general \
  -H 'Authorization: Bearer ao_prod_abcd...' \
  -H 'Content-Type: application/json' \
  -d '{"message": "Hello"}'
```

```json theme={null}
{
  "conversation_id": "conv_abcd1234",
  "message": "Hi there.",
  "public_run_id": "prun_abcd1234"
}
```

### Streaming Chat

```bash theme={null}
curl -N -X POST https://agents.example.com/v1/chat/default/general/stream \
  -H 'Authorization: Bearer ao_prod_abcd...' \
  -H 'Content-Type: application/json' \
  -d '{"message": "Stream the answer"}'
```

```
event: delta
data: {"text":"Hi"}

event: delta
data: {"text":" there."}

event: done
data: {"conversation_id":"conv_abcd1234","message":"Hi there.","public_run_id":"prun_abcd1234"}
```

### Continuing a Conversation

Thread the `conversation_id` from any prior response back in the body. The gateway reuses the same runtime session under the hood.

```bash theme={null}
curl -X POST https://agents.example.com/v1/chat/default/general \
  -H 'Authorization: Bearer ao_prod_abcd...' \
  -H 'Content-Type: application/json' \
  -d '{
        "message": "What is my name?",
        "conversation_id": "conv_abcd1234"
      }'
```

### Resume an In-Flight Run

```bash theme={null}
curl -N "https://agents.example.com/v1/chat/default/general/runs/prun_abcd1234/stream" \
  -H 'Authorization: Bearer ao_prod_abcd...'
```

`runs/{id}` returns `202 Accepted` while the run is still dispatching or running, then the terminal payload once durable.

***

## Step 6 — Update or Disable a Published Agent

`PATCH /api/profiles/{name}/published` accepts any subset of the published knobs plus `enabled`. Operators can toggle tool-event streaming on an already-published agent with `exposeToolEvents`. Setting `enabled: false` is a fast kill switch that survives without losing history.

```bash theme={null}
curl -X PATCH https://conductor.example.com/api/profiles/general/published \
  -H 'Authorization: Bearer ao_...' \
  -H 'Content-Type: application/json' \
  -d '{"rateLimitRpm": 30, "exposeToolEvents": true, "enabled": true}'
```

Full unpublish is `DELETE /api/profiles/{name}/published`. The row is soft-deleted with `unpublished_at` so the slug becomes immediately reusable for a different profile.

***

## Step 7 — Revoke or Rotate Keys

### Revoke One Key

```bash theme={null}
curl -X DELETE https://conductor.example.com/api/profiles/general/keys/key_abcd1234 \
  -H 'Authorization: Bearer ao_...'
```

The gateway returns `401 unauthorized` on the very next request that uses the revoked bearer.

### Rotate the Bearer Without Downtime

1. Issue a new key
2. Roll the new token out to every client
3. Revoke the old key

The two keys are accepted in parallel; revoke only after every client carries the new bearer.

***

## Rotating the Signing Key

The gateway-to-conductor HMAC is a shared secret. Rotate by overlapping the previous and current keys on both sides.

1. Generate a new base64 key
2. Set `CHAT_GATEWAY_SIGNING_KEY_PREVIOUS` to the current key on conductor and gateway; set `CHAT_GATEWAY_SIGNING_KEY_CURRENT` to the new value
3. Roll restart both services
4. After at least one nonce window (60 s), remove `CHAT_GATEWAY_SIGNING_KEY_PREVIOUS` and roll restart again

Mismatched keys produce `502 upstream` at the gateway and `signature_invalid` logs on the conductor's internal listener.

***

## Rotating the API Key Pepper

The pepper is the HMAC key over every minted bearer hash. Rotating it invalidates every existing key — treat it as a security incident:

1. Issue new keys to every client first
2. Update the pepper on conductor and gateway
3. Revoke the old keys

There is no overlap window for the pepper. A future enhancement may add `AGENT_API_KEY_PEPPER_PREVIOUS`.

***

## Observability

The chat gateway emits structured JSON logs with `service=chat-gateway`. Key events:

| Log                         | Meaning                                                                                               |
| --------------------------- | ----------------------------------------------------------------------------------------------------- |
| `chat-gateway.ready`        | Boot complete; includes `conductor_internal_url` and `rate_limit_backend`                             |
| `auth.lookup_failed`        | Postgres lookup error during key validation                                                           |
| `chat.resolve_conv_failed`  | `conversation_id` missing or cross-agent                                                              |
| `chat.start_watcher_failed` | Conductor internal `POST /api/runs` returned non-2xx                                                  |
| `ingress.flush_failed`      | A gateway ingress-meter flush failed; the affected interval's request count is dropped                |
| `crash.recovery.sweep`      | Counts orphan `gateway_runs` rows swept on boot; rows without an internal run are finalized as `lost` |

Accepted public chat requests are also flushed into `usage_records` as the `ingress` meter. Operators can read the tenant-wide total and top published agents from `GET /api/usage`, or the scoped agent-detail series from `GET /api/profiles/{name}/metrics`.

Prometheus metrics are exposed on `/metrics`:

| Metric                                         | Type      | Description                              |
| ---------------------------------------------- | --------- | ---------------------------------------- |
| `chat_gateway_requests_total{route,status}`    | Counter   | Public requests by route and HTTP status |
| `chat_gateway_request_duration_seconds{route}` | Histogram | End-to-end request duration              |
| `chat_gateway_watcher_active`                  | Gauge     | Watchers currently in-flight             |
| `chat_gateway_run_terminal_total{status}`      | Counter   | Terminal `done` and `error` events       |

***

## Common Smoke Test

After deploy, this five-command sequence proves the surface end-to-end:

```bash theme={null}
# 1. Healthcheck
curl -s https://agents.example.com/healthz

# 2. Publish
curl -s -X POST https://conductor.example.com/api/profiles/general/publish \
  -H 'Authorization: Bearer ao_...' -H 'Content-Type: application/json' -d '{}'

# 3. Mint a key
TOKEN=$(curl -s -X POST https://conductor.example.com/api/profiles/general/keys \
  -H 'Authorization: Bearer ao_...' -H 'Content-Type: application/json' \
  -d '{"label":"smoke"}' | jq -r .token)

# 4. Sync chat
curl -s -X POST https://agents.example.com/v1/chat/default/general \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"message":"hello"}'

# 5. Stream chat
curl -N -X POST https://agents.example.com/v1/chat/default/general/stream \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"message":"stream it"}'
```

See [Chat Gateway API](/api-reference/chat-gateway) for full request and response schemas, and [Conductor publish endpoints](/api-reference/conductor#publishing-profiles) for every conductor route this guide touches.
