Channels¶
The cockpit (webview) is the default channel and needs no external platform. A chat
channel is an addition — one owner transport per agent, set with --channel, with the
cockpit 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) |
Quick start stands up webview.
Switching an agent's channel¶
Switching is a ledger scalar, not a rebuild:
agentctl agents set <agent> channel webview # needs user_email set first
agentctl apply <agent>
--channel webviewmakes the cockpit the only surface, so it requires--user-email: the gateway has no other way to route to the agent.--user-emailon any other channel is additive — that agent gets a cockpit too.- Each integration refuses to wire until its secrets are set. The refusal prints the exact
secret setline, with the value format (e.g.--value <xoxb-…>).
How the agent talks to its channel¶
Something has to hand each turn to the model and stream the answer back. Which process does that is the one thing the two runtimes do differently — everything else on this page (the channels, the routing keys, the lanes below) is identical either way.
| runtime | the process | how a turn flows |
|---|---|---|
| pi (default) | pi-agent@<name>.service → pi-run.sh → bridge.ts |
the bridge is the agent: it embeds the pi SDK and owns the channel directly. No PTY, no subprocess |
| claude | agent@<name>.service → start-agent.sh → the channel shim |
the shim spawns claude and drives it over stdio (see TXT mode below) |
Both speak the same WebSocket frame protocol to the gateways
(runtime/channel/protocol.ts + runtime/channel/shared/), which is why a Slack or
Teams gateway serves either runtime without knowing which it is talking to.
pi: the bridge is the agent¶
One long-lived, disk-persisted session per agent. The turn loop talks to a Channel
interface, and PI_CHANNEL (rendered by apply) picks the implementation:
PI_CHANNEL |
implementation |
|---|---|
telegram |
TelegramChannel — grammy long-poll, in-process. Lazily imported, so a non-Telegram agent never installs grammy |
teams · slack · webview |
SocketChannel — an outbound WS dial to the shared gateway |
The turn loop is identical across all four. A non-webview primary additionally gets the
cockpit as an additive lane when the host runs a gateway, fanned out to both.
There is no TXT mode on pi and nothing to switch: with no claude process to drive,
there is no second transport to choose between. AGENTCTL_TXT_MODE is not read anywhere
in the pi runtime.
TXT mode (claude only)¶
On the claude runtime something must still hand each turn to claude itself. TXT mode is
now the only way that happens: the shim spawns claude -p --input-format stream-json,
writes turns to its stdin and streams stdout deltas back, and depends on nothing outside
agentctl.
Both launchers exec the shim unconditionally. AGENTCTL_TXT_MODE has no second transport
to select: unset or truthy is the working configuration, and the shim reads the flag only to
tell its two roles apart — the TXT driver that owns claude's stdin, and the MCP child the
telegram catalog MCP launches.
A falsy AGENTCTL_TXT_MODE leaves the agent mute
The shim stops spawning claude and hands each turn to an MCP notification nothing
consumes. Inbound messages keep arriving and nothing answers them.
A subsession inherits its parent's value explicitly, because systemd units inherit nothing from each other.
Upgrading. A running agent keeps its old transport until it is restarted, and apply
only starts the unit — a no-op on one already running. Restarting ends every agent's
current session:
agentctl agents restart all
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 |
the authoritative routing key; gateways key their routing table on (agent, session) |
CHANNEL_SESSION (env) |
read by a channel process, which stamps its own lane onto every outbound frame |
"" |
the primary lane |
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.
Adding a chat channel¶
| channel | install | secrets |
|---|---|---|
| webview | webview-gateway install · webview-login install, both behind your HTTPS proxy — see the cockpit |
WEBVIEW_HMAC_KEY + your auth driver's config |
| telegram | none; each agent runs its own poller | the per-agent bot token, passed at add time |
| slack | slack-gateway install (Socket Mode, no public URL) |
SLACK_BOT_TOKEN (xoxb-…), SLACK_APP_TOKEN (xapp-…) |
| teams | teams-gateway install (needs a public messaging endpoint) |
TEAMS_APP_ID (GUID), TEAMS_APP_PASSWORD, TEAMS_APP_TENANT_ID (GUID) |
Telegram, Slack and Microsoft Teams (click-through + CLI)¶
Telegram.
- Click: DM @BotFather → /newbot → copy the token.
- CLI: agentctl agents add <name> --channel telegram --bot-token <token>. --channel
telegram is required — webview is the default channel, so the token alone is no
longer 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.
- 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, reactions:write, users:read, users:read.email,
files:read → 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.
- 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 … · az bot msteams
create …. Then the three secret set … + teams-gateway install.
► TENANT: Teams is the one channel whose transport lives in your own Azure
tenant. The full sequence, the trust boundary and the cross-host runbook are in
The Teams gateway.
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.