FEATURES.md
Rama main · réplica de lectura del repositorio original.
# ChannelGate — Features
## Company email domain auto-approval
- Admin UI → Users → Auto-approval accepts exact company email domains, disabled by default.
Authenticated inbound provider identities can gain Approved access before engine selection;
this never grants Admin or bypasses channel access rules, quotas, or license admission.
- Slack fetches the current human profile (requires `users:read.email`). Google Chat and Teams
use provider-supplied sender email when available; missing email remains manual approval.
Editable names, message text and run-API attribution cannot establish an email identity.
- Unchecking Approved in the admin user editor records a manual opt-out from auto-approval;
checking it clears that opt-out. Removing domains/disabling the policy retains prior approvals.
- Existing known Slack users can be previewed and explicitly approved, with fresh identity,
current policy and manual opt-out checks on apply. Up to 500 users per batch; lookup failures
are reported and grant nothing. Policy changes and automatic approvals are audited.
## Registered inventory limits and Enterprise harness approvals
- Non-Enterprise installs allow at most 20 registered channels (including DMs and the API
conversation) and 20 stored human users, including unapproved accounts. Registration checks
are atomic; existing records remain editable and usable when an install is already over its cap.
Legacy free-license payloads cannot bypass these caps. The no-key monthly one-conversation
limit and Registered 500-message monthly limit remain. Enterprise defaults to unlimited.
- Enterprise organization admins can approve one or more harnesses per channel through the
admin editor or Slack Settings → Agent. Unrestricted is the default. Pins, saved sessions,
API overrides, automation and fallback cannot launch outside the approved set; queued work
rechecks the live policy. Claude memory review is skipped when Claude is excluded.
- Approval changes are audited. Restrictions remain enforced after Enterprise expiry/downgrade;
changing or clearing them requires Enterprise. These approvals govern managed engine launches,
not arbitrary shell commands already permitted to Worker/Admin authors.
## Channel agent directory
- `search_agents` finds registered specialist channel agents by purpose, examples and effective
shared skills, with channel links and bounded pagination. Current organization, template,
channel-section and explicit channel grants/dependencies resolve at lookup; personal skills
never contribute. Managers describe or hide their own agent with `set_channel_agent_profile`.
- The always-injected `gateway-usage` guide checks the directory when a specialist may help and
recommends a channel without sending messages, delegating or transferring files. Organization
skills can supply company terminology; no company directory is hardcoded in the product skill.
- Built-in reconciliation refreshes channel metadata at startup and nightly at 03:00 in the host
local timezone without an engine turn or user schedule. Metadata caches narrow keyword searches;
returned recommendations still verify live metadata/membership and fresh gateway access.
The daemon registers its existing Slack lifecycle manager in the live connector registry;
disconnects fail closed and reconnects resolve the current client without retaining old readers.
- Visibility protects the reply's audience as well as its requester. DMs, group DMs, hidden,
archived and Lean agents are excluded. Private destinations cannot appear in public/shared
sources; other results require the whole current source roster to belong to the destination.
A one-to-one bot DM uses its requester's access. Admin status never substitutes for membership.
Slack supplies the membership interface; unsupported/disconnected platforms fail closed.
## Composio connection visibility
- Slack Settings → **You** shows the viewer's personal connected Composio applications and
accounts directly below their Composio credential. The channel's **MCP** page shows the shared
connections and whether they use the channel or organization identity. Both lists load after
the native modal opens, offer Refresh, and page through all reported accounts in compact rows.
Credential forms remain undisturbed; late loads cannot overwrite newer navigation. Each read
rechecks viewer ownership, current channel access and the resolved credential scope.
- Admin UI user pages list that person's personal Composio applications and accounts. Channel
Connections pages list the shared identity and identify channel credentials versus organization
defaults; personal accounts remain on user pages. Each row shows the app, account alias/available
account identity, connection ID, status, default selection and account type when provided.
- Lists load on demand and have a Refresh connections button. Personal mode discovers the apps
published by Composio and reads each app's account metadata; a visible caveat explains that
inactive/unpublished connections may be absent. Missing or incomplete provider metadata is
reported explicitly. SDK mode lists all account pages for the exact stable user/channel identity
under the existing Enterprise entitlement, without creating sessions or authorizations.
- Inventory reads retain personal-only and channel → organization resolution, refusal of
organization defaults, and DM suppression of shared identities. Responses contain allowlisted
metadata only, never connector keys, account credential payloads or business records.
- Slack Settings renders populated Cloud MCP and skill pickers on the MCP, Skills and You pages
without a native Slack `invalid_arguments` error; selection still saves immediately.
## Slack channel membership approvals
- Managed Composio Slack join, invitation and channel-creation actions require a one-use human
approval card, including direct actions and nested execution batches through either identity.
Auto/Admin mode and remembered tool permissions cannot supply this decision. The card names the
connection, account selectors, destinations and invitees; oversized previews fail closed.
- Container and host runtime connections, including SDK sessions, traverse the same daemon check.
Denial, timeout, unavailable approval, cancellation, disconnect or expired/revoked authority
prevents forwarding. Ordinary structured reads and writes continue.
- Opaque Composio workbench/bash/proxy/API executors are refused because arbitrary persistent
code can hide a membership change. Use structured connector actions instead. This enforcement
covers managed Composio; independently supplied credentials, custom MCPs and browser sessions
remain outside that boundary. The always-loaded guide forbids bypass through any route and
treats sharing/posting requests as separate from permission to join or change the sender.
## Google Chat native conversation controls
- Google Chat `/help` now opens a compact Cards v2 guide with expandable topics, matching the
Teams quick-start experience. Sections carry emoji headers; quick-start entries and commands
are icon rows (Material icons) with the bold command over its description, and typed example
phrases use a chat icon. The card is the whole reply, with no separate text bubble. If card
delivery fails, the complete text guide is returned.
- `/settings`, `/secrets` and `/model` open requester-only panels in their source space/session.
General, Variables, MCPs, Skills, Automations and Resume reuse the same guarded settings policy
as Teams. Channel defaults and session overrides have separate model/effort previews and Apply
actions. Credential entry opens a private browser form using one-use grants, HttpOnly cookies,
CSRF checks and current space membership; stored values are never displayed or prefilled.
- `/files [folder]`, `/sendfile <path>` and the agent's `share_file` show private file controls:
browse, text/Word text preview, download, and eligible browser editing/uploads. Paths remain
confined to the workspace and every click/browser request repeats current authorization.
Browser links require the gateway Public URL; files are not uploaded to Drive or published.
- Final answers, errors and stops carry compact statistics plus **Files**, **Variables**,
**Settings**, and up to three referenced-file buttons. The sealed menu survives restarts;
opened panel drafts expire after 15 minutes. Google-enforced private viewers keep file and
credential links visible only to the requester in the original space.
- Native approval cards remain in the source conversation. Pub/Sub card and text controls use a
separate durable lane so stop/status remain responsive while agent turns are running.
Registered slash commands are rebuilt from Google's SLASH_COMMAND annotation (or the literal
message text), because `argumentText` carries only the arguments; a numeric command ID is never
mapped to a name. In a group chat or unthreaded space, a top-level `/stop`, `/status` or
`/clear` that quotes the request or a bot reply targets that quoted session. The working
placeholder is edited in place with a compact heartbeat (phase, run-slot position, elapsed,
last activity, subagents, "Still connected") — Chat has no typing indicator — and is replaced
by the final answer; if that edit fails the answer is posted and the placeholder removed.
Completed space/group answers mention the requester. Approval refusals (not authorized, wrong
scope, already handled) and control errors are shown privately to the clicker with the policy
reason; REST bodies, system errors and host paths collapse to generic retry guidance. Deny
ignores the comment field. A refused private-browser save shows a value-free reason and keeps
the session. Open panel drafts and browser-settings sessions are in memory and need reopening
after a restart; a restart-interrupted click is reported only to its clicker. Google Chat
dialogs and typing events remain unavailable; card buttons act only with the HTTP endpoint
connection (next section).
- Automated coverage and pending live Claude/Codex gates: → TEST-PLAN: Google Chat native controls.
## Google Chat HTTP endpoint (working card buttons)
- A Chat app has one connection setting, and Google delivers card and button clicks only to an
**HTTP endpoint URL** connection — never to a Pub/Sub topic, and the Workspace Events API has no
click event type. With the topic connection every card button ends in Chat's "unable to process
your request". ChannelGate therefore serves `POST /api/googlechat/events` on its HTTPS listener,
mounted ahead of admin auth and the Host/Origin guard like the Teams webhook: it reads no cookie
and authenticates every request itself (`src/platforms/googlechat/verify.js`).
- Verification fails closed: RS256 by a key in Google's published set (cached a day, one refresh
per minute for an unknown `kid`), issuer `accounts.google.com`, audience equal to the configured
endpoint URL (`<Public URL>/api/googlechat/events`), `email_verified`, and the sender exactly the
configured **Chat app service account** (an add-on's `service-<project-number>@gcp-sa-gsuiteaddons…`)
or Google's `chat@system.gserviceaccount.com`; a standalone app with the project-number audience
presents a JWT issued by that account, verified against its own key set with the configured
**project number** as audience. Anything else answers `401` with no detail (the reason is logged);
an endpoint without a Public URL or project number answers `503` naming the remedy; a connected
transport is required (`503` otherwise).
- **Typed settings commands.** `/settings <option> <value>` changes General settings without a card
click: `mode read|worker|admin`, `auto|lean|network on|off`, `access approved|admins|none`,
`managers admins|members|custom`, `vpn on|off`, and `[thread|channel] engine|model|effort
<value|default>` (thread by default). Each maps onto the same `settings.access` /
`settings.vpn` / `settings.runtime` action the card buttons call (`settings-commands.js`), so
authorization, manager checks, mode policy and audit are unchanged; the reply is the policy's
own outcome. Named guests/managers, credentials and destructive actions stay on cards or the
private browser form. The `/settings` card leads with this command list. This is what works
on a Pub/Sub-connected app, where Chat delivers no card clicks to the gateway.
- **Click and ignored-event logging.** Every card click logs `card click received via
pubsub|http: <action>`; a click whose shape is not recognized logs `card click dropped`, and any
other unhandled event logs `event ignored`, each with a value-free shape (CloudEvent type,
legacy type and key names only — never text, names or ids; `eventShape` in `events.js`).
- An add-on Chat app dispatches a click to the URL in the button's `action.function`, so the
connector rewrites every card action's function to the endpoint URL as it posts or edits a card
(`routeCardActions`, following the live Public URL); the action name rides the `cgAction`
parameter, which the click parser prefers. A short name there made Chat answer "unable to
process your request" before the click reached the gateway.
- Accepted events feed the same transport handler the Pub/Sub puller feeds — messages, membership
(`addedToSpacePayload`/`removedFromSpacePayload` and the legacy `ADDED_TO_SPACE` types now parse
without CloudEvent attributes), app commands and `buttonClickedPayload` clicks — so gating,
authorization, dedupe (body hash for clicks, message name for messages), the durable inboxes
and the private control cards are unchanged. The endpoint acknowledges with an empty JSON
object as soon as the event is durably queued, inside Google's 30-second window; every reply,
card edit and private card still goes through the Chat REST API. The Pub/Sub subscription stays
connected and keeps delivering Workspace Events reactions.
- Settings → Google Chat shows the endpoint URL once a Public URL exists and gains **Chat app
service account** (validated as a `…gserviceaccount.com` address, lower-cased) and **Google
Cloud project number** (numeric). Both are listed back; neither is a secret. The transport's
status line reports `endpoint <url>` or `endpoint off (set Public URL)`.
- Automated coverage and the live gate: → TEST-PLAN: Google Chat HTTP endpoint.
## Teams reply menu
- Every Teams answer, error and stop carries the reply menu under its run-stats line, like
Slack's footer: **📂 Files**, **🔑 Variables**, **⚙️ Settings**, and one **📄 <file>** button for
each workspace file the answer named in inline code (up to three). Each opens the same private
Teams window as `/files`, `/secrets`, `/settings` or `/sendfile <path>`.
- The buttons hold no server-side state, so they keep working on older replies and across
restarts. Each carries its conversation, session, requester and file path sealed with an
HMAC under a per-deployment key stored in the gateway database. A click is honoured only in the
conversation Teams reports for it and only by the requester; another person is told to open
their own controls. Every window re-applies the clicker's live workspace authorization, and
file paths stay confined to the conversation's folder.
- Which files get buttons is the same platform-neutral rule Slack uses
(`src/gateway/reply-files.js`). Connectors opt in with `replyMenu`; Google Chat renders Cards v2.
## Teams workspace file previews and delivery
- Teams agents receive `share_file`, which posts **Open file** in the current conversation and
opens the requester's private Teams file window. Current gateway access and Teams roster
membership are checked before delivery and again when opening/downloading. A gateway Public
URL is required for browser downloads; sending a file this way does not publish a public link
or upload it to OneDrive. Slack-only tools are omitted from Teams and Google Chat MCP sessions.
- Read-only file callbacks share a 3.5-second response budget and cancel stalled membership
requests. Opening and browsing reuse the authorization result within that one request, avoiding
duplicate network checks; each subsequent click still checks current membership, and cheap
local policy checks repeat after the network request to catch in-flight revocation. Failed
task/fetch requests return a renderable error card. File launchers persist their bot/owner/tenant,
source workspace/session and selected-file references in SQLite, surviving reconnects, restarts
and in-memory eviction within their original 15-minute lifetime. Dialog drafts remain private
and ephemeral. Missing, expired and unknown references get distinct errors and bounded log
reason codes without payloads or opaque IDs. Settings and approval mutations keep their existing completion semantics.
- The file window previews up to 2,600 bytes of text/Markdown as literal monospace text, states
when the preview is truncated, and downloads the complete original file. A Word `.docx` (up to
20 MB) shows a read-only plain-text preview of its body — no formatting, images or layout, and
no Edit — extracted daemon-side with bounded ZIP inflation; encrypted, malformed or oversized
documents and other binaries such as PDF show download guidance. Preview and Back to files work
without a Public URL. **Edit file** opens the browser editor (live Markdown preview) for text
files up to its own 250,000-character / 1 MB limit, not the 3,000-character in-chat limit;
eligible text editing and uploads retain their existing write-access checks.
- Public links still require the configured feature switch, Public URL, explicit duration (at
most 48 hours) and native human approval. The existing Teams approval delivery fix keeps that
card actionable without supplemental private links. Regression fixtures cover minting only
after Approve for both engine claims. Live Teams acceptance and private registry records are
pending; see TEST-PLAN → Teams workspace file previews and delivery.
## Routing ongoing requests to their owning scope
- Both engines receive a scope decision before persisting “always”, “from now on”, corrections,
or recurring requests: facts go to memory, reusable procedures to their existing skill,
changes to one automation to its existing schedule, and behavior across tasks to channel
instructions. One-off requests need no persistent write. The managed block carries this rule
in Clean mode too; the operating guide gives the spam-cleanup example and verification steps.
- Agents inspect the existing source, preserve personal/shared account scope and unrelated
instructions, and report saved versus pending changes. Missing skill/schedule editing access
does not justify a fallback to channel instructions or a declarative memory copy. The memory
skill and reduced-tool reviewer carry the same boundary.
- `list_schedules id` returns one conversation-scoped task's complete prompt and configuration.
`update_schedule` edits its prompt/title in place for the creator or a gateway admin, requiring
the exact old `expected_prompt` and an atomic row comparison. It retains the job's identity,
cadence, creator, enabled state, delivery, and history; edits apply to future fires. Thread-loop
content uses its own controls. Existing delivery-only edits keep their prior contract.
- Regression and live Claude/Codex fixtures: TEST-PLAN → Ongoing request routing.
## Provider selection readiness
- The admin channel Runtime picker lists enabled engine adapters, including optional providers,
and retains their saved engine/model selection when an unrelated channel setting is saved.
- Enabled optional providers must have their own configured credential and endpoint before a
runtime selection is saved in `/model`, Slack Settings, admin default/channel/DM saves, model
shortcuts or explicit provider directives. Rejection explains the required Settings repair and
preserves the existing engine/model/effort pins; it never substitutes another provider account.
- Provider admission is an optional adapter hook, separate from CLI installation/health. Admin
settings validate the complete candidate so enabling/configuring/selecting a provider can be one
atomic save. Removing a credential does not rewrite existing pins or defaults; the selected
provider still fails closed at execution until repaired.
## Processed Slack attachment retention
- Successful nonempty attachment downloads leave a small delivery receipt under the thread's
`uploads/<thread>/.downloaded/` folder. Removing processed originals no longer causes every
ordinary thread reply to download them again. Failed downloads stay retryable. A newly attached
file or explicit `slack_download_file` request can still download its original. Receipts contain
no private URLs, credentials, or attachment content, and use the same no-follow writer.
## Claude Cloud MCP selection admission
- Claude discovery separates CLI connectivity from isolated-runtime admissibility. A connected
server with host credentials, a missing definition, or a stale transport is marked unavailable
with a safe reason; rejected command targets and credential-bearing URLs never enter the catalog.
- Slack and Teams Cloud MCP management and the admin editor offer activation only for admitted definitions.
New channel, DM, personal, organization and template selections are rechecked against current
operator definitions before saving, including stale cached discoveries. Previously stored
selections remain removable; unrelated saves preserve them and runtime admission still drops
unusable optional connectors with a notice while allowing the turn to continue.
- The shared admin MCP picker round-trips the exact saved reference for an unavailable selection,
even when discovery redacts its transport metadata. Channel saves refresh the picker from the
committed record, so removing an unavailable grant disables re-addition and later unrelated saves
cannot restore it. New selections still require current backend admission.
## Drive sync status for unlinked channels
`get_channel_drive_folder` distinguishes the gateway's enabled/key-configured state from an
individual channel's folder link. An unlinked channel reports sync off and asks for a link;
only a linked channel receives the next-sweep notice. Missing gateway prerequisites still
report the disabled switch or missing service-account key. Both Claude and Codex read the same
control-plane status.
## Automation health and quiet delivery
- Automations have independent controls for **run visibility** (`visible` or `silent`), **result
messages** (`always` or `on-result`), **failure notifications**, and **destination** (standard,
channel, daily thread, chosen existing thread, or the existing conditional creator DM). Defaults
preserve visible runs and all replies. Silent tasks omit startup messages; silent daily threads
create their anchor only when there is output to deliver. Multiple daily executions may share a
chosen thread while each receives a fresh engine session; `/loop` remains the explicit
resumed-session exception. Reminders retain their single-message behavior.
- Every agent task receives a final structured outcome instruction. The daemon records **engine
completion separately from agent-reported task outcomes**: completed work, healthy no-op, blocked,
failed, or unreported. Missing, malformed, duplicate, incomplete, or contradictory connection
reports cannot count as healthy empty checks. Only an explicit healthy `no-op` is filtered by
`on-result`. Failure/unreported messages remain visible unless the owner selects
failure-notification suppression. Connection checks are names and available/unavailable/unknown
status, explicitly agent-reported rather than independently verified.
- Admin UI → Automations → **Runs** shows KPIs, run/request/due timestamps, scheduling delay, last
activity, duration, observed agent activity, engine/model, tools, token use, cost (unknown stays
unknown), connection checks and bounded event logs. The dialog refreshes every five seconds while
open. **All run history** includes deleted and one-time tasks. `get_schedule_runs` exposes only
the current conversation through the control MCP; `update_schedule` changes delivery policies
without modifying the schedule creator or execution privileges.
- Migration 32 adds a daemon-only SQLite run ledger independent of the schedule row. History covers
newly observed attempts, including deduplicated disconnected-transport/concurrency deferrals, and
retains 90 days of terminal records. It does not backfill older executions or prove missed fires
outside the scheduler’s existing five-minute catch-up window. Logs retain at most 200 safe event
kinds/tool names/statuses per run, plus bounded redacted summaries and diagnostics; they omit tool
arguments, outputs, reasoning, paths and credential values.
- Delivery retries reuse the saved completed result and its run record, without rerunning tools.
Unknown external effects after interruption pause the task for inspection. Proven replay-safe
engine failures preserve recurring eligibility. Task success and message delivery are
independently auditable, so a chat outage cannot erase completed work or token spend.
## Slack Settings redesign (2026-10)
- **Seven pages behind one dropdown.** The first line is the conversation name with a page
dropdown: **Agent · Access · MCP · Variables · Skills · Schedules · You**. A DM has no Access
page. Slack lays out a modal's button rows as an equal-width two-column grid, so seven tab buttons
would take four rows. Old page ids (`general`, `runtime`, `network`, `resume`, `secrets`,
`automations`) still land on the page that now owns their controls.
- **Agent:** one row of engine · model · effort dropdowns per scope. When Settings is opened from a
thread, *This thread* comes first, with *Use channel default* while it is pinned; then *Channel
default*. The mode is a dropdown in the same style, with Auto-approve · Lean · Internet ticks
beneath it. Managers and admins edit them; everyone else reads one line. Below that: one VPN line,
and this thread's resume command when there is a session.
- **Access:** *Who can use it* and *Who can manage it*, then **People**: only the people with a
setting of their own here (guests, excluded members, named managers), plus *Find a person…*, which
is Slack's own people search. Picking someone shows just their row, with *Show list* to go back;
someone outside the channel is named as such. Everyone the policy admits needs no row, so the page
stays short in any size of channel and draws without a Slack API call. Each person has a **Use**
tick, plus a **Manage** tick when the channel names its managers; admins show as "always".
- Ticking someone the policy would refuse grants them guest access (`allowedUsers`).
- Unticking an approved member records a per-channel exclusion (`excludedUsers`). The run gate
(`isAuthorized`) honors it on every surface; admins are never excluded. The exclusion list is
audited with the other list fields.
- The searched person survives each tick's repaint. Unticking a guest, or re-ticking an excluded
member, removes their exception, so they leave the list. A list over 60 people is cut there,
with a line pointing to the search (Slack caps a window at 100 blocks).
- **MCP:** Composio, Make toolbox, Toolbox, Custom MCP and (admins) Cloud MCP each have their own
section.
- Edit or Connect opens the fields in place, with the URL or label prefilled and the token box
always empty. Remove (with a confirmation) sits inside the open form.
- The Toolbox row shows the gateway's Toolbox server URL; the token is per scope.
- *Fall back to organization tokens* is one tick.
- Cloud MCP is one multi-select per engine. A cold catalog says it is loading instead of
blocking the page.
- **Variables:** *Channel* and *Organization* sections. Organization variables are admin-only;
personal ones are under You. Each variable is one line (name, last four characters, where it may
go) with a ⋯ menu:
- **Replace value** — the same name gets a new value; its type and allowed servers are kept.
- **Allowed servers** — opens with the current list. A non-empty list becomes the variable's
declared servers and makes it secret again; an empty list clears declared and admin-approved
servers, so the built-in servers or per-server approval apply.
- **Make readable** — asks in place, because programs then see the real value.
**Make secret** applies at once. A password-like variable stays readable and says why.
- **Remove** — asks in place.
Slack's overflow menu can carry only one confirmation for all its options, which is why the two
irreversible choices confirm on the page instead.
- **Skills:** the template dropdown and one picker for the channel's own skills, with a line for
what the template and the organization add. This replaces the four overlapping lists and the
pushed manager.
- **Schedules:** one line per schedule (▶/⏸, when, last result) with a ⋯ menu (Pause/Resume,
Delete — which asks in place). A one-time reminder or a loop has one Cancel/Stop button that
confirms.
- **You:** the viewer's own Composio account, Toolbox token, custom MCP servers, variables and
skills, edited the same way. They apply only to runs the viewer starts, in every conversation.
- **Inline forms.** Slack allows input fields only in a modal with a submit button. While a form is
open the footer reads **Save** / **Close**; saving is the view submission, so a refusal appears
under its own field. Each form's field ids are keyed by the form, so Slack never carries a typed
value from one form into another. The open form and any pending confirmation ride in the view's
metadata; changing page clears both. After an asynchronous save refusal, the reopened form uses
a fresh identity for its token or variable-value input so Slack clears the typed secret, while
non-secret fields remain filled. A blank token on an existing connection still keeps the saved one.
- **Explainers removed:** the permission paragraph, per-field footnotes and the long Access note.
What remains is the 🔒 line shown where the viewer cannot edit something.
→ TEST-PLAN: Slack Settings redesign.
## Custom MCP connections (Bearer token)
- An admin can add a remote MCP server by **name, HTTPS URL and Bearer token** to a conversation
(admin UI → conversation → Connections → *Custom MCP servers*) or to one person (Users → the
person → *Personal MCP servers*). Each scope holds at most five. OAuth-only servers are out of
scope and go through Composio.
- **In Slack too:** ⚙️ Settings → **MCP** lists the conversation's servers; **You** lists the
viewer's own. Both are masked like the admin UI (server name, URL and last four token characters)
and edited in place. The conversation's servers can be changed only by the channel's managers and
admins (`canManage`, re-checked on every click and submission); anyone edits their own. Saves and
removals go through the same `custom-mcp-store.js` path as the admin UI. Audit rows name the
Slack user. Address and membership checks acknowledge Slack at once, time out after 15 seconds
without saving, and re-check the editor's current authority before the write. The Teams settings
dialog does not offer this yet. → Slack Settings redesign.
- Runs receive a conversation's servers as `custom-<name>` and the AUTHOR's own as `my-<name>`, in
Claude and Codex turns (fresh, resumed, cross-engine fallback) and in SSH sessions. The prefixes
keep them apart from each other, the built-in servers and Cloud MCP selections. Lean injects
none. The HTTP run API, whose author is not authenticated, gets the conversation's servers but
never a personal one. Claude's per-run lockdown admits each server by name and URL and
pre-approves its tools, as for the other gateway-injected remotes. A container run's settings list
the name only. The URL is listed only for a host turn, because a URL may carry a query-string key.
Like the Make toolbox, the daemon relays the server whatever the conversation's network switch
says.
- **The token never enters the container.** As with the Make toolbox, the daemon registers URL +
`Authorization: Bearer …` in the in-memory relay registry, and the container gets only the
socket bridge and signed capability. Codex's container bundle holds only the capability. A
direct-host `/sudo` turn dials the server itself (Claude: header in the 0600 MCP config; Codex:
a headers helper reading the 0600 bundle).
- **Public addresses only, checked twice.** A save refuses non-HTTPS URLs, embedded credentials,
fragments, and any host that resolves to loopback, private, link-local or metadata addresses.
The daemon relay sits outside the egress proxy, so each dial repeats the check and pins the
connection to the vetted records (`src/mcp/public-fetch.js`). It uses its own agent, never an
env proxy, and never follows a redirect.
- **Write-only.** Listings (`GET /api/channels`, `/api/users`, the per-scope `custom-mcps`
routes) return name, server name, URL, last4 and who/when. There is no reveal path; replace a
token by entering a new one. Saving an existing name without a token keeps the stored token.
- Replacing or removing a server drops that one server from every live relay grant in its scope
(`revokeRemoteMcpServer`). The old token stops working at the next relayed request; the turn's
other servers keep running. Tokens are redacted from replies, the live stream and job output,
and a rotation retires a warm Claude process through the relay digest. Audit events
`channel_custom_mcp_set/_removed` and `user_custom_mcp_set/_removed` carry the name and URL host,
never the token.
- The gateway tool `list_channel_mcps` also lists the conversation's custom server NAMES, never a
URL or token. The `gateway-usage` guide tells the agent these are added by people in the admin UI, never by
writing MCP config files, which the engines ignore (`--setting-sources ""`,
`--strict-mcp-config`, Codex `--ignore-user-config`).
→ TEST-PLAN: Custom MCP connections.
## Email accounts (IMAP + SMTP, per person and per conversation)
- A mailbox is connected **once, on one form** — name, address, optional display name, IMAP
server/port/user/password, SMTP server/port/user/password — to a conversation (channel scope,
shared by everyone who talks there) or to one person (personal scope, usable only in runs they
author). Each scope holds at most five. Ports default to 993 (IMAP) and 587 (SMTP); security is
derived from the port (993/465 implicit TLS, anything else a **mandatory** STARTTLS upgrade —
there is no cleartext option). Blank SMTP user/password reuse the IMAP ones. Rules in
`src/gateway/email-accounts.js`, writes in `email-account-store.js`.
- **Tested before stored.** Every save logs in to the IMAP server and the SMTP server (no message
is sent) and refuses the record naming the failing side (`IMAP login failed (host:port): …`).
The daemon dials each server through the same public-address check as custom MCP relays, pins
the connection to the vetted address and verifies TLS against the configured host name:
loopback, private, link-local and metadata destinations are refused at save time and again at
every dial. A server that does not upgrade to TLS fails the connection.
- **Where to connect one.** Admin UI → the conversation → Connections → *Email accounts*, or
Users → the person → *Personal email accounts* (`/api/channels/:id/email-accounts/:name`,
`/api/users/:id/email-accounts/:name`). In Slack: ⚙️ Settings → **MCP** → *Email accounts*
(the conversation's; channel managers and admins) and **You** → *Email accounts* (the viewer's
own) as inline forms, with the save acknowledged at once and the connection test allowed up to
45 s. In Teams: Settings → **MCPs** → *Email accounts* (channel block and *My connections*). In
Google Chat: Settings → *Open private settings* — the private browser form, since a public card
cannot take a password. All surfaces call the same store; audit events
`channel_email_account_set/_removed` and `user_email_account_set/_removed` carry the name, the
address and the server hosts, never a password.
- **Write-only.** Every listing (`GET /api/channels`, `/api/users`, the per-scope routes, the
Slack/Teams/Chat views, the browser form) returns servers, ports, security, user names,
`hasPassword` and who/when. There is no reveal path. Saving an existing name with the password
boxes empty keeps the stored passwords; a new IMAP password with a shared SMTP password rotates
both. Passwords are also pushed into every run's output redactor (`emailSecrets` from
`resolveRunIntegrations`), so no reply, stream or job log can echo one.
- **The agent uses it through gateway tools, in the daemon.** `list_email_accounts`,
`email_mailboxes`, `email_search`, `email_read`, `email_download_attachment` (into
`uploads/email/<account>/`, ≤ 50 MB, no-follow writes), `email_move`, `email_set_flag` and
`email_send` (From is the account's address; attachments are opened through the confined-file
opener, 10 files / 25 MB; `in_reply_to` threads; a copy is appended to the `\Sent` mailbox
when the server exposes one; refused when the account's *allow sending* is off). ImapFlow and
Nodemailer run in the daemon process; a container never holds a mailbox password and the
`.claude/settings.json` allowlist pre-approves the tools like every other gateway tool. The
memory reviewer's reduced toolset does not get them.
- **Scope resolution is explicit.** A turn sees the conversation's accounts plus the VERIFIED
author's own; the HTTP run API's named author is attribution only and gets the channel scope
alone, and another person's turn in the same conversation never sees someone's personal account.
`account` takes a name, `personal/<name>`, `channel/<name>` or the address; a bare name present
in both scopes is refused until qualified; with exactly one account it may be omitted. The
`gateway-usage` guide (`references/email.md`) carries the pronoun rule ("my" = personal, "your /
the team's" = channel), the confirm-before-send rule, the shared-thread privacy note and the
instruction to send people to Settings instead of asking for a password in chat.
- **Not a Composio replacement.** Gmail, Outlook and the other apps a Composio account already
holds stay on Composio; email accounts cover the mailboxes it does not — custom domains, app
passwords, shared inboxes, transactional SMTP relays.
→ TEST-PLAN: Email accounts.
## Platform activation emoji settings
- Admin Settings → Agent defaults → **Message activation reactions** has independent Slack,
Microsoft Teams and Google Chat lists. The existing `mentionReactions` key and Slack selection
remain compatible; Teams uses `teamsMentionReactions`, Google Chat uses
`googleChatMentionReactions`. Empty lists restore each platform's defaults.
- Teams selection is live for native activities and Graph history intake, including the existing
robot, Alien and Like alias families, Unicode/reference-ID matches and qualified robot metadata.
Custom selections replace activation defaults; Stop and Tick stay fixed. Saved configuration
changes carry a monotonic timestamp, prevent historical/queued activation replay, and preserve
existing event identities and reactor authorization. Teams help shows its current activation list.
- Google Chat's Unicode emoji selection (default 🤖) acts once reaction events are enabled (below).
## Google Chat reaction controls
- Chat apps receive no interaction event for a reaction. With Settings → Google Chat → **Reaction
events topic** set, the transport keeps one Google Workspace Events subscription per space the
app belongs to (`google.workspace.chat.reaction.v1.created`/`deleted`, resource data included,
app authentication with the admin-approved `chat.app.messages.readonly` scope, on a separate
token so an unapproved scope never affects messaging). A stateless reconciler lists the app's
subscriptions every 30 minutes and on startup, space joins and lifecycle events: it renews any
with under two hours left (four-hour maximum), reactivates suspended ones, recreates expired
ones, retires duplicates and those for spaces the app left, and never touches subscriptions on
another topic. Status appears in the transport line (`reactions N/M spaces` or the error).
- Events arrive on the existing pulled subscription. The reactor (`users/…`) maps to exactly one
gateway user recorded from that person's delivered messages; the reacted message and its space
are fetched to resolve thread and session. Start runs the reacted person's text as the reactor,
or continues from the app's own answer; Stop and Tick ride the durable control queue, so Stop
interrupts a running turn and cancels queued work for its session (author or admin); Tick
closes a tracked reminder. Freshness bounds (Stop 10 min, start 1 h, Tick 24 h), removal
tombstones and queued-row withdrawal, event identity from reaction name + provider time, and the
durable inbox's deduplication keep any reaction from acting twice or late. Unknown, ambiguous,
bot and unapproved reactors never act.
## Perplexity research delegation
- Admins can enable a shared Perplexity subscription login in Settings and import the session
token produced by `pwm login`. The login is masked on settings reads; leaving the field blank
preserves it, and an explicit clear disconnects it. The account is gateway-wide and research
requests consume its subscription quota.
- Claude and Codex can call the managed `perplexity-research` MCP, including in read-only
channels: `perplexity_research` delegates a self-contained public-source question in quick or
deep mode and returns an answer with citations; `perplexity_models` discovers model IDs;
`perplexity_usage` reports remaining quotas. Deep Research selects its own model. The helper
exposes no authentication, upload, workspace, private-connector or library-save tools.
- The real login stays in gateway settings. Eligible proxy containers receive only a
channel-bound relay placeholder in their HOME; the proxy substitutes it only in the exact
Perplexity session cookie on `www.perplexity.ai`, with current settings, channel identity and
live-work authorization checked on every request. Network-off, Lean, host and legacy bridge
runs do not receive this optional MCP. Rotation and clearing take effect at the relay.
- The image pins the unofficial `perplexity-web-mcp-cli` research client. This integration is
opt-in, uses subscription quotas rather than API billing, and keeps the conversation's selected
coding engine. Setup and operating limits: [`docs/PERPLEXITY-RESEARCH.md`](docs/PERPLEXITY-RESEARCH.md).
## Teams text runtime and transport reliability
- Text `/model` respects a conversation's dedicated Codex login, refusing a different engine
before changing its session selection. `/status` and `/effort` resolve only models and efforts
compatible with the selected engine, including inherited defaults. Text selection saves the
runtime triple atomically and refuses work or competing settings changes admitted meanwhile.
- Verified ordinary Bot Framework events reach durable SQLite intake before HTTP acknowledgement.
Queued events resume after reconnect; uncertain running work produces an interruption notice
without automatically repeating tool effects. Control and Graph-event intake have separately
bounded capacity so Stop remains reachable while ordinary work occupies every dispatch slot.
Pending-work checks and cancellation cover durable queued turns after the same mention,
author and sudo admission checks as ingestion; native model cards also respect that queue.
- Bot Framework token, HTTP and response-body reads are bounded to 15 seconds. Foreground
placeholder delivery accepts cancellation; timed-out or cancelled sends are never
automatically replayed when their delivery outcome is uncertain.
- Authenticated, allowlisted Bot Framework service URLs persist per bot and conversation.
Foreground replies, edits, roster reads and unattended delivery retain the original region
after reconnect; private file/card delivery creates its personal chat through the source
conversation's service and retains that route too.
- Deterministic scratch fixtures cover these paths for Claude and Codex configurations. Actual
provider turns and native desktop/web/mobile acceptance remain required; fixture results do
not establish live Microsoft client acceptance.
## Microsoft Teams model command
- Bare `/model` opens a native picker in the source conversation laid out like Settings →
General: the current session selection, Engine with a **Load models** button, then Model and
Effort listing only the loaded engine's choices, and **Apply to this session** to change its
next turn. Load models repaints the same card without saving. Apply after changing Engine
without Load models loads that engine instead of saving. The loaded engine is held server-side
with the card, and Apply must name it. A conversation on its own Codex login shows Codex only; `/settings` → General manages conversation defaults. Typed
`/model <engine> <model|default>` and `/effort <level|default>` remain available.
- The picker preserves channel threads and group quote/session continuity, offers enabled
engines, respects a dedicated channel Codex login, and rechecks membership and runtime policy
on submission. Busy sessions refuse changes; expired, foreign and replayed controls cannot
write. Execute and legacy Submit both replace the original card with the outcome.
## Unavailable skill grants
- An unavailable personal skill or `requires:` dependency no longer blocks the author's entire
turn. Available skills still load into the isolated per-run plugin/catalog; missing, awaiting-review
and removed entries stay out. A warning names unavailable skills in streamed and final replies,
including either cross-engine fallback. Shared-skill omissions use the same warning path.
Stored grants remain unchanged so restoring a source can reactivate it. Personal ownership,
runtime artifact and confinement errors still reject the run.
## Microsoft Teams help guide
- Teams `/help` opens a compact native Adaptive Card: a quick start, four common commands
in a highlighted panel, then six expandable topics (all commands, files/voice, settings/access,
skills/memory, reminders/background work and reactions). Short labeled paragraphs and native
monospace examples keep it scannable. Topic buttons expand/collapse locally without running
commands. Card and complete paragraph-spaced text fallback share one content source.
It covers mentions and session continuity, reaction activation, local voice transcription,
stop/queue behavior, private workspace files and file consent, settings and variables,
Composio, skills, conversation memory/rules, reminders, schedules and background work.
Examples use Teams controls and explain their permissions and configuration requirements.
The authorized native command responds before an engine turn, in the requesting conversation.
Quoting the help card in a group chat continues the same session.
- Help reaction labels show the currently configured Teams picker names, preserving custom
entries and explaining when activation is disabled. Stop sign and Tick button / Checkmark button
remain listed as session controls. Raw internal reaction IDs are omitted from the help copy.
The guide explains that typing an ID as a message does not add a reaction. Teams intake recognizes
both `hearteyesrobot` and the picker shortcut `:hearteyesrobot:` as activation IDs. Like (`like` or
Unicode 👍, including skin tones) is an alternative. Alien accepts `alien`, `:alien:`, Unicode 👽
and the documented `1f47d_extraterrestrialalien` ID. Its separate persisted
introduction cutoff excludes Alien history from before this expansion without resetting earlier
cutoffs. Graph also accepts the Heart eyes robot variant encoded as 😍 only with the exact
provider name `Heart eyes robot` and no custom content URL in both added history and current
reactions. Plain Heart eyes and unnamed history stay inactive. Its separate persisted cutoff
prevents newly recognized robot metadata replaying older history. Existing robot IDs remain supported alongside
`stopsign` and `2705_whiteheavycheckmark`, plus Unicode/legacy aliases. Activation starts a request
as the reactor. Stop cancels only the selected session’s active and queued work with the same
author/admin checks as `/stop`. Channel replies and group quotes resolve to their stored root.
Tick closes a tracked reminder’s acknowledgment chain from its original or escalation message;
reaction removal cannot reopen it. A tick on an ordinary message reports no pending reminder.
Teams personal follow-up dismissal remains unavailable. All actions pass conversation admission,
reactor authorization and the sudo thread gate before any mutation; target text cannot execute
a slash command. Graph and native events retain single transport ownership, stable deduplication
and fresh/current reaction checks. Newly accepted alias spellings have a persisted subscription
cutoff so older reactions cannot activate on later unrelated Graph updates. Group/channel reactions need configured event delivery and
Microsoft permissions. The handlers precede engine dispatch for Stop/Tick.
When Graph omits reaction history, a current activation reaction's fresh provider creation
timestamp can start a request, including on an older message. A persisted upgrade cutoff excludes
pre-upgrade snapshot reactions. Snapshot retries and later history share one addition identity;
existing removal history wins. Stop/Tick use the same fallback under their own persisted cutoff
and only while the reaction is at most ten minutes old, so a late fetch cannot stop newer work.
Bot Framework rosters accept both modern `aadObjectId` and legacy `objectId` Entra identifiers
when resolving the addressable reactor; ordinary conversation/user authorization still applies.
The operational audit distinguishes Graph callback receipt, validation, authenticated target
acceptance, rejection and retryable failures using bounded metadata. Credentials, validation
tokens, message bodies and untrusted resource identifiers are excluded.
## Claude login for nested commands
- Before each container turn, the gateway refreshes a protected Claude login independently of the
selected engine. A Codex, Qwen or Qwen EU turn can explicitly run `claude -p --model <Claude model>`
without switching its thread. Fresh, resumed, background-agent, scheduled and API turns use the
same setup. The channel HOME retains only a channel-bound egress placeholder; OAuth refresh
tokens and real API keys stay on the daemon. Missing authentication clears the managed login
without preventing another configured engine from starting.
- Qwen's main process keeps its own provider endpoint and credential. A nested `claude` launcher
clears the inherited provider settings and nested-session guard, then loads the Anthropic relay.
SSH/editor launchers preserve the same HOME fallback. API-key installations also use protected
relay grants limited to `api.anthropic.com`; rotation, revocation and live-work checks remain
enforced by the proxy. Persistent setup requires proxy mode and a bound channel; host and legacy
bridge runs retain their existing authentication paths. API keys configured for a custom
Anthropic-compatible endpoint are excluded from the nested Anthropic relay. This does not create a provider login
when the operator has configured none, or add gateway accounting/MCP context to a bare child CLI.
## Gateway model shortcuts
Admins can add gateway-wide model shortcuts in Settings → Access & security → Model shortcuts. Each name selects an engine and model. In Slack, `@agent :astra task` pins that engine and model to the current thread and runs the task; `@agent :astra` switches the thread without starting an agent turn. Slack's automatically inserted space after the mention is accepted. Changing a mapping affects later shortcut selections, while threads already pinned keep their selected model. Channel runtime-change access and dedicated Codex-login restrictions apply.
## Public documentation website
- `documentation/` builds a static Astro/Starlight documentation section for
`channelgate.dev/docs` in the same Vercel website project as the marketing pages and blog.
The public handbook source now covers 152 pages: 107 dedicated feature guides in 12 groups,
13 configuration guides, 13 control references, four directories/maps, and the existing
15 overview, walkthrough, installation,
technical-reference and resource pages. Feature guides explain use, setup, examples,
permissions and practical limits; configuration guides distinguish gateway, conversation,
personal and thread scopes.
- The welcome page keeps the same persistent sidebar as articles, with a Start here section,
grouped feature navigation, configuration guides, technical references and resources. Search,
current-page navigation, theme controls and responsive layout use Starlight. A shared handbook
registry defines directory cards and sidebar entries together. Native Slack report artifacts,
Google Drive workspace sync and durable thread loops have their own guides alongside the
workspace, engine, knowledge/integration, access, platform, automation and operations topics.
- Thirteen published guides are generated at build time from this repository's canonical
Markdown files; the other 139 pages are authored in `documentation/src/content/docs/`.
Repository-relative links resolve to a published guide or the source file on GitHub's `beta`
branch. The export copies only the `/docs` subtree and entry point into an existing static
website output, then merges documentation URLs into its sitemap. The existing Vercel project
builds these docs automatically from public beta on each website deployment using
`documentation/scripts/vercel-build.sh` and a temporary checkout outside the website output.
- The functionality map links all 45 areas of the product inventory to practical guides.
The control reference documents all 106 gateway and 10 external skills-library tool definitions
with inputs, defaults, authority, results, limits and examples. A source-registration check
requires each tool exactly once; unknown or duplicate reference headings fail the build.
Documentation CI also runs on tool-source and feature-catalog changes. This verifies inventory
and control coverage; peer review verifies user-facing claims against current implementation.
- Automation guides document scoped run history and outcome evidence, quiet result filtering,
failure notifications and fixed-thread delivery, including exact creation, update and history
arguments. Engine completion is described separately from agent-reported task success.
- Handbook source and configuration were cross-reviewed against the public feature record,
canonical operator references and implementation. Microsoft Teams and Google Chat remain
explicitly Beta; optional VPN provisioning, engine-specific approvals and admin-only host
access retain their documented restrictions. This documentation change introduces no gateway
runtime behavior. The complete-functionality expansion is published at `/docs`; build,
browser, export, preview and production evidence is recorded in `TEST-PLAN.md`.
- The Microsoft 365 access guide explains Teams chat/team resource consent, separate Outlook
authorisation, selected file grants and the drive allowlist, Microsoft access versus agent
activation, and sensitive-data processing. It links from security navigation and the Teams
guide and explicitly requires verification of actual tenant grants.
## Conversation sources and Teams names
- Conversations has an independent source filter: All sources, Google Chat, Slack, or Teams.
It combines with search and All / Channels / DMs, including legacy Slack records.
- Teams channel titles use `#team-channel`, taking names from activities or the bot’s
team/channel directory. Existing ID-only records refresh on the next Teams message;
the UI shows `#teams-channel` while unresolved. IDs, work folders, and settings stay stable.
Directory errors preserve a previously resolved name and never block a turn.
## Runtime history transfer and Google Chat failure recovery
- Switching a thread between its container and `/sudo` host carries native history without
treating the transfer's own lease as an active occupant that blocks container recreation.
Preparation reports waits, responds to Stop, and falls back to transcript recovery after a
bounded preparation timeout. An explicit Stop never starts a replacement engine turn.
- Fatal Google Chat receiver errors stop intake and mark the connection as failed; health and
connector availability no longer report a stopped receiver as connected. Pub/Sub permission
failures name the Subscriber role and reconnect action. Reconnecting clears the failure, and
callbacks from older connections cannot overwrite the replacement connection's state.
## Channel-specific Codex authentication
- Runtime settings ask which Codex login a channel uses. The shared choice shows the channel's
engine, model and effort controls; gateway Codex sign-in lives under Settings → Agent defaults.
The channel choice shows its dedicated Codex login and fixes its engine to Codex. Slack and
Claude continue using the gateway connection.
- Admins can select the gateway Codex login or a dedicated host-side `CODEX_HOME` for a channel
from Runtime → Codex authentication. The dedicated directory is keyed to the conversation ID
and does not fall back to another account when no login exists.
- A sign-in method dropdown starts ChatGPT device code sign-in as soon as it is selected, or
reveals the OpenAI API key field. The code copies when clicked. The editor polls sign-in state,
lets an admin cancel a pending device flow,
and selects the channel login after success. The API returns only the method and device code;
the key enters the Codex CLI through standard input and never appears in a response or argv.
- Device code parsing handles the current Codex CLI's terminal coloring and full code length;
the login process receives the public TLS CA bundle when the host uses a proxy. The browser
shows the sign-in link and code as soon as the CLI supplies them.
- The operator can sign in to that directory with ChatGPT subscription access or an OpenAI
Platform API key. In proxy-mode containers, both methods use a channel-bound placeholder:
ChatGPT access tokens are relayed as JWT-shaped values, while API keys are relayed only to
`api.openai.com`. The host `auth.json` and any refresh token stay outside the container.
- The selected login also applies to an admin's direct-host Codex turn. The authentication
source is recorded in channel policy audits; no credential value is included.
- Selecting a channel's own Codex login fixes that channel and its threads to the Codex engine.
Runtime settings hide the redundant engine picker, and `/model` goes straight from scope to
Codex model and effort. Older Claude sessions switch to fresh Codex sessions on their next turn.
The admin UI describes the separate login storage without showing its internal host path.
- Slack channel Settings shows the channel's own Codex login status, model and effort when the
dedicated login is selected. Its engine control disappears. Codex Cloud MCP discovery and
selected MCP launch policy use that channel's login; the gateway catalog remains separate.
## Optional isolated VPN database service
- The host operator can provision a per-channel OpenVPN 3 Linux service and an unprivileged MySQL
verification/extractor container with `npm run vpn`. Only the dedicated VPN receives TUN and
`NET_ADMIN`; ordinary channel images, mounts, networking and agent permissions stay unchanged.
- The dedicated image pins OpenVPN 3 Linux, uses a private service bus, and is checked against
its production-source digest before starting. ON refreshes the protected supervisor bundle and
replaces an obsolete runtime; existing ordinary channel containers keep their original rights.
- Imported profiles are allowlisted and regenerated, with database-only tunnel routing, a
firewall kill switch, isolated credentials and no host socket or published ports. Configuration
is stored in channel metadata; protected profile revisions and a standalone operator bundle
survive channel recreation and gateway updates.
- A user systemd supervisor starts the pair after reboot/user-manager startup, monitors routing
and stops both on failure. Kernel locking excludes concurrent changes. Operator controls cover
configuration, build, status, start, stop, enable/disable and read-only `SELECT 1`/schema verification.
- After operator provisioning, channel managers/admins can turn VPN on/off through Claude or
Codex, the web channel Network controls, or Slack Settings → Network. Admitted members can read
status. All paths use the same fixed helper, current authorization and sanitized diagnostics.
- Status distinguishes automatic startup, connecting, connected and failed; readiness is freshly
checked. OFF also cleans up manually started owned containers. Network off blocks startup and
stops a supervised pair. No arbitrary commands, profile import or SQL extraction are granted
through these controls. See `docs/CHANNEL-VPN.md` for setup and limitations.
- The `gateway-usage` chat manual carries `references/channel-vpn.md`: roles, the host-operator
provisioning runbook (`npm run vpn` configure → build → install-unit → enable → status →
verify), the profile allowlist, failure classes and who fixes each, rotation and retirement.
Asked to set up a VPN from a channel container, the agent hands over the filled-in runbook
instead of attempting it, and never asks for a profile or password in chat.
- The 30-second VPN health check verifies only the tunnel and its route. It never opens a
database connection, so it cannot run up MySQL's connect-error count and get the tunnel address
blocked (error 1129).
- A turn-on or turn-off that the helper refuses answers with the same fixed diagnostic the status
row uses: a VPN image that no longer matches the gateway's source says to rebuild the image
instead of a generic "Could not change VPN state". Only a known failure class crosses from the
helper; its free text and provider output never do.
- Admitted channel users can ask either engine to list databases/tables, describe a table, or read
bounded matching rows using `query_channel_database`. The channel-bound extractor enforces fixed
read operations, a read-only transaction, row/byte/time limits, fresh authorization and Network
checks. It exposes no arbitrary SQL, credentials, shell, host selector or cross-channel target.
Configured VPN/database secret references are withheld from new ordinary agent launches, while
the protected operator service can still resolve them; unrelated channel variables are retained.
- Whole tables can be read page by page. `select_rows` ordered by the table's single-column
primary key (or a NOT NULL unique integer/char column) returns `nextCursor`, the last key as a
string, and the next call passes it back as `after` (keyset paging, never OFFSET); `null` marks
the last page. `count_rows` returns the row count for the same equality filters, so the pages
can be shown to add up. Each call is a separate read-only transaction, not one snapshot. An
extractor built before paging keeps serving the other operations and answers paged or counted
requests with the rebuild remedy.
Regression: `test/channel-database.test.js`, `services/vpn-image/test_query.py`,
`services/vpn-image/test_vpn3.py`, `test/channel-vpn-control.test.js`, `test/channel-vpn-web.test.js`,
`test/slack-vpn-settings.test.js`, `test/vpn-profile.test.js`, `test/vpn-service.test.js`,
`services/vpn-image/test_checks.py`; live isolation: `services/vpn-image/live_acceptance.py`.
## Admin-only direct-host sudo threads
- An organization admin can type `/sudo` (or `/sudo on`) in a Slack thread to make that thread's
subsequent turns execute directly on the gateway host as the daemon OS user. `/sudo status`
reports the posture and `/sudo off` returns future turns to the channel container. The command
refuses to cross the boundary while that thread has running or queued work.
- Sudo is scoped to one thread, not a channel setting. Only current organization admins may
activate, deactivate, message, or launch background work from it. Non-admin messages are rejected
before Slack hydration, queueing, attachment reads, or process spawn, and `runMessage` repeats the
admin check so alternate ingress, unattended work, stale rows and caller-supplied author IDs
cannot turn the stored flag into authority.
- The registered host runtime uses the daemon account's native filesystem, process namespace,
HOME, installed commands and network. It is intentionally outside the rootless container
boundary. Per-run engine environments remain allowlisted, secrets remain redacted, and channel
grants/MCP policy are still compiled normally. Stored channel metadata and run-API overrides
cannot select this runtime.
- Claude and Codex session files are carried container→host on enable and host→container on disable
when the backend supports it; an unavailable carry falls back to the existing transcript-healing
path. Idle warm processes from the previous boundary are retired when the flag changes.
Regression: `test/sudo-thread.test.js`, `test/runtimes-core.test.js`,
`test/session-carry.test.js`, `test/engine-runtime-isolated.test.js`,
`test/codex-args.test.js`, and `test/runtime-access-facts.test.js`.
## Interactive Slack clarification questions
Claude and Codex can ask for missing information through the shared `ask_questions` gateway tool.
Short question sets appear in the thread; longer sets open a paged modal from **Answer questions**.
Each request accepts 1–20 questions: single choice with up to four custom-labeled options (including
Yes/No), multiple choice with up to ten options, or written text. Choice questions can also accept
custom answers. Required and custom-answer settings default to true. Automatic presentation uses
the message for at most four non-text questions and a modal launcher otherwise; the caller can
explicitly choose message (at most four questions) or modal presentation.
Scheduled Slack tasks also receive `ask_questions`, addressed to their creator. A signed real
delivery thread is separate from the fresh per-fire engine session; silent/channel-only tasks can
post a top-level form that becomes its own answer thread. Submission resumes the originating
scheduled session, even after a one-time schedule retires, and serializes with that run. A single
pending scheduled form also accepts a typed answer; multiple cards require choosing a card with
Submit. Creator access is checked again after queue waits and on restart recovery. API principals,
helper agents, and memory reviews cannot create forms.
Custom text replaces a single choice or supplements multiple choices. Choices remain drafts until
final submission. Only the requester can answer; stale forms and
duplicate submissions cannot replace a completed answer. Requests and saved drafts persist through
daemon restarts, while stop/clear cancels pending requests. Submission queues the answers into the
same author's thread for continuation. The tool itself returns promptly with the pending request,
so agents can finish independent work without occupying a waiting turn. Clarification never replaces
the existing approval mechanism. The bundled guide teaches both engines when to use the tool and
falls back to ordinary questions when it is unavailable. Live acceptance gates: `TEST-PLAN.md`.
## Explicit sudo schedules
Ordinary task schedules default to the channel container. Organization admins can explicitly set
`sudo:true` with `create_schedule`/`update_schedule`, or the admin Automations editor's **Run as
sudo on the gateway host** checkbox. Sudo tasks run as the daemon OS account with the host filesystem,
HOME, network and engine permission bypass. Reminders and thread loops cannot enable this option.
The task keeps its original creator; creation, edits and each fire require a currently admin owner.
Enabling or editing a task that remains sudo through MCP requires an admin approval of the exact
request. Disabling sudo returns future fires to the container. Listings label the host setting.
Ordinary tasks never inherit sudo merely because their creation or delivery thread uses `/sudo`.
A form posted by an authorized sudo schedule retains that source posture for its original
creator's human continuation, including after one-time retirement. The creator must still be an
admin; this does not make the Slack delivery thread a sudo thread or let other senders use host
history. Invalid schedule identity, revoked admin status, disabled tasks and untrusted API authors
cannot mint schedule host authority. Regression: `test/schedule-sudo.test.js`,
`test/scheduled-questions.test.js`, and the MCP approval and schedule editor suites.
## System health
The last admin navigation item, **System health** (`/system-health`), shows daemon-side Linux
CPU, RAM, swap, filesystem capacity and system load. The collector runs every five seconds
independently of open browsers; the page supports pause, refresh and Live/1h/24h/7d/30d ranges.
Minute aggregates retain resource averages and peaks for 30 days. Storage history lasts 186 days,
with older samples reduced to hourly aggregates. The capacity forecast reports insufficient
history until an observed trend supports it; no historical points are generated to fill gaps.
Storage warnings start at 85%, critical at 95%. Peaks and collection status precede hardware.
Hardware inventory refreshes at startup, every five minutes and on manual refresh, with a bounded
change log. Unavailable hardware fields stay unknown; temperatures, serial numbers, credentials,
and hardware-management writes are excluded. Metrics use the filesystem containing the gateway
runtime root, and the collector runs in the host daemon without widening any engine container's
mounts. All metrics endpoints require the existing admin session. See `docs/OPERATIONS.md` and
the engine-independent System health acceptance gates in `TEST-PLAN.md`.
## Plugin packages through skill sources
Git and folder skill sources recognize Claude/Codex plugin manifests and store a complete package
as one revision. Existing Review, rollback, templates, organization/channel/personal grants, and
source sync govern the package. Catalog and assignment pickers show Plugin badges and safe
component summaries. Claude loads approved native skills/commands/agents and admin-gated hooks;
compiled inline hooks use Claude's event map so approved hooks execute instead of being ignored.
Codex receives approved skill catalogs. Both receive supported MCP transports through explicit
namespaced grants. When both manifests exist, each engine uses its own inline or file-based MCP
declarations; single-manifest portable packages remain supported. Unsupported components and
authentication produce actionable failures. Compiled
files stay inside the channel container's artifact mounts; updates rotate warm snapshots and
revocation removes future runtime grants. Source path/symlink/size validation retains last-good
revisions on failed sync. Details and compatibility limits: `docs/SKILLS.md`.
- **Slack reports shared-folder conflicts:** an authorized mention or typed command that cannot
start because channels share a folder with conflicting skills or memory settings gets a reply
in its triggering thread. The reply asks an admin to assign separate folders or align settings,
then retry. It exposes no other channel names or filesystem paths. The conflict still blocks
workspace writes and engine startup. → TEST-PLAN: Slack shared-folder conflict replies.
- **Read/search account routing:** unrestricted information requests may search either or both
available Composio identities without an account-selection interruption. Explicit account and
record restrictions remain binding. Writes, sends and other state changes require the intended
identity and connected account, reusing prior choices; unresolved mutation identity asks while
independent reads can continue. The bundled guide, managed instructions and fresh/resumed
prompts carry the same rule for Claude and Codex. → TEST-PLAN: Read/search account routing.
## Reply menu and standalone Slack menu
- **Every AI reply in Slack ends with the same menu: 📂 Files · 🔑 Variables · ⚙️ Settings**, with
visible labels, always all three, in that order. That holds for a streamed or classic answer, a
failed turn (the error line carries it), "🛑 Stopped.", an answer after an engine/model switch or
failover, an HTTP API run into a thread (streamed, fallback delivery or failure), a scheduled
run and its failure notice, a background job or agent continuation, self-diagnosis, and a turn
replayed after a restart (answer or failure). When an answer overflows into follow-up messages,
the stats and menu ride the LAST one, never the streamed head. Referenced-file `📄` buttons follow
the menu. There is no 💻 Resume button on any of them.
- **Native 👍/👎 answer feedback**: the same footer ends with Slack's `context_actions` block
carrying `feedback_buttons` (`cg_reply_feedback`) on every streamed, classic, unattended and
fallback answer that carries the stats footer (not on bare notices such as "🛑 Stopped."). Any
channel member's verdict counts — it is an opinion, not a privilege: the click is acknowledged,
recorded as a `reply_feedback` event (channel, thread, message, clicker, verdict, requester) and
a 👎 answers the clicker privately, inviting the correction in the thread. If Slack rejects the
block on a terminal write, the footer is retried without it (see the tiered recovery under
*Composed replies*). → TEST-PLAN: Answer feedback.
- Binding: a reply with a Slack requester binds the menu to that person (another member's click
is refused, as before; admins may still open another user's file button). An automation post
(schedule, background job, API run, restart recovery) has no Slack requester, so its menu opens
for whoever clicks it. That grants nothing: every open re-applies the clicker's own live channel
authorization and binds the modal to that clicker.
- Slack only. Google Chat and Teams receive the plain text of those notices and answers.
- `/menu` returns only one card with the same **Files, Variables, Settings** buttons, directly
from the daemon without starting Claude or Codex. The registered slash command is private to its
caller; `@agent /menu` inside a channel thread (or `/menu` in a DM message) posts the card in that
thread. All controls remain available before a session exists and retain their authorization
checks. Resume Session is a tab in Settings and the `/resume` command; a 💻 Resume button on a
card posted before this change still opens the resume modal (it reads the thread's session when
clicked and rechecks requester, channel, access and membership).
- Existing Slack installations must add `/menu` from `slack-app-manifest.json` to their installed
app configuration. The typed `@agent /menu` route needs no Slack app reconfiguration.
- **Current network policy accompanies every attempt:** fresh, resumed, recovered and fallback
prompts state the resolved network switch, including Clean runs. An off switch instructs the
engine to explain the current restriction rather than present a cached response as a new request.
The note states whether THIS attempt's container enforces the switch (the egress proxy is its
only network) or only advises it (the legacy bridge mode, a `rawNetwork` channel, a `/sudo` host
thread); other tool restrictions still apply. The added fact contains no credential inventory or
values. → TEST-PLAN: Network policy on resumed turns.
- **Control-plane results report received human approval:** a tool that awaited an approving
decision returns a receipt alongside its original outcome, including returned refusals or error
results after approval. The receipt identifies neither an actor nor a particular approval UI and never claims
the mutation succeeded. It explicitly distinguishes the recorded human decision from channel
Auto mode, which cannot supply this approval. Denied, pending durable and explicitly exempt calls retain their distinct
results. Guidance warns that cards outside the model transcript are not evidence of a bypass.
→ TEST-PLAN: Control-plane approval receipts.
- **Deleting a personal skill does not strand its author:** each trusted turn omits catalog-proven
tombstones for that author's locally created personal skills. Stored grants and revision history
remain intact for restoration; restored skills become active again on the next turn. Unknown,
unavailable and unapproved grants still fail visibly when materialized. Foreign personal skills,
including dependencies, are refused rather than exposed or treated as the author's deletions.
→ TEST-PLAN: Deleted personal skill grants.
- **Skill approval cards describe the requested ownership and grants:** personal creation
previews its automatic personal grant; shared-library creation distinguishes a channel grant
from no grant; channel-scoped creation states its automatic activation here. Invalid personal
channel scope is identified without promising a creation. The existing approval, ownership,
publication and grant rules remain unchanged. → TEST-PLAN: Skill creation approval details.
- **Text commands remain controls after file sharing:** canonical current-message attachments
are recovered even when Slack omits them from a trigger. Historical thread files cannot turn
text-only retired-command notices, stop/cancel, pending or other gateway controls into an agent request.
Ordinary followups, unknown commands, queued tasks and engine-native `/compact` retain their
normal attachment context; bare `/next` gives its usage hint. → TEST-PLAN: Commands after file sharing.
- **Accurate Composio ownership guidance:** logical personal/agent identities describe routing,
not proof of a connected service owner's identity or universal sharing across channels. Managed
instructions and each attempt's identity preamble preserve explicit personal/shared routing and
the ambiguity stop even when both connections reach the same service owner. The guide explains
channel-specific identities and optional organization-default reuse without exposing credentials.
→ TEST-PLAN: Composio ownership and channel scope.
- **Truthful completion and visible restart recovery:** explicit Claude/Codex execution failures
remain failures even after partial output; missing terminal completion cannot masquerade as a
finished answer. Partial diagnostics and spent usage are retained, and ambiguous failed work is
not automatically replayed. Restart survivors reserve their thread order before Slack connects,
receive progress together, and use the normal global run limit. New messages use the existing
queue/steer controls; reconnecting Slack retries pending recovery without another daemon restart.
→ TEST-PLAN: Incomplete turns and restart recovery.
- **Discoverable channel credentials:** each non-Clean conversation or background-agent attempt receives a current,
sorted inventory of channel environment variable names actually supplied to that attempt,
including resumed turns and explicit empty inventories after removal. Values and suffixes
stay out of the prompt; Clean runs omit the inventory. The operating guide checks these names,
relevant skills, CLI authentication and selected-account MCP tools before requesting access,
while preserving account selection, tool permissions and network policy. Channel secrets are
injected into process environment, not the project's `.env`; CLI authentication may need an
explicit option or header. Operator guide overrides retain their precedence.
→ TEST-PLAN: Channel credential discovery.
- **Instruction approvals without a deadline:** `update_channel_instructions` saves the exact
rule and returns pending immediately. Approve, Deny and Comment remain actionable after a
gateway restart; approval applies the change once, without keeping an engine running. Changed
instructions, destination or requester authority fail closed. Replace requires an admin
requester and approver. Complete rule previews are limited to 2400 characters without fenced
code. Native permission and freeform plan approvals retain their live-call timeout; bearer links
retain their independent expiry. → TEST-PLAN: Durable instruction approvals.
- **Timezone-safe one-time automation edits:** the Admin editor sends the browser-selected local
time as an explicit ISO instant. Saving and reloading preserves the chosen time even when the
browser and daemon use different timezones; recurring cron timezone policy is unchanged. →
TEST-PLAN: One-time automation editor timezone.
- Release artifact scanning recognizes only exact SHA-256 fingerprints of reviewed public
toolchain fixtures. PEM exceptions bind the complete key, never a header or first body line;
repository/history scans and unknown or altered artifact credentials remain strict. Public
provenance is recorded with every exception.
A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks.
- **Consistent MCP startup budgets:** selected optional Codex HTTP and stdio servers receive
the same 120-second handshake ceiling as other remote integrations. Claude's default
`MCP_TIMEOUT` is 120 seconds at every engine spawn, with explicit operator overrides preserved.
Tool-call deadlines, the gateway control server's 60-second Codex budget, grant filtering and
the quiet-run watchdog are unchanged. A startup ceiling does not promise that a provider will
await every server before its first model request. → TEST-PLAN: MCP startup budgets.
- **Accurate channel-control descriptions:** MCP mode/network descriptions and confirmations
describe the container boundary, the egress proxy's enforcement of the network switch (advisory
only where the proxy is not the channel's network) and the separately resolved operator-home
grant. They do not promise domain filtering or automatic access to host credentials. Auto
distinguishes engine tool approval from explicit control-plane sign-offs.
→ TEST-PLAN: Channel-control description accuracy.
- **Accurate approval scopes:** explicit agent sign-offs and durable actions offer only a
decision for that exact request. Native tool permissions retain Once, This thread and Forever.
The shared decision handler rejects unsupported scopes before changing grants or consuming
the request. → TEST-PLAN: Approval scope capabilities.
- **One owner for native loop ticks:** after a warm Claude turn completes a native pacing
call, its process retires so its idle timer cannot duplicate the daemon's durable tick.
Already queued messages resume the stored session in a fresh process; ordinary turns keep
warm reuse. No running turn is interrupted. → TEST-PLAN: Native loop timer ownership.
- **Skill usage provenance:** the chat usage report shows one total per skill, current
conversation metadata, an exact UTC window and authors from recorded usage events. Inferred
file reads stay distinct from invocations in explanatory text; missing historical identities
remain unknown. Author aggregation stays channel-scoped and discloses partial results.
Structured API signal counters are preserved. → TEST-PLAN: Skill usage report provenance.
- **Evidence for access claims:** the operating guide distinguishes mount-parent scaffolding
and image files from the named gateway store, and uses current Slack channel metadata instead
of workspace slugs for targeted searches. Unverified access claims are not durable memory.
- **Scoped Slack reads:** the operating guide keeps retries within an explicitly requested
message scope. Empty searches do not permit history sweeps; channel metadata can resolve a
name without retrieving unrelated messages. → TEST-PLAN: Exact-message read scope.
- **Skill-grant cost comparison:** an over-cap channel grant reports the current cost before
granting, the projected cost on the next message, and the soft cap in both admin UI/API and
chat tool feedback. Estimates cover organization and template grants plus dependencies;
activation remains advisory, and repeating a grant does not inflate the estimate.
→ TEST-PLAN: Skill-grant context comparison.
- **Complete background-agent reports:** successful Claude and Codex agent reports retain their
full redacted content in durable delivery state. Long reports use the regular outbound chunking
pipeline, including after transport recovery, without a second model turn or silent preview cutoff.
→ TEST-PLAN: Complete background-agent report delivery.
- **Per-attempt runtime identity:** every orchestrated turn receives its selected engine,
configured model/effort and fresh/resumed session state, including clean mode, model retries,
session recovery and cross-engine fallback. The assistant is told to distinguish configured
aliases from provider-reported identity and leave unexposed harness defaults unknown.
The access preamble also states the launch's resolved admin permission bypass decision,
including resumed, background and fallback attempts. It distinguishes this decision from
channel Admin mode, writable tools and Codex's internal sandbox/approval settings; callers
without a resolved decision report unknown. The restricted memory reviewer states disabled.
→ TEST-PLAN: Per-attempt runtime identity.
- **Disposable Linux lifecycle evidence:** the manually dispatchable Linux lifecycle workflow
exercises a fresh dedicated-account systemd install with the full rootless Podman image,
HTTP liveness after restart, encrypted fixture backup/restore and non-destructive uninstall.
A local fixture upstream also proves the real CLI updater restores Git and restarts systemd
after a candidate test failure or readiness failure; engine smoke and test/pretest commands
are controlled fixture inputs.
Its script refuses non-hosted or occupied hosts. A separate KVM guest workflow exercises an
actual OS reboot, service autostart and persistent database/container-volume fixtures.
Authenticated engine update smoke and conversation/session acceptance remain separate live
gates. → TEST-PLAN: Disposable Linux lifecycle workflow. The update's engine smoke (a
throwaway container, one fixed prompt per configured engine, before the update and after the
restart) counts a provider **usage limit** (weekly/session plan cap, spent credits, rate limit)
as REACHABLE, noted "reachable but usage-limited": the CLI started in the new container,
authenticated and reached its provider, which is what the smoke proves (2026-09-28, an update on
Atlas was refused over a Claude weekly limit). A bad login, a missing CLI or a wrong answer still
refuses the update or rolls it back.
- **Service-account image provisioning** uses the same explicit environment as the systemd
daemon so operator XDG/container storage settings cannot redirect a fresh build into another
user's private Podman store, and runs from the service-owned checkout so an operator-private
invocation directory cannot prevent Podman namespace setup.
- Development acceptance policy: behavior changes include reproducible Claude and Codex
acceptance definitions and clearly separate automated evidence from live operator validation.
Public contributors do not need access to a private QA service. → TEST-PLAN: Development acceptance policy.
## Release readiness remediation
- **Enterprise integration scope:** Composio SDK implementation lives under `src/ee`, is labeled
**Beta**, and requires Enterprise entitlement at settings, session, runtime and bridge boundaries.
Personal API principals cannot claim an author's SDK identity; the bridge permits only signed
session destinations. SDK Beta currently supports Slack identities. Standard MCP mode remains free.
- **Beta surfaces:** Google Chat and Microsoft Teams are labeled Beta in adapter/UI metadata and
setup docs. Their replies use the engine's actual result content, and reminders/background
delivery resolve their own connector even with Slack disconnected.
- **Trust boundaries:** numeric destination checks handle mapped IPv6; password replacement and
removal require current-password proof; API tool overrides are reduce-only; editor leases live
outside agent-writable artifacts; container bind sources reject symlinked path components. The
Claude login relay and the shared Codex sign-in mount are unchanged (audit findings 5 and 8 are a
pending product decision, not shipped).
- **Secret handling:** masked dialogs collect password proof. Known credential values are redacted
from primary/fallback replies, stream events, errors and background delivery checkpoints. Shell
jobs also redact injected channel secrets inside their detached container process before stdout
or stderr reaches the durable artifact log. Independent stream buffers protect split values,
and the wrapper survives the daemon while preserving the command's terminal status. The wrapper
receives secret names only; jobs do not inherit engine service credentials. Existing historical
raw logs are not rewritten by this change. Exact-value redaction is not a vault and does
not prevent an engine from transforming a usable credential.
- **Durable work:** memory mutations serialize across gateway processes. Bounded nonblocking reads
reject special files and pin parent directory descriptors before publication. API/schedule/background
execution checkpoints distinguish unknown interrupted runs from completed results awaiting
delivery. Results retry delivery without replaying unknown tool work. Chat accepts into SQLite
before ACK, uses bounded conversation dispatch and stops intake promptly. Individual memory
files replace atomically; a multi-file batch is not crash-atomic. Delivery can repeat if a remote
acknowledgment is lost after acceptance.
- **Release controls:** license verification is bound to the current key and request generation;
the patched dependency lock, candidate/history scans, runtime SBOM/model inventory and signed
artifact workflow supply explicit candidate evidence. License 1.3 reconciles unchanged bundled
EE distribution and Enterprise feature use. Live and legal gates remain in the release checklist.
## Conversation settings and persistent memory
- **Reliable provider CLI device login:** the always-on operating guide and on-demand ChannelGate
credential guide share one lifecycle: isolate conflicting token precedence to the subprocess,
relay link/code as commentary, retain and poll the same TTY/session in the same assistant turn,
recover a lost/expired session with a new code, and finish only after CLI confirmation plus a
non-secret identity/access check. → TEST-PLAN: ChannelGate skill package.
- **Deterministic Composio identity selection:** `composio-user` is always the active requester's
personal account; `composio-agent` is the shared agent account. Pronouns and named connection
aliases select between them, ambiguous apps connected to both require a question, and connection
discovery recognizes normalized MCP names and verifies active aliases without silent fallback.
A missing personal identity is never substituted: a request for the requester's own account is
served by `composio-user` or answered with the fact that it is absent, never from the shared
identity's data — that identity holds other people's accounts.
The rule now travels with the FACT it applies to: every run that injects Composio prepends one
engine-neutral line to its prompt naming the identities that turn received — both (with the
ask-first stop), only the requester's, or only the shared one (where a request for "my inbox" is
answered by saying so and stopping). It is per run and per author, so a shared instruction file
never has to carry a per-author sentence, and a channel with no Composio gets no line at all.
→ TEST-PLAN: Composio identity and connection discovery.
- **Focused conversation settings:** Access presents Read-only, Worker, and Admin as the
base modes, with Auto and Lean as independent options; Custom is no
longer offered. MCP Connections, Cloud MCP, Environment tokens, and Skills are separate first-class
channel pages rather than nested beneath Tools. Conversation Skills and Skills → Templates share
an Active skills / Add skills editor with name/description search and source filtering.
Compact rows keep name, slug and source inline, with descriptions hidden until the skill is
clicked (or expanded by keyboard). Both panels have matching fixed heights and one scrollbar
each; add/remove retains list position. Narrow screens keep the source in expanded details.
Conversations separate locked organization, template and repository-section grants from removable
additional skills; inherited skills cannot be added twice. Template changes preserve explicit
additions, unavailable saved grants remain visible, and failed assignment loads preserve saved
grants on unrelated saves. Filters never alter selections or mark a conversation dirty. The channel-level
Grant Tier switch is gone.
- **Slack settings for authorized users:** replies requested by anyone allowed to use the agent add a
requester-bound **⚙️ Settings** footer button. *(The page layout below was replaced on
2026-10-15 — see **Slack Settings redesign**; the authorization rules here still hold.)* Its
Block Kit console mirrors the web setup concepts across six pages — **General Settings**, **Variables**, **MCP**, **Skills**,
**Automations**, **Resume Session** — shown as one row of tabs (*General · Variables · MCPs ·
Skills · Automations · Resume*) at the top, the open page highlighted; Slack wraps the row on a
narrow modal. Legacy page ids
(`runtime`, `access`, `network`) and the former *Page* dropdown still resolve, so a Settings view
opened before either change keeps navigating.
**General Settings** is everything that decides how the conversation runs: its Engine & model
scopes, then **Access** (with the network switch and the VPN row). Engine & model edits in place — six dropdowns,
no nested form, each saving the
moment it is picked. It shows and edits BOTH scopes: the **channel default** (written to the
channel record and audited like any policy change) and, when Settings was opened from a reply
inside a thread, **that thread's own pins** (the per-thread overrides the `/model` wizard's "just
this thread" scope and the `claude`/`codex` directive write, which beat the channel at run time).
An unset field preselects the label of what it inherits — the gateway default, an org DM
template's value, or "Follow channel (…)" for a thread — so nothing reads as a blank (an unset
model or effort names what the channel's OWN harness would use, so a Claude-pinned channel on a
Codex-default gateway reads the Claude default, never the Codex one); each
scope's model and effort lists follow the harness THAT scope resolves to, so a Codex-pinned
thread inside a Claude channel offers Codex models. Changing one field drops only the dependents
it invalidates (a harness change always clears the model, and the effort when the new harness
stops offering it), a value stored outside the current catalog stays visible instead of silently
reading as the default, and a pinned thread gets a **Follow channel default** button that clears
all three. An unpinned thread whose live session was minted by the other harness is named in the
tab (it keeps running there until an engine is pinned or the session is cleared) rather than
being moved silently — the deliberate difference from `/model` → *This channel*, which does move
it, because here both scopes are on screen together. Engine & model can be changed by every
authorized user in this console; MCP Connections
rotates/removes the write-only Composio, Toolbox and Make MCP credentials plus inherited-token
policy, and edits the Composio account label shared with the web setup form. Labels are shown
in the summary and prefilled for editing; changing or clearing a label preserves the token.
Only admins can see or manage Cloud MCP capabilities, independently for Claude and Codex;
Skills labels each source as Skill Template, Channel Skills, Channel Skills Including Template,
Org Skills, or All Shared Skills, with directions to the corresponding controls. Org Skills
explicitly require admins to add/remove them in the admin UI; both combined lists explicitly
explain that their summaries update automatically when their source groups change. The **Skill
Template** is one value out of a known list, so it is a dropdown ON the page that saves the
moment it is picked — not a button that pushed a second modal to show the same dropdown. With no
templates defined the row stays a read-out that points at the admin UI. A Settings view opened
before the control moved repaints onto the page instead of pushing, and a template modal that
was already open still saves. Skills supports direct grants and live template assignment; Secrets lists all supported variables with
a confirmed Remove button on each row and Add/update directly on the tab. Entry forms return
to the refreshed parent tab after saving, without duplicate manager pages or growing modal stacks.
Deletion persists and refreshes the same page without waiting for folder/skill provisioning. Inherited and template grants are labelled and cannot be removed from
the wrong tier. Credential forms never prefill stored values, and all views expose only
configured/masked state. Opening it, navigating, submitting, and every individual mutation
re-check current channel membership plus agent authorization, including explicit channel guests,
so historic controls cannot retain revoked access. Cloud MCP actions additionally re-check admin
status. Secrets can be edited in every channel mode, including through `/secrets`; file-edit
permissions and the separate `/model` command policy are unchanged.
The **Access** section of that page is manager-only — its controls render only for current
admins/channel managers, and everyone else sees a line saying so while keeping the rest of the
page. They are live controls on the page, not a summary behind a button into a pushed form: a
**Mode** select (including Admin/full access), an **Auto / Lean / Network** checkbox group, a
**Who can use it here** and a **Who can manage this channel** select, and two member pickers for
named guests and named managers. Each one saves the moment it is changed, the same in-place rule
the engine and model rows follow. Selecting
Full access does not grant anyone an administrator role; bypass still requires an admin author.
One dispatch describes exactly ONE field — the rest of the policy is read back from the stored
record, so a repainted neighbour can never resubmit a stale value — and every save keeps the
guarantees the submitted form had: the channel-membership lock, named users validated against
live human channel membership, management and roles re-checked at the write boundary, and audited
policy changes. A rejected value writes nothing at all.
The **VPN** is one row directly under that Auto/Lean/Network checkbox group rather than a
section of its own: `*VPN* — On / Off / Not configured / Starting / Stopping / Failed /
Unavailable`, its Turn on / Turn off / Refresh controls still gated to admins and channel
managers, and no prose restating what the state word already says. A message is kept only for
the two states whose label cannot explain itself — a failure and an unavailable service — which
is the same diagnosis a refused toggle reports. Readers who cannot see the access controls (an
ordinary member, and every DM) get the channel's network policy as a one-line read-out beside
it, since the checkbox that would otherwise show it is not rendered for them. A conversation with
no provisioned VPN service is answered from metadata alone — no status helper runs, and the row
reads *Not configured* immediately — so only a provisioned channel spends a subprocess and a
second view update filling in *Checking status…*.
**Resume Session** is a page of its own, open to every authorized user: it renders THIS thread's
copyable `cd "…" && <engine> --resume <id>` terminal command (the container `exec` form for a
containerized channel), resolved from the session at render time so a stale Settings view cannot
hand out a cleared session's id, plus the session id and the folder it belongs to. Opened outside
a thread, or in a thread that has not run a turn yet, the tab says so instead of showing a
command. It replaces the 💻 reply-footer button, which is gone from run footers.
**Automations** lists this conversation's scheduled tasks, reminders and `/loop` wake-ups (up to
20, then a pointer to the admin Schedules page): title, kind, on/paused, the cron with its next
run or the one-time run time (both in the gateway's zone), remaining loop ticks, creator and last
status. Any authorized user may **Pause**/**Resume** a recurring schedule and **Delete** any row
(*Cancel* for a one-time row, *Stop loop* for a loop; each asks first) — the same authority the
agent's `create_schedule`/`delete_schedule` tools give them. A one-time row or a loop is never
paused. Resuming is refused at the per-channel enabled-schedule limit, an id not in THIS
channel's rows is refused, and every change is logged (`schedule_updated` / `schedule_deleted`,
`via: slack_settings`). New automations are still created by asking the agent.
A DM sees the same six pages, with its mode/Auto/Lean buttons at the top of General Settings and
no Access summary. Work-dir and gateway-wide settings are not exposed here.
→ TEST-PLAN: Conversation settings + on-demand memory.
- **Truthful guest access:** approved members appear selected because they already have access;
admins are selected and locked, while explicit guest grants remain independently editable.
- **Uncapped, on-demand channel memory:** Markdown remains the portable source of truth. Fresh
sessions receive only a bounded catalog (counts, topic names, retrieval contract), never the
memory body. `search_channel_memory` rebuilds and queries a derived SQLite FTS5 index and
`read_channel_memory` loads one validated source; both handlers return through the injected MCP
response formatter. Writes no longer fail on a character budget. The index is optional per
engine build: a Node whose SQLite lacks FTS5 (22.13, the floor) still boots, and search falls
back to a plain scan with the same AND semantics and excerpts until the index exists.
→ TEST-PLAN: Conversation settings + on-demand memory.
## Maintainable module and persistence boundaries
- **No-build admin modules**: the browser entrypoint delegates same-origin/CSRF-aware requests to
`admin-api.js`, reusable DOM/dialog/secret-field rendering to `admin-view.js`, and pure save
reconciliation/routing state to the existing `admin-state.js` / `admin-routes.js` modules.
- **Slack ingress boundaries**: Bolt registration remains in `slack/app.js`; pure control-text,
mention, subtype, and trusted-bot normalization lives in `message-normalize.js`; process-lifetime
queue/dedupe ownership lives in `message-lifecycle.js`; status rendering is a controller; the
message pipeline retains orchestration while preserving its public compatibility exports.
- **Crash- and contention-safe config**: secret JSON replacement writes a same-directory 0600 temp,
fsyncs, then atomically renames. User and channel read-modify-write patches take an
immediate SQLite transaction and skip omitted fields, preventing concurrent writers from
silently restoring stale security or credential fields. → TEST-PLAN: Phase E module boundaries.
## Dynamic engine model catalog
- **One live catalog for every selector:** Codex's authenticated, installed CLI supplies its current
selectable models through `codex debug models`; hidden entries are excluded and each model keeps
the exact reasoning efforts the CLI reports. Slack's `/model` wizard and the Admin UI consume the
same registry snapshot, so newly available models such as Astra appear without a ChannelGate code
change. Discovery is bounded and cached for six hours; a failed refresh retains the last good
snapshot, or the bundled fallback on a cold start.
- **Claude's model choices state exact versions:** the picker offers Opus 5.5, Fable 5.1,
Sonnet 5.5, and Haiku 4.5 under their full model IDs. Sonnet 5.5 uses its native 1M
context window and verified cache-aware pricing for external transcript usage. It omits duplicate aliases and special
modes from the normal choices. Previously saved Claude aliases and full IDs remain valid;
the Slack picker shows the current saved value, and the Admin UI round-trips it. The curated
list is reviewed when Anthropic releases a new model. Provider CLI/package upgrades are
deliberately separate and continue through reviewed dependency PRs.
→ TEST-PLAN: Dynamic engine model catalog.
## Chat-platform adapter kernel
- Validated `PlatformAdapter` contract with a CLOSED 30-key capability spec: an undeclared
capability resolves to the LEAST capable value, an unknown capability KEY throws instead of
answering "no", and internally inconsistent reply modes (streaming without edits, modals without
a card primitive, edits without an edit budget, mixed threading without native threads) are
rejected at registry load.
- Descriptors for Slack (GA), Google Chat and Microsoft Teams encoding each surface's researched
limits — edit budget per conversation, threading model (including mixed threaded/flat surfaces),
Markdown reach, native artifacts, attachment reach, mention syntax and broadcasts.
- Conversation ids namespaced per platform (`gchat:`, `teams:`); Slack's stay bare so every row
written before multi-platform support keeps resolving with no migration, and an unknown platform
on a stored record resolves to Slack rather than to a degraded surface.
- Capability-driven outbound degradation: pipe tables become fixed-width blocks, headings become
emphasis, images become labelled links, lists become literal bullets, quotes flatten — fenced
code stays byte-exact, and a message is chunked at the platform cap without ever leaving a fence
open. The model writes one dialect; each surface gets what it can render.
- One shared mention matcher across all three formatters (exact directory match, longest name wins,
emails/code spans untouched, streaming hold-back for names straddling deltas), with per-platform
rendering and control-sequence defanging. Teams emits the `<at>` tag and its matching entity,
filtered per chunk so a split answer never ships an orphan entity.
- `ChatConnector` interface (post/edit/remove/openDm/threadFor/directory) as the single wire-format
boundary; `postNotice`/`postDirectMessage` is the one path for unattended daemon posts
(scheduler, background jobs, follow-up digests, nudges, self-diagnosis, restart recovery, API
runs, shutdown). A platform whose transport is not wired gets a connector that THROWS on write —
a silent no-op would read as a delivered answer.
- `gateway-usage` resolves per platform: `platforms/<id>/<name>.md` overlays the shared reference,
the adapter's `guideDrop` removes files describing absent capabilities, `{{PLATFORM}}` is
substituted at materialization, and admin overrides still win — now per surface. Each platform
ships a `references/platform.md` stating exactly what renders there.
→ TEST-PLAN: Chat-platform adapter kernel.
## Google Chat and Microsoft Teams transports (Beta)
### Teams event and control additions (teams-ms branch; not deployed)
- Native signed Bot Framework edit events (`messageUpdate` / `editMessage`) preserve message
session identity and require an actual bot mention outside DMs. Robot reaction additions
(`🤖`, `robot`, `robot_face`) act as the reactor, with normal user/channel authorization;
removal and unrelated reactions do nothing. Native reaction coverage is limited by Microsoft's
bot event delivery; reacting to arbitrary user messages requires the separate Graph feed.
- Optional Graph subscriptions observe only configured installed conversations, fetch messages
using daemon-owned credentials, validate subscription/tenant/client-state/resource scope, and
persist event work before dispatch. Subscription renewal and restart recovery are daemon-owned.
When enabled, Graph owns group/channel edit/reaction dispatch while Bot Framework keeps new
messages, attachment delivery, and personal-chat edits/reactions. Graph events older than 24 hours
are ignored to prevent historical replay after deduplication retention; uninstall retires the
subscription and invalidates queued work. This does not grant tenant-wide observation or auto-approve any author.
- Portable text controls provide `/help`, `/status`, `/stop` (`/cancel`), `/clear`,
`/model [engine] [model|default]`, and `/effort [level|default]`. Quote the original message or
bot reply and include the bot mention to control an existing flat group session. Unquoted
group commands refer to their own new session. Runtime changes use the existing channel admin
policy (approved personal chats retain their own runtime choices); stop/clear cannot cancel
another person's active or queued work unless the requester is an administrator.
- Work in one session queues with a visible position; unrelated sessions remain independent.
Clear aborts active/queued work, waits for completion, and fences late session saves. Google Chat progress
shows the resolved model and current app/tool name, including completion/failure, thinking and
queue states. Phase changes coalesce at most once every three seconds (slower when the surface's
edit budget requires it); 30-second heartbeats show elapsed time, last activity and running
subagents. Tool arguments/results and reasoning payloads never appear in status. Pending
progress edits finish before the final answer replaces the placeholder. Interactive
permission escalation is not enabled by these text controls.
- Teams and Google Chat foreground replies carry the same compact statistics as Slack: configured
model (provider model only when none is configured), duration, this turn's input/output tokens,
optional cost and context use. Container image references are omitted from reply footers.
Teams statistics use a small, subtle native footer with an 8-pixel spacer above them, attached
to the final answer chunk; answer Markdown and mention entities keep their normal rendering.
Teams shows a temporary “⏳ Agent is working…” message and refreshes its native typing indicator
every four seconds without overlapping requests. Typing stops and drains before final delivery;
unsupported typing disables refresh for that turn while the working message remains visible.
Neither display includes detailed tool/model/heartbeat status. The answer
replaces it; if editing fails, successful fresh delivery is followed by removal of the working
activity, keeping only the answer, requester mention and statistics. Cleanup failures are logged.
Completed Teams foreground replies in channels and group chats append a native mention of
the authenticated requester after the answer and before statistics, matching Slack. The mention
rides only the last chunk and survives failed placeholder edits. DMs and progress/error/stop
notices skip this automatic tag. Sender identity comes from intake, so roster lookup failure
or ambiguous display names cannot redirect it; display labels are escaped before insertion.
The global **Show message cost** setting applies to every surface. Statistics survive a
failed placeholder edit through fresh delivery; errors/stops do not invent usage statistics.
- Native Adaptive Cards provide Approve/Deny/Request changes actions and a **six-page
Settings console**: General, Variables, MCPs, Skills, Automations and Resume. `/settings` opens
a compact **Open settings** launcher in the original channel/thread or chat. Clicking it opens
a large private popup inside Teams; all six sections, navigation, confirmations and saves stay
there, retaining the original conversation and session. `/secrets` uses the same popup and
starts on Variables. All six section buttons appear in one centered row above the form;
`/settings` selects General and displays its form immediately, including after reopening.
Each opening has isolated drafts and confirmations with a 15-minute expiry. General shows both channel defaults and session pins, filters compatible
models/efforts, labels inheritance, respects dedicated Codex login locks and offers Follow
channel default. Teams runtime lists have one **Apply to channel** and one **Apply to thread**
button. Each model list contains only models for that scope's selected engine. After changing
Engine, **Load models** refreshes its choices without saving; incompatible draft model/effort
values clear to default. Compatible saved full model IDs stay selectable even if discovery
omits them. The compatible triple is saved atomically for that scope. Stale forms refuse to
overwrite newer runtime settings. Other settings keep their explicit controls.
Runtime edits in this console admit every authorized member, independently of the `/model`
text-command policy. Access policy and native member selection require managers/admins; Cloud
MCP and organization variable changes require admins. Complete roster selectors fall back to
the authenticated website above 25 members or the native card budget; stored selections are
never silently dropped. VPN uses the existing service, reporting unavailable where that
service does not support the Teams identifier.
Variable scopes, connection tokens, skill grants/templates and schedules share Slack's stores;
MCPs → **My connections** saves/disconnects the owner's personal Composio and Toolbox tokens
inside the settings window, without a DM or credential-bearing chat message. Disconnecting a
personal connection revokes its live remote-MCP author grants. Public model cards cannot open
settings pages or mutate settings; all settings actions require an authorized settings dialog.
catalogs/templates/automations paginate. Inputs never prefill saved secrets, new secret entries
are masked, and destructive controls use consumed server-held confirmation tokens. Native
secret entry follows the existing 8,000-character/16-KiB submission bounds. Resume reads the
current session and its engine/container terminal command at display time, subject to current
role and runtime access. Personal/organization metadata and eligible administrator session
commands appear only inside the private dialog; its chat launcher never receives form contents.
Settings dialogs use `task/fetch`/`task/submit` with task continuation responses and native Submit
actions, without a separate website. Signed envelopes establish the actor; opaque expiring state
binds the original workspace, user, delivery conversation and tenant when known. Launcher state
cannot mutate settings, dialog state cannot be used by inline Execute/Submit, and current roles
and membership are checked on every action and sensitive write. Other members open their own
controls. Broadcast mentions remain unimplemented. An optional approval changes
comment refuses the current action even if Approve was clicked.
- `/files [folder]` posts an **Open files** launcher in the original conversation/thread. Its
paginated Teams dialog offers download, upload and eligible text-edit links through the existing
browser grants, filesystem confinement and edit policy, with current Teams membership and
gateway authorization checked again at use. Listings and bearer links stay inside the owner's
dialog; actions never open a separate personal chat. Detail/upload pages return to browsing.
`/sendfile <relative-path>` in a channel/group posts **Open file** for the selected file in the
same conversation. In an existing bot DM it offers native file consent there: nonempty files up to 10 MB,
ten-minute consent expiry, a bounded pending snapshot pool, and explicit Accept/Decline.
Revocation, expiry, actor mismatch or an uncertain prior upload cannot become another upload.
Native file consent rejects group/channel requests instead of opening a DM. Teams approvals
stay on the original thread's native card, with no supplemental private bearer links. Native
card delivery failure refuses the action and posts retry/admin guidance in the source thread.
- Optional group/channel file reading (`teamsFilesEnabled`, default false) uses at most 32
explicitly allowed drive IDs (`teamsFileDriveIds`). A canonical SharePoint path is resolved
inside an allowed drive and its returned identity is checked before downloading. Selected-site
application read grants must be configured externally; the gateway grants nothing automatically
and does not use the broad-permission Graph `/shares` route. Sharing shortlinks are unsupported.
Metadata and byte streams are bounded, redirects are refused, and Graph tokens never accompany
the file-host request. Native preauthenticated personal uploads remain available independently.
- Downloadable audio is transcribed locally with the shared Whisper setting and cancellation;
the portable path never calls Slack transcript APIs. Raw audio paths are withheld from the
engine, ordinary files remain available, failed audio alone produces an explanation without an
engine turn, and typed text can continue with a visible missing-transcript note. A successfully
transcribed gateway-downloaded audio source is removed immediately; failed sources remain for a
retry, and symlinks or paths outside the managed `uploads/` root are refused. Progress starts
before download/transcription. Every attachment intake has a unique storage directory so
simultaneous same-name files or later edits cannot overwrite bytes another turn is reading.
- The branch's Slack parity inventory and remaining surface-specific work live in
`docs/TEAMS-PARITY.md`. These additions have mocked regression coverage; actual Microsoft
delivery, tenant consent, federated chats, and Claude/Codex live acceptance remain unexecuted.
- Flat DMs use their qualified conversation ID as a nonempty engine session key. In flat group
chats, each new message starts a separate session; quoting a user message, follow-up, or bot
reply resumes its original session. Reply-to-session mappings persist across restarts and are
scoped to the conversation. Teams recognizes current quotedReply entities and legacy Skype
Reply blockquotes. Native channel threads are preserved, and flat replies stay flat. Group
messages still require a bot mention, including quoted replies.
- **Google Chat runs outbound-only**, like Slack: a Cloud Pub/Sub PULL subscription consumes the
events Google publishes for the Chat app, so there is no inbound endpoint and no tunnel. The pull
loop long-polls, backs off with full jitter, acks before dispatching (a turn outlives any ack
deadline), dedupes at-least-once redeliveries, and STOPS on a configuration fault (revoked key,
missing `roles/pubsub.subscriber`, absent subscription) instead of retrying it forever.
- **Microsoft Teams runs on the documented Bot Framework endpoint** (`/api/teams/messages`), which
authenticates every request itself against the Bot Framework JWKS — RS256 only, issuer, audience
compared in constant time, expiry with clock skew, and the token's `serviceUrl` matched to the
activity's. It acks in 200 ms and runs the turn afterwards, because Bot Service retries anything
it did not get a fast 2xx for. Azure Relay (no public URL) remains a transport swap in front of
the same handler.
- **Both credentials sets are hand-rolled against `node:crypto` and `fetch`** — an RS256 JWT
assertion exchanged for a Google access token, an Entra client-credentials grant for Teams — so
neither surface adds a dependency to a product whose pitch is confinement. Tokens are cached,
single-flighted, and dropped on a 401 so the next call re-mints.
- **Three hostile inputs are validated at the boundary, not downstream:** Chat resource names
(a `..` segment or a stray slash never reaches a URL path), Teams conversation/activity ids (the
`;messageid=` reply suffix is ours to build, never to accept), and the Teams `serviceUrl` (an
allowlist of Bot Framework hosts — the URL arrives in the activity and receives our bearer token,
so an unvalidated one is a credential-exfiltration primitive). Teams attachment downloads are
restricted to Microsoft-owned hosts for the same reason.
- **One platform-neutral ingest path** turns an inbound message into a turn: the mention gate (DM
needs none, anywhere else must address the bot), the same authorization rules as Slack, channel
registration with the surface stamped on it, attachments written into the gated folder with the
same no-follow discipline, `runMessage`, the usage ledger, and one answer that replaces its own
"working on it" placeholder. Both surfaces get their own run origin, and NEITHER is escalatable:
escalation needs an interactive permission prompt the author can answer, and until a surface has
one its runs use the folder allowlist like every other run.
- **Google Chat's DM threading quirk is handled explicitly**: Chat mints a fresh thread per
top-level DM message, so a first-seen DM thread is the main flow (one continuous session, reply at
top level) and a thread the user replies into is a real side thread. A threaded reply always
carries `messageReplyOption`, without which Chat silently starts a new thread.
- **Live connect/disconnect per platform** from the admin Settings page (credentials write-only,
`has*`/`last4` on every listing, values only through the re-authenticated reveal endpoint), plus
per-platform health that distinguishes *not configured*, *configured but not connected*, and — on
Teams — *connected for sending, but no public URL, so nothing can be delivered*.
- **Teams onboarding uses Microsoft's CLI end to end**: the Admin UI and operator guide give the
install/login commands, generate a copy-ready `teams app create` command from ChannelGate's exact
public `/api/teams/messages` event endpoint, map the emitted credentials to Settings, and show
how to obtain the generated app's Teams install link. The procedure covers device-code login,
verifying the target tenant, private credential storage, and a real inbound/reply check; CLI
commands are checked against stable 3.0.3.
→ TEST-PLAN: Google Chat and Teams transports. Setup: `docs/PLATFORMS.md`.
- The operating guide covers host container-storage housekeeping: reading free space and per-image
container counts, distinguishing a live runtime root from test leftovers, and the tag-versus-image-ID
and dangling-HOME-volume traps. It is diagnosis-only by contract — the assistant reports what is
reclaimable and never deletes without an explicit request or an admin-configured schedule.
`npm run runtime:storage` produces that report (`--json` for machines) and removes only with
`--apply`: stopped channel containers created from a superseded image (never a running one),
runtime images other than the current, the previous spec (`--keep-previous`) and any in use,
untagged top-level leftovers, and another install's containers and volumes only when every
folder it mounted is gone — never a channel's HOME volume, never a second live gateway sharing
the account's store. Reclaimable space is counted per content-addressed layer, so shared layers
are not double-counted. → TEST-PLAN: Host container-storage housekeeping guidance.
## Engine adapter kernel
- Validated `EngineAdapter` and `RunContext`/`Principal`/`Origin` contracts fail closed for unknown
engines, unsupported network policy, or adapters without confinement and health compilers.
- Claude pooling and Codex execution/MCP policy run behind adapters; fallback routing is a directed
registry graph, and every registered CLI receives a boot version/readiness probe.
- Gateway notices about a turn — the failover line, the license allowance warning, a skipped MCP
connection, a substituted model — are announced to the delivery layer before the answering
engine spawns (`announceAnswerNote`), so a streamed Slack answer leads with them instead of
losing them to `content`, which only non-streaming surfaces render. Delivery subtracts each
streamed note from what is still owed individually, so a note is shown once whatever order the
orchestrator assembled `content` in. → TEST-PLAN: Cross-engine failover spawn contract.
- A cross-engine failover spawns the fallback harness under the same contract the primary would
have received: its own reminted MCP payload materialized for its own transport (a file for
Claude/Qwen, argv overrides for Codex), the channel permission lockdown, and the approval-prompt
route. Per-run settings and MCP config files are provisioned for the whole failover route, so an
engine that declares neither (Codex) cannot leave the harness it fails over to without them.
Clean mode still injects an explicitly empty payload in both directions.
→ TEST-PLAN: Cross-engine failover spawn contract.
- Slack and Admin selectors consume registry manifests. Codex optional MCPs under
`--ignore-user-config` receive complete credential-safe definitions; a selection without one is
dropped from the launch with a reason, the same contract Claude's resolver uses.
- OpenCode proves the third-engine contract without orchestrator or UI conditionals. Its adapter is
deliberately restricted to workspace read/glob/grep/list with model-tool network off: shell,
edits, external directories, plugins, MCP, and bypass modes fail closed because OpenCode
permissions are not an OS sandbox. JSON streaming, session resume, cancellation, usage/cost, and
health/version are supported. See `docs/OPENCODE-ADAPTER.md`. → TEST-PLAN: OpenCode proof adapter.
- **The Qwen harnesses** prove the kernel a second way: full-capability harnesses that reuse the
`claude` CLI against an Anthropic-compatible provider, generated from a provider table with no
orchestrator or UI conditionals — a second provider (Model Studio's EU region) is a table row,
not a second code path. → TEST-PLAN: Qwen harnesses.
## Public website
- The public `channelgate.dev` website and customer portal are deployed separately on Vercel;
their source, lead routing and platform services are outside this repository. This repository
ships the self-hosted gateway daemon and its built-in local admin UI. A chat development channel is a
gateway conversation with its own workspace and container, not a Vercel deployment.
- **Public entrypoint:** README leads with a descriptive product heading, team benefits and setup,
followed by eight feature groups, practical use cases and explicit platform/engine support.
Searchable product terms and descriptive documentation links cover self-hosted AI agents, Slack,
Teams, Google Chat, Claude Code, Codex, MCP and workflow automation. The homepage distinguishes
Codex skills/estimated costs, on-demand memory, SQLite storage, background restart outcomes,
bot artifacts versus Composio canvases, and the actual container/credential/data-flow boundary.
License, partner links and support status stay visible; promotional assets are linked only when
they exist. Maintainer release/presentation instructions live in `docs/MAINTAINER-RELEASE.md`.
→ TEST-PLAN: README hero guards.
## Conversation gateway
- Slack Socket Mode listener across DM / group DM / public channel / private channel
(`@slack/bolt`). → TEST-PLAN: Slack gateway.
- Native Slack progress and answer isolation: when a run emits task/tool/thinking progress before
its answer,
its collapsible live card is posted as the first thread message and updated there; the Markdown
answer streams into a separate later message, so expanding “Thinking completed” can never split
a sentence. A shared queue preserves creation order, each stream rolls independently before
Slack's age limit, and answer footer/fallback behavior stays on the answer only. Text-only turns
keep their single-message shape. → TEST-PLAN: Observability (Slice 6).
- Native Slack answer image previews: up to five unique image files referenced from the channel
workspace with standard Markdown image syntax are realpath-confined and uploaded into the thread
as native Slack files after the answer, giving them Slack's thumbnail, download and full-preview
UI. Public HTTP(S) references remain Block Kit image blocks. Fenced examples, missing/non-image
files, escaping symlinks and duplicates are ignored; upload/preview failure never costs the
completed text answer. Native streaming, classic recovery and unattended delivery use the same
bounded behavior. → TEST-PLAN: Observability (Slice 6).
- Mention gating: DM = no mention; channel/group/private = require `@bot`. → TEST-PLAN: Slack gateway.
- Thread-scoped Claude sessions — new thread = new session, replies resume. → TEST-PLAN: Foundation.
- Subagent completion contract (mechanical) — every generated channel settings file installs a
Stop hook (`src/gateway/hooks/stop-subagents.mjs`): the Claude CLI refuses to end a turn while
background Agent/Task subagents are still running, forcing the parent to wait and incorporate
their results (bounded at 30 blocks so a hung subagent can't wedge a thread; background shell
tasks are exempt on purpose). Codex needs no hook — its orchestrator natively joins subagents
before a turn completes. The old injected instruction rule is removed. → TEST-PLAN: Foundation.
- Background AGENTS (`run_agent_in_background`) — the durable subagent: the daemon runs a separate
engine session (Claude or Codex, same channel folder/lockdown/mode, launching author's tokens,
fresh session on a synthetic thread key, cold — no warm-pool residue) on a self-contained task,
streams its progress into the job tail, then posts the agent's self-contained final report
(capped 12k chars) directly through the sanitized/chunked unattended-delivery path. A completed
report never depends on a second model turn (and therefore cannot be stranded by that model's
usage limit); shell jobs and failed/incomplete agents still use an interpreted continuation.
Nested agents keep their synthetic session keys for engine isolation, while Slack report delivery
resolves the original thread timestamp. Synthetic scheduled keys post at channel level. Recovery
gives an already completed nested report whose old delivery attempts were exhausted one bounded
retry from its saved output; it never reruns the agent or a completed continuation.
Allowed in every channel mode
(the run enforces the channel's own permissions; approval prompts still surface in-thread).
Both job kinds post a "started" note in-thread with a *Check status* button (ephemeral live
status: runtime + recent activity; finished jobs answer with the persisted log tail, and a
failed ephemeral falls back to a visible thread reply); `/status` marks agent jobs with 🤖.
Agent jobs may run up to ONE WEEK (shell jobs keep a 60-minute default — they run unsandboxed);
the cap is a runaway backstop, not a budget, matching the never-kill-quiet-turns watchdog rule.
Jobs persist in `bg_jobs` and recover across daemon restarts (agent jobs finish as
"interrupted"). The injected `gateway-usage` skill and the MCP tool description both make the
routing rule explicit: delegated work that may outlive the caller's turn MUST use
`run_agent_in_background`, never an engine-owned background Agent/Task, and the caller ends its
turn without polling or asking the user to prompt it later. → TEST-PLAN: Background jobs.
- Plain-language process outcomes: successful background work says **completed successfully**;
failures distinguish general errors, rejected input/options, missing or non-executable commands,
time limits, operating-system interruption/forced stop, and restart-unknown results. The same
semantic formatter covers Claude, Codex, OpenCode, engine health/MCP discovery, Drive sync,
voice transcription, CLI compatibility checks, Whisper provisioning, and transactional update
commands. Structured provider details remain actionable (quota reset, authentication, denied or
invalid requests, temporary provider outage), and short subprocess diagnostics are secret-redacted
before display, while raw exit values/signals stay only in structured logs/error metadata for
diagnosis. → TEST-PLAN: Plain-language process outcomes.
- Admin outranks auto (`adminUnattendedTier`, 2026-08-08): a non-escalated run whose stored author
is a gateway admin in an adminMode channel — every daemon origin: background agents,
continuations, schedules, recovery — runs at the AUTO tier for both engines (writable folder
sandbox, permission prompts auto-approved) instead of the read floor that used to make admin
channels' background work silently no-op. Never an escalation: the
`--dangerously-skip-permissions`/sandbox-off path stays exclusive to live admin foreground
turns (A2), untrusted API principals never qualify, non-admin authors keep the normal floor,
and the surviving control-plane ("agent"-type) cards still require a human click. The `/mode admin`
confirmation spells the split out. → TEST-PLAN: Background jobs, Security checks.
- Deterministic Codex MCP startup: Codex does not block a turn while its MCP servers come up — it
takes whichever finished before it builds the first request, and a resumed turn reaches that
point far sooner than a fresh one. So every header-bearing remote MCP (both Composio identities,
the Toolbox, a channel's Make toolbox) is dialled by Codex itself over its native
streamable-HTTP transport rather than bridged to stdio, and its credential is produced at
startup by a per-run `http_headers_helper` script that reads the run's 0600 secret bundle — the
token never appears in Codex's argv or environment. Remote servers still get
`startup_timeout_sec=120` (gateway control server 60) as a ceiling on the handshake itself.
Before this, the `mcp-remote` bridge needed ~2.4 s to answer `tools/list` and made only the cold
window, so the SECOND turn of a Codex thread had no Composio tools at all. Since the
remote-MCP relay (below, Container runtime) this native transport is the sudo-host shape; in a
channel container the same servers are relayed by the daemon over the control socket.
→ TEST-PLAN: Background jobs, MCP.
- Resilient image/file attachments: every accepted Slack trigger is hydrated from the exact
canonical root/reply before processing; `message` and `app_mention` delivery is deduplicated by
message identity; direct, legacy, attachment, and file-block references are merged; incomplete
ids resolve through `files.info` with one bounded pending-file retry. A file-only thread message
followed immediately by an `@mention` carries that file forward, but intervening text prevents
stale-file reuse. Ordinary files still land only in the confined channel `uploads/<thread>/`
folder and enter the engine prompt as local paths (images render visually via the Read tool).
Canonical/read failures preserve usable event files and text prompts, while missing download URLs
produce an explicit skipped-file note.
- **Attachments up to 500 MB, never held in memory.** One shared ceiling
(`ATTACHMENT_MAX_BYTES`, `src/util/bounded-bytes.js`) governs every inbound attachment — Slack,
Google Chat, Teams and a Run API `fileUrl` — and every sink streams the body straight into the
channel folder through `writeStreamNoFollow` (exclusive no-follow temp + rename, the running
total checked per chunk, a declared Content-Length over the cap refused before a byte is read, an
over-running body cut off with its temp removed and the destination untouched). A refusal names
the size and the limit (`263.4 MB exceeds the 500 MB attachment limit`) so the person knows what
to shrink; a Slack download above 8 MB announces itself in the assistant status while it runs. A
body that turns out to be Slack's HTML sign-in page is refused from its first chunk, before
anything is committed. → TEST-PLAN: Slack gateway (Slice 4).
- **On-demand attachment download, this channel only (`slack_download_file`).** The pre-run path
delivers the files on the triggering message; anything the person shared earlier — the thread's
first-message recording, a file posted before the mention — is visible in history (id, name,
size) but was never in a run's prompt, and the bot could only ask for a re-upload. The gateway
tool takes a `file_id` (or a pasted Slack file link), asks Slack for the descriptor with the bot
token, refuses it unless Slack reports it shared in THIS channel (`listChannelIds`, the same scope
rule as Slack Lists), streams it through `downloadSlackFiles` into `uploads/<thread ts>/` under the
shared cap, and hands the model a LOCAL path — never a private URL or the token. A file already
in the folder is reused. Un-gated (it lands only in this thread's folder). The hydration step
also carries a thread ROOT's attachments into a later reply marked `carriedFrom:"root"`, and the
pipeline downloads such a file only while it is missing from the folder and its declared size
fits the cap — a retry of a delivery that never happened, not a re-attachment on every reply;
files behind intervening text elsewhere in the thread are still never reached. The
`gateway-usage` reading reference tells agents to download rather than ask for a re-upload.
→ TEST-PLAN: Slack gateway (Slice 4).
Current-channel history/thread tools expose only safe file id/name/MIME/size metadata—never
private Slack URLs. → TEST-PLAN: Slack gateway.
- Optional local voice prompts with Slack fallback: after the normal trigger/auth gates accept a
DM, `@mention`, or 🤖 reaction, enabled local Whisper (`whisper.cpp` v1.9.1 + multilingual
`large-v3-turbo`) is tried first. Disabled/unavailable local transcription falls back per clip to
Slack's completed full VTT transcript; absent transcripts prompt the user to click **Generate
transcript** and re-trigger. Fresh installers ask whether to provision Whisper, updates honor the
stored setting, and disabled mode never downloads raw audio. Typed text remains instructions and
raw audio is excluded from Claude/Codex. Successfully resolved downloaded audio is removed from
the channel's `uploads/` folder, including when Slack transcript fallback completes after a local
failure; unresolved audio is retained for retry. → TEST-PLAN: Voice prompts.
- Native Slack **channel file explorer**: the 📂 reply button opens a Block Kit modal rooted at the channel's
effective working folder. Its title identifies the authoritative stored Slack channel name, and
its subtitle shows the full absolute current directory, refreshed on every navigation. The
*Browse channel files* message shortcut opens it for a selected thread, and every reply's menu carries a
`📂 Files` button (requester-bound when the reply has a requester) for one-click access, beside the
**⚙️ Settings** snapshot button described above, and gateway admins may open a
control attached to another user's bot reply. When an agent names up to five
existing files inside its effective working folder for review, the same footer adds deduplicated
`📄 filename` buttons in mention order. A named path may be absolute or written relative to the
working folder — the form an agent inside a gated channel folder actually uses, and the one that
keeps the host's home directory out of the Slack message. Relative references resolve against the
run cwd before the same realpath confinement check, and must carry a separator or an extension,
so ordinary inline code (`main`, `npm test`) and URLs never become controls. Each is bound to the requester/channel/thread and opens
the existing explorer directly on the exact file preview. Button notices are ephemeral to the
clicker and explicitly remain in the source thread instead of leaking into the channel timeline.
Every sibling footer control carries a
unique registered action id, so one or many review targets remain valid Block Kit; encoded spaces
and source-line suffixes are accepted, while missing files, directories, malformed paths, and
realpath escapes produce no control. The injected Slack writing guide makes the
inline-code form a standing rule rather than a footnote: `gateway-usage` rule 3 (seen every turn),
a worked ✅/❌ block in `references/writing-replies.md`, and a `references/git-repos.md` step
telling repo tasks to report the landed path instead of a `.worktrees/<slug>/…` copy that step 4
deletes before the reply is even rendered. Older local Markdown links are still detected.
Folder navigation, pagination, metadata, and bounded text previews run daemon-side with no AI
call. Every interaction rechecks authorization + membership; realpath confinement permits only
contained symlinks. Every contained regular file and directory is visible and previewable,
including dotfiles, `.env`, `.git`, `.claude/settings*.json`, the complete `.claude/skills/` tree,
`CLAUDE.md`, `AGENTS.md`, and memory files. Broken or root-escaping symlinks are still shown by
name but have no open control.
Protected/internal paths remain read-only in the explorer, and forged action state cannot escape
the working root. In writable modes, *New file* exclusively creates one safely named UTF-8 file
with optional initial text and audits `channel_file_created`; *New folder* creates one safely
named directory in the folder on screen. Native create and inline-edit forms pop after successful
submission and refresh the existing browser/preview, so repeated operations do not exhaust Slack’s
modal stack. Cancel returns to the untouched parent; invalid inputs and stale edit hashes keep the
form open. Post-acknowledgement failures refresh the surviving parent rather than a closed form.
Public URL settings accept either a complete URL or a
bare hostname, which is normalized to HTTPS. When a public URL is configured, the single *Upload files / folder* button
opens a one-time, user-bound browser session for up to 200 files / 250 MB total and preserves
nested folder paths (browser pickers omit empty folders). Selected files travel directly from
the browser to the gateway folder and are never staged in Slack file storage. Editor/upload
sessions open directly from Slack on the first cross-site navigation: explicit SameSite=Lax
cookies support the top-level GET redirect, with HttpOnly, HTTPS Secure, per-session path and
expiry unchanged. Cross-site POSTs do not receive those cookies; every write still requires its
session's CSRF header and current channel authorization. These flows
refuse traversal and protected names except uploaded `CLAUDE.md`, and audit every result.
A refused, oversized, or failed file is skipped while the remaining selected files continue.
The final summary counts uploads and skips, lists each skipped path with its reason, and keeps
only skipped files selected for retry. A selection over the upload limits does not disable the
whole batch; successful writes still obey the server's session limits.
File and folder uploads allow `CLAUDE.md` at the root or nested in a project. An unchecked
*Overwrite existing files* choice keeps numbered copies; selecting it atomically replaces files
at their original names. Replacement operates on a symlink itself and never writes its target;
project-local `.claude/skills/` and `.claude/settings*.json` files are accepted as part of
folder uploads, including when uploading into an existing project subfolder. The channel-root
`.claude/` policy and aliases pointing to it remain protected; other protected names and
directory escapes remain refused. Uploaded project skills stay in the project tree for later
personal/channel catalog registration, and uploaded settings do not replace gateway policy.
Gateway-owned folders refresh their managed instruction block on the next turn and keep the uploaded channel
instructions outside it. Read-only
mode hides write controls and Full remains admin-only. With a Public URL configured, every file
preview also offers *Download*: a requester-bound, 10-minute, single-use URL rechecks channel
authorization + membership and realpath confinement, then streams the complete file directly
from the gateway with no Slack file copy, including files above Slack's 25 MB sharing limit; each
accepted transfer audits `channel_file_downloaded`. A confirmed *Share* copies a
selected regular file (≤25 MB) only into the originating channel/thread; *Send to me* delivers the
complete selected file privately through the bot's Slack DM, independent of the user's network.
Preview truncation is explicitly presentation-only. In
Worker/Auto modes, authorized members get both the native Slack *Edit* popup (for files up to
3,000 characters) and *Edit in browser* for valid UTF-8 text files up to
250,000 characters / 1 MB regardless of extension, including `.env*`, JSON/YAML/TOML, scripts,
configs, and extensionless files. Managed, credential/token/secret, key, binary, and invalid-UTF-8
paths stay read-only. The configured public gateway URL opens a full-page editor with a live
Markdown split preview for Markdown. A 10-minute one-use capability is exchanged for a one-hour HttpOnly,
same-site, one-file browser session; the route is no-store/CSP/CSRF protected. Full mode remains
admin-only, and every load/save rechecks access/membership/mode, verifies the open-time content
hash, atomically replaces the confined file, and records an audit event. Without a public URL the
Slack controls its native modal dimensions; apps cannot request a larger popup. Without a public
URL, the 3,000-character Slack modal editor remains available on its own. Existing Slack apps must
apply the latest manifest to activate the message shortcut. `/files` is no longer registered or
advertised; a typed legacy request gets a retirement notice without starting an engine.
→ TEST-PLAN: Channel file explorer.
- **Slack Agent Sessions and the native Stop button** (`src/slack/agent-sessions.js`,
`src/slack/agent-interface.js`). Every status write drives the thread's agent session natively
(`agents.sessions.setStatus`: `processing` while a turn works, `active` when it ends — asserted
once per transition and re-asserted every five minutes so a long turn never hits Slack's
one-hour `processing` expiry) and then writes the activity phrase through the legacy
`assistant.threads.setStatus` bridge, which is the only surface that accepts a phrase. The
session's display name follows the resolved model, so Slack's control reads **Stop Opus 1M**
or **Stop GPT-6.1 Sol** instead of the app name. Runtime changes update it immediately;
separate threads retain separate names. Before resolution it says **AI agent**, or the engine's
**CLI default** when no model is reported. This uses the per-session `username` override and
requires `chat:write.customize` (add the scope and reinstall existing apps). A rejected override
retries the same native lifecycle without customization, preserving Stop; a missing scope is
retried after one minute without requiring a daemon restart. The global bot name is unchanged.
The app manifest subscribes to `agent_session_stopped` (and `agent_session_title_changed`), which is what
makes Slack render its native **Stop** button on the thread while the session is `processing`,
in DMs and in channel threads alike. Pressing it stops exactly that thread through the same
`stopRunsInChannel` path and authorization gate as a `stop` message or 🛑 reaction (redelivered
envelopes are deduplicated, the bot's own events ignored); whether or not a run was found the
session is moved off `processing`, because Slack never does that on its own.
Stop also closes that turn's progress producer immediately when its abort signal fires. Late
engine output, warmup notices and status heartbeats cannot restart processing while the runner
unwinds; cold-engine steering uses the same cancellation boundary. A stop during directory or
thread-context preflight does not start a replacement indicator or runner.
Repeated cancellation cleanup calls share one bounded completion, keeping a steered successor
behind its predecessor's pending status clear for the existing cleanup grace.
Per-thread titles use `agents.sessions.rename` (200 chars). A workspace or app without the native surface
(`feature_disabled`, `missing_scope`, `unknown_method`,
`missing_agent_session_stopped_event_subscription`, …) falls back to the legacy methods for the
rest of the process after one warning; a per-thread refusal (`not_authorized`) only skips that
thread and never flips the mode; an ordinary channel thread that only the native surface accepts
still gets the lifecycle (and the Stop button) while the phrase stays off there. Mid-turn stream
rollovers pass `session_status: "processing"` on `chat.stopStream` so a retired message cannot
clear the loading state. Both events and `reply_feedback` are logged to the `events` table
(`agent_session_stopped`, `agent_session_renamed`). → TEST-PLAN: Slack Agent Sessions & native
Stop.
- Slack Agent app (`agent_view`): native status animation (`assistant.threads.setStatus`) with
progress-tracking phrases in agent threads. As soon as the spawn runtime resolves, the prominent
loading indicator identifies its configured model through `loading_messages` (for example,
`Opus 4.8 1M · Gathering information…`, with the model first so right-edge truncation cannot hide
it). The shared status boundary caps the rotation at Slack's documented ten messages and each
message at the API-enforced 50 characters, adding a surrogate-safe ellipsis when needed so an
overlong thinking/tool phrase cannot disable the live status. The compact activity line beneath
the composer retains the same model while showing tool/thinking phases, and a cross-engine
failover replaces both labels mid-turn. Streamed placeholder message elsewhere. Top-of-Messages-tab
suggested prompts (seeded on `app_home_opened` tab=`messages`, derived from the channel's MCP
servers + skills) and per-thread
auto-titling (`assistant.threads.setTitle`) so the Messages-tab timeline reads well.
→ TEST-PLAN: Slack gateway.
- Auto-registration of any conversation the bot sees (index + default meta + folder).
- In-thread replies with a placeholder that streams → final answer + token/cost footer. Every
footer reports only that message's root turn: resumed Codex sessions refresh their settled
container-volume accounting path before the baseline snapshot, so cumulative provider-session
totals cannot masquerade as one reply's tokens/value. Canonical ledger descendants remain
additive and separate from the message footer. → TEST-PLAN: Slack gateway.
- Slack replies use native Slack text streaming: the answer is written live via
`chat.startStream` / `appendStream` / `stopStream`, with the run-stats footer appended as a block
at `stopStream`. Answer deltas use `markdown_text`, so small GFM pipe tables render as styled
inline tables (including code/bold cell content); the classic-post fallback preserves them as
aligned monospace grids when streaming is unavailable. A rejected footer block is retried without
controls (and classic delivery preserves the stats as text), so cosmetic Block Kit validation can
never turn an already-completed engine answer into `run_error`. If Slack rejects the stream's
terminal call outright (a rate-limited `stopStream`), the classic recovery removes the partial
streamed message before posting the complete answer and puts the footer on that answer, so a
failed finalization still leaves exactly one reply rather than a truncated copy, a duplicate and a
stats-only trailer. If Slack has ENDED the answer's stream instead
(`message_not_in_streaming_state` / `message_not_found` on an append or on the terminal stop), the
answer is republished rather than abandoned: a fresh stream receives the complete compiled answer,
the refused delta is replayed onto it, the footer lands on that surviving message and the stranded
copy is deleted only once the replacement is durable — a lost streaming window costs a message,
never a delta or the footer. Alongside the answer, tool calls
and the agent's plan render in Slack's native
**task_update card**, streamed as its own message directly ABOVE the answer — a turn with progress
is two bot messages (the live card, then the uninterrupted answer beneath it), a text-only turn
stays one. A tool, subagent, notice or progress-report row that arrives after the answer has
started still opens the card; only the content-free liveness pulse is suppressed then, so one
preamble sentence before the first tool call never costs the turn its toolbox. Rows are
grouped into ONE collapsible "steps" card
via `task_display_mode: "plan"` (not a separate box per step): each tool call is a row that starts
`in_progress` and closes as soon as Claude or Codex reports its result (with
next-step/answer fallback for older events), and a `TodoWrite` plan upserts a row per item carrying
its real pending/in_progress/complete state. Raw tool results never enter the progress event.
A tool that FAILED is labelled on its own row (`⚠️ Bash(…) · failed`) and still closes `complete`,
because Slack derives the card's header from the aggregate row statuses — one `error` row repaints
the whole toolbox as "Something went wrong" even when the turn recovered and answered. `error` is
reserved for a stage the agent itself declares failed through `report_progress`.
Native in-turn **Claude and Codex subagents** use the same card with independently updating
parallel rows: the engine adapters normalize Agent/Task and every shape Codex reports a child in
— multi-child `agents_states` updates, `receiver_agents`/`receiver_thread_ids`, the
`SubAgentActivity` start/finish lifecycle (keyed on the child's thread id, so the spawning call
and the completion merge into one row) and the `collaboration` spawn call whose task name titles
it — while a collab call that names no child (Codex multi-agent v2 waits) renders the
coordination step itself rather than nothing. Because `codex exec --json` forwards NONE of those
identity-bearing shapes (CLI 0.153.4 sends one anonymous `wait` item and hides the spawn call
entirely), the Codex runner takes a child's identity from the only place it exists on the
daemon's side: the child's OWN rollout, whose `session_meta` names it (`agent_path`,
`agent_nickname`) and its parent. A collaboration item is the cue to read those rollouts and open
a named row per child while they work; the end-of-turn accounting pass — the same one that bills
subagent tokens — closes each row with the child's elapsed time and token spend. Completed rows
remain visible, elapsed/token/tool metadata appears when supplied, and
failed/stopped/missing-terminal lifecycles close with an explicit warning instead of leaving a
spinner behind. The assistant shimmer also reports how many native agents are active. These
joined children are distinct from durable `run_agent_in_background` jobs, which retain their
separate status-button message because they can outlive the turn. The card degrades quietly
(answer keeps streaming) where task chunks can't render, and the whole reply falls back to a
plain posted reply if streaming is unavailable. → TEST-PLAN: Slack gateway.
- Persistent toolbox plus temporary assistant status: the native `task_update` toolbox is durable
reply content in **every** thread type, including assistant/AI-app threads. Its tool, plan,
subagent, and heartbeat rows stream live into the card message and remain available in Slack
history after the run. Finalize, controlled stop, and every stream rollover re-send a complete
row snapshot as `chat.stopStream` chunks, sealing each toolbox into its message after streaming
ends. Slack REPLACES a row's title and status but APPENDS its rich `details`/`output`, so a row
sends those only when they actually change — the seal never repeats a value the live card already
rendered, and a value that grew carries just its added tail. A rollover reseeds the successor
message in full. During a long-run rollover, a successor first receives the complete compiled answer and
full toolbox snapshot (completed history plus current in-progress state); only after that copy is
durable is the retired bot message deleted, keeping one authoritative reply visible whenever the
answer fits Slack's single-message limit. A failed cleanup leaves both safe complete copies and
retries at terminal state rather than risking answer loss.
Independently, the native assistant-status shimmer is a temporary live activity surface: it
carries **thinking summaries** (the latest line of the actual reasoning text, throttled and
clipped to 80 chars; Codex reasoning summaries too; redacted thinking stays bare), tool labels,
subagent counts, and elapsed time; the prominent `loading_messages` line mirrors the same
activity (model label first). Because Slack wipes the status whenever the app writes in the
thread, tool and semantic progress-report toolbox writes restore it immediately, while streamed answer
appends restore it on a bounded cadence; the liveness tick also re-asserts it when unchanged.
Status writes are serialized AND bounded: one write is in flight at a time and exactly one
pending slot holds the latest phase, so a phrase superseded before it reached Slack is dropped
instead of being sent late, and a rate-limited workspace cannot build a backlog. Activity can
never race the terminal clear or resurrect the box after completion. Finalize and stop clear that
temporary status, but DELIVERY IS NEVER GATED ON IT: the clear is fired and waited on only for a
few seconds before the answer goes out, and it still lands afterwards (posting in the thread
clears the box on Slack's side anyway). Ordinary channel threads simply no-op that surface while
keeping the same persistent toolbox.
→ TEST-PLAN: Slack gateway.
- Long-turn liveness without Slack's five-minute red-card failure: the 20-second heartbeat is a
succession of completed native task pulses rather than one indefinitely open task. Pulse IDs
rotate every four minutes, the latest pulse is relabelled on finish/stop/delivery failure, and
recap rows omit every heartbeat generation. The message-level safeguard independently measures
the age of the actual Slack stream (not the earlier SDK helper): before 5 minutes it seeds a fresh
stream with the complete compiled answer and full toolbox, then removes the retired bot message.
Rollover repeats for arbitrarily long turns, final delivery closes the newest stream, an
unseedable successor falls back to complete classic delivery, and sanitized failure codes are
logged once per site without exposing payloads. When Slack ends a card's stream FIRST — an
append delayed past its window by rate limiting, or an age cap the local clock did not beat, both
reported as `message_not_in_streaming_state` — the card is republished rather than abandoned:
Slack renders an abandoned stream as a bare "Something went wrong", which would leave a red error
banner above the correct answer of a turn that merely hit (and handled) a failing tool. The
replacement receives the complete row snapshot, the stranded copy is deleted only after it is
durable, and a terminal seal that finds the stream already gone republishes the finished toolbox
the same way. The ANSWER stream recovers identically — the compiled reply is reseeded, the refused
delta replayed, the footer sealed onto the survivor — so a stream Slack ends first can cost a
message but never the answer or its footer. Only a replacement that cannot be made durable
degrades: to no card at all, or, for the answer, to the complete classic fallback (which also
removes the stranded partial).
→ TEST-PLAN: Observability.
- Dedicated progress report inside the unified toolbox: for long/substantive domain work,
the always-injected `gateway-usage` guide teaches skills to publish authoritative semantic-stage
snapshots through one shared, strict `report_progress` MCP contract used by Claude and Codex.
Long analysis/research/review and work spanning 100+ independent items must be split into bounded
subagent scopes (parallel when independent, with a separate verifier where accuracy warrants it).
Each delegated plan stage discloses the agent role, scope, actual model, and effort—or truthfully
says the value is inherited/not exposed—and refreshes exact item, batch, and disagreement counts
at meaningful checkpoints. Work that must outlive the turn still routes to daemon-owned agents;
contexts without the Plan tool never claim one. Slack streams a
`plan_update` title and rich `task_update` stage rows into the **same expandable toolbox** as the
low-level tool/subagent history, in the turn's own progress-card message directly above the
answer—never a third persistent message.
Stable step IDs retain pending/in_progress/complete/error state plus optional details, output,
and source links; each stage's details and output are delivered once, on the chunk that changes
them, so a finished plan never renders the same paragraph two or three times. The capability is
exposed only to visible, non-clean
interactive Slack and non-recovery Slack-backed API turns; daemon jobs, schedules, recovery,
diagnosis, and headless runs fail closed. Identical snapshots are deduplicated; controlled stops
and final-delivery failures mark only the active step as error while preserving completed/pending
work; chunk failures disable only the shared toolbox, leaving answer streaming intact. Progress-report
state is intentionally turn-local rather than crash-persistent. → TEST-PLAN: Slack
gateway.
- **Persistent API conversations:** optional `conversationKey` on `POST /api/runs` binds the
resolved Slack channel ID plus exact key to a durable thread root in SQLite, independently of
job-history retention. Repeated events post their requests and answers as thread replies and
resolve the live agent session inside the existing Slack/API queue; simultaneous first events
create one root. `threadTs`, `threadReused`, `threadPermalink` and `conversationKey` appear in
POST/status/list/webhook responses; unavailable permalinks are null. Deleted roots are replaced
under the same creation lock; Slack outages, missing scope or lost channel access return 503
without starting a headless run or creating another root. `idempotencyKey` remains separate
with its existing 15-minute window; a collision across keyed conversations returns 409.
Keyed session/resume fields are null until completion. → TEST-PLAN: HTTP run API conversations.
- HTTP run API (`POST /api/runs`) for automations: accepts message/file input, optional target
Slack channel, per-run engine/model/effort/mode overrides, idempotency keys, status polling,
stop, and completion webhooks. Channel-backed API runs post the full request in Slack and use the
same visible progress/streaming path as interactive turns; headless API runs stay silent and
finish through status/webhook. A job whose run was in flight at a daemon restart is marked
`interrupted` and never run again (its external actions are unknown); a result that was already
saved is still delivered and its webhook still fires. Every settled run
publishes a cost: the engine's own dollar amount when it reports one, otherwise the usage
ledger's priced estimate for that same run (Codex reports none), flagged `costEstimated` in the
status response and the webhook — `null` only when nothing anywhere knows.
→ TEST-PLAN: Automation.
- **An API run behaves like an admin's message in its channel.** The run API key (like an admin
session on `/api/runs`) is an admin credential. Every run acts as one fixed principal, `api`
(`src/config/api-principal.js`): an admin of the target channel with no person behind it. The
run gets what an admin's Slack message gets: the channel's mode, including Auto (tool prompts
auto-approved) and, in an Admin channel, `--dangerously-skip-permissions` for a Full run. It also
gets channel memory (search, read, save and the post-reply memory review), the channel's and
organization's skills, MCP connections, channel secrets, and the gateway tools, including the
admin ones. The surviving control-plane cards still need a human click. It gets no
personal scope: Composio is the channel's shared `composio-agent` identity only, with no
`composio-user` and nobody's personal Toolbox token, secrets, skills or SSH keys. The `author` a
request names is attribution only. An untrusted capability naming a real admin never borrows that
admin's rank. Background work and schedules the run starts are owned by `api`, and their later
runs rank the same way. `ask_questions` stays off because no person could answer it. A per-run
`mode` override narrows tools only. The container, its mounts and the operator-home grant follow
the channel's own mode, so an override never recreates the container. API runs join their
thread's per-thread queue (Slack follow-ups get Steer / Queue / Cancel, and a Slack stop or steer
stops the API run), and the returned `resumeCommand` enters the channel's container. Per-user
API keys that act as a proven person are planned. → TEST-PLAN: HTTP run API channel parity.
- Codex JSONL progress: Codex `item.started` / `item.completed` events for MCP tool calls, shell
commands, and final agent messages feed the same Slack status/log stream as Claude, so Codex
turns no longer look silent while tools run. Gateway-owned MCP tools are pre-approved inside Codex
because the gateway MCP server already enforces schedule/reminder/background/channel-admin policy.
→ TEST-PLAN: Engines; Modes & approvals.
- Provenance: each turn tells the agent who requested it and where (metadata, not an instruction), so
it can address people correctly. Tool access stays the sender's (re-resolved per message).
- Slack split-view provenance: `app_context_changed` snapshots are held per workspace/user and
injected only into that authorized user's ordinary DM prompt, so phrases such as “this channel”
or “this thread” resolve to the Slack conversation visible beside the agent. Entity types and fields are
allowlisted, bounded, short-lived, and never injected into channels, clean turns, stale events,
or another user's request. → TEST-PLAN: Slack gateway.
- Real `@`-mentions: the agent just writes `@Display Name` in its reply and the daemon rewrites it to
a real `<@UID>` Slack mention (a blue, notifying tag) — no id lookup at generation time, so it costs
zero extra tokens. Backed by a cached workspace directory (`users.list`, name→id, 15-min TTL,
warmed at connect; needs the `users:read` scope). Exact-match only (longest name wins), so unknown
names, emails (`bob@x.com`), Slack's own `@here`/`@channel`, and text inside `` `inline code` `` are
left alone, and an ambiguous name (two users, same display name) is never guessed. Works in `stream`
mode too — a mention split across delta chunks is held whole via a small buffer. Applies to
interactive replies plus scheduled-run and background-continuation posts. → TEST-PLAN: mention resolution.
- Ambiguous bare first names fail safe: if two members answer to the same first-name token — including
a handle/first-name collision — `@FirstName` stays literal instead of notifying the wrong person;
exact full names and unambiguous handles still resolve. → TEST-PLAN: mention resolution.
- `/status` (slash command only): a compact report of what a channel is working on — live background
jobs, scheduled work, and warm/in-flight sessions. A plain "status" message deliberately goes to
Claude (it summarizes the thread's actual work), not the canned report.
- In-thread commands (typed as normal messages): `/help` (a practical operating guide covering
channel/DM addressing, 🤖 engagement, thread stopping/steering, files, personal Composio setup,
the skill catalog, channel memory/rules, the automatic gateway skills, reminders/schedules,
long-running background work, useful status checks, and the complete command reference),
`/clear` (drop the thread's session — next
message starts fresh), `/context` (token usage + % of the context window from the last turn),
`/resume` (the copyable `cd "…" && claude --resume <id>` terminal command for this thread's
session — kept out of reply footers; the same command is a tab in Slack Settings → **Resume
Session** → modal (the 💻 buttons on older `/menu` cards and "🛑 Stopped." messages still open it).
`/resume <command or session id>` runs the same trip in REVERSE: paste that line back and the
thread adopts the existing local session, so a conversation started in a terminal on the gateway
machine (or left behind by a cleared thread) continues in Slack. Accepts the full pasted command,
the bare engine invocation, or a bare id, surviving Slack's `&&` escaping, smart quotes,
and backticks. **Runtime-aware lookup:** a containerized thread's transcripts live in the
channel's own HOME volume, so the id is looked for in the stores this channel can actually reach,
cheapest first — the daemon's engine dirs (a legacy, pre-container session), the volume read
straight off the host where it is traversable, and otherwise the channel's container itself,
asked through the runtime's read-only `inspectState` (one `sh -c` that globs the layout and
returns each match's mtime and opening records; nothing is copied out). Whichever store answers,
the same rule decides. **Same-channel only:** the transcript's own recorded `cwd` — never the
pasted `cd` — must be this channel's effective work dir, so a session from another channel's
folder is refused with a pointer to the channel that owns it; an id already bound to another
thread is refused too (one session, one thread). Unknown ids are refused rather than bound
blindly — naming the harness the PASTED command named (a `claude --resume` line pasted into a
Codex thread is not a missing "Codex session") and saying where the gateway looked — the
thread is pinned to the harness that minted the id, the warm pool entry for the replaced session
is evicted, and the adoption is audit-logged as `session_adopted`),
`/pending` (alias `/followups`; the bare words `pending` / `my followups` work too, exact-match
like `stop` — the caller's own live list of threads the AI is waiting on them for, formatted like
the twice-daily digest; friendly "nothing pending" when empty),
`/model` (the runtime wizard — one message that walks scope: *This channel* or *Just this thread*
(buttons) → harness: *Claude*/*Codex* (buttons, plus *Use defaults* to clear the scope's
overrides) → model (one button per model) → effort (one button per level) — every step is a flat
list of buttons, no dropdowns, with the choice already in force marked ✓ and highlighted;
each step persisting as it's clicked. Because a step persists on click, a mis-click is corrected
in place rather than by re-running the command: steps 2–4 carry **← Back** to the step before
them and the final card carries **Change again**, both repainting the SAME message (walking back
writes nothing and undoes nothing — the re-pick overwrites what the wrong click stored, and the
repainted step shows what is actually in force). Thread scope
writes per-thread engine/model/effort overrides that beat the channel at run time; channel scope
applies to the thread it was clicked in as well as to later ones — it drops that thread's own
engine/model/effort overrides so nothing shadows the new channel values, and when the thread's
live session belongs to the OTHER harness it pins the thread to the chosen one so the switch
actually reaches it (the done card then says the thread restarts there with its earlier messages
replayed). A thread already on the chosen harness, or one with no session yet, is left unpinned
and keeps following the channel; Settings →
Access & security chooses whether channel changes are admin-only (default) or available to every
authorized channel user, while anyone approved may customize their DM; typed `@bot /model` is the command — no manifest slash command is
registered (a thread-aware Bolt handler answers if one is ever added);
the old `/engine` and `/effort` are retired and answer with a pointer), `/compact` (Claude-only),
`/mode` (see below),
`/delete` (org-admin only: wipe THIS thread — replies first, parent last — hard-scoped to the
triggering channel+thread; the bot token deletes the bot's own messages, and when an optional
Admin User Token (xoxp) is set in Settings → Slack credentials, everyone else's are deleted via a
per-call token override too — workspace prefs permitting; anything Slack refuses is tallied +
reported in the ephemeral summary, which also says how to enable full deletion when no user token
is set; refuses non-admin authors, mid-run threads, and top-level use; a deleted bot-owned root
also drops the session like `/clear`), and `/update` (admin-only — git pull + install + restart, detached).
→ TEST-PLAN: In-thread commands.
- Stop an in-flight run four ways: Slack's **native Stop button** on the thread (the agent session's
`processing` state, see *Slack Agent Sessions and the native Stop button*); a plain-text **stop
word** (`stop`, `cancel`, `abort`, `halt`,
`nevermind`, …) — bare in a DM, @mentioning the bot in a channel thread (the mention gate drops an
un-mentioned channel message before the stop word is ever read); a **stop emoji reaction** (🛑 `octagonal_sign`, ❌ `x`,
✋ `raised_hand`, `no_entry`, …) on any message in the thread; or the **`/stop` slash command** —
which Slack does **not** allow inside threads, so the button, words and reactions are the in-thread path. All post
"🛑 Stopped." with a resume command. Codex's native thread ID is persisted when the CLI
announces it, before the first turn completes, so an interrupted first turn never offers the
gateway's provisional UUID as a resume ID. The early notification uses the same clear-generation
guard as final session persistence, including fallback sessions. The command names the harness **the stopped thread was
running on** — resolved per stopped thread (per-thread override → the engine that minted the
thread's live session → the channel's engine → the gateway default), because a session id is
engine-specific and a card built from the gateway default alone hands a Claude session a Codex
resume line. → TEST-PLAN: In-thread commands.
- **A stopped run stops answering**, on either engine: "🛑 Stopped." is the last thing the thread
receives. Answer text still queued behind Slack's rate limiter is dropped rather than flushed by
the stop path (which used to create the answer message itself, lazily, and post the whole buffered
reply beneath the stop card), and the stop flag is re-read immediately before every delivery call
— the streamed finalize and the chunked fallback — so a stop landing during usage bookkeeping
still wins. Text that had already reached Slack stays, closed with a
`🛑 _Stopped — partial answer._` marker so a cut-off stream is never read as a finished answer.
→ TEST-PLAN: In-thread commands.
- Stopped-request replay: if a user stops a turn after the Slack thread already had a live session,
the gateway stores only that stopped user request and prepends it once to the next turn in the same
thread. This fills the missing request after a killed Codex turn while leaving first-message stops
alone (the next turn can rely on Slack thread context). → TEST-PLAN: In-thread commands.
- Busy-thread steer-or-queue choice: a message sent while the thread is mid-turn pauses behind an
owner-only, single-use card with **Steer Conversation**, **Add to Queue**, and **Cancel Request**.
After a valid Steer or Queue selection, the temporary bot card is deleted so the thread retains
only the user's original follow-up message. Cancel drops only the paused message and deliberately
leaves the run in progress going. Steering interrupts warm Claude in-protocol and cleanly
terminates a cold Claude or Codex child; queuing leaves the active run untouched and preserves
FIFO order. Outstanding cards survive daemon restarts, while expired/replayed/foreign clicks
cannot duplicate or hijack the message. Slack redeliveries are matched by exact run id and
dropped silently rather than raising a second card; boot recovery skips a row a redelivered copy
already picked up, so the same message is never answered twice.
Prefix **`/next`** to queue directly without the choice.
→ TEST-PLAN: In-thread commands.
- Mention-by-reaction: react with a configured emoji (default 🤖 `robot_face`) on a message to treat
it as an `@bot` mention. Scoped to the bot's own surface (a DM, a brand-new top-level message, a
thread with an existing gateway session, or a sessionless thread this gateway bot has posted in —
its own reminder/daemon root, or a reply such as a `/model` or `/help` answer, which never mint a
session) so those threads can be engaged immediately without hijacking another agent's thread. On
first engagement, the bounded Slack replay includes the bot-authored root reminder and preceding
replies before the reacted request. The reactor becomes the author (their authz + tokens apply).
→ TEST-PLAN: Slack gateway.
- Answer-on-join: when a tag pulls the bot into a channel (Slack's "mention a bot that isn't here →
invite it" flow), the message that triggered the invite predates membership and never arrives as an
event. On join the bot scans the channel's recent history (15-min window) for that pending `@bot`
mention and replies to it in-thread — one reply, the most recent mention, skipped if it already owns
that thread so a leave/re-join can't resurface an old tag. → TEST-PLAN: Slack gateway.
- Background jobs with auto-continue: the `run_in_background` gateway MCP tool hands a long shell
command to the daemon (which outlives the one-shot `claude -p` subprocess); on completion the
daemon re-injects a turn into the same thread (same session resumes with full context) and posts
the continuation — no lost work, no "I'll continue automatically" dead-ends. → TEST-PLAN: Automation.
- Background jobs survive a daemon restart: each job is persisted in SQLite's `bg_jobs` table; on boot
a still-running job is watched to completion, and one that exited while the daemon was down gets a
forced "interrupted by restart" continuation — a thread never silently stalls.
- **Container-only runtime settings.** Settings expose the CLI, image, idle/container limits,
resource caps and optional Full-access home mount. There is no host runtime switch or Claude
subscription-token field. Boundary validation rejects invalid CLI argument values before save.
→ TEST-PLAN: Container runtime and Release readiness remediation.
- Interactive turns survive a daemon restart (auto re-run): cold Claude/Codex engine subprocesses and
warm Claude sessions run in process groups so their MCP children can be killed as a unit; controlled
daemon restart/stop terminates those groups, preserves the `active_runs` row, and lets the next boot
replay the interrupted Slack turn. Each running turn is recorded in the `active_runs` table (written
at start, deleted on normal completion/error/stop); any row still present at boot was interrupted.
On startup the daemon snapshots those rows before Slack reconnects, then auto re-runs each: posts a
"🔁 interrupted by a restart — picking it back up" note, resumes the same thread/session when possible,
and re-runs the exact original prompt as the original author. The recovered turn immediately reuses
the normal native progress controller, so its temporary shimmer, persistent toolbox, heartbeats,
tool events, and answer deltas stay visible instead of going silent until final delivery.
→ TEST-PLAN: Automation (restart recovery of interrupted turns).
- Restart recovery joins the same per-thread FIFO as live Slack work and advertises the progress-report
capability consistently; a changed warm-session fingerprint drains the current promise chain
before replacing its process, so recovery and a newly arrived turn cannot terminate one another.
- Safe manual restart keeps Slack online while it checks engine turns, background jobs/agents, API
runs, and update transactions, then rechecks every 30 seconds for up to five minutes. It starts
shutdown only after work is idle and cancels with an in-thread explanation if work remains.
Shutdown then performs its existing bounded drain and sweeps every tracked cold and warm process
group (with a final hard-kill fallback). Completed rows are cleared before exit while genuinely
interrupted rows remain recoverable on boot. → TEST-PLAN: Automation.
- One-time ("run at") schedules: `create_schedule` accepts `in_minutes`/`run_at` to fire once and
auto-delete (e.g. "remind this channel in 2h") alongside recurring cron; recurring crons are held
to a minimum interval (default 60 min) with per-channel + concurrency caps.
- Scheduler startup immediately checks the durable cursor, including cron minutes missed during
restart recovery. Catch-up is bounded to the five most recent minutes and never predates a
schedule's creation, re-enable, or cron edit. Durable epoch-minute claims prevent replay after
restart, including repeated local clock labels at a daylight-saving fold. A queued task can
start safely after restart; an interrupted engine execution is paused for reconciliation, and a
saved result retries delivery without rerunning tools. Reminder claims precede posting: a crash
at that external delivery boundary can lose a reminder but cannot automatically duplicate it.
Short transport outages defer unclaimed recurring work within the same five-minute window.
→ TEST-PLAN: Scheduling restart durability.
- Schedule times are the GATEWAY's local zone, and they say so. Crons are matched against the
daemon's own clock, so every container receives the daemon's IANA zone as `TZ` (at create and on
every exec) instead of the image's `Etc/UTC` — `date` and both engines read the same wall time as
the scheduler. The tool replies name the zone rather than relying on that: a one-time schedule is
confirmed as "2026-09-08 09:15 Europe/Bucharest (06:15 UTC)", a recurring one adds its resolved
next fire time, `list_schedules` repeats the zone, and the bundled reminder guidance tells the
agent to quote it back instead of converting. → TEST-PLAN: Scheduling & reminders.
- Recurring task schedules can opt into `delivery:"daily-thread"`: the first run of each
server-local day creates one top-level “Running” anchor and every result that day lands beneath
it. The durable anchor survives daemon restarts, the next day starts a new thread, and agent
sessions remain fresh per run — the session key is minted per fire and never derived from the
reused anchor, so grouping the day's results never joins them into one conversation. Existing
schedules retain standard per-run announcements. → TEST-PLAN: Scheduling & reminders.
- Scheduled tasks can use `interval_days` for a fixed 24-hour-day cadence (including exactly
14 days), and `delivery:"dm-on-match"` with a required `match_prefix` for quiet monitoring.
The scheduler posts no start or routine result; only a result beginning with the prefix is
sent as a DM to the creator, then the schedule stops. Failed runs remain in schedule status and events. The next interval
advances from the planned due time, skipping missed periods rather than replaying checks.
→ TEST-PLAN: Scheduling & reminders.
- Native `/loop` in a thread: Claude Code's own loop skill paces itself with `ScheduleWakeup` /
`CronCreate`, both of which are session-local (the harness documents its cron store as "gone when
Claude exits") and therefore inert in a headless turn. The daemon reads the pacing call out of the
engine stream and adopts it: the loop is re-armed as a thread-bound row in the schedule store, and
the tick RESUMES that thread's session and replies in-thread, so iterations accumulate context the
way they do in a terminal instead of restarting blind. Being the only schedule that runs on a real
thread key, a tick joins the same per-thread FIFO as live Slack work (the guard restart recovery
already needed), so a tick and a human message can never resume one session at once. Self-paced (`ScheduleWakeup` → one-time row
the next tick re-arms) and fixed-interval (`CronCreate` → recurring row) are both supported; the
cadence floor is 1 min rather than the unattended-cron minimum, because a loop is bounded by a
tick budget (24) instead. Every arm, refusal, and budget stop is announced in the thread, `stop`
in the thread cancels the pending tick as well as the running turn, and a re-arm replaces the
pending tick rather than stacking a second one. → TEST-PLAN: Automation.
- Reminder schedules (`kind:"reminder"`): post a SINGLE message (no Claude run, no token cost) rather
than running a session. With `ack:true` the reminder requires a ✅ — if nobody reacts within
`ack_escalate_minutes` (default 120) the bot posts a 2nd notice, then after `ack_dm_minutes`
(default 60) more it DMs the schedule's creator and closes the chain; a ✅ at any time resolves it.
Pending acks persist in `config/acks.json` so the chain survives a daemon restart. The renderer
owns the "⏰ *Reminder:*" label: one leading "Reminder:" the author already wrote is stripped from
the posted message, the 2nd notice and the DM, so the line never stutters.
- Personal no-response nudge: each user chooses whether the bot posts one gentle reminder addressed
to them when their last agent reply has gone quiet past a window (default 24h). The live user
preference follows them across channels and DMs and is self-service in Slack App Home; admins can
also edit it in Users. Strictly single-thread; never scans other channels. An org-level default
(Settings → Schedules & nudges, `defaultNudges`) is captured for newly seen users, and an "Apply
to all existing users" button intentionally replaces every existing personal choice.
- "AI is waiting on you" digests: the bot passively observes the channels it's in (every message,
mention or not) and tracks, per thread, who took part and who spoke last. It reminds about **one
thing only** — threads where the **AI is waiting for your decision**: a genuine AI thread (the bot
was @mentioned or posted) in which the **bot spoke last**, so the ball is now in a human
participant's court. It deliberately does NOT nudge you about human-to-human replies you owe; if you
replied last, the AI isn't waiting on you. Twice a day (08:00 & 14:00 Europe/Bucharest by default)
it DMs each approved user those threads, each line a permalink. Missed slots **catch up**: each
slot records a durable per-day "fired" marker in SQLite (`_meta`), and on every minute tick any
slot whose time has passed today but hasn't fired yet fires late (a Mac asleep at 08:00 that wakes
at 09:30 gets the 08:00 digest at 09:30; several missed slots collapse into ONE catch-up DM). The
durable marker also means a daemon restart never double-sends a slot that already fired, and a
slot due while Slack is disconnected retries until it can send. Replying in a thread clears it
automatically (the bot is no longer the last author); React ✅ on a source thread to dismiss that
thread manually, or react on the scheduled DM digest to dismiss **every source thread visibly
listed in that exact digest**. Digest snapshots persist in SQLite across restarts and expire after
14 days; overflow entries hidden behind “…and N more” stay pending. Removing the digest reaction
reopens only dismissals still owned by it, so a newer direct source-thread dismissal survives.
Any fresh source activity re-opens that thread. Silent when you have nothing pending. Scope-limited
to channels the bot is a member of and already manages; it never sees private DMs/threads it isn't
part of. Settings: enable, hours, timezone, done-emoji. The same report is available ON DEMAND: type `pending` or
`my followups` (or `/pending`, alias `/followups`) in any thread or DM and the bot replies with
the caller's own live list — same digest formatting + permalinks (a shared formatter, not a
copy), with a friendly "nothing pending" note when the list is empty. The bare words are
exact-match only (like `stop`), so "pending review my PR" is still a normal prompt.
→ TEST-PLAN: Scheduling & reminders; In-thread commands.
- Slack **Lists** (the native table/tracker): the gateway control MCP exposes `slack_list_create`,
`slack_list_add_item`, `slack_list_update_item`, `slack_list_items`, and `slack_list_info` — the bot
can spin up a tasklist (default **Name** + **Status** New/In progress/Done, or `todo_mode`/custom
columns) and read/add/update rows. Composio has no Lists coverage, so these call the Slack Web API
(`slackLists.*`) directly with the workspace bot token (needs `lists:read`/`lists:write`); text cells
are wrapped as rich text and select values accept the option label. `list_id` accepts a raw F-id or a
pasted List URL. A newly created standalone List is immediately granted write access to the current
conversation, so members can see and edit it; sharing failure is reported instead of returning an
invisible tracker. Available to any allowed user (no admin gate). → TEST-PLAN: Slack Lists.
- Native Slack **data tables**: `slack_post_table` (gateway control MCP) posts a Block Kit
`data_table` into the current channel + thread using the workspace bot token and existing
`chat:write` scope. Standalone read-only datasets render with a native header row, pagination,
sorting, and filtering. Supports 1–20 columns and 1–200 data
rows with exact rectangular-shape validation, numeric cells kept as `raw_number` for numeric
sorting, an accessible row-header column, and Slack's 20,000-character aggregate cell limit
(readable text; markup and link URLs do not count). **Rich cells**: a string carrying lightweight
Markdown — `[label](url)`, a bare https URL, `**bold**`, `` `code` ``, `~~strike~~` — becomes a
`rich_text` cell with real links and styling (a raw `<@U…>` in a string stays text, as in a
reply); an explicit object cell `{text, url}` / `{text, bold|italic|strike|code}` does the
same, `{user}` is the one way cell data pings someone, and
`{button: {label, url}}` becomes an `action_cell` **row button** (unique `cg_table_row_<r>_<c>`
action ids, acknowledged by the ack-only handler) that opens a public https URL. Header cells
stay plain text (markers stripped) because Slack refuses rich text there; URLs are validated as
absolute http(s) and user ids as Slack ids before the call.
Empty text cells display as an em dash. The top-level message always has supplied or generated
notification/accessibility fallback text. Channel/thread ids come only from trusted gateway
context; the tool cannot redirect a table. The injected `gateway-usage` skill distinguishes a
small explanatory GFM table embedded in the streamed answer from a sortable/filterable native
dataset, retaining CSV/TSV snippets for big/wide exports and Slack Lists for editable trackers.
Incoming pasted `table` and `data_table` blocks are also normalized from both message blocks and
attachment blocks into exact JSON rows for the active turn, current-channel history/thread tools,
and fresh-session thread replay. Numeric values stay numeric; rich-text cells retain readable
links, mentions, emoji, lists, and line boundaries. A table-only message counts as content (while
the normal channel mention gate still applies). No extra Slack scope is needed. Any allowed user.
→ TEST-PLAN: Native Slack data tables.
- Slack **file snippets** for big/wide tables and generated text: `slack_upload_snippet` (gateway
control MCP) uploads `content` (CSV/TSV/markdown/code) as a FILE into the current channel + thread,
so a CSV/TSV renders as a **scrollable spreadsheet grid** — the right shape for a large read-only
table/export vs. a cramped message code block or a 100-row Slack List. Daemon-side via the
workspace bot token (`files:write`) using Slack's external-upload flow
(`files.getUploadURLExternal` → POST bytes → `files.completeUploadExternal`), so it needs no
Bash/network in the channel; hard-scoped to the current channel. The filename extension drives
rendering (`.csv`/`.tsv` = grid; text/code = plain snippet). Any allowed user.
→ TEST-PLAN: Slack file snippets.
- **Agent file sharing into the thread**: `slack_share_file path:<relative> [comment]` (gateway
control MCP) posts an EXISTING file from the channel's working folder into the current thread as
a real Slack file, any type (PDF, DOCX, XLSX, PPTX, ZIP, images, HTML), up to 25 MB — the
agent-side twin of the file explorer's Share button. The daemon opens the file with
`openConfinedFile` (lexical + realpath confinement, `O_NOFOLLOW`, descriptor re-proved through
`/proc/self/fd`) and uploads from that proven descriptor, never a reopened path, so neither the
mounted operator home nor a symlink out of the folder can be shared. Posts as the bot with the
bot token, so no Composio account is chosen and no "which account?" question arises; a scheduled
run posts top-level. Audited as `channel_file_shared` with `via: "agent"`. Any allowed user, like
the explorer's Share button. The `gateway-usage` skill (rule 3, the capability map,
`writing-replies.md`, `sharing-files.md`) routes "send it", "attach the PDF", "trimite fișierul"
here, keeps Composio Slack uploads for OTHER channels/DMs, and `slack_upload_snippet` for text
the agent generates. → TEST-PLAN: Agent file sharing into the thread.
- Native Slack **charts**: `slack_post_chart` (gateway control MCP) posts Block Kit
`data_visualization` blocks into the current channel + thread using the workspace bot token and
existing `chat:write` scope. Supports line/bar/area charts (1–12 series, 1–20 shared category
points each; negative values allowed) and pie charts (1–12 positive segments), with Slack-length,
uniqueness, and same-category validation before the API call. The top-level message always carries
supplied or auto-generated plain-text fallback content for notifications/accessibility. No public
image URL, renderer, extra dependency, Composio connection, or channel-side network is needed;
channel/thread ids come only from trusted gateway context. The injected `gateway-usage` skill
routes chart requests to the tool and tells the AI not to truncate silently or duplicate the chart
in its prose. Any allowed user. → TEST-PLAN: Native Slack charts.
- **Composed replies** — `slack_compose_reply` (gateway control MCP, `src/slack/reply-blocks.js`)
attaches native Block Kit to the agent's FINAL answer so one message carries the takeaway and
its visuals: 1–12 ordered `sections` of type `text` (Markdown → mrkdwn section), `chart` (the
same fields and validation as `slack_post_chart`), `table` (the same as `slack_post_table`, rich
cells included), `collapsible` (a `container` block, collapsible, collapsed by default — sources,
validation detail), `card` (title, subtitle, body, subtext, public hero image, up to three link
buttons), `links` (an actions row of up to five link buttons) and `divider`; at most 40 blocks.
The MCP handler validates and acknowledges only (it tells the model which section broke which
Slack limit); the daemon observes the tool call in the engine's own stream — Claude's
`tool_use` block in `engines/stream.js`, Codex's `mcp_tool_call` item in `engines/codex.js`, the
same posture as `report_progress` — normalizes it into a `reply_blocks` event, and the Slack
progress writer appends the latest snapshot on `chat.stopStream` under the streamed answer text
and above the stats footer (a later call replaces the earlier one; no tool row is drawn for it).
With an overflowing answer the blocks ride the last follow-up with the footer; the classic
fallback carries them on the answer message. **Tiered terminal recovery**: Slack rejects a whole
`stopStream` for one bad block, so an `invalid_blocks` answer is retried with progressively
fewer, one suspect at a time — image previews (the known failure), then the composed blocks,
then the feedback controls, then with no blocks — each retry sending blocks only (never the
terminal Markdown the SDK already retains), so no visual can ever discard the answer or its
stats footer. Card copy is converted and defanged like reply text, so an injected `<!channel>`
never fires. Buttons take public https URLs only (`cg_reply_link_<n>` ids, ack-only). The tool
is offered only to runs with a live Slack progress writer (the same `progressReport` gate as
`report_progress`), so a scheduled, background or clean run is never told its blocks will
render. The `gateway-usage` skill documents
it in `references/composed-replies.md` with a row in the capability map and pointers from the
chart and table references. Slack only: other surfaces ignore the event. Any allowed user.
→ TEST-PLAN: Composed replies.
- Slack **reads & search**:
- **This channel (bot token, no login):** `slack_channel_history` / `slack_thread_replies` read the
current channel / a thread via the bot token, **hard-scoped to the current channel id** — safe
because the requester is already a member here (and so is the bot), so it exposes nothing they
couldn't scroll to. Can't wander to other channels. → TEST-PLAN: Slack reads.
- **Cross-channel / workspace search:** done via the **Slack toolkit inside Composio**
using the explicitly selected identity: `mcp__composio-user__*` for the active author's account
or `mcp__composio-agent__*` for the agent's own account. Slack enforces the selected account's
visibility. There is **no** separate hosted Slack MCP and no per-user `connect_slack` OAuth.
## Qwen harnesses (opt-in, Claude Code CLI against an Anthropic-compatible provider)
- A **provider table** (`src/engines/qwen.js`), one opt-in engine generated per entry: **`qwen` —
"Qwen (Claude Code)"** (QwenCloud's Token Plan / pay-as-you-go endpoint) and **`qwen-eu` — "Qwen
EU (Claude Code)"** (Alibaba Cloud Model Studio's EU/Frankfurt region, whose catalog also carries
the Kimi, GLM and DeepSeek families). Each is the same `claude` binary the
image already ships, pointed at that provider's Anthropic-compatible endpoint. It therefore keeps the
CLI's whole feature set — stream-json progress, the tool loop, Slack approval cards
(`--permission-prompt-tool`), `--mcp-config` connectors, `CLAUDE.md`, `.claude/skills`, plugin
dirs, cold session resume — while everything the PROVIDER owns is its own.
- **Opt-in, never implicit.** Unlike every other harness, a missing `engineEnabled` entry means
OFF: pulling this release does not add a harness to any picker, and the "never lock every
harness out" rescue restores the default harnesses only. Settings → Engine & runtime → *Harnesses
the gateway may use* turns it on; it then appears in the admin engine selectors, the Slack
channel Settings → *Engine & model* tab, and the `/model` wizard, and `qwen` works as a
per-thread engine directive. Turning it off removes it from all of them.
- **A card per provider, a credential per provider.** Settings renders one card per table entry
(rendered from `/api/settings` `qwenProviders`, never a hand-written copy of the table), each
holding that provider's own API key (write-only: `has*`/`last4` on listings, the value only
through the audited `POST /api/secrets/reveal`), its own endpoint and its own default model.
Saving one provider never touches another's. `ANTHROPIC_*` stays a reserved prefix for
per-channel secrets, so a conversation can never redirect its own provider.
- **A provider whose endpoint is account-specific ships none.** The EU entry's endpoint is
per-workspace (`https://ws-<workspace>.eu-central-1.maas.aliyuncs.com/apps/anthropic`), so no
default is shipped: the harness stays unconfigured — failing closed with both missing halves
named in one message — until the operator pastes their own.
- **The Anthropic credential never leaves with it.** A Qwen spawn drops the whole Anthropic family
— an inherited `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_BASE_URL` and the relayed
`CLAUDE_CODE_OAUTH_TOKEN` — before applying the provider's own values last. With no key
configured, or a rejected one, the turn fails closed naming the remedy; it never falls back to
the operator's Anthropic account, and its errors say "Qwen", not "Claude".
- **Live model catalog.** The Anthropic-compatible path serves no `/v1/models`, so the discovery
hook reads the account's own list from the sibling `/compatible-mode/v1/models` endpoint derived
from the configured base URL, filtered to text/tool models (the image, video, audio and realtime
families cannot hold a conversation, and neither can the single-purpose translation and OCR
models, so all are excluded). Each provider discovers its OWN account, so the two harnesses offer
different catalogs. Settings and `/model` therefore offer what the account can actually call, and
a model the provider adds needs no release. A shipped fallback list per provider covers a fresh
install or an unreadable account, and the Settings card says which one is in use.
- **No invented cost.** Claude Code prices every turn with Anthropic's table, which is fiction for
QwenCloud tokens, so the adapter drops that figure at the boundary and the harness declares no
rate of its own: Qwen turns are recorded with real tokens and NO dollar amount (never Codex's
configured rate either).
- **Outside the failover graph, both directions.** A Qwen limit must not spend the Anthropic quota,
and a Claude limit must not spend a QwenCloud balance. Cold runs only — a warm process holds the
environment it launched with, and the pool key carries no engine, so a rotated provider key or a
mid-thread harness switch could otherwise be served by a stale credential.
- One **Cloud MCP** selection serves Claude and every provider harness (same CLI, same file
transport, same catalog), so switching a channel's harness between them never silently drops its
connectors.
- **An id that is a prefix of another still selects the harness the user named.** Engine ids are
matched longest-first, so `qwen-eu do X` in a thread pins the EU harness rather than matching
`qwen` and leaving `eu` in the prompt.
→ TEST-PLAN: Qwen harnesses.
## Engines (Claude + Codex)
- Two CLI engines: **Claude** (default — warm sessions, exact cost, skills, `/compact`) and **Codex**
(OpenAI Codex CLI — one-shot per message, MCP via `-c` overrides + an HTTP bridge). Selection
precedence is **per-thread directive → per-channel → global default** — but an EXISTING thread
sticks to the engine that minted its session: changing the channel/global harness only affects
new threads; live conversations keep resuming on their own engine. A successful automatic
cross-engine failover makes the answering engine the thread's live session, so its next turn
resumes there even after the failed engine's cooldown ends. Older fallback-only sessions are
adopted on their next unpinned turn. The exception is a harness turned
OFF in Settings: those threads move to an enabled engine. A row minted for a brand-new thread
whose turn then dies BEFORE its engine starts (a pre-spawn credential gate, a runtime that cannot
come up) is dropped with that turn, so the next message is a first turn again on the channel's
current harness instead of "continuing on" one that never produced a session. → TEST-PLAN: Engines.
- Per-thread engine directive: a message starting with `claude` or `codex` (e.g. "@bot codex build
the feature") switches that thread's engine; it sticks until changed (persisted per channel).
The `/model` wizard's "just this thread" scope sets the same override (plus per-thread model +
effort), and its "this channel" scope sets it too when clicked inside a thread whose live session
belongs to the other harness — "this channel" includes the thread you are standing in. An
explicit switch — directive, either wizard scope, or per-run API override — is
the ONLY thing that moves an existing thread to the other engine: it drops thread model/effort
overrides that don't belong to the new engine, starts a fresh session (the new engine can't
resume the old one's conversation), and replays the Slack thread context into it so the new
engine continues the conversation instead of starting blind.
- Slack `@agent /fork <new message>` branches a completed Claude or Codex container session using
the engine's native fork command. The bot posts a new top-level message linking to the source,
posts the new request in that thread, links back from the source, and runs the request in the
forked session. The source session keeps its ID and history; the new Slack `thread_ts` maps to
the engine's distinct child ID. Engine, model, effort and clean-mode pins carry into the branch.
`@agent /fork :model-shortcut <new message>` selects a configured model for the child and
clears its inherited effort pin, leaving the source unchanged. It uses the same model-change
access policy as ordinary shortcuts. A shortcut for a different engine starts a fresh session
in the linked child thread and replays the source's Slack messages through the existing bounded
context handoff. It does not transfer the old engine's native transcript/tool state. The fork
command and later source messages are excluded; unavailable Slack history is refused before
creating the child. Clean threads stay bare without history replay. Dedicated channel Codex
logins stay on Codex. Unknown, disabled or invalid targets and missing request text are refused
before a child thread is posted.
A running or sudo/source-host session is refused until it can be forked safely.
- Gateway-wide **default model** per engine (Settings → Engine & runtime, validated like `/model`):
when a thread/channel/DM-template sets no model, the run gets the gateway default as an explicit
`--model`/`-m` — so the admin's interactive terminal `/model` choice (written to
`~/.claude/settings.json`) never leaks into gateway runs. Blank = the CLI's own default.
Precedence: thread/channel/per-request override → gateway default (per engine) → CLI default;
Codex-fallback turns use the Codex default. All admin-UI model fields (the two Settings defaults,
the channel Runtime card, channel/DM config editors) are **dropdowns** with the same curated
options as the `/model` wizard, switching with the selected/inherited engine; a hand-edited
non-curated id (e.g. a dated full id in settings.json) survives as an extra option so Save
round-trips it, while an other-engine leftover is dropped. The Claude list includes Fable 5.1 as
`claude-fable-5-1`; Fable is not offered as a GPT/Codex model. Settings also provides a confirmed,
admin-only reset that clears every channel's engine/model/effort overrides so new threads inherit
these gateway defaults again (effort resets with the other two because the `/model` wizard sets
all three in one pass); DMs, existing thread-owned sessions, access, tools, and tokens are
untouched. The confirmation asks for the scope, because channel meta only decides what
a NEW thread inherits: **Channels only** (the default) leaves every hand-pinned thread on its own
harness/model, while **Channels + threads** additionally deletes the per-thread `engine`, `model`
and `effort` pins (`/model` → "just this thread", a `claude`/`codex` directive) for those same
channels and reports how many were cleared. Per-thread `clean` and `sudo` postures and DM threads
are out of scope for both. → TEST-PLAN: Engines.
- A thread/channel/per-run Codex model that the provider explicitly rejects before generation
retries once with that harness's distinct gateway-default model — Codex (`invalid_request_error`) and Claude (`model_not_found`) alike. The runner must prove there was no output
or tool attempt; generic failures and partially executed turns are never replayed. Slack status,
the reply note/footer, and the audit event identify the default that actually ran. If the default
also fails, the original model error remains authoritative. → TEST-PLAN: Engines.
- **The substitution is always visible in the thread.** The note is announced to the delivery
layer (`answer_note`) before the retry spawns, so it leads the STREAMED answer a reader actually
sees — a note only prepended to the finished reply text reaches surfaces that render that text,
never a natively streamed Slack message. Both retry paths announce it (the channel's own harness
and the cross-engine fallback's own model), it is delivered exactly once whichever way the
answer arrives, and a note that could no longer lead the answer becomes a durable task-card row
instead of being dropped. → TEST-PLAN: Engines.
- **A refused model reads as a setting to fix, never as raw JSON.** Codex reports some provider
refusals by handing back the whole HTTP response body as its error message; the runner unwraps
that document (status, provider error type, sentence) before classifying, so a model the account
cannot use is a `model_rejected` rejection with the same fallback + note instead of an
unclassifiable failure. Whatever still ends as an error is posted as a sentence — the provider's
own words, never a JSON document — and a model rejection names the model and the remedy
(`/model`, or the admin UI). → TEST-PLAN: Engines.
- **Transient provider failures are retried in place (2026-09-03).** When the provider does not
answer a request — a 5xx or 529 "overloaded", a connection reset or timeout, or an unexplained
404 from the Codex backend (the 2026-09-03 ChatGPT Codex outage answered every request that way
for a few minutes) — the gateway re-runs the SAME turn on the SAME engine up to two more times,
ten seconds apart, before giving up (`CG_TRANSIENT_RETRY_ATTEMPTS`, `CG_TRANSIENT_RETRY_DELAY_MS`).
Two rules keep it safe: the failure must be replay-safe (the runner proved no tool ran — the
engines retry mid-turn themselves, and a gateway replay after a tool call could repeat a side
effect), and authentication / usage-limit / model-rejection failures are excluded because they
have their own paths (failover below, the same-engine model retry). Each attempt is a
`run_transient_retry` event, a daemon log line and a status-line notice ("retrying in 10s (1/2)");
a reply that needed more than one attempt says so in one italic line, and an exhausted error names
how often it was retried. The pause ends early on cancel. "Replay-safe" means nothing of the turn
reached anyone: no tool ran AND no text streamed (both engines). A retried FRESH Claude session
runs under a new session id (the CLI refuses to create the same id twice), and the warm Claude
process — which stays alive after a provider failure — rejects that turn with the classified
failure like print mode does, so the retry covers the default Slack path. Which failure kinds an
engine may replay is its adapter fact (`transientKinds`, `src/engines/adapters.js`): Codex's
`transient` (`classifyCodexFailure` — status codes, the CLI's underscore error codes, or outage
wording in the error EVENT, including a plain-text `turn.failed` capacity refusal; never its
stderr, which can quote a retry it recovered from), Claude's
`availability` / `connection` (an overload, a 5xx, a `server_error` label, a dropped connection —
never the catch-all `provider` kind or the bare "API Error:" prefix a rejected request also
carries). The knobs are read per turn, so `.env` / settings values count without a restart.
→ TEST-PLAN: Engines.
- **Codex capacity after partial work continues in its existing session.** A plain provider
"Selected model is at capacity" verdict after a tool or streamed output starts one new Codex
turn against the same session id, with a prompt to inspect completed work and finish the
remaining request. It never resends the original prompt, so prior tool actions are not replayed
by the gateway. An unpinned model switches to another model in Codex's catalog; a model chosen
specifically for the thread stays pinned. The status and final answer identify the continuation,
an audit event records the model switch, and a second failure stops rather than looping.
Replay-safe capacity failures before any work still use the transient retry/failover path above.
→ TEST-PLAN: Engines.
- **Bidirectional harness failover** when the engine driving a turn hits its usage/session/plan
limit or its authentication is unavailable — Claude→Codex and Codex→Claude are the same mechanism,
so a channel whose primary engine is Codex is not stranded until its ChatGPT quota resets (and
vice-versa). Terminal zero-token limit results and typed nonzero provider failures are both
handled, on both engines: Codex classifies its plan-credit rejection ("purchase more credits…"),
whether it arrives as a JSON error event or on stderr with a nonzero exit. A thrown failure is
replayed only when the runner observed **no tool call and no streamed output**, preventing a
partially-completed side-effecting task from running twice. Authentication uses a per-engine,
gateway-wide ~15-minute cooldown; usage limits use a per-engine, per-channel cooldown. The reply
names which harness answered and why, and if the fallback also fails the original actionable error
is kept. When failover can't cover a limit (disabled, or the other harness is off), the error line
tells the user they can say `claude`/`codex` in the thread to switch by hand. Toggle in Settings
(default on). → TEST-PLAN: Engines.
- **Failover after the in-place retries, and a choice of who decides (2026-09-04).** A provider that
stayed unavailable through the transient retries is a failover case too: with failover on, the
turn is answered by the other harness ("⚠️ _Codex hit a temporary provider error — retried 2×
before giving up — using Claude._") and the channel's next turns go straight there for five
minutes before the primary is tried again. Settings → *How a failover happens*: `auto` (default)
switches silently — `ask` posts a card in the thread instead ("⚠️ Codex hit a temporary provider
error — retried 2× before giving up. Nothing ran for your message. Switch this thread to Claude,
or try Codex again?") with buttons *Switch to Claude* / *Try Codex again*, and nothing runs until
the message's author clicks. A click re-runs the ORIGINAL Slack message (same text, attachments,
thread) on the chosen harness; *Switch* also pins the thread to it, exactly like the `claude` /
`codex` directive (the thread transcript is replayed into the fresh session). The card is the same
durable, expiring, single-shot record as the busy-thread card (`src/slack/engine-switch-choice.js`;
🛑 / `stop` discards it, a restart keeps it). Usage-limit and authentication failures follow the
same mode (a limit that arrives as the answer included). Only a watched Slack thread can be asked —
schedules, background agents, continuations and API runs always switch automatically. When BOTH
harnesses fail, the error says so in one sentence ("… — Claude could not answer either: …") and a
Slack thread gets the same card with *Try Codex again* / *Try Claude again*, in either mode. The
retry pause itself now hands the global run slot and the container lease back for its length, so
a sleeping turn never queues another channel's turn behind it. → TEST-PLAN: Engines.
- **A runtime the user PINNED is never traded away.** Failover exists so a DEFAULT never strands a
thread; a thread someone pinned by hand — the `/model` wizard's "just this thread" scope, a
`claude`/`codex` directive, or a per-run API engine/model override — is the opposite case, so it
gets the harness's own error instead of a quiet answer from the other one (different model,
different tools, none of the pinned session's context). The reply says so: a thrown failure adds
"this thread is pinned to X · `model`, so it was not switched automatically — say `codex` to move
it, or /model to repin it", and a limit that arrives as the ANSWER keeps that notice with the same
one-line explanation above it. Channel and gateway defaults are NOT pins — failover is exactly
what they are for — and neither is a stale thread model belonging to the other harness (it never
reaches the CLI, so it must not silently disable failover either). → TEST-PLAN: Engines.
- **Claude runs on the operator's OWN subscription — one login, resolved in one place.** The gateway
authenticates Claude with the `claude` sign-in of the user the daemon runs as
(`$CLAUDE_CONFIG_DIR`, else `~/.claude`): the login that person keeps alive in their own shell. It
is read where it lives and never copied, linked or mounted — the previous design symlinked
`.credentials.json` into the gateway's synthetic engine home, Claude Code replaced the link with a
plain file on its first rename-on-refresh, and the resulting independent session quietly aged out
while the operator's own login stayed perfectly valid (every Claude turn on one gateway had been
failing over to Codex for hours before anyone noticed). `src/gateway/claude-login.js` is now the
single resolver — a configured `claude setup-token`, else the operator's login, else a login signed
in to the gateway's engine home, else the daemon's `ANTHROPIC_API_KEY`, else a named remedy — and
every consumer reads it: the token relay, the container credential modes, the engine health probe,
the boot log and `/status`. What a run receives is a RELAY of that login's current ACCESS token in
`CLAUDE_CODE_OAUTH_TOKEN`, on the host exactly as in a container, refreshed under 30 minutes of
remaining life by a cheap haiku turn in that login's own config dir (the only sanctioned way, and
byte-for-byte what the operator's own shell does). An access token carries no refresh half, so the
operator's chain stays the only chain; the warm pool keys on the login's source file and the
token's expiry — never the token text — so a refresh retires a warm process holding the old one.
Fail-closed stays CONTAINER-only: a container has no other way in, while a host turn with no login
still runs and lets the engine raise its own error, with the remedy logged once an hour. The login
SESSION itself dies every few weeks and only a new interactive `claude` login moves that date, so
the source and the date are printed at boot, warned about three days ahead, and carried on
`/status` and `/api/health`. → TEST-PLAN: Engines.
- **The login expiry reaches a human before turns start failing.** Boot logs and `/status` only warn
when somebody restarts or asks; a daemon that has been up for a month never said a word, and the
first symptom was Claude turns quietly failing over to Codex. An hourly watch
(`src/gateway/login-watch.js`) now re-resolves the login while the daemon runs and DMs every admin
once per UTC day — from three days before the session expires, and daily while there is no usable
login at all (then the message is the resolver's own remedy list). The DM names which login
(kind + config dir), when it dies in UTC *and* in the gateway's local timezone, and the fix (sign
in with `claude` on the gateway host; the gateway picks it up on its next turn, no restart). The
log line is written on every tick; only the DM is rationed, and the "already told them today"
marker lives in the database, so a daemon restarted hourly cannot spam. A class change (expiring →
missing) is news the same day, and a login that goes healthy again clears the class so the next
expiry notifies afresh. One unreachable admin never costs the others their alert, and a tick that
reached nobody stays due. Disabling Claude suppresses credential resolution, watch warnings and
admin DMs on the next tick, without restart; it clears the reminder marker so re-enabling Claude
reports an unresolved login again. No token material ever rides the DM. → TEST-PLAN: Engines.
- **Codex sign-in is detected before, during, and after a turn** — a lost credential can no longer
present as a hang. Before spawn, the runner reads the same `auth.json` the CLI reads (the stable
engine `CODEX_HOME` first, the host state dir it is linked from second) and turns a signed-out
host into a replay-safe `authentication` failure without burning a turn; the probe fails OPEN, so
an unreadable or unfamiliar credential file never blocks a run. During a turn, Codex's own
sign-in phrasing on stderr ends the run within seconds — but only while no tool has run and
nothing has streamed, and only on wording no MCP child would produce (a bare `401` is not enough
to blame the harness's credential). Engine stderr is liveness, not progress, on every runner: a
process retrying an expired credential forever still exhausts its silence budget, and a wedged
turn's buffered stderr is classified on the way out — naming the cause and, when nothing ran,
letting it fail over instead of dead-ending in "produced no output". Codex's diagnostics (retry,
backoff, sign-in) are redacted, capped, and shown on the Slack heartbeat row in place of
"starting". Boot logs warn about an installed-but-signed-out CLI, `/api/health` reports every
harness (available · signed in · enabled), and the admin rail shows one chip per enabled harness
("Codex · signed out"). → TEST-PLAN: Engines.
- **Per-harness on/off switch** (Settings → Engine & runtime): turn off an engine you don't have set
up. A disabled harness disappears from every picker (admin UI global + per channel, and the Slack
`/model` wizard), is never used as a failover target, and any channel/thread still pointing at it
runs on an enabled harness instead — an existing thread gets a fresh session there with its
transcript replayed, rather than a failed cross-engine resume. At least one harness must stay
enabled: the Admin API refuses a save that would disable all of them or leave the gateway default
pointing at a disabled engine, and the reader fails open if the stored state is ever inconsistent.
→ TEST-PLAN: Engines.
- **Retired 2026-09-03 (Linux + containers only):** the host permission profiles, the semantic network modes and the `network_proxy` compilation —
inside its container Codex runs `--sandbox read-only` in read mode (which refuses network on its
own) and `danger-full-access` for write modes, with no permission profiles; the container itself
has no network of its own and goes out through the egress proxy. Codex confinement mirrors the channel via Gateway-owned permission profiles (never the legacy
broad-read sandbox modes): `gateway-readonly` (root denied, minimal runtime paths + workspace
readable, nothing writable) by default, `gateway-workspace` (adds workspace write + a private
per-run scratch dir as TMPDIR; `.git`/`.codex` stay read-only) when Bash/Auto mode is on, profile
network off by default, and full bypass only for an admin author in admin mode. The gateway's
semantic network modes are **off / approved domains / unrestricted**. Claude and Codex compile
the same normalized administrator allowlist into their OS sandboxes; Codex uses its permission-
profile `network_proxy`, keeps local/private destinations and listener escape hatches disabled,
and fails closed before spawn when the installed CLI lacks that feature. Full bypass remains
foreground-admin-only and is reported honestly as unrestricted network in Slack and the admin UI.
Non-Full runs pass `--ignore-user-config` so a host user's personal Codex config can't broaden a
Slack run. Known platform carve-out: Codex's `:minimal` grant keeps shared `/tmp` readable and
writable on macOS regardless of denies, so nothing sensitive (including the run scratch dir, which
lives under the sandbox-denied gateway root) is ever placed there. → TEST-PLAN: Engines.
- **Retired 2026-09-03 (Linux + containers only):** the mechanism only — the runtime root is not mounted into the container at all and the control
plane is reached over the read-only unix socket; the fact stands. Codex runs get NO filesystem access to the daemon runtime root (`~/.channelgate`): the local
gateway MCP server is a separate stdio subprocess outside the command sandbox, so schedules and
channel state remain reachable through its authorized tools only.
- **CLI catalog (`src/config/cli-catalog.js` — Vercel, Supabase, Make.com)**: the deploy CLIs the
channel runtime image ships, with the environment variable each one accepts instead of a saved
login. It feeds the `/secrets` name suggestions (every catalog name, so a channel acting as its
own account types `SUPABASE_ACCESS_TOKEN`, not a guess) and the host-sandbox write-deny list for
saved-login files. **Retired 2026-09-03 (Linux + containers only):** the Settings → Network →
"CLI integrations" switch, its `cliIntegrations` setting, the live "installed" badges
(`cli-detect.js` detection) and the read-only linking of the daemon's shared host login
(`~/.supabase`, `~/.vercel`) into runs. A container has no domain allow-list and its image
ships the CLIs, so the switch had no effect there; a channel's own provider login is a `/secrets`
variable and never the daemon's host-wide file. A stored `cliIntegrations` value is inert. The
git/gh baseline read re-allow for host runs went with the host runtime the same day — a container
reads the `git`/`gh` state in its own HOME volume, never the daemon's — and the catalog is now
only the `/secrets` name list. → TEST-PLAN: CLI integrations.
- **Retired 2026-09-03 (Linux + containers only):** the image ships the toolchain (`containers/versions.json`) and a channel installs anything else
into its own HOME volume, so there is no host toolchain to grant. **Toolchain reachability inside the sandbox** (`src/gateway/toolchain-paths.js`): the lockdown
denies reading all of HOME and re-allows only the work dir — and Claude Code implements that by
tmpfs-masking HOME, so a per-user install of the agent's OWN runtime does not become unreadable,
it stops EXISTING. On macOS this was invisible (Homebrew lives outside HOME); on Linux, where
node/npm/npx/vercel/gh routinely sit in `~/.local/bin`, every Bash channel reported "this sandbox
has no node/vercel" while `/usr/bin` tools kept working — and Settings still badged "Vercel:
installed", because detection scans the DAEMON's filesystem, not the sandbox's. Write-capable and
admin Claude runs and every confined Codex permission profile now re-allow READING the resolved
toolchain: the Node install prefix (npm, npx and
every globally-installed CLI are JS under `prefix/lib/node_modules` behind a `bin` shim) plus each
baseline binary, each granted as both
the PATH entry and its resolved PACKAGE ROOT — a globally-installed CLI's `bin` entry is a shim
that reaches the rest of its package by relative import, so granting the shim alone resolves the
binary and then dies on its first require. This matters only where npm's global root sits outside
the Node prefix (`~/.local/lib/node_modules` rather than `<prefix>/lib/node_modules`); where it
sits inside, the prefix grant already covered it — which is why the first cut looked correct on
one machine and failed on another with the same code. The emitted list is minimized, so a
directory grant absorbs everything under it and stays the minimal reviewable set. Surgical
by design: never `~/.local` wholesale (`.local/share` holds app data), never anything inside the
gateway root, and paths outside HOME are dropped as already-readable. Not gated on network —
`node build.js` is useful with egress off, and the grant carries no credential. READ-only: `bin`
and `.local` stay on SENSITIVE_HOME's write-deny list, so the delayed-escape protection
(plant-a-binary-now, run-it-unsandboxed-later) is untouched. Codex also sets
a run-private PATH directory containing only reviewed HOME-local CLI names as direct symlinks to
their real package launchers. This preserves npm/npx relative-module resolution when Codex's
permission profile would otherwise flatten a two-hop symlink into a broken copied file, without
exposing the rest of `~/.local/bin`. Claude runs get the same remedy as a STABLE
content-addressed launcher directory (`<gateway>/runtime/toolchain-bin/<digest>`, built by
`run-grant-artifacts.js`): the Claude sandbox materializes per-file read grants as binds, and a
SYMLINK entry cannot be bound — a host whose `~/.local/bin` shims are symlinks (node →
`../node/bin/node`) loses every shim inside the sandbox while plain binaries survive, so `node`
stopped existing in exactly the runs that were granted it (found live by the CLI-12 gateway
parity case: one deployment's layout escaped by luck, another's fully-symlinked layout lost all five). The
directory is granted as a DIRECTORY (symlink semantics survive a whole-dir bind), prepended to
the child PATH by `buildClaudeEnv`, and content-addressed so settings digests and warm
fingerprints stay stable until the host toolchain actually changes — which then retires warm
processes, as a toolchain change should. Airtable acceptance: DRV-01, DRV-02, CLI-12.
Codex also sets
`NODE_USE_ENV_PROXY=1` so Node-based CLIs honor its destination-restricted network proxy; this
changes proxy consumption, not the approved-domain boundary. → TEST-PLAN: CLI integrations.
- **Retired 2026-09-03 (Linux + containers only):** the tool, the card and `extraNetworkDomains` went with the host sandbox's allow-list — *Allow
network* stays as a per-channel switch the engines are told about, with no domain filtering; the
container-side egress proxy enforces it (off/on, private addresses always refused). **Per-channel domain approvals (`request_network_domain`)**: when a sandboxed command fails on a
blocked host, the agent may request ONE named domain; the gateway posts an Approve/Deny card
that ANY authorized user can approve (the human click is the control — injected content can
request but never click). Approved domains persist in the channel meta, join that channel's
engine sandbox lists on the next spawn, and are visible/prunable in the admin UI channel card.
Input accepts bare domains, `*.wildcards`, or URLs (hostname extracted); IPs, localhost, and
single-label hosts are refused; malformed stored values degrade to no extras rather than
breaking spawns. Invalid or already-allowed requests skip the card entirely (no approval spam).
→ TEST-PLAN: CLI integrations.
- Engine-aware optional MCP picker: channel, DM, and access-template editors load the catalog for
their effective Claude/Codex engine and retain each engine's selections independently. Claude
keeps its configured-server catalog and resolves selected transport definitions from fresh
operator configuration bytes at run admission. Its strict MCP payload contains only explicitly
selected, credential-free definitions plus the scoped built-ins; user/project setting sources
remain disabled. Missing, stale-URL, reserved-name, auth-dependent or unsupported definitions
never reach the payload: each one is DROPPED from that run with a category reason instead of
ending the turn, on both the primary and the failover engine. The turn completes on its
remaining connections, the answer is prefixed with which selections were skipped and why, and a
`run_mcp_dropped` event records the names and reasons for the admin (never a path, config value
or definition). An unreadable operator configuration drops every selection rather than bricking
the channel. Changes and revocations alter the warm-process fingerprint.
Codex queries the active app-server inventory and exposes
each runtime app family (Boost.space, GitHub, Sites, Skill Library, etc.) plus each configured
server as its own checkbox. Codex launches default-deny optional apps, explicitly enable only
the live connector IDs represented by selected families, and gates every discovered optional
server; existing family selections therefore survive connector-ID changes without a re-save.
The same policy applies to fresh/resumed and cross-engine failover runs, while clean mode enables
none. Gateway MCP list/add/remove controls receive the active run engine and operate on that
engine's independent selection set.
- Per-channel **Make MCP toolbox**: an admin can save one official Make/Celonis toolbox server URL
and Bearer key from Conversations → Tools, test it with a read-only MCP `tools/list`, replace it,
or clear it atomically. Normal Claude and Codex turns expose it as `make-toolbox`; clean mode
removes it. Claude carries the key only in the mode-0600 temporary MCP config, while Codex uses a
daemon-root 0600 secret bundle read by the local remote-MCP broker. The same broker protects
signed gateway capability plus legacy Composio and Toolbox credentials: Codex argv carries only the bundle
path + logical key, and the Codex process environment receives no connector secret. The global
child-env boundary admits exact reviewed engine variables only, never broad vendor prefixes.
→ TEST-PLAN: MCP injection & tokens.
- Claude channel settings explicitly pre-approve every embedded `mcp__gateway__*` tool, so
schedules, reminders, background jobs, Slack Lists, current-channel Slack reads, and channel admin
controls do not need an interactive approval prompt. Clean mode still removes the gateway MCP
entirely.
- **Schedules and reminders never ask for approval**: `create_schedule` and `delete_schedule` are
deliberately outside the control-plane approval gate, so "remind me at 9" or "check the progress
every hour" just works instead of stalling on an Approve card that refuses itself after four
minutes. A schedule can't escalate — it fires with origin `schedule` (never the sandbox-off admin
path), in the channel it was created in, as its creator, no more often than the configured minimum
interval — announces itself in-channel when it runs, and stays inspectable and reversible through
`list_schedules`/`delete_schedule`. → TEST-PLAN: Reminders & schedules, Security checks.
- **Minimum second-approval cards (2026-10-07).** The control-plane gate keeps its authority check
— a caller without a tool's tier is refused with no card — but no longer asks the person who
holds the authority to click a second time for changes that are channel-scoped, visible in the
thread and reversible. Automatic now, for the admin or manager who asked: modes (Bash, Auto, VPN),
network, working folder set/clear, Drive folder set/clear, MCP servers add/remove, SSH grants for
other people, personal and organization secrets set/remove, hiding a secret, connector tokens,
skill source set/sync/remove, skill scope, exclusion and governance, organization-wide skill
removal, template removal, proposal rejection, the guide's reset, the license key set/clear,
gateway update and restart in every mode, and replacing a channel's instructions (admins; the
handler makes the model quote the complete new text in its reply). Every one is still audited and
announced. Exactly eight cards survive, where one unasked call would silently hand
attacker-controlled content or a secret to OTHER channels and no later admin message could undo
it: Admin mode on/off, the gateway guide write, admitting a skill source, granting skills
organization-wide, ADDING skills to a template, APPROVING a skill proposal, approving a server
for a hidden secret (`allow_secret_host` and the proxy's first-use card), and making a secret
READABLE. Every surviving card is **durable** (`src/gateway/control-plane-approvals.js`): the
exact call — tool, arguments, requester, thread — is saved as a `control_plane` approval row, the
tool returns *pending* immediately, the card never expires and survives restarts, and the click
re-checks the requester's authority today and the clicker's tier, then applies the call in-process
as the requester through the same gateway MCP server a turn uses. An argument that is a secret
(a peer gateway's token) is never persisted: it is held in daemon memory under the approval id
and the executor names the remedy when a restart lost it. Card tiers: `admin` for the
deployment-wide ones; a secret's card follows its scope — the organization's needs an admin, this
conversation's anyone working in it, a personal one its owner (the new `owner` tier) — on both
`set_secret_mode` and the secret-host cards; an instruction append is anyone's. *Approve
forever* on a tool-permission card is now a channel manager's call (or an admin's), not
admin-only, and new non-DM channels default **Who can manage** to approved members
(`manageAccess:"members"`; rows stored earlier keep their value, a missing field still reads as
admins). → TEST-PLAN: Security checks (control-plane gate), Modes & approvals, Channel access model.
- Both injected Composio MCP identities are pre-approved by default: Claude settings include
`mcp__composio-user` and `mcp__composio-agent`; each emitted server sets
`default_tools_approval_mode:"approve"`. Codex mirrors both named approval configurations.
The lockdown's `allowedMcpServers` names every injected server AND lists each remote one by URL
(Composio, Toolbox, the channel's Make toolbox; `*.composio.dev` in SDK mode):
Claude Code matches remote servers by URL as soon as the list holds any `serverUrl` entry — a
picked global server adds one — and a name entry alone then no longer admits them (2026-09-04:
Composio silently "blocked by enterprise policy" in a channel with a picked server).
→ TEST-PLAN: MCP injection & tokens.
- Codex reasoning effort uses the same Runtime effort setting as Claude (set via the `/model`
wizard or admin UI): effort options switch with the selected/inherited engine, and Codex runs
receive `model_reasoning_effort`.
- Codex value is estimated, not billed spend: Settings exposes OpenAI Standard API-equivalent
per-model `$/1M` rates. Root turns are priced from request-level rollout deltas and native child
sessions are included without charging their copied fork prefix; Claude retains provider-reported
cost. The actual runtime model wins when a configured Claude model falls back to Codex. The
fallback picker mirrors the current authenticated CLI catalog (`gpt-6-astra`, the GPT-5.6
family, GPT-5.5 and CLI-only `gpt-5.3-codex-spark`); live discovery still wins. Spark remains
explicitly unpriced because OpenAI publishes no Standard API rate for that distinct model.
- Session recovery: Codex auth/session state uses a stable, grant-free `CODEX_HOME` while private
skills remain per-run under synthetic `HOME/.agents/skills`, so Codex 0.147+'s persisted rollout
paths survive cleanup without leaking grants. Resuming a session that no longer exists (including
a legacy temporary rollout pathname) silently falls back to a fresh session
rather than erroring.
- **Inactivity-based turn watchdog** (Claude + Codex): a turn is never killed for running long.
`COMMAND_TIMEOUT` controls quiet-process liveness checks and user updates; a live process keeps
running until the separate absolute `CG_MAX_SILENCE` budget is exhausted. Stdout is progress;
stderr does not reset that silence budget. `sessionKeepalive` applies only BETWEEN turns for
engines with warm sessions. → TEST-PLAN: Engines.
- **Failure recovery preserves uncertain work**: classified replay-safe failures before any tool
or streamed answer can use the configured recovery path. An ambiguous process death after work
does not automatically continue or replay. Boot recovery distinguishes queued work, interrupted
execution with unknown effects, and saved output needing delivery. Ordinary Codex process exits
retain engine, container runtime, exit status/signal and `processEnded` in structured diagnostics,
without treating a forced stop as proof of an out-of-memory event or authorizing a retry;
an empty result (0 tokens + no output) is treated as a FAILURE, never posted as "(no output)".
An ANSWERLESS turn — tools ran and tokens were spent, but the engine produced no text, which is
how the CLI reports a turn it aborted itself (exit 0, `is_error` result line, error subtype) —
is delivered as a notice naming that ending and the step count, and logged as `run_answerless`,
instead of reaching the thread as a bare "(empty response)".
Non-recoverable errors post a "session survived — send `continue`" hint when the session is
resumable. A session HEAL (resume failed / resumed empty → fresh session) replays the
Slack thread transcript into the healed session's prompt with a session-was-lost note, so the
retried message keeps the conversation's context instead of running amnesiac. → TEST-PLAN: Engines.
- **Self-diagnosis on run errors**: a non-recovered failure opens a NEW thread in the channel named
by Settings → `errorDiagnosisChannel` (slug, e.g. `gateway-slack`, whose folder is the gateway
repo; "" = off) with the error + stderr tail + recent channel events, and runs Claude there to
root-cause and PROPOSE a fix (never apply). 30-min global cooldown; a diagnosis thread's own
failures are never re-diagnosed; and failures that are provider ACCOUNT state rather than a
source defect — usage/spend limit, expired or missing sign-in, billing, a rejected model, a
provider outage, a dropped connection, plus a user's own stop/abort — are skipped entirely, so a
hit quota neither notifies the dev channel nor spends the cooldown a real crash may need.
→ TEST-PLAN: Engines.
## Environment secrets: three scopes
- **Organization, person, channel.** The same write-only secret store answers three questions, and
a run merges all three (`src/config/scoped-env.js`):
- **organization** — one credential the whole deployment shares, injected into EVERY
conversation's runs (the `GH_TOKEN` every channel needs to push, a company npm token). Stored
in `settings.json` (`orgEnv`, already 0600) and deliberately NOT in `config/settings.js`'s
`ENV_MAP`, so an arbitrary org name can never be copied into the daemon's own `process.env`.
Admin-only, because its blast radius is every channel and every author admitted there.
- **person** — a credential belonging to the human, not to any room. Injected only into runs
that person AUTHORED, in every conversation they talk in, and into nobody else's turn in those
same conversations. Stored on the `users` row (`data.env`), beside the personal Composio and
Toolbox tokens.
- **channel** — the conversation's own CLI logins, below, unchanged.
- **Precedence is organization → person → channel, most specific last.** The channel deliberately
outranks the person: a channel's secrets ARE its project identity, and a personal token silently
answering for the room's account is exactly the substitution the Composio identity rules exist to
prevent. A personal secret therefore FILLS a name the channel does not define; it never redirects
one the channel does. Every attempt's preamble names which scope supplied each variable (names
only) so the agent can say which account it acted as.
- **The personal scope is withheld from an unauthenticated principal.** The HTTP run API
authenticates its key, not the `author` it names, so that caller can never borrow someone's
personal secret by naming them — the same rule that already withholds their personal Composio and
Toolbox tokens. The organization and channel scopes are not identity claims and still apply.
- **The warm pool keys on the merged digest**, so a process started for one author is never reused
for another's message in the same thread while still holding the first author's secrets. Clean
mode injects none of the three, for the same reason it injects no MCP servers or skills.
- **Surfaces.** All three are managed from the same three places:
- **Slack** — `/secrets` and Settings → *Secrets* now render all three scopes in one modal, in
resolution order, each with its own rows and its own Add button. The organization's Add is
absent for a non-admin, and authority is re-derived from the clicked `action_id` (never from
the button's value) and re-checked on submit, so a forged click on an organization row is
refused. The entry form states which scope it writes into before you paste anything.
- **Admin UI** — Settings → Integrations → *Organization secrets*, and the Users drawer →
*Personal secrets*. One editor implementation serves all three (`public/admin-secrets.js`).
- **Chat tools** — one per verb, the scope an argument: `list_secrets` (all three scopes in one
live call — names, provider, masked tail, who set them; the organization's tails and authors
only for admins, the names for everyone as the run prompt already carries them; filterable
with `scope`), `set_secret` and `remove_secret` (`personal` by default; `organization` for
admins, with the approval card's tier following the scope; a conversation's own are written in
the Secrets modal or the admin UI, never pasted into a channel). A signed run capability is
what names the author, so an unverified principal gets no personal scope at all. In an SSH
session the env file the wrapper sourced is named by `CG_SESSION_ENV` and rewritten on every
change, and the session's own system-prompt note (`session.md`, appended by the wrapper with
`--append-system-prompt-file`; the managed block's 4 KB budget is for every run's rules) tells
the model to source it in the same command when it needs a credential added since its
`claude` started.
A person only ever sees their OWN personal secrets on any surface: the Slack modal is bound to
one owner and refuses a different clicker, and the admin UI reaches them per user record.
### A channel's own
## Getting files out of a channel folder
- **Composio file staging (`stage_file_for_composio`).** Composio's file-taking tools
(`GOOGLEDRIVE_UPLOAD_FILE`, `GMAIL_SEND_EMAIL` attachments, `SLACK_UPLOAD_FILE`, …) accept
neither a path nor base64 — only a `FileUploadable` (`{name, mimetype, s3key}`) naming bytes
already inside Composio's own storage — and the MCP surface exposes no way to produce one. A run
that generated a deliverable in its container therefore could not hand it over at all. The
gateway now performs Composio's documented three-step upload daemon-side (`POST
/api/v3.1/files/upload/request` → presigned `PUT` → the returned key) and gives the model the
object to pass straight through. The caller names which identity will run the destination tool
(`user` → `composio-user`, `agent` → `composio-agent`) and the key resolved for THAT identity is
the one spent, through the same precedence the MCP config uses; a named identity with no key is
reported rather than silently replaced by the other one. Every run that has a Composio identity
is also told this handoff rule and the exact `"user"`/`"agent"` argument mapping in its per-run
identity line (not only in the gateway-usage guide),
so an engine that never opens the guide's sharing page still stages instead of relaying base64.
The key never enters the container, never reaches the model, and never appears in an error
message. Nothing is published.
The REST upload accepts only a Composio PROJECT API key. The keys this gateway stores in personal
mode are CONSUMER keys (`ck_…`, the hosted MCP's credential), which that endpoint rejects, so
with a consumer key the gateway stages through the hosted MCP's own workbench instead: the daemon
opens its own MCP session on the same key, writes the file into the sandbox in base64 chunks of
768 KB (one request above ~5 MB is rejected), checks the md5 and mints the key with
`get_mount_file_s3_key` — a key the model's separate session can then use. Bytes still never
pass through the model. The sandbox path is a random directory plus a name reduced to safe
characters, so a file name can never become Python source. Consumer-key staging is capped at
25 MB (project keys keep 100 MB), within an overall 10-minute deadline; the MCP session is ended
afterwards, but the sandbox copy is kept because the s3key IS that file's storage (deleting it
breaks the upload — verified live). A known property of this route: the copy stays in that
identity's Composio file storage, so on `composio-agent` backed by the organization key it is
listable from any conversation sharing that key (its random directory name is not a secret) —
stage on `composio-user`, or give the channel its own Composio key, when that matters. The bytes are read from the SAME descriptor the confinement
check proved (O_NOFOLLOW + /proc/self/fd), bounded to the cap: the tool used to close that
descriptor and reopen the file by path, so a symlink swapped in by the container between proof
and read could have sent any file the daemon can read. Every error text is scrubbed of the key
and of long base64 runs before it reaches the model. A path outside the working folder answers `Staging refused:`;
anything that goes wrong after that answers `Staging failed:`, and the guide tells the model a
refusal is final (no copying the file in to get around it) and a failure is reported, not
worked around by pushing the bytes through a tool itself.
→ TEST-PLAN: Composio file staging.
- **Temporary public file links (`create_public_file_link`).** For destinations that ingest by URL
rather than by body, and for a person who simply wants a link. One file from the channel's own
working folder is served at `GET /f/<token>` — public by design, mounted outside the admin login,
like `/approve`. Two purposes with different rules: `upload` (5 minutes default, 15 maximum, 5
fetches, meant for a machine and explicitly not to be posted into the conversation) and `share`
(duration **required** — the model asks the person first — capped at **48 hours**, unlimited
fetches, and additionally gated by a human Approve card naming the file and the duration).
`list_public_file_links` and `revoke_public_file_link` make live links inspectable and killable.
→ TEST-PLAN: Temporary public file links.
- **What keeps it narrow.** The bearer token is 32 random bytes and only its SHA-256 is stored, so
the table yields no working link. A link records a channel and a RELATIVE path, re-resolved
inside that channel's working folder on every fetch through the shared confined open
(`src/gateway/confined-file.js`, now also used by the Slack file-download router): a path that
escapes the root, a symlink at the final component, or a file that has moved simply stops
working — so a channel whose container mounts the operator home can read that home with its file
tools but can never publish from it. Expiry, revocation and the fetch cap are one atomic claim.
`HEAD` probes (which ingest services send first) spend no download. Every failure returns one
identical 404, so a stranger learns nothing about which tokens were real. Every mint, fetch
(with IP and user agent) and revocation is audited in `events`.
- **Off by default, and killable at once.** The whole public-link capability is dormant until an
admin enables it in Settings → Public file links, and it also requires the gateway's Public URL.
The switch is re-read on every request, not at mint time, so turning it off kills every
outstanding link immediately. Composio staging is unaffected by the switch — it publishes
nothing.
## Per-channel environment secrets
- **A channel's own CLI logins.** Each conversation can hold its own credentials — its own Supabase
project, its own Vercel account — instead of every channel sharing whatever login the gateway
host has. Stored per channel (`channel_meta.env`) and injected as process environment at spawn,
so a CLI picks them up unaided: `supabase` and `vercel` read `SUPABASE_ACCESS_TOKEN` /
`VERCEL_TOKEN` without being told to. Never written to a file the run can read, never visible to
another channel, revocable per channel.
- **Write-only, by design.** Every surface — Slack modal, admin UI, API — returns the NAME, the
provider, the last four characters, and who set it when. There is no reveal path anywhere, not
even for an admin, and these deliberately do NOT join the `POST /api/secrets/reveal` allowlist
(which resolves a named field to a getter precisely so it can never become "read any config
key"). A lost token is re-issued at the provider. Add and update are the same blind write: no
prefill, full value retyped, the last4 flips to confirm it changed.
- **Provider interface from day one.** A stored entry is a reference with a provider, so an
external vault is a new provider rather than a rewrite. `local` ships; `vault` is on the
enterprise roadmap. An unknown provider throws at resolve time — a turn that quietly ran without
its credential looks like a deploy that did nothing.
- **The name rules are part of the security boundary.** Injecting an arbitrary name is code
execution (`LD_PRELOAD`, `NODE_OPTIONS=--require`, `PATH`) and identity hijack
(`ANTHROPIC_BASE_URL`), so a reserved set is enforced on write AND again at the runner boundary,
where `safeSpawnEnv` re-filters inside `buildClaudeEnv`/`buildCodexEnv` rather than trusting the
caller.
- **Case is the one thing that is forgiven.** Names are `UPPER_SNAKE` everywhere they are shown, so
a name typed in lower case is the same variable, not an error: the admin card upper-cases it
visibly as it is typed (and on blur, and before it is sent), and the store folds case itself, so
the Slack modal and any API client store, list and remove the same canonical name — setting
`supabase_token` over `SUPABASE_TOKEN` updates that entry rather than failing or duplicating it.
Nothing else is forgiven: a dash, a space or a leading digit is still refused (quoting what was
typed), and the reserved check runs on the folded name, so `path` cannot smuggle `PATH` past it.
- **Outbound value redaction.** Write-only in the UI is not write-only at runtime: the agent can
read its own environment and a failing CLI will echo a token into its error line. Exact values
are stripped from the reply, the live stream (holdback, so a value split across two deltas still
matches) and background-job output.
- **Surfaces.** `/secrets` and a message shortcut in Slack, a 🔑 Variables button on every reply
footer (including when the channel has none, so users can add the first), and a card on the
channel's admin page. Managing them needs the same privilege as running
commands with them (`canEditChannelFiles`); everyone authorized in the channel can see that they
exist.
- **Host credential suppression.** A channel supplying its own key for a catalog CLI stops getting
the daemon's shared saved login linked in behind it, so it can never silently fall back to a
different identity.
- **Not injected into OpenCode** (read-only, network-off): the credential would be pure exposure
for zero use. Clean mode gets none either, like every other injected grant.
## Isolation & security
- **Current, progressively disclosed ChannelGate skill.** The repository's `channelgate` skill is
now a concise router instead of a Claude-only lockdown recipe. Focused references cover the
Linux/container trust boundary, per-channel HOME and write-only secrets, provider CLI/device
login, Claude/Codex credentials, MCP identities, local skill catalog and materialization,
catalog-first channel memory, automation/attachments/platform capabilities, and the low-level
folder/headless MCP recipe. Its description names realistic administration/troubleshooting
triggers while explicitly excluding ordinary tasks merely performed through ChannelGate.
The same slice repairs the memory search/read tool registration so both use the gateway's MCP
text-response adapter instead of failing with `text is not defined`. A hosting reference gives
the recommended deployment server (4 dedicated vCPU, 16 GB RAM, 150 GB NVMe, Ubuntu 24.04 LTS,
static IPv4, `MAX_CONCURRENT_RUNS=4`), the pilot/busy tiers, and four recommended EU hosts with
dated price estimates: Hetzner CCX23 (≈ €25), netcup VPS 2000 G12 (≈ €16), RoLeaf Enterprise in
Romania (≈ €32) and Google Cloud `n4-standard-4` (≈ €139).
Version 3.2.0 also routes VPN/private-database requests to a bundled reference covering
supported isolated OpenVPN setup and the five-step task-specific VPN/SSH procedure: access
ownership, Secrets, identity verification, isolated connections, bounded read-only checks,
cleanup and operator handoff. This travels with gateway updates; no companion skill or
application-code change is needed.
→ TEST-PLAN: ChannelGate skill package.
- **Trustworthy release gate** (the macOS leg retired 2026-09-03 — Linux only): every pull request
runs the complete coverage-gated suite on Ubuntu at the exact Node 22.13 minimum and Node 24, plus dependency-free syntax /
whitespace checks, tracked-file secret scanning, production dependency audit, and independent
coverage floors for authorization, access-grant isolation, sandbox policy, secret handling, and
updater state. Claude
and Codex stubs exercise Slack message→reply plus their engine-specific resume/MCP/cancellation
contracts; a separate pinned nightly probes both real provider-free CLI command surfaces on Ubuntu.
All third-party Actions are SHA-pinned with read-only repository permissions. → TEST-PLAN:
Automated release gate (B2–B5).
- **Retired 2026-09-03 (Linux + containers only):** the filesystem-sandbox half — confinement is the channel's container (own HOME volume; only the
work folder, clean workspace and artifact dir mounted), while memory-off and the MCP allowlist are
unchanged. Per-conversation gated folder: filesystem sandbox confined to the folder, persistent memory
off, MCP allowlist. → TEST-PLAN: Security (confinement, allowlist, memory).
- **2026-07 full-codebase review remediation**: engine children run with a
minimal allowlisted env (`src/engines/child-env.js` — Slack tokens / approval secret never reach
sandboxed processes; daemon-IPC creds ride a 0600 `internal-auth.json`); MCP tokens off argv
(0600 temp file written only at spawn); admin UI binds loopback by default with a no-password
lockdown and an allowlisted fs root enforced at write AND read time (`effectiveWorkDir`); model
output escaped against `<!channel>`/`<@U…>` injection on every posting path (chunked, streamed,
scheduled, background); Slack event dedupe on redelivery; per-thread turn serialization with
durable queued-turn markers; warm-turn watchdog; stop aborts cold runs, queued runs, and whole
process groups; transactional store writes (`patchChannelMeta`/`saveSettings`); login backoff +
digest compare. → TEST-PLAN: Security checks + Review remediation (2026-07).
- Admin-selectable Composio provisioning: **Personal** preserves the existing user-token plus
channel→organization shared-token paths; **SDK** uses one write-only organization key to lazily
provision stable `slack:<workspace>:user:<id>` and `slack:<workspace>:channel:<id>` identities
with reusable per-Slack-thread Composio sessions. Personal connection management belongs to the
user; shared management follows the channel's existing `manageAccess`/`managers` policy. Mode
changes never delete saved Personal-mode or SDK credentials, and clean mode injects neither.
- Dual Composio identities per non-clean run, named so every tool call says whose account it is:
`composio-user` receives the active author's Personal-mode token or SDK user session;
`composio-agent` — the agent's OWN account — receives the channel token (else the organization
default) or the SDK channel session. Both may coexist; credentials are not persisted to channel
folders or logged. SDK keys remain behind a local stdio bridge, so Claude/Codex receive only
session endpoints. The injected operating guide teaches the model that `composio-agent` is simply
*its* account (where the credential comes from is an admin detail it never surfaces) and resolves
by pronoun across every app: “my” → `composio-user`, “your” → `composio-agent`, a named account →
whichever has it, no pronoun → the only account with that app connected (named in the reply), ask
when both have it; it reads each account's connected-app list before promising an action and never
substitutes. A **DM** is personal-only: neither the channel token nor the organization default
backs `composio-agent` there, and SDK mode mints no channel session for it, so a one-to-one
conversation can only ever act as the requester's own account (mpim/group DMs and channels are
unchanged). Which identities a run built is also stated IN the prompt: one line, prepended next to
the fresh-session memory catalog and the caller's provenance note, from the same predicate that
decides which servers the MCP config carries — so the note can never describe a server the run
does not have.
→ TEST-PLAN: Security (Composio isolation) + MCP injection & tokens.
- Per-run MCP config: the **gateway control MCP** is always injected (scheduling + channel-admin
tools, scoped to the channel/author, run outside the sandbox); personal/shared **Composio** and
embedded **Toolbox** (`makeitfuture-toolbox`) are injected only when their tokens resolve. Skills
are never an MCP server: they come from the gateway's own catalog as files (Skills platform
below). → TEST-PLAN: MCP injection & tokens.
- Token resolution: Personal-mode Composio is two paths (**user only** plus **channel →
org-default**); SDK mode replaces both active paths with stable user/channel identities without
mutating saved tokens. Toolbox retains **channel → user → org-default**. Tokens can be
set via `set_my_*_token` or the admin UI; org-default tokens and the write-only SDK key live in
Settings → Integrations.
- Per-channel opt-out of the org-default token fallback (`meta.noDefaultTokens`): a sensitive channel
refuses the broad gateway-wide tokens (channel/user tokens still apply).
- **Skills Manager integration retired (2026-09-05).** The `makeitfuture-skills` MCP injection, the
organization/channel/user Skills Manager tokens (their tools, routes, UI fields and secret
readers), the favorites stubs and the App Home favorites fetch are gone; Skills Manager remains a
standalone product. Stub folders the old integration left in a channel folder are pruned on the
channel's next message so they never shadow a catalog skill; the legacy `CLAUDE.md` favorites
block is still stripped. `scripts/migrate-skills-manager.mjs` moves a deployment's Skills Manager
data into the catalog (below). → TEST-PLAN: Skills platform (Core).
- Trusted bot apps allowlist (`settings.trustedBotApps`): lets a specific bot's posts (e.g. a Make.com
scenario) trigger runs by bypassing the reply-loop guard **only** — the message must still carry an
`@bot` mention and come from an approved author. → TEST-PLAN: Trusted bot apps.
- The Admin UI API reference includes the verified Make.com `slack:CreateMessage` blueprint for
triggering the bot through Slack: the bot's member ID and the Make IDs, saved explicit mention, approved bot-user and
trusted app/bot requirements, shared channel membership, Markdown settings, and root-thread
timestamp fallback. README points operators to the canonical example. → TEST-PLAN: Trusted bot apps.
- Folder-scoped agent memory, **skill-packaged** (informed by Hermes + Claude Code auto-memory;
design in the memory-improvement note (internal repo)): uncapped `MEMORY.md` and topic files are
the portable canonical store, with one declarative fact per line under
`People & preferences · Decisions · Environment & gotchas · Project state`) and
`memory/<topic>.md` files carry depth, linked from index lines as `[[topic]]` — an
Obsidian-style graph inside the sandbox, no cross-channel bleed, Claude's GLOBAL auto-memory
stays off. A derived SQLite FTS5 index is rebuilt from Markdown for bounded retrieval.
**Recall is catalogued, then requested:** each FRESH session gets source/topic names and directions
to `search_channel_memory` and `read_channel_memory`, never the full memory body. Nothing
memory-related is injected into `CLAUDE.md`: the protocol lives in a
gateway-maintained **`channel-memory` skill** in each folder (what is in context, concrete save
triggers, end-of-task checkpoint, the "stale in a week ⇒ not memory / declarative facts, not
imperatives" rule, consolidation ritual — refreshed write-on-change, pruned when memory is off)
and in the **`update_channel_memory`** gateway MCP tool, whose description re-states the save
triggers every turn. **Saves are batched and atomic:** one call takes an `operations` array
(`add` with optional `section`, whole-line `replace` on a unique substring, `remove`,
`write_topic`), applied all-or-nothing with no storage cap; duplicate adds are no-ops; instruction-shaped or
secret-shaped content is refused; after three failed saves in a row the tool tells the model to
move on. The tool writes daemon-side, so saving works in EVERY mode — including `read` channels
(which also keep the narrow direct `Write(MEMORY.md)` grant). Responses show a usage meter.
**Background memory review** (`src/gateway/memory-review.js`): after a delivered foreground turn,
a small reviewer run (Settings: model, default `haiku`; interval, default every 5 non-trivial
turns per channel, 0 = off; a correction/preference/decision-shaped message reviews at once; a
turn in which the model saved on its own resets the cadence) reads the thread transcript and the
index inside the same gated folder with every mutating tool denied and a gateway MCP narrowed to
the save tool, saves what the model did not, and posts "🧠 Memory updated — …" in the thread
when it did (Settings toggle) so a wrong save is visible and correctable. Memory activity is
measured: `memory_saved` (with a review flag), `memory_review`, `memory_review_error` audit
events and usage rows with `task_kind = memory_review`. Memory saves never
require an approval card, including when the channel uses a custom project working folder; signed
capability/trusted-principal, allowed-root, action/path, and enablement checks still apply.
The daemon injects its resolved filesystem + workspace roots into both Claude and Codex gateway
MCP subprocesses (through Codex's secret broker too), so their isolated `HOME` directories cannot
redirect default or custom-folder memory into disposable per-run state.
→ TEST-PLAN: Channel memory.
- **`gateway-usage` skill — the Slack operating manual, in every folder**: a gateway-managed skill
(like `channel-memory`) materialized into each channel's `.claude/skills/` on every run, so the
agent always knows how to operate inside Slack. The gateway also creates
`.agents/skills → ../.claude/skills` so Codex discovers the exact same canonical tree (including
granted skills and channel memory). Conflicting `.agents/skills` entries or a symlinked `.agents`
parent are archived outside discovery before the canonical link is installed. `SKILL.md` carries the always-on description
(“use at the start of every task”) + the core reply rules and explicit trigger vocabulary for
tables, charts/graphs, and every other gateway capability; `references/*.md` hold the detail
(streamed Markdown replies, @-mentions, inline/native/export/List tables, native charts,
reminders/scheduled tasks, canvases, reading history/search, memory & standing rules, background
jobs, channel/gateway admin, and host-side Claude/Codex authentication recovery) — progressive
disclosure. Authentication guidance directs operators to log in or configure an API key on the
gateway computer/VPS and never requests engine credentials in Slack. This **replaces** the always-on
Slack-format guide that used to be baked
into every `CLAUDE.md`. Source is a per-file **overlay**: the built-in default ships in the repo
(`src/gateway/gateway-usage/`, in git — restorable) and admins can override any file live at
`~/.channelgate/config/gateway-usage/` (override wins per file, so gateway updates still surface
new/changed default files nobody edited). Injected in every mode incl. clean; marker-guarded so a
real granted skill of the same name is never clobbered; write-on-change with stale-file pruning.
Customized/restored via the **`get_gateway_guide`** (anyone) / **`update_gateway_guide`** /
**`reset_gateway_guide`** (admins) gateway MCP tools — per-file or whole-guide restore-to-default.
→ TEST-PLAN: Gateway-usage skill.
- **Three base modes with independent options:** Read-only, Worker, and Admin. Read-only
routes edits and commands to approval; Worker grants commands and file writes inside the channel
container. Admin grants Worker to non-admin members and the permission bypass only to trusted
admin authors. The shared settings file always disables bypass, even when it grants Worker tools.
**Auto** and **Lean** are independent side toggles: Auto approves tool requests for every admitted
member; Lean removes optional skills and connectors. In Admin mode, Lean applies to non-admins,
while admins retain their configured tools and context. Changing Worker ↔ Admin preserves both
toggles. Choosing Read-only clears Auto; enabling Auto on Read-only selects Worker. Existing Full
access records gain the documented Worker fallback; existing Auto and Lean flags are preserved.
Network remains a separate setting. → TEST-PLAN: Base modes and independent options.
- Read mode's filesystem contract is engine-neutral: ordinary runs get bounded gateway MCP tools
to list, read, and literal-search the effective channel workdir. They realpath-check every target,
refuse traversal and escaping symlinks, cap file/search output, and expose no write or shell
operation. Claude may keep using its native Read/Glob/Grep; Codex uses these tools when its
read-only permission profile intentionally withholds command execution. → TEST-PLAN: Engines.
- The web channel editor, custom DMs, and default templates share the three-mode picker and
side toggles. The Slack reply's **Settings** button exposes Read-only/Worker/Admin plus Auto/Lean
on its manager/admin-only Access tab for channels. DMs retain Runtime mode controls with
Admin selection restricted to admins. Every click rechecks user
authorization and channel membership, and every change is audited. `/mode read|worker|admin` changes the
base while preserving applicable options (`bash` remains a compatibility alias). Legacy run-API
presets remain accepted with their existing capability-reduction boundary.
- **Two-axis access model** (per channel, both editable in the admin UI): **Who can use** (`meta.access`:
Approved members / Admins only / Locked) and **Who can manage** (`meta.manageAccess`: Org admins only /
Channel members / Custom + `meta.managers[]`). The Slack Access page delegates channel mode (including Admin), Auto, Lean, network and use/manage policy to
current managers. Work-dir stays org-admin-only. The gateway control MCP (`requireManage` vs
`requireAdmin`) and in-thread `/mode` command retain their narrower admin checks; default `admins` means relaxing it is strictly opt-in. Settings →
**Reset all channels' access to default** (confirm-gated, audit-logged) restores use/manage to defaults
and clears custom guest + manager lists, leaving capability/skills/tokens untouched.
Slack authorization denials name the channel restriction (admins only or named users) and its
Access remedy; only deployment approval denials direct the user to Users settings. Denied messages
still stop before attachment hydration or an engine run.
→ TEST-PLAN: channel access model.
- **Live three-tier skill and connector grants**: organization defaults, channel grants, and the
authenticated active user's grants are unioned for each run, with the more specific tier
replacing duplicate connector definitions. Admin editors can move among Org / This channel /
This user without copying defaults into every record; new channels therefore inherit current org
grants immediately. Claude and Codex selections remain engine-specific, OpenCode exposes no MCP
tier, invalid skill identifiers fail closed, and user-effective settings/skills are provisioned
through engine-specific synthetic homes and private per-run settings/skill views so host-global
customizations and concurrent channel users cannot leak or race grants. Shared gateway skills use
an immutable warm-safe plugin; optional personal/library overlays stay cold and private.
Caller-supplied API identities never receive the user tier. → TEST-PLAN: channel access model.
- **Channel-scoped guest roster**: the Admin UI's **Guest access — named users** checklist loads
only the selected Slack conversation's current human members, including Slack Connect/external
guests, rather than every recorded MakeItFuture user. The server independently validates every
submitted guest ID against a fresh live roster (bots, deleted accounts, stale and forged IDs are
excluded); a shared per-channel mutation lock ensures `member_left_channel` removes a departed
user's saved grant even when a validated Admin save is already in flight. Roster display uses
Slack's bulk user directory with targeted Slack Connect fallbacks; saves resolve profiles only
for submitted current candidates. If Slack is disconnected or its roster cannot be read, guest
editing disables without falling back to the org directory or clearing existing grants, while
unrelated channel settings remain saveable. Even a successful roster can omit a saved guest:
Network, Lean and other unrelated saves omit `allowedUsers` until the operator edits the guest
checklist. A successful save clears that pending edit; a failed save retains it for retry.
Explicit guest edits still replace the list through current-member validation, and inherited
approved access stays visibly checked after a save.
→ TEST-PLAN: Channel access model; Admin UI.
- Interactive permission approvals in Slack: a non-admin run that hits a non-allowlisted tool posts
buttons in the thread — **Approve once**, **Approve for this thread**, **Approve forever** (persisted
to `meta.approvedTools`; a channel manager's or admin's call since 2026-10-07), **Deny**. Only the
run's author, an admin, or an approved user may click; no click within 4 minutes auto-denies. Driven by the `permission_prompt` MCP tool → the daemon's
internal approval endpoint. → TEST-PLAN: Modes & approvals.
- Gateway control-MCP identity is authenticated with a daemon-signed, expiring run capability
bound to the exact author, channel, slug, Slack thread, origin, and engine. The MCP server derives
authority only from verified claims and revalidates every tool invocation; missing, expired, or
tampered grants fail closed. Principal trust is itself signed, so an API caller cannot name a
Slack admin and acquire gateway tools; engine fallback remints the claim for the engine that
actually runs. Warm-session reuse fingerprints the stable authority scope and a bounded renewal
bucket rather than the capability nonce, preserving pooling without reusing stale authority.
Caller-controlled `CG_CHANNEL_ID` / `CG_AUTHOR_ID` identity fields no longer ride the generated
MCP configuration. → TEST-PLAN: Modes & approvals.
- Agent-initiated approvals (Slack approval objects): the agent (Claude or Codex) can call the
gateway `request_approval` tool to ask the user to sign off on a plan or proposed action instead
of ending with a plain "shall I proceed?" It posts a Slack approval object in the thread with
**Approve**, **Deny**, and **Comment** (request-changes, opens a modal) buttons — reusing the same
`/internal/approval` endpoint and click-authorization as permission prompts — and returns
`{ approved, feedback, decided_by }` to the agent so it continues, or addresses the feedback. The
card shows the full `details` text (not the 60-char tool preview); documented in the
`gateway-usage` skill (`references/approvals.md`). → TEST-PLAN: Modes & approvals.
- **Link-based approvals alongside native controls.** Slack uses native buttons and supplemental
private links; Google Chat uses private links. Teams uses its original-thread native card without
supplemental links or action DMs, and refuses the action if that card cannot be delivered.
Every eligible approval card (permission, control-plane, durable background-shell) and
every busy-thread card can therefore ALSO be minted as short-lived, single-use, HMAC-signed links —
one per action the recipient may actually take (*Approve once* / *Approve for this thread* /
*Approve forever* / *Deny*, and *Steer* / *Queue* / *Cancel* for a busy thread) — and delivered
**privately to the person who raised the request**: a Slack ephemeral in the same thread, or a
DM on a surface with no ephemeral primitive. Never in the shared thread: a link is a bearer
credential. `GET /approve/<token>` renders a confirmation page (tool, clipped command/plan
preview, conversation, requester, expiry, and the one action this link performs) and **changes
nothing** — link unfurlers, preview services and scanning proxies fetch these URLs, so a GET with
a side effect would let the unfurler decide the request before a human saw it. `POST` (the page's
single Confirm button) resolves it through the same `applyApprovalDecision` /
`applyBusyThreadChoice` the buttons and the admin API use, so scope semantics, the durable
compare-and-swap, the waiting MCP call and the in-thread card update are identical; the waiting
agent reads `decided_by: "link"`. The signature covers id + action + scope + expiry, so a *Deny*
link cannot be edited into an *Approve forever* one; the nonce is spent by one atomic UPDATE, so
a link works exactly once; deciding a card retires every other link for it; and the requester's
own authority is re-checked on every POST, so an admin-tier sign-off gets a Deny link and no
Approve link at all. Unknown/used/expired → a plain page and 404/410 that says nothing about any
other request, with per-IP backoff on bad tokens; responses are `no-store` + `noindex`. Settings →
Connection → **Approval links**: `auto` (default — where there are no native buttons, plus selected
Slack testers once `publicUrl` is set), `always` (eligible recipients, with loopback fallback), `off`.
**Testing with AI** is a compact searchable Slack user selector (`aiTestingUsers`, empty by default).
Search names or IDs, choose results with a click or arrow keys + Enter, and remove selected-user
chips with ×. Results are bounded and scrollable; search alone does not mark Settings dirty.
Saved users missing from the directory remain visible and removable. Only
selected users receive supplemental permission, control-plane, durable-shell and busy-thread
links; both `auto` and `always` enforce it, including for admins. Removing a user stops links
on subsequent cards immediately. Native buttons and existing link expiry remain unchanged;
membership grants no additional permissions. Surfaces without native buttons retain their links.
Every link decision logs `approval_resolved_by_link` (ids, decision, scope — never a value).
→ TEST-PLAN: Modes & approvals.
- Admin-only dangerous permissions (admin author **and** adminMode channel); non-admins run the
folder allowlist. → TEST-PLAN: Security (dangerous perms).
- **Retired 2026-09-03 (Linux + containers only):** the deny lists and the allowlisted domain set,
and with them the sandbox-egress meaning of **Allow Network** (formerly: "sandbox egress to an
allowlisted domain set, default GitHub" — that filtering no longer exists and is not coming back
in this shape). Inside the container the gateway root, the sensitive home paths and other
channels' folders are simply not mounted, and the former per-channel **Allow Bash** deny-list
(Bash + Write/Edit with writes kept away from the gateway root, `.ssh`, `.aws`, `.config`,
`.claude`, keychains and every other channel's folder) is the container boundary instead.
→ TEST-PLAN: Sandbox boundaries.
- **Allow Network is enforced by the egress proxy, and says so where it is only advisory.** A
proxy-mode container (the default) has `--network none`, and the per-channel switch is the
proxy's live policy (see Container runtime → egress proxy). Under the legacy
`containerEgressMode = "bridge"`, a channel's `rawNetwork` escape or a `/sudo` host thread the
switch only states intent, and that is said out loud everywhere instead of being inferred from a
missing suffix: the mode label carries the state in BOTH directions (`Read-only · network off` /
`Bash · network on`), `/mode` and `/status` add the caveat (`network off (advisory — not enforced
for this container)`) only where it applies, the gateway-managed block at the top of every
conversation's instruction file states the switch AND whether it is enforced, and the
`run_config` event records `networkEnforced` and `egress` beside `networkPolicy` so an operator
reading it after an incident cannot mistake one for the other. `NETWORK_POLICY_ENFORCED` in
`src/engines/network-policy.js` is now `true`; every surface asks `networkEnforcedFor(target)`.
→ TEST-PLAN: Sandbox boundaries; Egress proxy, placeholders and `--network none`.
- **Retired 2026-09-03 (Linux + containers only):** nothing replaces it — with no host sandbox there is no user namespace for AppArmor to restrict,
and rootless Podman brings its own uid mapping (`--userns=keep-id`, `/etc/subuid`). **Linux hosts keep their Bash sandbox under Ubuntu's AppArmor userns restriction**: Ubuntu 23.10+
(24.04 LTS included) stacks any unconfined process that creates a user namespace into a
capability-stripped profile, which kills Claude Code's sandbox on its first `setgroups` write.
`scripts/apparmor/claude-code-userns` is a targeted mediating profile attached to the Claude
version binaries (host hardening stays on; no sysctl flip), installed and live-verified by
`scripts/apparmor/claude-userns-fix.sh`. The daemon names the condition at boot
(`src/engines/linux-userns.js`) and `install-systemd.sh` prints the remedy when it detects the
restriction. The same file carries a `codex-userns` block for the Codex standalone binaries:
without it the capability strip spares Codex's filesystem sandbox but starves its
approved-domain `network_proxy`, which then resets every tunnel (allowed domains included) —
the boot assessment flags a stale pre-Codex install of the profile and says to re-run
`--apply`. → TEST-PLAN: Security (Linux userns sandbox).
- **Background shell jobs are gated by mode AND by where they run (2026-10-06).** The mode gate
is unchanged: the channel must be auto-mode (or admin-mode with an admin author). Inside the
channel's CONTAINER, an Auto-mode job then starts directly, as the author's own work — Auto
already auto-approves every foreground command in that same container and
`run_agent_in_background` runs there with no card, so the former admin-tier "Run it" card bought
nothing but a stall for non-admin authors (five of five durable cards in the Symphonia channel
were container jobs a non-admin could only Deny). Nothing a run can do changes what its container
mounts, so the job gains no access a foreground Auto turn did not have. Only an UNISOLATED
target — the `/sudo` host lane, or an embedder's own backend — still posts the durable
exact-command card, at the admin tier and naming the daemon account; that card is never
auto-approved. The `bg_start` audit event records the authority (`approvedBy`: the author for a
direct start, the clicker otherwise) and whether the job was isolated. Every agent-type approval
card now states its authority tier on the card ("Only a gateway *admin* can approve this; anyone
eligible may Deny or Comment"), so a member learns it before clicking rather than from the
ephemeral refusal after. → TEST-PLAN: Security (background gating), Automation.
- **Admin home access is author-specific:** current organization admins in Admin/Full-access
channels can receive the optional operator-home mount (`containerFullAccessHome`, off by
default). Members and guests use a separate project container with Worker and the selected
Auto/Lean options, so they can edit the project without reading the operator home. The daemon
checks current roles at each spawn, including background work; memory reviews always use the
project lane. Admins retain legacy container/HOME/artifact state. Project authors get a fresh
`cgp2-` container, HOME and scratch volumes and a separate `.runtime-project-v2` artifact root;
they cannot
inherit old admin transcripts, CLI logins or run artifacts. Switching lanes starts fresh
engine history without importing admin context. Project files and channel memory remain
shared. The existing SSH refusal for home-enabled Admin channels remains in force.
The defensive home guard still denies writable tools if an untrusted run somehow receives a
home-mounted target. → TEST-PLAN: Container runtime (author-specific home access).
- Approval-based authorization: admins + approved (MakeItFuture-list) users may talk in any
channel they're in and in DMs; unknown users denied everywhere (incl. DMs) unless added as a
per-channel guest. New users are recorded as un-approved pending an admin's approval.
## Container runtime (v0.8 P1)
- **Accurate container access instructions:** the shared operating guide states the default
boundary and the optional Admin/Full-access operator-home grant. Each generated guide names the
resolved runtime's `containerFullAccessHome` setting and operator-home mount, including a gateway
with the switch on but a Worker channel without the mount. It distinguishes the channel's own
`$HOME` from the mounted operator home, admin-only home access from members' project access, and
a missing runtime target from a known absent grant. The same facts reach Claude and Codex skill
discovery, including clean workspaces. Admin guide overrides keep their documented precedence.
→ TEST-PLAN: Container runtime (operator-home guide acceptance).
- **Current access facts on resumed turns:** every engine attempt also receives the resolved
home-access setting/mount in its prompt, with explicit precedence over stale thread claims.
Guide generation and attempt prompts share one access-note generator. Fresh/resumed, clean,
model-retry, healed and fallback attempts retain these facts; an enabled gateway switch is
distinguished from the current channel's actual grant and a proposed switch to Admin mode.
Environment credentials are described as usable when injected, with masked listing/reveal
surfaces and redacted outputs. → TEST-PLAN: Container runtime (resumed access facts).
- **Host gateway store attribution:** attempt prompts separately report whether the gateway's
runtime directory and database are covered by resolved host mounts, without naming unmounted
host paths. Container-local `/opt/channelgate`, `/home/agent` state and parent scaffolding
around a bind mount do not establish host-store access. Explicitly mounted workdirs and
operator-home grants are recognized; engine-storage masks remain excluded. This guidance
describes existing mounts and never changes authorization. → TEST-PLAN: Resolved Clean and
gateway-store facts.
- **Access checks honor the requested scope:** attempt guidance limits existence, metadata and
permission-only checks to resolved mount facts and non-mutating metadata. It forbids reading
contents or creating, changing or deleting temporary probe files for such requests; write
access that metadata cannot establish is reported as unverified. This applies to both engines,
including resumed and fallback attempts, without changing permission enforcement.
**Since 2026-09-03 this is the ONLY runtime (Linux + containers only):** the `host` backend, the
gateway-wide kill switch, the per-channel runtime pin and the host↔container session carry below
are retired, bullet by bullet; everything else stands.
- **Where a turn runs is a declared backend, resolved once.** `src/runtimes/` is to WHERE an engine
process runs what `src/engines/` is to WHICH engine runs and `src/platforms/` to which surface
answers: a `RuntimeBackend` contract (`prepareTarget` · `ensureUp` · `spawn` · `probe` · `signal`
· `acquireLease` · `destroy` · `fingerprint` · `describe` · `resumeCommand` · `helperCommand`)
with two implementations — `host` (today's `child_process.spawn`, `kill(pid,0)`, negative-pid
group kill, unchanged byte for byte) and `container`. Validation is fail-closed at load: a backend
that omits a method, omits one of the four capabilities (`isolated`, `processGroups`,
`detachedSurvivesDaemon`, `persistentHome`) or declares an unknown one does not load, an unknown
capability KEY throws, and an undeclared capability reads as the least capable value. A new
backend is a module plus a registry entry — never another `if (backend === "container")` branch.
→ TEST-PLAN: Container runtime (v0.8 P1).
- **One decision per turn, one target downstream.** `resolveRuntime()` runs once and hands the same
RuntimeTarget to the folder generator, the MCP config builder, the engine runners, the artifact
paths, background jobs, memory review and the session stamp. The target carries the backend id and
object, the REASON it was chosen, slug/platform/meta, the `cwd` the run executes in, the durable
`workDir` (the bind-mount source; equal to `cwd` outside clean mode), the `cleanWorkDir`, the
`artifactDir` (null on the host), the container-runtime settings snapshot, and — for a container —
the resolved container block. Nothing downstream asks "is this a container?"; it asks the target's
declared capabilities. → TEST-PLAN: Container runtime (v0.8 P1).
- **Retired 2026-09-03 (Linux + containers only):** the kill switch, the per-channel `meta.runtime` pin and the admin-mode → host rung — every channel
resolves to the container backend, there is no host to return to, and the daemon refuses to boot
without a container CLI. **Backend precedence, stated once**: the gateway-wide kill switch `containerRuntimeEnabled:false`
→ `host` (reason `disabled`, the v0.8 rollback lever); a channel in **admin mode** → `host`
(`admin-mode` — an admin channel is deliberately unconfined, so it is honest about it);
`meta.runtime` = `host`|`container` → that (`channel`); otherwise the gateway
`containerDefaultBackend` (`default`). An invalid pin falls through to the default rather than
failing. A per-run override may only REDUCE capability, so the decision reads the CHANNEL's stored
`adminMode`/`runtime` and never the overridden run view — `mode:"read"` cannot move a turn off its
container. Clean mode changes the cwd, not the backend. → TEST-PLAN: Container runtime (v0.8 P1).
- **Retired 2026-09-03 (Linux + containers only):** the kill-switch clause only — a missing container CLI is now a refused BOOT rather than a refused
turn; the rest of the bullet stands. **Fail closed, never a silent fallback.** No usable container CLI, no built image, or no engine
login ends the turn with the sentence that names the remedy (`npm run build:image` on the gateway
host; `claude setup-token`; `codex login`) — the backend never quietly demotes the channel to the
host, because a confinement boundary that disappears without saying so is worse than a refused
turn. Only the explicit kill switch returns channels to the host. A missing engine credential is
raised as a CONFIGURATION failure (`details.runtimeCredential`), not a provider outage, so
cross-engine failover cannot answer with the other harness and hide the one thing an operator has
to fix; the existing rule that a user-PINNED harness/model never fails over is unchanged.
→ TEST-PLAN: Container runtime (v0.8 P1).
- **The container spec.** Rootless **Podman** is preferred (`auto` probes podman, then docker; the
kind is detected from what `info` returned, not from the binary name, because `docker` may be a
podman shim). Rootless podman gets `--userns=keep-id` so files the agent writes in a bind mount
stay owned by the daemon user with no chown pass; everywhere else the uid is pinned with
`--user <uid>:<gid>`, per container and per exec. Mounts, all at IDENTICAL absolute paths because
transcripts, `/resume`, git worktrees and every path the model prints have to stay valid on both
sides: the channel's resolved workdir, the clean workspace (so clean mode works in a container),
and the per-channel artifact dir `~/ChannelGate/.runtime/<platform>/<slug>` (0700) that holds this
run's engine-facing files. The channel's HOME is a **named volume** at `/home/agent`; the daemon's
MCP socket directory is mounted **read-only** at `/run/channelgate`; outside the egress proxy the
Codex `auth.json` is a single-FILE mount (the one deliberate exception to "mount directories,
never single files" — see the credential bullet; behind the proxy Codex is relayed with no mount,
container-secrets P4); `/tmp` and `/var/tmp` are per-channel named volumes, for durability (own
bullet below). Hardening: tmpfs `/run` only (64m, noexec — pid files and the socket mount, which
must be fresh at every start);
`--cap-drop ALL` plus only `DAC_OVERRIDE`/`CHOWN`/`FOWNER`; `--security-opt no-new-privileges`;
`--init`; `--pids-limit` (1024 by default), `--memory` and `--cpus`, all three dropped with a
warning when the host does not delegate cgroup controllers (probed once with a throwaway
container, so a whole fleet does not fail one spawn at a time). Every container carries
`channelgate=1`, `cg.install`, `cg.platform`, `cg.channel`, `cg.fingerprint`, `cg.image` and
`cg.created`; names are `cg-<install>-<platform>-<slug>` clamped to a DNS-safe length with a
collision-proof digest tail, where `<install>` is a digest of the runtime root's real path — two
gateways on one host (and one container daemon) never collide, and discovery is by name and then
VERIFIED by the install label, so a container that merely answers to our name is never touched.
→ TEST-PLAN: Container runtime (v0.8 P1).
- **What a container can never see**: the gateway root, `config/`, `gateway.db`, the per-channel
metadata folder, the daemon's own checkout, and the operator's `~/.claude` / `~/.codex`. The only
sources under the gateway root are the clean workspace and the read-only socket directory. The
per-run environment — which carries the channel's own provider logins — rides a 0600 `--env-file`
under the channel's metadata folder (host side, never mounted) and never `-e KEY=VALUE`, because
argv is world-readable in `ps`; the file is deleted when the child closes, and files a crashed
daemon left behind are swept. Values a line-oriented env file cannot represent are DROPPED and
their names logged rather than silently truncated. Host-only variables (`PATH`, `NODE_PATH`,
`TMPDIR`, the XDG set, `SSH_AUTH_SOCK`, …) are stripped so the image's own values stand, and
`HOME`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `CG_RUNTIME` are owned by the backend.
→ TEST-PLAN: Container runtime (v0.8 P1).
- **Lifecycle: created lazily, stopped when idle, removed almost never.** The container is created
on the channel's first run (`tini` as PID 1, `cg-init` preparing the HOME volume on every start,
then `sleep infinity`); an exited one is **started**, not recreated, which returns it in well under
a second with its writable layer intact. Reuse is decided by a FINGERPRINT of the create-time
configuration — resolved image ID (not the moving tag), mounts, network, uid strategy, applied
limits, hardening flags, credential mode — carried in the `cg.fingerprint` label; everything
per-exec (env, channel secrets, prompt, mode, model) is deliberately outside it, so rotating a
channel secret retires the warm ENGINE process through the pool's own fingerprint and does not
tear down a container that background jobs are living in. A stale fingerprint recreates only when
nothing holds a lease; otherwise the turn uses the container it has, says so, and recreates at the