Skip to main content

Getting started

This page covers three things: running Converger with docker compose (the quickest way to try it), setting up a local development environment on Linux, macOS or Windows, and sending a first message through the REST API to watch it get delivered.

Option A: docker compose​

docker-compose.yml starts Postgres, a one-shot migration step, the app and a local observability stack (Prometheus, Grafana, Loki, Jaeger, an OpenTelemetry collector). No secret is committed to the repository. Compose reads them from a .env file next to docker-compose.yml and refuses to start while a required one is missing.

1. Create .env with generated secrets​

VariableRequiredHow to generateUsed by
POSTGRES_PASSWORDyesopenssl rand -hex 24 (hex keeps it URL-safe, it is embedded in DATABASE_URL)db, migrate, app
SECRET_KEY_BASEyesmix phx.gen.secret or openssl rand -base64 64 | tr -d '\n' (at least 64 bytes)migrate, app
CLOAK_KEYyesopenssl rand -base64 32 (base64 of exactly 32 bytes)migrate, app (encrypts channel secrets and configs at rest)
GF_SECURITY_ADMIN_PASSWORDyesopenssl rand -hex 16grafana
PHX_HOSTno, default localhostpublic host name for generated URLs and the default WebSocket origin checkapp
FORCE_SSLno, default false in composeset true only behind a TLS-terminating proxy (together with TRUSTED_PROXIES)app
TRUSTED_PROXIESnocomma-separated proxy IPs/CIDRsapp

On Linux, macOS, or Git Bash on Windows (which ships openssl):

cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
SECRET_KEY_BASE=$(openssl rand -base64 64 | tr -d '\n')
CLOAK_KEY=$(openssl rand -base64 32)
GF_SECURITY_ADMIN_PASSWORD=$(openssl rand -hex 16)
EOF

In Windows PowerShell without openssl:

function New-Secret([int]$bytes) {
$b = New-Object byte[] $bytes
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b)
return $b
}
$hex = { param($n) -join ((New-Secret $n) | ForEach-Object { $_.ToString("x2") }) }
@(
"POSTGRES_PASSWORD=$(& $hex 24)"
"SECRET_KEY_BASE=$([Convert]::ToBase64String((New-Secret 64)))"
"CLOAK_KEY=$([Convert]::ToBase64String((New-Secret 32)))"
"GF_SECURITY_ADMIN_PASSWORD=$(& $hex 16)"
) | Set-Content -Encoding ascii .env

You can also cp .env.example .env and fill in the values by hand. .env is gitignored and excluded from the Docker build context. Never commit it.

warning

The release refuses to boot with a SECRET_KEY_BASE shorter than 64 bytes, and with the SECRET_KEY_BASE or CLOAK_KEY values that were once published in this repository (security). Back up CLOAK_KEY together with the database: without it, channel secrets cannot be decrypted (ADR-0012).

POSTGRES_PASSWORD only takes effect when the postgres_data volume is first initialized. If you already have that volume from an older checkout, either change the password inside Postgres (ALTER USER postgres PASSWORD '...') or remove the volume (docker compose down -v).

2. Allow your browser to reach /admin​

The admin panel is protected by an IP allowlist, ADMIN_IP_WHITELIST, which defaults to 127.0.0.1,::1. Inside the container, requests from your host's browser arrive from a Docker network address rather than from 127.0.0.1, so /admin will most likely answer 403 Forbidden. docker-compose.yml does not pass ADMIN_IP_WHITELIST through. For a local, non-public setup, add a docker-compose.override.yml (compose merges it automatically):

# docker-compose.override.yml - local use only
services:
app:
environment:
ADMIN_IP_WHITELIST: "127.0.0.1,::1,172.16.0.0/12,192.168.0.0/16"

The allowlist accepts single addresses and CIDR ranges, IPv4 and IPv6. Behind a reverse proxy, set TRUSTED_PROXIES instead, so that the real client IP is used (ADR-0011, security). The tenant portal (/portal) has no IP allowlist.

3. Start the stack​

