Channels¶
Which surfaces an agent can be reached on, how to switch between them, how a turn actually reaches the runtime, and the full platform-side setup for each: Telegram, Slack, Microsoft Teams, and Google Workspace as a capability.
The cockpit is the default channel and needs no platform. A chat channel is an
addition: the same agent, reachable somewhere its owner already is. Pick one per agent as
its owner transport (--channel), and the cockpit stays available alongside it.
| channel | owner transport | routing key | host service | needs |
|---|---|---|---|---|
| webview | the browser cockpit | --user-email |
webview-gateway + webview-login |
nothing external |
| telegram | a Telegram bot | --owner-id (or pairing) |
none (per-agent poller) | a bot token from @BotFather |
| teams | Microsoft Teams | --owner-aad |
teams-gateway |
an Azure app + a public messaging endpoint |
| slack | Slack DMs | --owner-email |
slack-gateway |
a Slack app (Socket Mode, no public URL) |
webview needs nothing external and is what Quick start stands up. The other three are add-ons: each wants an app or a bot registered on the platform's side, and all but telegram want a host gateway on top.
Switching an agent's channel¶
An agent with no chat platform is fully supported: --channel webview has the cockpit
as its only surface, which is why it requires --user-email (the gateway has no other way
to route to it). Adding --user-email to an agent on any other channel is additive and
gives it a cockpit too.
Switching later is a ledger scalar, not a rebuild:
agentctl agents set <agent> channel webview # needs user_email set first
agentctl apply <agent>
Each integration refuses to wire until its secrets are set, and the refusal prints
the exact secret set line (with the value format, e.g. --value <xoxb-…>).
How the agent talks to its channel (TXT mode)¶
Whatever channel an agent is on, something has to hand a turn to claude and stream the
answer back. The default, TXT mode, spawns claude -p --input-format stream-json and
writes turns to its stdin; it depends on nothing outside agentctl. The older plugin path is
still selectable per agent and needs the private claude/channel capability, which an
account's org policy can disable.
Lanes: how one agent carries several conversations¶
Every frame carries an optional session, normalized so that the empty string means the
primary lane. RegisterFrame.session is the authoritative routing key — gateways key
their routing table on (agent, session). A channel process reads CHANNEL_SESSION from
its environment and stamps its own lane onto every outbound frame, so agents need no
retraining to participate.
session is not a trust dimension. Authorization stays agent × owner-identity; a
lane selects which conversation a frame belongs to, never what the caller may do.
The two transports, and putting one agent back on the plugin¶
There are two ways, and the default is the first:
| how | what it depends on | |
|---|---|---|
| TXT mode (default) | the shim spawns claude -p --input-format stream-json, writes turns to its stdin and streams stdout deltas back; the model the pi runtime already uses |
nothing |
| plugin | claude --channels plugin:telegram@…, which lets an MCP shim inject a turn |
the private claude/channel capability, which the account's org policy can disable |
The default inverted because of how the plugin path fails. When an org disables the channel
capability, claude comes up with no channel plugin: the transport keeps accepting
inbound messages and nobody consumes them. The agent is silently mute, and the reason is
printed on a pty the unit sends to /dev/null (an afternoon of debugging on magi,
2026-08-03). A default that someone else's policy change can switch off, silently, is not a
default worth keeping.
AGENTCTL_TXT_MODE is therefore on unless explicitly falsy (0/false/no,
case-insensitive). Unset means on. To put one agent back on the plugin path:
agentctl secret set AGENTCTL_TXT_MODE false --agent <name>
agentctl apply <name>
Four things parse this flag: the shim (server.ts), start-agent.sh,
start-subsession.sh and ensure-telegram-pin.sh. They must agree exactly, because
two parsers for one flag is how you get a half-enabled mode where the launcher takes one
branch and the shim the other. A subsession inherits its parent's setting explicitly
(systemd units inherit nothing from each other); a sub that lands on the other transport
than its parent is deaf for its whole life.
Upgrading: a running agent keeps its old transport until it is restarted; apply only
starts the unit, which is a no-op on one already running. The deb prints the required
command; run it when convenient, since it ends every agent's current session:
agentctl agents restart all
Adding a chat channel¶
Web-view (the default). Secrets: WEBVIEW_HMAC_KEY plus your auth driver's config.
webview-gateway install · webview-login install, both behind your HTTPS proxy. See
the cockpit.
Telegram, Slack and Microsoft Teams (click-through + CLI)¶
Telegram. No host gateway; each agent runs its own poller. Secret: the per-agent bot
token, passed at add time.
- Click: DM @BotFather → /newbot → copy the token.
- CLI: agentctl agents add <name> --bot-token <token>; the token alone is enough.
► PAIR: message the bot once and the owner is captured, or pass --owner-id to skip.
► LOGIN: the bot DMs an auth URL → authorize → paste the code back → the agent is live.
Slack (slack-gateway, Socket Mode, no public URL). Secrets: SLACK_BOT_TOKEN
(xoxb-…), SLACK_APP_TOKEN (xapp-…).
- Click: api.slack.com/apps → Create (From an app manifest, or scratch) → Socket
Mode on → App-Level Token (connections:write) = SLACK_APP_TOKEN → OAuth &
Permissions bot scopes chat:write, im:history, im:write, users:read, files:write
→ Install to Workspace → Bot User OAuth Token = SLACK_BOT_TOKEN → Event
Subscriptions → bot event message.im.
- CLI: Slack has no create-CLI; the "CLI" path is the app manifest (paste it in
step 1). Tokens are still minted by the Install button. Then:
secret set SLACK_BOT_TOKEN · secret set SLACK_APP_TOKEN · slack-gateway install.
Microsoft Teams (teams-gateway, needs a public messaging endpoint). Secrets:
TEAMS_APP_ID (GUID), TEAMS_APP_PASSWORD, TEAMS_APP_TENANT_ID (GUID).
- Click (Azure Portal): App registrations → New → copy Application (client) ID =
TEAMS_APP_ID, Directory (tenant) ID = TEAMS_APP_TENANT_ID → Certificates &
secrets → New client secret = TEAMS_APP_PASSWORD → Azure Bot resource →
Messaging endpoint = https://<teams-gateway-host>/api/messages → enable the Teams
channel → sideload the Teams app package.
- CLI (az): az ad app create --display-name agentctl-teams --query appId -o tsv
· az ad app credential reset --id <appId> --query password -o tsv
· az account show --query tenantId -o tsv
· az bot create -g <rg> -n agentctl-teams --app-type SingleTenant --appid <appId>
--endpoint https://<host>/api/messages · az bot msteams create -g <rg> -n
agentctl-teams. Then the three secret set … + teams-gateway install.
The long version, with the trust boundary drawn out, is
Microsoft Teams: full tenant setup below.
App manifests (Slack YAML, Teams package) are referenced above but not yet shipped in-repo; TODO follow-up (
packaging/payload/manifests/). Until then configure via the click-through steps.
Microsoft Teams: full tenant setup¶
A Teams bot fronted by one shared gateway (agentctl teams-gateway) that routes by
owner identity to per-agent pi sessions. One-time tenant setup for the whole fleet;
after that a Teams agent is just an agents.yaml entry. Replace the <...>
placeholders; if IT owns your Azure/Entra, the prerequisites list what to request.
The only public surface is POST /api/messages (Bot Framework → gateway,
JWT-validated). /register, agent↔gateway traffic, and secrets stay private.
flowchart LR
subgraph OWNER["Owner laptop"]
TC["Teams client<br/>desktop / web"]
end
subgraph MSG["Microsoft 365 / Bot Framework — global, multi-tenant (Microsoft-operated)"]
TEAMS["Microsoft Teams<br/>service"]
CONN["Bot Framework Connector<br/>(activity serviceUrl relay)"]
end
subgraph TENANT["Your Azure tenant — single tenant + subscription"]
ENTRA["Entra ID / Azure AD<br/>app registration · validate inbound JWT · mint app token"]
subgraph VNET["Private network / VNet (NSG)"]
subgraph VMGW["Gateway VM — no agents"]
TLS["TLS reverse proxy :443<br/>exposes ONLY /api/messages"]
GW["Teams gateway<br/>/api/messages + /register<br/>route by owner AAD id"]
end
subgraph VMA["Agent VM A"]
AGA["Teams agent<br/>+ local secret store"]
end
subgraph VMB["Agent VM B"]
AGB["Teams agent<br/>+ local secret store"]
end
end
end
PROVIDER["LLM provider<br/>(model API)"]
AGA -. "0 · wss://gateway/register + reg token · PRIVATE" .-> GW
AGB -. "0 · wss + reg token · PRIVATE" .-> GW
TC -- "type message" --> TEAMS
TEAMS --> CONN
CONN == "1 · POST /api/messages · PUBLIC INTERNET<br/>HTTPS + signed JWT" ==> TLS
TLS -- "to gateway (loopback)" --> GW
ENTRA -. "validate inbound JWT" .-> GW
GW -- "2 · turn · WSS (private)" --> AGA
AGA -- "3 · inference · HTTPS egress" --> PROVIDER
PROVIDER -- "tokens" --> AGA
AGA -- "4 · stream chunks · WSS (private)" --> GW
ENTRA -. "mint outbound app token" .-> GW
GW == "5 · POST activities → serviceUrl<br/>HTTPS egress + app token" ==> CONN
CONN --> TEAMS
TEAMS -- "streamed reply" --> TC
classDef public fill:#ffe1e1,stroke:#d33,stroke-width:3px,color:#000;
classDef internal fill:#e6f2ff,stroke:#06c,color:#000;
classDef cloud fill:#eef7e6,stroke:#3a3,color:#000;
class TLS public;
class GW,AGA,AGB internal;
class TEAMS,CONN,ENTRA cloud;
Prerequisites (request from IT if you lack them):
- Azure subscription + resource group, with Contributor (or Azure Bot Contributor) on it.
- App-registration rights in Entra (Application Administrator), or have IT register the app and hand you the appId + client secret.
- Teams Administrator (or an app-setup policy) to allow custom-app sideloading or org-catalog publish.
- Admin consent for Graph application permissions: only for proactive messaging / directory lookups; a 1:1 reply bot needs none.
- A public HTTPS route to the gateway's /api/messages (real domain + trusted cert; a bare IP won't work).
- az CLI (≥ 2.x; az bot is in the core CLI, no extension), signed in with the rights above.
1. Register the provider (once per subscription, as Owner/Contributor):
az provider register -n Microsoft.BotService
2. Create the bot identity + Azure Bot. If IT registers the app, skip the az ad lines and start from their appId + secret.
az login
az ad app create --display-name "<BOT_DISPLAY_NAME>" --sign-in-audience AzureADMyOrg # → note appId
az ad sp create --id <APP_ID>
az ad app credential reset --id <APP_ID> --display-name teams-gateway --years 1 # → store the password (secret) in sops
az bot create -g <RG> -n <BOT_NAME> --app-type SingleTenant --appid <APP_ID> --tenant-id <TENANT_ID> --sku F0 --location global
az bot msteams create -g <RG> -n <BOT_NAME> # enable Teams
az bot update -g <RG> -n <BOT_NAME> --endpoint "https://<GATEWAY_HOST>/api/messages" # point at the gateway
3. Build the Teams app package. Fill runtime/teams/manifest/manifest.json ({{BOT_APP_ID}}, names, {{GATEWAY_HOST}}) + two icons (color 192x192, outline 32x32), zip, upload in Teams (sideload or org catalog).
4. Wire the app into the gateway. With TEAMS_APP_ID set the gateway runs in Azure mode: validates the inbound Bot Framework JWT on /api/messages and mints an Entra token for outbound replies.
agentctl secret set TEAMS_APP_ID --value <APP_ID>
agentctl secret set TEAMS_APP_PASSWORD --value <CLIENT_SECRET>
agentctl secret set TEAMS_APP_TENANT_ID --value <TENANT_ID>
agentctl teams-gateway install
5. Add a Teams agent (pi-runtime, routed by owner Entra object ID (a GUID), not the UPN; no bot token, no pairing):
# get the object id: az ad user show --id <user@tenant> --query id -o tsv
agentctl secret set OPENROUTER_API_KEY --value <KEY> --agent <name>
agentctl agents add <name> --channel teams --owner-aad <owner-object-id> \
--runtime pi --provider openrouter --model z-ai/glm-5.2
Hosting the gateway. It needs a long-lived process (a persistent /register
WebSocket, so not an Azure Function) and a public HTTPS /api/messages on a real
domain with a trusted cert (a bare IP won't work). Simplest: a VM running agentctl + the
gateway with Caddy in front (auto Let's Encrypt cert), exposing only
/api/messages publicly and 404ing everything else so /register never reaches the
public face:
<domain> {
handle /api/messages* {
reverse_proxy 127.0.0.1:3978
}
handle {
respond 404
}
}
Use handle blocks, not a bare reverse_proxy 127.0.0.1:3978: the plain proxy would
expose /register too, and pairing respond 404 with reverse_proxy @webhook at the
same level shadows the proxy (Caddy directive ordering). handle blocks are mutually
exclusive, so the first match wins: public /api/messages, private everything else
(/register is reached only over the private vnet, NSG-limited). Set the bot endpoint to
https://<domain>/api/messages. It also ships as a container
(ghcr.io/<owner>/teams-gateway) to run behind an existing reverse proxy.
Cross-host Teams gateway (joining the fleet)¶
A cross-host Teams setup runs the public gateway on its own VM (only
teams-gateway, no agents) while the agents run on a separate private primary
host. Both hosts are writers on the same shared GitOps remote (multi-writer
fleet, per-agent shards commute). The gateway is just a fleet member that adopts
the shared ledger: it builds its Teams allow-map (TEAMS_AGENTS) from its own
copy of the ledger's Teams agents, so until it adopts the shared ledger it knows
zero agents and rejects every /register (teams-gateway status →
agents: 0 in the ledger).
Below, primary = the existing fleet writer running the agents; gateway = the freshly-provisioned second host. Steps are ordered, and each has the why. These are the sharp edges.
- Pin every agent to the primary FIRST (
run_on: <primary hostname>on each agent shard). Critical and non-obvious:agentRunsHere()treats an empty agent host as "run on EVERY host". Agents from a single-host ledger carry no pin, so the instant the gateway adopts the ledger and runsapplyit would provision + start every agent a second time: two Telegram pollers per bot → 409 conflict storms. There is no CLI to setrun_onon an existing agent today (agents setrejects it;agents addonly auto-pins brand-new agents toos.Hostname()), so this is a direct ledger edit for now. (Rough edge: worth a futureagents set run_on/agents pinverb.) - Give the gateway credentials to reach the shared repo, e.g. copy the git credential / deploy key the primary uses to push the fleet remote.
- Point the gateway's
/opt/agentctlat the shared remote and adopt it.agentctl fleet adoptfetches + hard-resets to the remote's default branch and self-registers the host's age recipient. - Caveat (older agentctl):
fleet adopthard-codedmain, so on amaster-default fleet (e.g. Azure DevOps) you had to check out the branch by hand:git fetch && git checkout -f -B master origin/master. Fixed by #109 (detects the remote's default branch). - The adopt hard-reset overwrites the gateway's local ledger, so any gateway-local-only secrets (its Teams bot creds) must be re-seeded afterward (step 6).
- Gateway
apply: self-registers its age recipient intohost_keys, skips the primary-pinned agents (assigned to another host), and pushes. - Primary
fleet sync+apply: picks up the gateway's recipient and re-wraps the sops store to include it, so the gateway can decrypt/seed fleet secrets. (Only an existing recipient can re-wrap.) - Gateway
fleet sync+ re-seed the Teams bot creds the adopt wiped:TEAMS_APP_ID/TEAMS_APP_PASSWORD/TEAMS_APP_TENANT_ID. They're decryptable now because the gateway is a recipient (step 5). - Set
host.teams_gatewayto the gateway's HOSTNAME, not its IP, and add an/etc/hostsentry on the primary so agents resolve it. Why: the gateway only recognizes itself as the gateway whenhost.teams_gateway == effectiveHostName()(os.Hostname()); with an IP it never self-identifies, soapplynever rendersTEAMS_AGENTSthere. Agents dialws://<hostname>:3978/register. - Gateway
apply+ restartagentctl-teams-gateway: rendersTEAMS_AGENTSfrom the now-shared ledger; agents that were retrying/registerconnect immediately.
Troubleshooting. teams-gateway status showing agents: 0 in the ledger means
the gateway hasn't adopted the shared ledger (step 3), or host.teams_gateway is
set to an IP rather than the hostname (step 7).
Capability setup (not a channel)¶
Google Workspace (google-workspace MCP + login google) is a capability an agent is
wired into on any channel, not a channel of its own. It needs GOOGLE_CLIENT_ID +
GOOGLE_CLIENT_SECRET in the secret store and a one-time consent-screen setup in the Cloud
Console, most of which has no CLI.
The scopes, the 6 APIs, and the console steps¶
Secrets:
GOOGLE_CLIENT_ID (…apps.googleusercontent.com), GOOGLE_CLIENT_SECRET.
The consent screen requests 8 scopes → you enable 6 APIs:
gmail.modify · gmail.send · drive · drive.file · calendar · documents ·
spreadsheets · presentations ⇒ enable gmail · drive · calendar-json · docs ·
sheets · slides googleapis.
- Click (Cloud Console): create/select a project → APIs & Services → Enable those
6 → OAuth consent screen → User type Internal (your Workspace org, e.g.
ajil.ch) — Internal ⇒ no verification and no 7-day refresh-token expiry →
Credentials → Create OAuth client ID → type Web application (for the broker;
or Desktop for loopback) → Web only: add Authorized redirect URI
https://<BROKER_URL>/oauth/callback → download JSON (has the "web" key).
- CLI (gcloud): gcloud projects create agentctl-fleet ·
gcloud services enable gmail.googleapis.com drive.googleapis.com
calendar-json.googleapis.com docs.googleapis.com sheets.googleapis.com
slides.googleapis.com --project agentctl-fleet. Console-only caveat: the
consent screen (Internal) and the OAuth client ID have no gcloud/API:
gcloud does the project + API enablement, the rest is the clicks above. Then:
secret set GOOGLE_CLIENT_ID · secret set GOOGLE_CLIENT_SECRET ·
mcp add google-workspace <agent> · login google <agent>|self.