Architecture
Synced from
channels/docs/architecture.md. The repository is the source of truth.
The Channels extension turns external activity into trusted, body-free wakes for one resident Pi session. Transport sources detect activity; channel adapters read and act on the untrusted content after the agent wakes.
The A2A task plane is specified in A2A task plane; its per-source contracts live in the source conformance matrix.
The native agent-to-agent and operator-to-agent plane is specified separately in Agent Session Gateway. It reuses this extension’s body-free wake and channel-tool boundary while keeping chat distinct from session observation, control, and hosted workload lifecycle.
Boundaries
Section titled “Boundaries”There are three layers:
- A source detects work and accepts a content-addressed activation through its required task sink.
- The channel tools give the agent one stable read/respond interface.
- An adapter translates that interface to Slack, Chatto, Mattermost, Zulip, JMAP, Signal, GitHub, or a future transport.
The agent should not need SDK-specific skills, raw HTTP recipes, credentials, or JSON response parsing. A channel skill teaches workflow and policy only.
Agent-facing tools
Section titled “Agent-facing tools”The extension registers two high-level tools:
channel_read({ locator })channel_respond({ locator, response })channel_read returns bounded context with one target message and a handled
flag. channel_respond returns whether the reply was submitted, whether the
source item was marked handled, an optional response id, and an optional warning.
The readable tool output marks all fetched content as untrusted; the same result
is also available as structured tool details.
The contract deliberately accepts plain text only. Attachments, interactive
controls, rich blocks, edits, and approved WhatsApp templates have materially
different capabilities and policy constraints. Add them later as explicit,
typed capabilities instead of a transport-specific options bag.
This is a conversational response API, not a universal channel-action API.
GitHub approvals, message edits, attachment uploads, and WhatsApp template sends
need separate typed capabilities. If a workflow needs to close an item without
replying, add a channel_complete operation when that use case is implemented;
do not overload channel_respond with an empty response.
Locators
Section titled “Locators”A locator is a self-contained, opaque, versioned string:
<adapter>:v<version>:<base64url-adapter-payload>For example, Slack emits slack:v1:.... Core code reads only the adapter and
version prefix. Only the owning adapter may decode and validate the payload.
Callers pass the full locator through unchanged.
A self-contained locator avoids mutable process-local lookup state. Its adapter can evolve the payload without exposing native ids through the common interface. Locators contain structural identifiers only, never sender-controlled content such as message bodies, subjects, or filenames.
Adapter responsibilities
Section titled “Adapter responsibilities”Every action adapter owns:
- locator decoding, versioning, and native identifier validation;
- authenticated context retrieval and pagination;
- selection of bounded context and the exact target;
- native reply addressing, such as a Slack thread or quoted Signal message;
- native handled-state behavior and duplicate-suppression strategy;
- service-specific limits, retry policy, and actionable errors.
The common tools own:
- tool schemas and agent guidance;
- routing from locator prefix to a lazily loaded adapter;
- consistent untrusted-content markers;
- the invariant that a partial result (
replied: true,handled: false) must not cause a duplicate reply.
Slack, Chatto, Mattermost, Zulip, and the agent relay implement exact-item action adapters. Signal stores its exact receive envelope in Task history before the CLI stream advances; sending is performed by an external skill, not an in-repo responder. JMAP and GitHub expose exact Task locators to their existing operations without channel action adapters.
Evaluation against real channels
Section titled “Evaluation against real channels”Every adapter exposes the same two tools while retaining its channel’s reply and handled-state semantics.
| Channel | Recommended library/API | Read mapping | Respond and handled mapping | Boundary implication |
|---|---|---|---|---|
| Slack | Official @slack/socket-mode and @slack/web-api |
conversations.history for a top-level mention; paginated conversations.replies for a thread |
chat.postMessage into the thread, then reactions.add on the target |
A reply can succeed while the handled reaction fails, so the result needs both states. |
| Chatto | Generated ConnectRPC clients pinned to a specific schema, plus the realtime WebSocket protocol | Validate the notification, then use room or thread events-around RPCs | Create a message in a new or existing thread, then dismiss the exact notification | Notification state, not message state, is the duplicate-suppression boundary. Protocol v2 is pinned because Chatto is pre-1.0. |
| Mattermost | Native WebSocket plus the Mattermost REST API | Fetch the exact post, then bounded channel history or thread posts | Create a post at the correct root, then add white_check_mark to the target |
WebSocket mentions is recipient-scoped; reply and reaction remain separate operations. |
| Zulip | Direct use of realtime event queues and REST | Fetch the exact message, then a bounded channel/topic or DM narrow | Send to the same topic or DM recipients, then react to the target | Channel allowlists do not suppress DMs. REACTION_ALREADY_EXISTS is already handled, while other failures produce partial success. |
| JMAP mail | Direct fetch today; jmap-jam is a typed candidate for the adapter |
One bounded Email/get for the task locator’s exact account and email ID |
Email/set plus EmailSubmission/set, with onSuccessUpdateEmail moving the submitted draft to Sent |
A deterministic delivery header supports exact non-draft Email/query lookup after a crash; a matching draft is reused on retry. Reply response IDs identify the outbound email. Creation IDs are request-scoped, so they are distinct per object and are not treated as cross-request idempotency keys. |
| Signal | signal-cli JSON-RPC daemon |
Persist each receive notification in an adapter-owned durable inbox before emitting its locator | send with quote timestamp/author; receipts are available but there is no universal durable “handled by this bot” marker |
A discarded receive notification cannot be fetched later. The locator must resolve to durable adapter-owned state. |
| GitHub | Official octokit |
Resolve a notification subject to its issue, pull request, review, or comment context | Create a plain-text comment or review, then mark the notification thread read | Comment replies fit the common tool. Approval, request-changes, merge, and issue mutations need typed GitHub capabilities. Some token types cannot use every notifications endpoint. |
| WhatsApp Cloud API | Direct Meta Graph API initially; Meta’s former Node SDK is archived | Webhook payload plus app-owned conversation storage | Send /messages with context.message_id, then mark the message read |
Free-form replies are limited to Meta’s 24-hour customer-support window; approved template sends need a separate typed capability. |
Matrix timelines, Teams replyToId, and Discord message references also fit an
opaque locator plus read/respond pair, but read receipts and reply semantics
differ. Each adapter therefore owns handled state and reply addressing; the core
API does not define Slack-specific threads or reactions.
Source boundary
Section titled “Source boundary”Every channel implements the same ChannelSource contract:
- inspect environment variables without importing the channel implementation;
- dynamically import the source only when that channel is selected and configured;
- start the source during
session_start; - commit a native activation with stable identity, an opaque locator, and no sender-controlled prompt text;
- close the source during
session_shutdown.
Message bodies and other sender-controlled values never belong in a wake prompt. The action adapter fetches those values through an authenticated client and exposes them only inside the tool’s untrusted-content markers.
Dynamic-import convention
Section titled “Dynamic-import convention”extensions/index.ts owns a lightweight registry. Each registration has a
dependency-free configured() probe, an asynchronous load() for the source,
and, where implemented, loadActions() for the action adapter:
slack: { configured: () => Boolean(process.env.SLACK_APP_TOKEN || process.env.SLACK_BOT_TOKEN), async load() { const slack = await import("./sources/slack.ts"); const config = slack.slackConfigFromEnv(); return config ? slack.createSlackSource(config) : undefined; }, async loadActions() { const slack = await import("./sources/slack.ts"); const config = slack.slackActionsConfigFromEnv(); return config ? slack.createSlackActions(config) : undefined; },},| Channel | Configuration probe | Dynamically imported module |
|---|---|---|
| JMAP | any XIN_* credential exists |
sources/jmap.ts |
| Signal | SIGNAL_NUMBER or SIGNAL_CLI_CONFIG exists |
sources/signal.ts |
| GitHub | GITHUB_TOKEN exists |
sources/github.ts |
| Slack | SLACK_APP_TOKEN or SLACK_BOT_TOKEN exists |
sources/slack.ts |
| Chatto | any CHATTO_* adapter variable exists |
sources/chatto.ts |
| Mattermost | any MATTERMOST_* adapter variable exists |
sources/mattermost.ts |
| Zulip | any ZULIP_* adapter variable exists |
sources/zulip.ts |
The probe intentionally detects partial configuration so startup can log an
actionable incomplete-configuration error. Do not add static channel SDK imports
to extensions/index.ts; type-only imports are safe because TypeScript erases
them.
Dynamic imports avoid evaluating an unused channel SDK and isolate a channel-specific module failure from unrelated sources. They do not avoid installing declared dependencies. Separate published channel packages would be needed if install or image-size isolation becomes important.
Task wake serialization
Section titled “Task wake serialization”Every work-producing source commits through the task plane. Provider identity
deduplicates redelivery before a wake is queued. The durable queue offers one
body-free Task prompt at a time, marks that Task WORKING when Pi starts the
matching turn, and grants exactly that Task as active authority. No source,
protocol listener, or channel registry calls pi.sendUserMessage directly.
Slack implementation
Section titled “Slack implementation”Slack’s Socket Mode source listens on the SDK’s catch-all slack_event, first
acknowledges every envelope, and then emits a slack:v1 locator only for an
app_mention. Acknowledging before filtering prevents other subscribed event
types from being redelivered. The action adapter:
- validates and decodes the locator;
- fetches the exact top-level message or paginates the thread until it finds the exact mention;
- returns at most ten messages, retaining the thread root when applicable;
- posts one reply to the thread root;
- adds the configured handled reaction to the exact mention.
The handled reaction suppresses normal reprocessing after a successful reply,
but channel_respond is not transactionally idempotent. A process failure after
Slack accepts the reply but before the reaction succeeds can leave an uncertain
outcome. If the tool reports replied: true, callers must not retry the reply.
Authentication and initial Socket Mode connection run under the shared source
supervisor. Once connected, the Socket Mode SDK owns reconnection. A failed source
import, configuration, or startup is caught at the per-source boundary, so the
remaining selected channels still start. The action adapter caches a successful
auth.test identity, but evicts a failed authentication promise so a later tool
call can recover from a transient Slack or network failure.
Required configuration:
SLACK_APP_TOKEN=xapp-...SLACK_BOT_TOKEN=xoxb-...SLACK_CHANNEL_IDS=joinedThe app token requires connections:write. The bot requires
app_mentions:read, channels:history, chat:write, and reactions:write;
add groups:history for private channels. SLACK_CHANNEL_IDS defaults to
joined when omitted. Explicit channel IDs narrow event handling below the
channels the bot has joined; joined cannot be mixed with IDs.
Chatto, Mattermost, and Zulip implementations
Section titled “Chatto, Mattermost, and Zulip implementations”Chatto opens /api/realtime, negotiates protocol v2, subscribes to the
authenticated projection, and converts unseen pending mention items in
notifications_replace into chatto:v1 locators. The locator identifies the
notification, room, exact message event, and optional thread root. Reads
revalidate the notification and retrieve at most ten room/thread events.
Top-level mentions start a thread; threaded mentions stay in their thread.
Handled state is the exact notification’s dismissed state. The vendored schema
and compatibility boundary are recorded in
extensions/vendor/chatto/SCHEMA.md.
Mattermost authenticates its /api/v4/websocket connection with a bot token and
accepts posted events only when the server’s per-recipient mentions list
contains that bot. A mattermost:v1 locator identifies the exact post and
channel. REST reads return at most ten posts, retaining the thread root when
applicable. Replies use the existing root for a thread or the target post for a
new thread, then add white_check_mark to the target.
Zulip registers a message event queue and long-polls it until shutdown or
expiry. When Zulip returns BAD_EVENT_QUEUE_ID, the polling loop exits and the
supervisor registers a replacement queue. During teardown, the adapter attempts
to delete the allocated queue and ignores one the server already considers
expired. Channel messages require the bot’s mentioned flag and optional
numeric channel allowlist. Direct messages remain eligible. A zulip:v1
locator identifies the exact message and, for channel messages, its channel.
Reads use a bounded topic or DM narrow. Replies preserve the original topic or
DM recipient set, then add white_check_mark; an existing identical reaction
counts as handled.
If a reply succeeds but the item remains unhandled, the adapter returns
replied: true, handled: false. Callers must report that warning and must not
retry the reply automatically.