Self-hosting

The agents backend is a single Go binary. Run it in your own cloud, point the SDKs at it, and nothing about an agent changes: the same agent.yaml syncs to it, the same sessions open on it, the same client SDKs talk to it. This page covers the YAML it reads, how callers prove who they are, and how to move the data you already have into it without a window where writes are lost.

What you run

Piece Why Without it
The router (acceleration/cmd/router) Serves the API, routes models, holds sessions Nothing
Postgres Agents, conversations, keys, usage The router routes but records nothing, and /health says so
Redis Live provider health and the daily limits Routing ignores which providers are struggling
An object bucket Recordings behind a voice of your own Voices of your own are unavailable

Provider credentials stay in the environment, one variable per provider, and a provider whose credential is missing is skipped rather than fatal. So a router with only OPENAI_API_KEY set routes to OpenAI and to nothing else.

The quickest way to see all of it running is the compose file in the repository, which starts Postgres, Redis, the router and the dashboard together:

git clone https://github.com/GetStream/Vision-Agents
cd Vision-Agents
docker compose up --build

On your own machines, the binary is enough. It runs its own migrations on start, so there is no separate step before the first boot:

go build -o router ./acceleration/cmd/router
./router --config /etc/router.yaml

Your YAML

Everything that is not a provider credential is one YAML file, and --config says which:

/etc/router.yaml
addr: ":8080"
# Where callers reach you, which behind a load balancer is not where you listen.
# Telephony vendors fetch a call plan from it on answer.
public_url: https://agents.example.com
log_level: info

postgres:
  dsn: postgres://agents:secret@db.internal:5432/agents?sslmode=require

redis:
  addr: cache.internal:6379
  username: agents
  password: secret

auth:
  mode: api_key
  # Unseals the stored key secrets. Keep it out of this file: pass it as
  # ROUTER_AUTH_KEK, from whatever holds your secrets.
  kek: ""

# Browser origins allowed to call the API and open sockets. Empty means none,
# which is right unless a browser app talks to this deployment directly.
cors_origins:
  - https://app.example.com
# CIDR ranges your own proxies sit in. Decides how much of X-Forwarded-For is
# believed. Empty means none of it is.
trusted_proxies:
  - 10.0.0.0/8

# The default for what one end user may spend in a UTC day. An app sets its
# own with `quota` in its policy. A 0 is no default cap on that half, and an
# app's own `quota` still applies.
rate_limit:
  messages_per_day: 200
  tokens_per_day: 5000000

# How long recorded changes are kept while somebody is moving their data here.
data_move:
  retention: 168h

# The Stream app whose secret signs call events and the tokens a browser joins
# a call with. Only needed for voice on Stream Video.
stream:
  api_key: your_stream_key
  api_secret: your_stream_secret

# Paths to capability files. Empty means the catalogue built into the binary.
routing_config: ""
phone_config: ""
voices_bucket_url: s3://voices?region=eu-west-1

rate_limit is only the default. An app sets its own with quota: see Daily limits.

postgres.dsn and redis.addr are the two a self-hosted deployment always sets. An empty postgres.dsn is a router that records nothing, which is fine for a smoke test and not for anything else.

Three ways to choose the file, in the order they win:

How What it does
--config /etc/router.yaml The file this deployment runs on
ROUTER_CONFIG_FILE=/etc/router.yaml The same thing, for a chart that sets environment and not arguments
ROUTER_ENV=local|testing|staging One of the files built into the binary, when you name no file of your own

Saying nothing at all picks local, which points at the Postgres and Redis that the compose file starts.

Every setting also has the environment variable it has always had, and the variable wins over the file. ROUTER_POSTGRES_DSN, ROUTER_REDIS_ADDR and ROUTER_AUTH_KEK are the three worth using that way, because a DSN and a key encryption key are secrets and a file checked in beside your charts is not where a secret belongs.

Authentication

auth.mode picks between four deployments:

Mode Who a caller is Use it when
api_key An API key, plus a token signed with the secret behind it. The default The router is reachable by anything you did not write
proxy Whatever the thing in front of the router already decided, read from headers it overwrites You have a gateway that authenticates
noauth The customer named in X-Customer-Id, taken at face value, and every caller is that customer's backend A laptop
custom An authenticator you supply in code You have an answer of your own

The default verifies, on purpose. A default that trusts whatever reaches it is only correct when something else guarantees that nothing does, and that guarantee is a NetworkPolicy in one deployment and nothing at all in the next. api_key refuses to start without a database and auth.kek, rather than refusing every request later.

Warning:

noauth means the tenant is whatever the caller writes in a header. Anything that can open a TCP connection to the router is every customer on it. Keep it on a laptop.

The first credential

api_key mode cannot bootstrap itself over HTTP: every endpoint wants a key and there is nobody to issue the first one. A command run beside the database can:

ROUTER_AUTH_KEK=$KEK ./router --config /etc/router.yaml keys create \
  --org "Acme" --app-name "Acme production" --key-name default
organization  01JC...
app           01JC...
key           vak_live_7f2b...
secret        vas_live_9a41...

The secret is shown once. Set it where your SDK reads it and keep it out of the router's own config.

