# Gateway setup — the catalog, the credentials, and what the box does with them

The deploy wizard's third question is where the coworker talks to people, and with what credentials. That answer spans four places — the panel, the control plane, the job payload, and the guest inside the box — and before this document existed each of them kept its own copy of the platform list. They had drifted:

  • the panel offered telegram, whatsapp, slack, discord, web, api;
  • the wizard accepted telegram, slack, lark, discord, teams, email;

so the panel could not deploy Lark/Teams/Email at all, and picking whatsapp produced a form the control plane rejects. Worse, Lark was modelled as lark + LARK_APP_ID/LARK_APP_SECRET, while the Hermes inside a box keys that platform feishu and reads FEISHU_APP_ID/FEISHU_APP_SECRET (gateway/config.py: Platform.FEISHU = "feishu", gateway/config_env.py). A "Lark" box therefore booted with credentials nothing reads — a gateway that cannot start.

There is now one catalog: control-plane/rekan/services/gateways.py. The panel renders from it over HTTP, the wizard validates against it, box.yaml is built from it, and the guest applies what box.yaml says.


# 1. The catalog

slug (product)labelHermes platformroute fieldcredential env vars
telegramTelegramtelegramchat_idTELEGRAM_BOT_TOKEN (required)
slackSlackslackchannel_idSLACK_BOT_TOKEN (required), SLACK_APP_TOKEN (required), SLACK_SIGNING_SECRET
larkLarkfeishuchat_idFEISHU_APP_ID (required), FEISHU_APP_SECRET (required), FEISHU_ENCRYPT_KEY, FEISHU_VERIFICATION_TOKEN, + settings FEISHU_DOMAIN, FEISHU_CONNECTION_MODE
discordDiscorddiscordchannel_idDISCORD_BOT_TOKEN (required)
teamsMicrosoft Teamsteams (see open items)team_idTEAMS_APP_ID (required), TEAMS_APP_PASSWORD (required), TEAMS_TENANT_ID
emailEmailemailinbox_addressEMAIL_IMAP_HOST (required), EMAIL_IMAP_USER (required), EMAIL_IMAP_PASSWORD (required), EMAIL_SMTP_HOST

Two kinds of entry, both of which travel in gateway.credentials and land in box.env:

  • secret — a credential value. Required ones block a deploy; every one is masked in a preview and never written to box.yaml.
  • setting — a platform's fixed choice (only Feishu/Lark has these today: FEISHU_DOMAIN = lark | feishu, FEISHU_CONNECTION_MODE = websocket | webhook). Not required, not masked, and refused if the value is outside its vocabulary.

Served to the panel by:

GET /api/v1/setup/gateways        # same auth as the rest of /setup

The response carries slugs, labels, the Hermes platform name, the route field with its help and example, and every credential field with its label, help text, required, secret, kind, default and choices. It carries no value of any kind — the catalog holds names and help, never a credential, so there is nothing to mask and nothing to leak.


# 2. Lark (Feishu), in full

Everything an operator needs, and where to get it:

env varrequiredwhere the value comes from
FEISHU_APP_IDyesLark developer console → your app → Credentials & Basic Info → App ID (cli_…)
FEISHU_APP_SECRETyesSame page → App Secret. Hermes cannot authenticate without it.
FEISHU_ENCRYPT_KEYnoOnly for a webhook connection with event encryption enabled
FEISHU_VERIFICATION_TOKENnoThe event-subscription verification token (webhook mode)
FEISHU_DOMAINno (default lark)lark = Lark (larksuite.com), feishu = Feishu (feishu.cn)
FEISHU_CONNECTION_MODEno (default websocket)websocket = the box dials out (no public URL), webhook = Lark pushes events to the box

Console: https://open.larksuite.com/app (the panel's help line links the same page). The app needs its bot capability enabled, and the chat you name as the route is the only chat the box may answer in.

Where the mapping happens, in one line: the slug the panel and the API use is lark; the platform the box's Hermes is configured with is feishu. That mapping lives in gateways.py (GatewaySpec.hermes_platform) for the control plane and in hermes_platform_for() in image/guest/rekan-box-apply for a box whose box.yaml predates the catalog.


# 3. What the operator supplies, and where the values land

  1. The panel fetches the catalog, the operator picks a platform, and the wizard's credential sub-step renders only that platform's fields — password inputs for secrets, selects for settings, each with its help text and a required marker. Lark appears in the picker like any other platform.
  2. The panel sends the platform slug, the route, and the credential values (POST /api/v1/boxes for the panel's path; the wizard's own POST /api/v1/setup/deploy carries them the same way).
  3. The control plane validates: an unknown platform, an unknown credential name, a value outside a setting's vocabulary, or a missing required credential are each refused with a per-field message the panel shows verbatim (gateway.credentials.FEISHU_APP_SECRET: FEISHU_APP_SECRET is required to connect Lark — without it the gateway cannot start, so the box would come up unreachable.).
  4. The values ride the job payload, and the worker pushes them to /etc/rekan/box.env (mode 0600, owned by the box user). That is the only place a credential value ever lives. box.yaml declares the credential names (credential_keys, required_credential_keys), never a value; the guest installs box.env as ~/.hermes/.env where Hermes reads it.
  5. box.yaml also carries gateway.platform (the slug, lark) and gateway.hermes_platform (what Hermes is configured with, feishu), so the guest enables the platform Hermes knows (feishu.enabled true) and confines the bot to the approved route.