docker compose up -d --build
docker compose ps

Start-up order:

  1. db (Postgres 17) becomes healthy (pg_isready).
  2. migrate runs /app/bin/migrate once with CREATE_DB=true. It creates the converger_prod database and applies all migrations under a Postgres advisory lock, so concurrent runs apply each migration exactly once. Then it exits.
  3. app starts only after migrate has completed successfully, and runs /app/bin/server (PHX_SERVER=true bin/converger start). The image's default command never runs migrations (ADR-0022, deployment).
ServiceURL / port
Converger (API, /admin, /portal, WebSockets)http://localhost:4000
Prometheus metrics of the app (needs METRICS_TOKEN in .env, sent as a bearer token)http://localhost:4000/metrics
Health probeshttp://localhost:4000/health/live, http://localhost:4000/health/ready
Prometheushttp://localhost:9090
Grafana (user admin, password GF_SECURITY_ADMIN_PASSWORD)http://localhost:3000
Jaeger UI (traces)http://localhost:16686
Postgres (loopback only)127.0.0.1:5432

Check the migration output with docker compose logs migrate, and the app with docker compose logs -f app. The production log format is JSON.

To apply new migrations after pulling a newer version, run docker compose up -d --build again. The migrate service runs before the new app container starts.

4. Create the first admin account​

There is no built-in admin password. Create the first super_admin with the release task Converger.Release.seed_admin/0:

docker compose exec \
-e ADMIN_EMAIL=[email protected] \
-e ADMIN_PASSWORD='choose-a-long-password' \
app /app/bin/converger eval "Converger.Release.seed_admin()"
VariableDefaultBehavior
ADMIN_EMAIL[email protected]Email of the account.
ADMIN_PASSWORDgeneratedAt least 12 characters (PASSWORD_MIN_LENGTH), not containing the email. When unset, a random password is printed once. The account is then flagged must_change_password, and after the first login you are sent to /admin/password until you change it. A super_admin is then sent to /admin/security to set up TOTP two-factor authentication (required for the roles in ADMIN_REQUIRE_2FA_ROLES).

The task does nothing if an admin user already exists, and prints Admin users already exist; nothing to seed.

5. Log in​

Open http://localhost:4000/admin/login and sign in with the email and password. Failed logins are rate limited to 5 per minute per IP and per account. The admin panel has pages for tenants, channels, conversations, routing rules, admin users, tenant users and audit logs, plus the Oban dashboard at /admin/oban. See Admin panel and tenant portal for every page and the light/dark theme toggle.

Option B: local development​

Toolchain​

.tool-versions pins the versions used in CI and in the Dockerfile:

elixir 1.19.5-otp-28
erlang 28.5.0.5

With asdf or mise, run asdf install or mise install in the repository root. mix.exs requires Elixir ~> 1.18. Other Elixir 1.18+/OTP 27+ combinations may work, but only the pinned versions are tested.

You also need:

  • PostgreSQL (17 is what compose and CI use; at least 11 is needed for the built-in sha256() used by a migration). The first migration creates the uuid-ossp and pgcrypto extensions, so the role needs the rights to do that (the default postgres superuser has them).
  • A C compiler and make, because bcrypt_elixir (admin and portal password hashing) is a NIF compiled from C. On Windows you can skip it with CONVERGER_PASSWORD_HASHER=pbkdf2. See the per-OS notes below.

The dev container bundles all of this (toolchain, C tools, Postgres) for any OS.

  • Node.js is not needed. There is no asset pipeline. The admin and portal layouts load Phoenix and LiveView from jsDelivr.

Per-OS notes​

Linux (Debian/Ubuntu shown):

sudo apt-get install -y build-essential git

macOS:

xcode-select --install