The secret is printed and never logged or shown again. The app id it prints is the tenant: it is what every row is keyed by. If you are moving off another deployment, pass the app id you had there with --app-id, so the rows you import still belong to something:

./router keys create --app-id 1234567 --key-name moved

Running it again for an app that exists adds a second key rather than refusing, which is how you rotate.

What a device gets

A device never holds the secret. Your backend mints a token for the user, the device sends the key and that token, and the router marks it as a device: it may open and hold its own conversations, and everything that configures an agent answers it with a 403. That split is the same self-hosted as hosted.

Point your SDK at it

Server-side SDKs take the router's URL and a key and secret. Use the ones keys create printed, not your Stream app's.

export STREAM_ACCELERATION_URL=https://agents.example.com
export STREAM_API_KEY=vak_live_7f2b...
export STREAM_API_SECRET=vas_live_9a41...

Leave STREAM_ACCELERATION_AUTHENTICATE unset. It says the router is behind Stream's own authenticating proxy, which a deployment of yours is not.

The Go SDK is the exception: it reads these variables only for the proxy, and names a tenant with STREAM_ACCELERATION_CUSTOMER_ID otherwise.

Client SDKs only need the URL and the credentials your backend hands down. None of them holds a secret.

let agents = VisionAgents(apiKey: "vak_live_7f2b...", url: URL(string: "https://agents.example.com")!)
agents.setUser(User(id: "alice"), token: tokenFromYourBackend)

Move your data here

Three endpoints move a tenant between two deployments, and all three are server-side only.

Endpoint What it does
GET /v1/data/export Everything the calling app has, as one JSON object per line, ending with the cursor its changes carry on from
POST /v1/data/import Writes an export, or a batch of changes, into this deployment
GET /v1/data/changes?after=<cursor> What has happened to the app's rows since that cursor

The customer is always the caller. Nothing in the request body or the query string can name a different one, so an export is your own data and an import lands under your own app however the file was edited on the way.

An export deliberately does not carry:

  • API key secrets. Key rows are not exported at all. Mint new ones on the new deployment with keys create.
  • OAuth tokens for connected plugins: access tokens, refresh tokens and the state behind a login in progress. A connection comes across and has to be authorized again.
  • Organizations and apps. Those are the account, and the new deployment has its own. keys create --app-id is what puts the rows under the right one.

Everything else comes across: agent configs, router configs, conversations and their turns, knowledge, campaigns, phone numbers, usage. Voice recordings live in the object bucket rather than in Postgres, so the rows come across and copying the bucket is yours to do.

Switch over with no downtime

router replicate does both halves: the copy, and everything that happens after the copy. Run it on the new deployment, pointed at the old one.

  1. Create the app and a key on the new deployment, reusing the app id you have today:

    ./router --config /etc/router.yaml keys create --app-id 1234567 --key-name moved
  2. Start replicating. The copy is read at one moment; the changes carry on from exactly where it stopped:

    ./router --config /etc/router.yaml replicate \
      --from https://accelerate-us-east4.gcp.stream-io-api.com \
      --api-key vak_live_7f2b... --api-secret vas_live_9a41...

    It prints caught up at cursor N once it is following live, and keeps following until you stop it.

  3. Point your SDKs at the new URL and deploy. Writes that were in flight against the old deployment are still being replayed.

  4. Run it once more with --once, which stops as soon as there is nothing left to catch up on, and you are moved.

Applying the same change twice lands where applying it once would, so a replay that overlaps the switchover is not a problem. The one thing that does not wait is data_move.retention, seven days by default: a move that takes longer than that starts again from a fresh export rather than resuming from a cursor the old deployment no longer honours, and asking for one returns 410.

Recording changes costs nothing until somebody is moving. The triggers return early unless an export has started, and they stop again on their own when nobody has read a page for the retention window.

Doing it by hand

The endpoints are plain HTTP, so a move is two curl commands if you would rather:

curl -sS https://old.example.com/v1/data/export \
  -H "X-Api-Key: $OLD_KEY" -H "Stream-Auth-Type: server" \
  -H "Authorization: Bearer $OLD_SERVER_TOKEN" > export.ndjson

curl -sS -X POST https://agents.example.com/v1/data/import \
  -H "X-Api-Key: $NEW_KEY" -H "Stream-Auth-Type: server" \
  -H "Authorization: Bearer $NEW_SERVER_TOKEN" \
  --data-binary @export.ndjson

The server token is an HS256 JWT signed with the key's secret, carrying server: true, no user_id, and an expiry. The last line of the export names the cursor: if it is missing, the stream was cut short and the file is not a complete export.

Who may move data

The three endpoints refuse three kinds of caller, and the refusals are worth knowing before you wonder why a request failed:

  • A caller nobody authenticated gets a 401. There is no customer, so there is no data.
  • A device gets a 403. A token your backend minted for a user lets them hold a conversation, not walk off with the account.
  • Every caller gets a 403 while the router runs in noauth mode. There the customer is whatever the caller says it is, so serving an export would mean handing anybody anybody's data for the price of naming them.

Next steps

  • Architecture: what the backend is doing with all this.
  • Quickstart: an agent answering against it.
  • The router README documents every provider credential and every setting in depth.