34 — Channel onboarding: lifecycle, governance, admin/desktop UX
Status: Phases 1, 2, 3, and 4 implemented, plus multi-bot-per-channel
(Phase 5, 0.11.19) — allowlist store, gateway pending lifecycle, admin API
routes, Admin UI panels, the multi-tenant CorePool, the Discord and
Slack bridges, PATCH .../allowlist/{key} (unified with the existing
binding route-override path), the vak doctor "gateway channels" check
with --repair wiring for expired pending entries, the
known-workspaces picker, channel capability overlays, and now a first-class
Bot identity independent of surface. See each phase section below for
what landed.
Channel delivery is a projection of the shared outcome contract, not a second
conversation model. Goal updates, result status and evidence remain in the
session ledger and OutputTimeline; Telegram, Discord and Slack receive the
bounded text/action fallback their capabilities support. A shortened or
non-interactive channel message must never change completion, approval or
authority semantics. See docs/design/52-outcome-directed-runtime.md and
docs/design/30-output-engineering.md for the cross-surface contract.
Two sub-pieces are deliberately deferred, not silently dropped:
- Real-time transports for the new bridges. Discord's gateway
websocket and Slack's Socket Mode would each add a websocket
dependency the workspace does not have today (no
tokio-tungsteniteanywhere inCargo.lock). Both bridges instead poll an explicitly configured set of channel ids (DISCORD_CHANNEL_IDS/SLACK_CHANNEL_IDS) — correct, dependency-free, and the same "watch a few channels" shape the Telegram long poll has. The cost is that channels must be named up front rather than DMs being auto-discovered. - Interactive approval components on Discord/Slack. Forwarded
approval gates render as a typed yes/no prompt (
Reply \yes <id>`), the same fallback Telegram used before its inline keyboard, resolving through the one existingparse_verdictpath. Discord buttons and Slack Block Kit are the follow-up; the adapters declaresupports_actions: false` so nothing renders buttons that do not exist.
Written after a live incident
(2026-08-28): the Telegram bridge returned 403 for a chat that used to
work, because gateway.chat_allowlist is a config-file-only setting with
no UI, no runtime API, and no visible pending-request state — the operator
had to grep security-events.jsonl to find out why, then hand-edit a
.vak/config.toml and manually bounce the gateway process to apply it.
Separately, the gateway's default workspace once silently became whatever
directory vak self services-sync happened to be run from (the tool's own
source checkout, in the incident). Durable services now always use the
canonical ~/vak-home; this doc also answers "which workspace should this
channel talk to?" These are the same underlying
problem: adding a channel/chat has no first-class lifecycle — today
it's an implicit side effect of config files and shell commands, not a
flow with visible state, approval, and governance.
Current state (as built, docs/design/22-gateway.md)
gateway.chat_allowlist/chat_allowlist_openlive in.vak/config.toml(privileged, project-config only), loaded once at process start. Changing it requires a hand-edit + process restart —self services-syncregenerating an unchanged unit does not restart the process, so even an operator who knows to edit the file can end up with a service that silently keeps running on stale config (today's incident, twice).- A rejected chat is recorded as a
chat_allowlist/chat_rejectedsecurity event (vak_core::security_events) — discoverable, but only by an operator who thinks to look, after the fact, with no forward path from "I see it was rejected" to "now let it through" except re-editing the same file. - The gateway's workspace is the canonical default workspace
(
vak_config::paths::default_workspace()), independent of the directory where service management is invoked.GatewayBindingdoes carry a per-bindingworkspaceoverride in the data model (admin.rs'sconfigured_workspacefield), but nothing in any UI or bridge ever sets it — so every channel silently inherits the process workspace, and nothing tells you what that workspace even is until you go look. - Admin UI's
GatewayStatustype already fetcheschat_allowlist/chat_allowlist_openfrom/admin/api/gateway/status, butGatewayView()never renders them — the only place the concept appears in the UI is as a filter chip on the security-event log. Desktop Settings has a Telegram bot token field (the credential) and nothing for chat scope. - There is no admin API route to mutate the allowlist at all — only
PATCH /admin/api/gateway/bindings/{key}(route override) and the read-onlyGET /admin/api/gateway/status.
Net effect: onboarding a channel today means (1) a stranger messages the bot, (2) they get silently rejected, (3) the operator has no notification this happened, (4) fixing it means finding the right file, hand-editing TOML, and knowing to restart a background service — four steps with no UI at any of them, and step 4 has already burned real time twice.
Proposed lifecycle
message arrives, key unknown ──▶ PENDING (recorded, not rejected silently)
│
▼
operator reviews in Admin UI / Desktop Settings:
chat id, surface, sender, first message text, arrival time
│
┌────┴────┐
▼ ▼
APPROVE DENY (or ignore — expires after N days, configurable)
│
▼
operator picks, at approval time (not silently inherited):
- workspace this channel talks to (explicit path, defaults to the
gateway's own cwd but must be a visible, overridable choice)
- route: inherit workspace default, or pin provider+model
│
▼
ALLOWED — written to a live-reloadable store, applied without restart
│
▼
ongoing: visible in a channel list (workspace, route, added_at, added_by,
last_active) with a REVOKE action; revoke takes effect immediately
Every transition is a security_events entry (chat_pending,
chat_approved, chat_denied, chat_revoked) — governance is an
audit-log read, not a TOML diff.
Storage: move off config.toml, onto a live store
chat_allowlist stays a valid config.toml key (declarative, GitOps-able
for scripted deploys — someone bootstrapping a fleet still wants to
commit a starting allowlist). But the source of truth for a running
gateway becomes a schema-versioned file next to bindings.json:
<sessions_home>/gateway/allowlist.json
{
"schema": 1,
"entries": [
{
"key": "telegram:8846301562",
"status": "allowed", // pending | allowed | denied
"workspace": "/Users/x/Projects/assistant",
"route": null, // null = inherit workspace default
"added_at": "...",
"added_by": "admin",
"first_seen_text": "..." // only kept while pending, for review
}
]
}
At startup, config.toml's chat_allowlist seeds this file if it doesn't
exist yet (one-time import, same pattern as other migrations); after
that, the file is authoritative and config.toml is not consulted again
for a live process — same relationship bindings.json already has to
route overrides. GatewayState holds this in memory and every mutation
(approve/deny/revoke) updates it in place — no restart, matching how
PATCH /admin/api/gateway/bindings/{key} already works.
Admin API additions
GET /admin/api/gateway/allowlist list all entries (any status)
POST /admin/api/gateway/allowlist/{key}/approve {workspace?, route?}
POST /admin/api/gateway/allowlist/{key}/deny
DELETE /admin/api/gateway/allowlist/{key} revoke an allowed entry
Known gap: no pause. The status enum is exactly pending | allowed | denied; there is no fourth "temporarily off, config kept" state. deny
is permanent and sticky ("never re-prompts" above); revoke (the DELETE)
does not flip a status at all — it removes the entry outright
(GatewayState::allowlist_revoke), so the workspace/route/permission-pin/
capability-policy an operator configured is gone with it, and the next
message from that chat starts a brand-new pending review from zero. An
operator who wants to quiet one channel for a while without losing its
setup, or without taking down the whole bridge (removing the bot token
stops every channel on that surface, not just one), has no lever for it
today. A paused status — dispatch rejected the same way denied is, but
carrying the same workspace/route/permission_mode/policy fields
allowed does, flip-backable to allowed without re-approval — is the
natural shape for it; not yet built.
workspace on approve is the fix for today's incident: it's an explicit
field the operator fills in (defaulting to the gateway's own cwd, shown
plainly, not assumed silently), not a value nothing ever surfaces.
UI additions
Admin UI (crates/vak-admin-ui, extends the existing GatewayView):
a "Pending channels" panel above the binding list when any entry is
status: pending (badge count in the nav, matching the existing pattern
for approvals/inbox), each with Approve (workspace + route pickers) /
Deny. The existing binding list gains a chip showing allowlist status per
target instead of pretending the concept doesn't exist there.
Desktop Settings (crates/vak-client-ui, extends the Telegram
section): once a bot token is configured, a "Chat access" subsection
lists pending/allowed chats the same way, so a single-user desktop setup
doesn't require opening the web admin console just to approve their own
phone's chat id.
Editing an already-allowed entry, and what changing workspace means
Phase 1 as first written only set workspace/route at approve time —
an allowed entry was otherwise immutable except for revoke. That's not
enough: an operator needs to re-point a channel later (move it to a
different project, change its pinned model) without revoking and
re-approving from scratch, which would lose added_at/added_by
provenance and momentarily 403 the channel.
Fix: add
PATCH /admin/api/gateway/allowlist/{key} body: {workspace?, route?}
for any allowed entry (pending/denied entries aren't edited, they're
approved/denied/re-triggered). This does not create a new entry or touch
added_at/added_by — it mutates workspace/route in place.
This must reuse the existing session-rotation contract, not bypass
it. docs/design/22-gateway.md already establishes: "Session contracts
never mutate... a changed effective route rotates to a new frozen session
while preserving the old append-only ledger." Editing an allowlist
entry's workspace or route is exactly the kind of change that
invalidates a bound session's contract, so PATCH .../allowlist/{key}
must go through the same stale-detection path PATCH .../bindings/{key} already uses — not a second, divergent way to change
a channel's effective route. Concretely: the allowlist entry's
workspace/route and the binding's workspace/route override are
the same conceptual field observed from two admin surfaces (Phase 1's new
one and the pre-existing one); implementation should have exactly one
source of truth for "what does this channel route to" that both surfaces
read/write, not two configs that can drift from each other. (This is a
correction to Phase 1/2's implementation, not just future work — worth
checking before Phase 2 lands whether AllowlistEntry.workspace and
GatewayBinding.workspace already risk this exact drift.)
Implemented. The drift the parenthetical above warned about was real:
allowlist_approve wrote route onto the entry, but binding_route at
dispatch only ever read the binding's provider/model override, so the
entry's pinned route was dead config. There is now exactly one resolver,
GatewayState::effective_route_override — the allowed entry's route when
it has one, the binding override otherwise — and
PATCH .../bindings/{key} writes through to the entry when one exists, so
neither surface can hold a stale answer. PATCH .../allowlist/{key}
mutates the entry and then calls invalidate_binding_revision, the same
"drop the cached revision, never the ledger" move set_route_override
makes, so an edit takes effect through the existing rotation path on the
next inbound message.
Workspace no longer resolvable (directory moved, deleted, or a typo'd
path was approved): the pool (Phase 2) fails to start a Core for it. This
must not silently 500 on the next inbound message — it should reject with
a clear "workspace unreachable, ask an operator to repair" error and
surface as a vak doctor failure (see below), so it's caught before a
user ever hits it, not just when they do.
Lifecycle completeness: doctor and repair
Channel state is now part of the system's health surface, not a config
detail. vak doctor (crates/vak-core/src/health.rs /
crates/vak/src/doctor.rs) gains a new check:
gateway channels: fails if anyallowedentry'sworkspaceno longer exists / isn't readable, or if anypendingentry has sat longer than the expiry window (see below) without being acted on. Pass detail lists counts; fail detail names the offending keys sodoctor's output is actionable without a separate admin-console trip.
vak doctor --repair (already built for self-install drift) gains a
matching mechanical fix for the one case that has one:
- an expired
pendingentry auto-denies (see expiry policy below) — mechanical, no judgment call, matches the existing "act only on checks with a known fix" rule fromAGENTS.mdinvariant 19. - an
allowedentry with an unreachableworkspaceis not auto-repaired (repointing it to a different workspace is a judgment call, not mechanical) —doctor --repairreports it and points atPATCH .../allowlist/{key}or the Admin UI, same as it already defers provider-auth and config-warning failures to the operator today.
Implemented. vak_core::health::gateway_channels_check reads
<sessions_home>/gateway/allowlist.json directly (no Core started per
workspace just to test one) and is collected alongside the existing
checks; crates/vak/src/doctor.rs's repair_known_failures gained the
matching case. [gateway] pending_expiry_days (default 7) is the window,
following the KNOWN_GATEWAY_KEYS pattern.
One implementation note worth stating plainly: doctor --repair runs in
a different process from a live gateway, which holds the allowlist in
memory. The repair therefore edits the store on disk (preserving unknown
fields, temp-file+rename), and GatewayState::load applies the same
expiry on startup — so a running gateway converges on the same state at
its next restart rather than the two paths fighting. Expiring from inside
a live gateway on a timer was not added; the startup pass plus doctor
covers the case without a new background task.
Open questions (resolved / for review before implementation)
- Expiry for pending entries — resolved: 7 days, configurable
via a
[gateway]key following the existingKNOWN_GATEWAY_KEYSpattern. Auto-deny (not silent deletion — a denied-by-expiry entry stays visible withadded_by: "expiry"so "why did this stop working" has an answer) is now wired intodoctor --repairabove rather than left as a standalone cron-only cleanup. - Multi-operator approval — single-user desktop doesn't need it, but
a hosted gateway with an admin team might want "who approved this" to
matter beyond the audit log. Deferred;
added_byis captured from day one so it's available if needed later. - Per-chat rate/cost caps — out of scope here;
finopsalready caps globally perdocs/design/42-managed-work-contracts.md. Whether a per-channel cap belongs on the allowlist entry is a separate decision. - Should
workspaceon an entry be restricted to a known/registered set of workspaces, or free-text any path the gateway process can read? Resolved toward a picker, not free-text: the Admin UI's workspace field (approve, and the new PATCH-based edit above) should offer a dropdown of "workspaces vak has actually been run in" (a list already derivable from existing session ledgers grouped by cwd — checkvak-store's indexed sessions for a ready source) with a fallback "custom path" option that free-types but visibly warns it's unverified until the pool successfully starts a Core there. This directly serves the doctor check above: a typo'd path is caught at entry time by offering known-good options first, not just diagnosed after the fact.
Phase 1 scope vs. later phases
Phase 1 (this doc's original scope: allowlist store, admin API, Admin UI pending/approve/deny/revoke) does not include real multi-tenant Core isolation or Discord/Slack bridges — those were flagged as non-goals, then promoted to Phase 2/3 below once it was clear "control surface" means all three need to exist for the feature to be complete, not just the allowlist mechanics.
Implemented. The allowlist store, gateway pending-lifecycle change, admin API routes, and Admin UI panels described above are built. Phases 2 and 3 below are built too — see each section's own status note.
Phase 2: multi-tenant Core isolation
Today: one gateway process = one Core = one cwd, fixed at process
start. A channel's workspace field (Phase 1) only selects which
already-loaded workspace's config/route to route to — it doesn't run that
workspace's own Core. That's a real gap: a channel approved for
/Users/x/Projects/other-repo while the gateway process's own Core is
rooted at a different cwd has no way to actually get that other
workspace's tools, sandbox, permission mode, or session ledger — only its
provider/model pair, via the existing route-override mechanism. It cannot
actually work as designed without this.
Design: GatewayState holds a CorePool — a map from canonical
workspace path to a lazily-started Core instance, instead of the single
state.core it holds today (which becomes the pool's entry for the
gateway's own default workspace). On inbound dispatch, after allowlist
resolution, look up the entry's workspace; if it's not the default,
resolve (or start) that workspace's Core from the pool instead of using
state.core directly. Each pooled Core gets its own:
- session ledger home (already implied — sessions are keyed by cwd today, per docs/design/22's "Sessions remain append-only JSONL trees keyed per-cwd" — this phase makes that actually reachable from a remote channel instead of only from a local CLI/desktop client already sitting in that cwd).
- permission mode / sandbox / trust resolution, loaded the normal way
Core::new_with_trustalready does for any workspace. - idle eviction: a pooled Core with no active binding and no recent activity (default: 30 min, configurable) is dropped, so a gateway fielding channels for a dozen workspaces doesn't hold a dozen live Cores (and their sandboxes/file watchers) forever. Re-approving or the next inbound message on that channel simply re-starts it.
- resource bounds: a process-wide cap on concurrently pooled Cores (default TBD, propose 8) with the oldest-idle evicted first when the cap is hit — prevents an approval mistake (or a compromised approver) from turning the gateway into an unbounded fork bomb of Core instances.
What stays single-process: this is still one vak serve --gateway
process, one bearer token, one admin surface — it pools Core instances
(the per-workspace session/tool/permission engine), not processes. A
workspace that genuinely needs process-level isolation (a different user
account, a different machine) is out of scope here — that's still "run
another gateway process," unchanged from today.
Admin UI: the Gateway page's workspace picker (Phase 1's approve
flow) needs a live indicator of which workspaces currently have a pooled
Core running (vs. cold, will start on next message) — reuses the existing
ops status pattern already shown elsewhere in the admin console
(GATEWAY routing control's summary strip).
Security: a pooled Core for workspace W still goes through the exact
same trust/permission resolution as running vak locally in W — pooling
does not grant a channel more access than a local session in that
workspace would already have; the approval step (Phase 1) is what gates
a channel getting there at all.
Implemented. crates/vak-server/src/core_pool.rs's CorePool holds
the map; GatewayState::core_for_entry (gateway.rs) resolves it on
dispatch after allowlist resolution, ahead of session creation. Idle
eviction and the pool cap are both config keys under [gateway]
(core_pool_idle_secs, default 1800; core_pool_max, default 8) — see
crates/vak-config/src/lib.rs's KNOWN_GATEWAY_KEYS. GET /admin/api/gateway/status reports pool state under core_pool, rendered
by the Admin UI's new "Core pool" panel and a warm/cold chip in the
pending-channel approve flow.
Per-channel permission mode
Phase 2 made a channel run its target workspace's own Core — including
that workspace's permission mode. That closed one gap and exposed the
next one, straight from the field: permission mode is now workspace-
scoped and nothing else. Every channel routed to a workspace inherits
that workspace's .vak/config.toml mode, so an operator who wants a
personal Telegram chat to be read-only while a team channel keeps
workspace-write has exactly one lever — stand up a second, otherwise
identical workspace purely to vary trust level. That is the same problem
the route override already solved for provider/model, and it takes the
same shape.
Design: AllowlistEntry grows permission_mode: Option<PermissionMode>, a sibling of the existing route: Option<AllowlistRoute> and read the same inherit-or-override way.
None — the default, and every pre-existing entry, since the field is
#[serde(default)] — inherits the workspace's own configured mode, which
is exactly today's behavior. Some(m) pins this one channel to m.
Cap, never escalate. An override is not applied blindly. The target workspace's own resolved mode is a ceiling, and the pin is clamped to it:
effective = min(channel_pin, workspace_configured_mode)
So a channel may match or reduce what the workspace already grants, and
can never exceed it. Pinning full-access on a channel routed to a
workspace whose config says read-only yields read-only, not
full-access. This preserves Phase 2's invariant verbatim — a pooled
Core still never gives a channel more than a local vak run in that
workspace has — and it makes the failure mode of an operator mistake a
denial, not an exposure. The clamp is PermissionMode::capped_by
(crates/vak-config/src/lib.rs), a min over an explicit permissiveness
rank(); both the enforcement path and the admin display path go through
that one function, so the number the console shows and the number the
dispatch path pins cannot drift.
CorePool keying. This is the part that must be right. A Core
carries exactly one permission mode. If two channels sharing a workspace
but holding different pins were handed the same pooled Core, whichever
resolved first would silently dictate the other's permissions — the
lower-trust channel would exercise the higher-trust channel's mode. So
the pool key widens from a canonical workspace path to the pair
(workspace, permission_override). Un-overridden channels keep key
(workspace, None) and therefore keep sharing the exact instance they
share today, including the gateway's own permanent default entry.
Override entries are ordinary evictable entries under the same
idle-eviction and cap semantics as any other — an override on the
gateway's own workspace is evictable, because it is not the gateway's
Core.
Capping happens once, in CorePool::resolve_at, on the freshly
constructed Core before it is published to the map — so no request can
ever observe the instance at its un-capped mode. The ceiling read there
is Core::effective_permission_mode() on a Core that has no override
yet, which is precisely the workspace's own configured value.
Admin API: POST .../allowlist/{key}/approve and PATCH .../allowlist/{key} both accept an optional permission_mode field
alongside the existing workspace/route — same requests, no new
endpoint, exactly the way route already rides along. Absent, null, or
"" means inherit (and on PATCH, clears an existing pin, mirroring how
an empty route object clears a pinned route). An unparseable value is a
400, never a silent inherit: a typo must not quietly change a channel's
access. GET .../allowlist entries now carry permission_mode (what was
pinned), workspace_permission_mode (the ceiling),
effective_permission_mode (what the channel actually gets), and
permission_capped. The PATCH .../bindings/{key} write-through
preserves an entry's pin — that surface only speaks about routes and must
not clear a permission pin as a side effect.
Admin UI: the pending-channel approve form and the "Edit access"
editor each gain a "Pin a specific permission mode (otherwise inherits
the workspace default)" checkbox, parallel to the existing provider/model
pin, revealing the same three-button mode-btn control the Settings
page's "Permission Mode" panel uses. The channel row shows "Effective
permission" beside "Effective route", labelled with its provenance
(inherited / channel override / capped by workspace), and approving with
an over-broad pin raises an alert toast naming what it was reduced to.
Audit: a capped grant is recorded as a permission_capped security
event — a new EventKind::PermissionCapped rather than a reused
config_change, so a silently-reduced grant is greppable in the audit
log. It means an operator believes a channel has access it does not have,
which is worth its own kind. It fires in two places: at approve/PATCH
time (the moment the operator sets it) and in CorePool::resolve_at (the
moment enforcement actually applies), so the log shows both intent and
effect.
Warm pooled entries now recheck the persisted permission ceiling on every
cache hit. This security-only path preserves narrower channel pins and
cancels the old Core permission lease before applying a tighter ceiling. The
regression warm_pool_entry_rechecks_a_permission_mode_change_written_after_it_started
and the live steering regression both pass.
Phase 3: Discord/Slack bridges + per-surface admin UI
The routing model is already surface-agnostic by construction
(key = "{surface}:{chat}", InboundChannel trait per docs/design/22's
"Inbound channel identity" section) — Phase 3 is adding the actual bridge
clients and their onboarding UI, following the Telegram bridge
(crates/vak-server/src/surfaces/telegram.rs server-side adapter,
vak telegram CLI bridge process) as the template for both.
Discord bridge: a vak discord --server <url> --token <bot-token>
bridge process (new binary or vak subcommand, matching vak telegram's
shape), long-polling or gateway-websocket per Discord's bot API,
implementing InboundChannel with chat = Discord channel id, sender
= Discord user id. Delivery adapter registered in
crates/vak-server/src/delivery.rs alongside the existing
TelegramAdapter, for forwarded-approval inline components (Discord
buttons are the equivalent of Telegram's inline keyboard).
Slack bridge: a vak slack bridge using Slack's Events API +
chat.postMessage, same InboundChannel shape, chat = Slack
channel/DM id, sender = Slack user id. Slack's interactive Block Kit
buttons play the same role as Telegram's inline keyboard for approvals.
Per-surface admin/desktop UI: extends the Phase 1 Pending
channels/Registered channels panels (which are already surface-agnostic
in rendering, since they key everything off surface:chat) with:
- a bot-token field per surface in Desktop Settings, mirroring the
existing Telegram token field exactly (same storage convention: the
user secret scope, resolved through
vak_config::credentials, save restarts the bridge). - a surface icon/label in the Admin UI channel list so a mixed
Telegram+Discord+Slack deployment reads clearly at a glance (the
gatewayicon path already in App.tsx's icon map is generic; add per-surface icons or at minimum a text badge). - no new allowlist concept — Phase 1's pending/approve/deny/revoke
lifecycle already applies uniformly to any surface's
key, exactly as designed; Phase 3 only adds the bridges that can actually deliver asurface:chatkey from Discord/Slack in the first place.
Implemented. crates/vak-server/src/surfaces/discord.rs and
crates/vak-server/src/surfaces/slack.rs implement InboundChannel, driven by
vak discord / vak slack (same flags and env-first credential handling
as vak telegram); DiscordAdapter and SlackAdapter are registered in
delivery.rs when their bot tokens are set. Phase 1's UI was confirmed
surface-agnostic — nothing keyed on "telegram" — so the panels needed only
a SurfaceBadge, not generalizing. Desktop Settings renders one
Telegram-shaped token field per surface from chat_surfaces in
GET /config, via PUT/DELETE /config/bot-token/{surface}; only Telegram
has a managed service unit today, so the others say plainly that the
bridge is started by hand rather than promising a restart that will not
happen. See the two deferrals at the top of this doc for the polling
transport and typed-verdict approvals.
Phase 4: channel capability overlays (0.11.3)
The channel editor now persists a restrictive ChannelPolicy alongside each
allowlist entry. It supports inheritance or explicit allow/deny patterns for
built-in tools, MCP servers/tools, discovered skills, and lifecycle hooks. An
explicit empty allow list blocks the category; deny patterns always win.
The policy is enforced at every relevant boundary: the Core filters built-in
tools, skills, and hooks; the MCP meta-tool filters its advertised tools and
rejects disallowed calls; and the permission engine receives the corresponding
channel-scoped rules before dispatch. CorePool includes a serialized policy
fingerprint in its key so channels sharing a workspace cannot share a Core
with different capabilities. Existing entries default to inheritance, and
route-only binding edits preserve their policy.
The Admin UI exposes these controls in both pending approval and registered channel editing. It makes clear that skills control visibility while permission rules remain the security boundary, and that secrets stay workspace-owned. The policy does not copy API keys into channel state or grant an overlay access to a workspace it was not already approved to use.
Phase 5: multi-bot-per-channel (0.11.19)
Before this phase a "channel" (telegram/discord/slack) was the bot:
Core::bot_token_env mapped each surface to exactly one env var
(TELEGRAM_BOT_TOKEN, etc.), and setting a second token for the same
surface silently overwrote the first — there was no way to run two Telegram
bots side by side, and no policy tier scoped to a bot identity rather than a
chat or a workspace.
Data model. Bot { id, surface, label, token_env, policy, permission_mode, route, workspace } (vak-server/src/gateway.rs) is now a first-class
entity, independent of the legacy single-slot env vars, stored in
<sessions_home>/gateway/bots.json next to allowlist.json/bindings.json.
AllowlistEntry gained bot_id: Option<String> (which bot this chat is
bound to) and inherit_bot_policy: bool (default true — the explicit
"break inheritance" switch: when false, the chat resolves purely against
the workspace and the bot tier is skipped entirely).
Resolution chain. Dispatch now composes three tiers — bot → chat →
workspace — reusing the pre-existing Option<T> = inherit / Some(T) =
override convention rather than inventing a new one:
- Capability policy:
ChannelPolicy::merge(bot_policy, chat_policy)(vak-config/src/lib.rs) folds the two tiers beforeCore::apply_channel_policyruns — an_allowlist from the chat wins outright when set, otherwise falls back to the bot's;_denylists concatenate across tiers since denies only ever remove access, never grant it. - Permission mode: chat pin capped by bot mode capped by workspace mode
(
GatewayState::core_for_entry), via the samePermissionMode::capped_bythe chat→workspace cap already used — a bot can narrow but never widen what the workspace allows, and a chat can narrow but never widen what its bot allows. - Model/route:
effective_route_overridenow falls through chat route → bot route → legacy per-binding override → workspace default.
Storage and credentials. A bot's token is stored under its own env var
(BOT_TOKEN__<ID> by convention, though the migration path below reuses the
existing legacy var name), set/cleared via PUT/DELETE /gateway/bots/:id/token — never returned by the admin API once set, same
property the legacy per-surface token slot already had. GET/POST /gateway/bots and PATCH/DELETE /gateway/bots/:id manage the row itself
(label, policy, permission_mode, route, workspace).
Migration. The first time bots.json doesn't exist, GatewayState::load
synthesizes one Bot row per surface that already has a legacy token
configured, pointing at the same env var the legacy slot uses — so an
existing single-bot deployment shows up as an editable bot identity
immediately, without any operator action and without changing which env var
the running bridge reads.
Bridges run per bot, not per surface. vak telegram/discord/slack --bot-id <id> (vak/src/cli.rs, vak/src/main.rs) resolves its token from
that specific bot's token_env instead of the fixed legacy slot. Because
TelegramBridge's InstanceLock was already keyed by token (not global), a
second vak telegram --bot-id telegram-sales --server ... process runs
concurrently with the first and receives that bot's messages independently.
Outbound delivery (AdapterRegistry::built_in, vak-server/src/delivery.rs)
falls back to any configured multi-bot token when the legacy env var for a
surface is unset, so a bot created purely through the new flow can send
replies too. Phase 5 shipped this arbitrarily (with two or more bots on one
surface, outbound reply routing picked one token regardless of which bot a
chat was actually bound to) — Phase 6 below closes that gap.
Admin UI. Connect and Credentials both grow an "+ Add another bot" list
below the existing single-slot rows (ExtraBotsList/BotRow,
vak-admin-ui/src/App.tsx) — this is the concrete answer to "why can't I
add a second bot". The live per-chat editor (ChannelAccessEditor) gained a
bot picker scoped to the chat's surface and an "Inherit this bot's policy..."
checkbox bound to inherit_bot_policy. BotAccessEditor gives a bot its own
settings panel under Connect, built from the exact same components
(WorkspacePicker, ChannelPermissionPicker, ChannelCapabilityPolicy) the
per-chat editor uses, rather than a second, different-looking form.
Phase 6: bot-scoped channel identity (0.11.20)
Phase 5 gave a bot its own token, policy, and permission tier, but a
channel's identity was still surface:chat — one allowlist entry, one
session, one policy, no matter how many bots were actually members of that
physical chat. Two bots added to the same Telegram group collided onto one
shared conversation and whichever bot's row happened to hold the policy;
outbound replies picked one bot's token for the whole surface arbitrarily
(Phase 5's documented limitation, quoted above). This phase makes a channel
genuinely support multiple independent bots, not just multiple bots that
happen to share a surface.
Key scheme. An allowlist/session/binding key stays surface:chat for a
legacy or single-bot chat; once an inbound bridge tells the gateway its own
bot id (InboundRequest.bot_id/InboundBody.bot_id, threaded from
--bot-id), the key becomes surface:chat:bot_id
(gateway_inbound, vak-server/src/gateway.rs). legacy_key_for strips the
third segment back off. Because every multi-bot bridge already sends its own
bot id (the fix that preceded this phase), every chat routed through one
becomes bot-scoped automatically — no configuration or opt-in needed.
Non-breaking migration. A bot-scoped key seen by
allowlist_resolve_inbound for the first time checks for an already-
Allowed legacy surface:chat row and, if found, clones its
workspace/route/policy/permission forward under the new key instead of
starting a fresh pending review. This covers both a chat approved through
the admin console before bots were scoped into the key, and a
chat_allowlist row seeded from config.toml — either would otherwise stop
matching the instant a bridge started sending a bot id, forcing a needless
re-approval. A second bot joining the same physical chat inherits
independently too, producing its own entry rather than sharing the first
bot's.
Bot-scoped outbound delivery. AdapterRegistry (vak-server/src/ delivery.rs) now holds one adapter per (surface, bot_id), built from
every bot in bots.json with a token set, not just one arbitrary pick per
surface. resolve reads the bot id off a three-part target and returns that
bot's own adapter; a target naming a bot with no token configured fails with
a specific error rather than silently answering under a different bot's
identity. deliver_record normalizes the target back to surface:address
before calling an adapter's send, so none of the four existing
per-adapter target.split_once(':') call sites needed to learn about the
bot id segment.
What did not change. The synchronous per-turn reply path (a bridge
process posts to /gateway/inbound with wait: true and replies with its
own bot_token directly) was already correctly bot-scoped before this phase
— it never went through AdapterRegistry at all, since each bridge process
is one bot. Only proactive/asynchronous delivery (approval forwarding,
scheduled pushes) needed the fix above.
Non-goals (still, even after phases 2-6)
- No cross-machine/cross-account process isolation (see Phase 2's "what stays single-process").
- No email, SMS, or other non-chat surfaces — deferred until a concrete
need names one; the
InboundChanneltrait doesn't preclude it, but nothing here designs for it yet. - The admin UI does not yet visually group a physical chat's several bot-scoped rows together as "one chat, N bots" — Chats lists them as separate rows (each independently editable, which is correct), but nothing yet says "these three rows are the same Telegram group."