Windows: bcrypt_elixir builds its NIF with MSVC and nmake. Without them, mix compile stops with ** (Mix) "nmake" not found in the path. (issue #53). Pick one of three ways:

No C compiler (pure-Elixir hashing). Set CONVERGER_PASSWORD_HASHER=pbkdf2 in every shell you run mix from (or once, as a user environment variable):

$env:CONVERGER_PASSWORD_HASHER = "pbkdf2"
mix setup
mix test

mix.exs then leaves bcrypt_elixir out, and admin and tenant passwords are hashed with PBKDF2-SHA512 (pbkdf2_elixir). Everything else is the same; the whole test suite passes in this mode. A database seeded from a bcrypt build has bcrypt hashes that this build cannot verify, so log-ins fail with a warning in the log: run mix ecto.reset or reset the password. Production builds (the Dockerfile) keep bcrypt (ADR-0057). Kafka channels do not run natively on Windows in either mode (their crc32cer NIF is not built, see broker channels).

Dev container. Open the repository in the dev container (Docker Desktop with the WSL 2 backend). It has the Linux toolchain and Postgres, and nothing has to be installed on Windows besides Docker and VS Code.

MSVC. To build bcrypt natively:

  1. Install Visual Studio Build Tools and select the "Desktop development with C++" workload (MSVC compiler and Windows SDK).
  2. Run every mix command from the "x64 Native Tools Command Prompt for VS" (or a Developer PowerShell set to x64), so that cl.exe and nmake are on PATH. A plain PowerShell or Git Bash window does not have them.
  3. If bcrypt_elixir was already compiled in the wrong shell, rebuild it there with mix deps.compile bcrypt_elixir --force.

WSL2 with the Linux instructions also works.

Postgres for development​

The dev and test configs (config/dev.exs, config/test.exs) connect with these defaults:

VariableDefault
DB_USERNAMEpostgres
DB_PASSWORDpostgres
DB_HOSTNAMElocalhost
DB_NAMEconverger_dev (dev), converger_test + MIX_TEST_PARTITION (test)

The simplest way to match them is a throwaway container:

docker run --name converger-pg -e POSTGRES_PASSWORD=postgres -p 127.0.0.1:5432:5432 -d postgres:17-alpine

To use the compose db service instead, run docker compose up -d db and set DB_PASSWORD to the POSTGRES_PASSWORD from your .env. Compose still validates every required variable in .env, even when you start only db.

Set up, run, test​

mix setup # deps.get + ecto.create + ecto.migrate + priv/repo/seeds.exs
mix phx.server # http://localhost:4000 (or: iex -S mix phx.server)

mix setup runs priv/repo/seeds.exs, which creates the first super_admin exactly like the release task. Set ADMIN_EMAIL and ADMIN_PASSWORD before running it, or copy the one-time password it prints. In PowerShell, set them with $env:ADMIN_PASSWORD = "...".

In development:

  • The endpoint binds to 127.0.0.1:4000 (PORT overrides the port), so the default admin allowlist (127.0.0.1,::1) already matches your browser.
  • CLOAK_KEY is not needed. config/dev.exs uses a fixed development-only key.
  • Webhook channels may target localhost and 127.0.0.0/8. The SSRF guard blocks private targets everywhere else (ADR-0014).
  • Traces are not exported unless you set OTEL_EXPORTER_OTLP_ENDPOINT (for example http://localhost:4318 with the compose collector running).

Tests and the pre-commit gate:

mix test # creates and migrates converger_test, then runs the suite
mix test test/path/to_test.exs
mix test --failed
mix precommit # run before every PR
mix dialyzer # slow, separate from precommit

mix precommit runs in the test environment and mirrors the CI lint and test jobs. It fixes formatting and unused lock entries instead of only checking them:

  1. compile --warnings-as-errors --force
  2. deps.unlock --unused
  3. format
  4. credo --strict
  5. sobelow --config
  6. hex.audit
  7. deps.audit
  8. test --warnings-as-errors

See ADR-0021 for the CI gates and the LF line-ending rule. On Windows, .gitattributes (* text=auto eol=lf) checks files out with LF even when core.autocrlf=true, so mix format gives the same result as on Linux CI.

Your first message, end to end​

This walkthrough uses an echo channel. Echo is an outbound-only adapter that answers every activity with a copy sent by "bot". It needs no external service, but it goes through the full pipeline: transactional outbox, Oban delivery job, middleware, adapter and delivery record. The commands assume http://localhost:4000 (compose or mix phx.server).

1. Create a tenant (admin UI)​

Go to Admin, Tenants, enter a name (for example acme) and click Create. You can leave the alert webhook URL empty. The tenant's API key (cvg_live_...) is shown once. Only its SHA-256 hash is stored (tenants). Copy it:

export API_KEY='cvg_live_...'

2. Create a channel (admin UI)​

Go to Admin, Channels and pick the tenant, type echo, mode outbound (the only mode echo supports) and a name such as echo-test. Click Create. The channel secret is shown once. It is stored encrypted, and you need it to issue client tokens:

export CHANNEL_SECRET='...'

Tenant users can sign in to the tenant portal (/portal/login, with tenant name, email and password) to see the tenant's channels, conversations, routing rules and deliveries (including failed ones, which they can replay). They cannot create channels or tenants. An admin creates tenant users under Admin, Tenant Users.

3. Get a client token​

The Converger client API (/api/v1/converger, inspired by Direct Line) takes the channel secret as a bearer token on tokens/generate and returns a short-lived token for that channel. user.id is optional. It identifies the end user for per-user socket ids.

curl -s -X POST http://localhost:4000/api/v1/converger/tokens/generate \
-H "authorization: Bearer $CHANNEL_SECRET" \
-H "content-type: application/json" \
-d '{"user": {"id": "user-1"}}'
{
"conversationId": null,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 1800
}

Without curl: Client access on the channel row (admin panel, or the tenant portal for owners and admins) has Issue test token, which returns the same token and shows the base and WebSocket URLs to paste into a chat client such as chativa's sandbox (admin UI). A site without a backend uses a publishable key instead of the secret.

export TOKEN='eyJ...'

Tokens are valid for 1800 seconds. Renew one with POST /api/v1/converger/tokens/refresh and the current token as the bearer. Token generation is rate limited to 10 requests per minute per channel.

4. Start a conversation​

curl -s -X POST http://localhost:4000/api/v1/converger/conversations \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json"
{
"conversationId": "5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 1800,
"streamUrl": "ws://localhost:4000/socket/converger/websocket?token=eyJ...&conversation_id=5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c"
}

The returned token is scoped to this conversation. Use it from now on. streamUrl is where a WebSocket client connects to receive activities in real time (WebSocket).

export CONV='5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c'
export TOKEN='<conversation token from the response>'

5. Post an activity​

curl -s -X POST "http://localhost:4000/api/v1/converger/conversations/$CONV/activities" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-H "x-idempotency-key: hello-1" \
-d '{"type": "message", "from": {"id": "user-1"}, "text": "Hello, Converger"}'
{ "id": "0e7d4c1a-8b2f-4f6e-a1c3-7d9e2b4f6a80" }

The response comes back after the activity and its delivery job have been committed in the same transaction. If the job could not be enqueued, nothing is committed and you get 503 unavailable with the message "Activity could not be accepted, please retry" and details.reason delivery_enqueue_failed. Send the same request again with the same x-idempotency-key: it returns the same id and creates no second activity.

Client-settable fields are type, text, attachments and channelData (stored as metadata). from.id becomes the activity's sender. The server sets everything else, including timestamps (activities, ADR-0005).

6. Read the conversation​

The echo reply is produced asynchronously by the Oban deliveries queue. It usually appears within a second.

curl -s "http://localhost:4000/api/v1/converger/conversations/$CONV/activities" \
-H "authorization: Bearer $TOKEN"
{
"activities": [
{
"id": "0e7d4c1a-8b2f-4f6e-a1c3-7d9e2b4f6a80",
"type": "message",
"from": { "id": "user-1" },
"text": "Hello, Converger",
"timestamp": "2026-10-09T10:15:02.481230Z",
"attachments": [],
"conversationId": "5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c",
"channelData": {}
},
{
"id": "9a1f3e5d-2c4b-4d6f-8e0a-1b3c5d7e9f21",
"type": "message",
"from": { "id": "bot" },
"text": "Hello, Converger",
"timestamp": "2026-10-09T10:15:02.733914Z",
"attachments": [],
"conversationId": "5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c",
"channelData": { "echo_of": "0e7d4c1a-8b2f-4f6e-a1c3-7d9e2b4f6a80" }
}
],
"watermark": 2,
"has_more": false
}

watermark is the seq of the last activity returned. Pass it back as ?watermark=2 to fetch only newer activities. Use ?limit= to change the page size (default 100, at most 1000).

The same conversation is visible through the server-to-server API with the tenant API key. That API returns the canonical activity shape, including seq:

curl -s "http://localhost:4000/api/v1/conversations/$CONV/activities" -H "x-api-key: $API_KEY"
{
"data": [
{
"id": "0e7d4c1a-8b2f-4f6e-a1c3-7d9e2b4f6a80",
"type": "message",
"sender": "user-1",
"text": "Hello, Converger",
"attachments": [],
"metadata": {},
"idempotency_key": "hello-1",
"seq": 1,
"conversation_id": "5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c",
"tenant_id": "3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9",
"inserted_at": "2026-10-09T10:15:02.481230Z"
},
{
"id": "9a1f3e5d-2c4b-4d6f-8e0a-1b3c5d7e9f21",
"type": "message",
"sender": "bot",
"text": "Hello, Converger",
"attachments": [],
"metadata": { "echo_of": "0e7d4c1a-8b2f-4f6e-a1c3-7d9e2b4f6a80" },
"idempotency_key": "echo:0e7d4c1a-8b2f-4f6e-a1c3-7d9e2b4f6a80",
"seq": 2,
"conversation_id": "5b0c8f2e-3c1d-4a8e-9f3a-1d2e3f4a5b6c",
"tenant_id": "3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9",
"inserted_at": "2026-10-09T10:15:02.733914Z"
}
],
"meta": { "watermark": 2, "has_more": false, "limit": 100 }
}

The server-to-server API can also post as a named sender. POST /api/v1/conversations/:id/activities with x-api-key and a body such as {"type": "message", "text": "Hi from an agent", "sender": "agent-7"} returns 201 with {"data": {...canonical activity...}}. Without sender, the sender is "user".

7. See the delivery​

Open Admin, Conversations and select the conversation. The transcript shows both activities with their delivery status: sent for the echo channel. A delivery that keeps failing would stay pending while it retries and end as failed. The Oban dashboard (/admin/oban) shows the jobs of the deliveries queue.

To close the conversation, run POST /api/v1/converger/conversations/$CONV/close (bearer token) or POST /api/v1/conversations/$CONV/close (x-api-key). The server then emits a conversationUpdate activity, and further posts are rejected with 409:

{
"error": {
"code": "conversation_closed",
"message": "Conversation is closed",
"details": null,
"request_id": "F8n2kQx1c9ZQAAAB"
}
}

.../reopen opens it again (conversations).

Common errors​

Every error response uses the same envelope, {"error": {"code", "message", "details", "request_id"}}, like the 409 above. Branch on code; details holds extra data such as the per-field errors of a 422 (for example {"text": ["should be at most 65536 byte(s)"]}). The full list of codes is in errors.

StatusCodeCause
401unauthorized ("Missing or malformed Authorization header")No authorization: Bearer ... header on a /api/v1/converger route.
401unauthorized or token_expiredExpired token, a channel secret used where a token is required, or a token from another installation.
403channel_inactive ("Channel not found or inactive")The channel was disabled or deleted.
403forbiddenA conversation-scoped token used for another conversation.
401unauthorized ("Invalid or inactive API Key")Wrong x-api-key, or the tenant is inactive (/api/v1 routes).
409conversation_closedThe conversation is closed or expired.
422invalid_message, field errors in detailsValidation failed (size limits, unknown type, ...).
429rate_limited, with details.retry_after_ms and a Retry-After headerRate limit exceeded (deployment).

Next steps​