What the guest does with it, in order: install box.env → apply box.yaml → set <hermes_platform>.enabled → gate the route → install and start the gateway only if a credential it needs is present (a gateway with no token cannot connect, and starting one anyway wedges the apply).

The key names are part of the contract with the box's Hermes, not ours. What a box's Hermes 0.21.3 actually reads was measured on a live box (esther, Agent Box on 10.99.0.240) on 2026-09-24 — every config-key shape this product used turned out to be a no-op:

what the apply triedresult on the box
gateway.platform telegramaccepted with "not a recognized config key" — no effect
gateway.headless falsesame — no effect
gateway.telegram.chat_id <id>refused outright, "nothing was written"
telegram.allowed_users <id>written, no effect on the DM gate or the startup warning
platforms.telegram.allowed_users <id>written (Hermes redirects gateway.platforms.… here), no effect
platforms.telegram.enabled falsewritten, and the gateway still connected to Telegram
TELEGRAM_ALLOWED_USERS=<id> in ~/.hermes/.envthe warning disappears and the adapter's authz enforces it

So the route goes in as the platform's own allowlist variable in .env, right after box.env is installed:

  • a negative id or an @handle is a chat (group/channel) → <PLATFORM>_ALLOWED_CHATS
  • a positive id is a user (a DM) → <PLATFORM>_ALLOWED_USERS

Consequences worth knowing:

  • The credentials in .env are what select the platform. A box with no token for its platform serves nothing — that, not a config key, is what "gateway disabled" means. (Measured: platforms.<p>.enabled: false does not stop a gateway whose token is present.)
  • A gateway is a systemd unit that only root can install or drive. hermes gateway install --system run as the box user fails with "System gateway install requires root", and the old fallback — a user-run hermes gateway restart — started a manual gateway that reported "✓ Gateway is running" during the apply and died with the exec session. Every box came up with no unit, no gateway and nothing on boot. The install runs as root (which is also what bakes User=<box user> into the unit), the unit is enabled and restarted with systemctl, and the files the root install writes into the box's hermes home are chowned back to the box user.
  • Write a route, not a key. A key Hermes does not read is worse than no key: the apply looks fine and the box answers where nobody approved. The apply logs the variable it gated the box with, and warns loudly when it has none.

# 4. Adding a platform

  1. Add a GatewaySpec in control-plane/rekan/services/gateways.py (slug, label, hermes_platform, route field + help + example, credentials, extras).
  2. Add gateway.<slug> and one gwcred.<ENV> line per credential to both web/src/locales/id.js and en.js — web/tests/i18n.test.mjs fails on any asymmetry, and web/tests/wizard-gateway.test.mjs reads gateways.py and fails if a locale is missing an entry the catalog names.
  3. If the platform's Hermes name differs from the slug, add it to hermes_platform_for() in image/guest/rekan-box-apply (the fallback for a box.yaml older than the catalog) and to the built-in required_creds() map.

The panel needs no change: it renders whatever the endpoint returns.


# 5. Open items (measured, not guessed)

  • Teams. The build on the boxes does ship a Teams platform (plugins/platforms/teams/adapter.py, teams in gateway/config.py), but the catalog's credential names do not match it: the catalog declares TEAMS_APP_ID / TEAMS_APP_PASSWORD, while the adapter reads TEAMS_CLIENT_ID / TEAMS_CLIENT_SECRET (plus TEAMS_TENANT_ID). A Teams box deployed today would therefore come up with a gateway that never authenticates — measured off the box's source, not yet off a live Teams gateway. Its route gate (TEAMS_ALLOWED_USERS) is written by the guest and is likewise unverified. Do not sell Teams as supported until the credential names in gateways.py are corrected against the adapter and a real Teams box reaches READY with a live gateway.
  • Email. The wizard's email fields are EMAIL_IMAP_HOST, EMAIL_IMAP_USER, EMAIL_IMAP_PASSWORD; this Hermes build's email platform reads EMAIL_ADDRESS/EMAIL_PASSWORD as well as the two hosts. Unchanged here on purpose (it is a different platform with its own operator workflow), flagged so the next round does not have to rediscover it.
  • A live Lark handshake needs a real app ID/secret and a real chat, which only the operator can supply. Everything up to the guest's configuration is verified; see docs/wizard-gateway-verification.md.