Skip to content

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_TOKENOAuth & 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_PASSWORDAzure 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 statusagents: 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.

  1. 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 runs apply it would provision + start every agent a second time: two Telegram pollers per bot → 409 conflict storms. There is no CLI to set run_on on an existing agent today (agents set rejects it; agents add only auto-pins brand-new agents to os.Hostname()), so this is a direct ledger edit for now. (Rough edge: worth a future agents set run_on / agents pin verb.)
  2. 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.
  3. Point the gateway's /opt/agentctl at the shared remote and adopt it. agentctl fleet adopt fetches + hard-resets to the remote's default branch and self-registers the host's age recipient.
  4. Caveat (older agentctl): fleet adopt hard-coded main, so on a master-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).
  5. 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).
  6. Gateway apply: self-registers its age recipient into host_keys, skips the primary-pinned agents (assigned to another host), and pushes.
  7. 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.)
  8. 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).
  9. Set host.teams_gateway to the gateway's HOSTNAME, not its IP, and add an /etc/hosts entry on the primary so agents resolve it. Why: the gateway only recognizes itself as the gateway when host.teams_gateway == effectiveHostName() (os.Hostname()); with an IP it never self-identifies, so apply never renders TEAMS_AGENTS there. Agents dial ws://<hostname>:3978/register.
  10. Gateway apply + restart agentctl-teams-gateway: renders TEAMS_AGENTS from the now-shared ledger; agents that were retrying /register connect 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 expiryCredentials → 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.