learn / 02·11 — under nexus · channels

text your engineWITHOUTopening a door

A channel lets you message your engine — Telegram today — without the two things that make a bot scary. The token lives in the engine, never in the workload, and a stranger who finds your bot gets a pairing code, not your inbox. Config is topology: set one env var and the poller joins the supervision tree; unset it and the code is inert.

channels10 min read
A small figure speaking into a slot in a monumental sealed bright-green relay tower; their words pass through a glowing pairing gate while a guard mechanism stamps a six-symbol code — 1970s sci-fi style, bright, vast machine over tiny human

a bot is a public ADDRESS

You want to text your engine. Message my agent on Telegram — ask it for a deploy status from the bus, tell it to kick off a run. Every mainstream way to do that is scary in the same two ways, and they're worth naming precisely before we fix them.

First, the token. The standard recipe pastes a bot token into the code that does the talking — the workload holds the secret. That's the wrong owner. The workload is exactly the thing that fetches untrusted pages, runs generated code, and reads model output; give it the credential and a single leak or a prompt injection exfiltrates it. The nexus lesson made the abstract case that secrets stay in the engine — this page is that argument made concrete for messaging.

Second, the door. A bot is a public address. Anyone who finds it can speak into your system, and in the agent era that's not spam — it's an injection front door. An open inbox feeding an agent means any stranger can put text upstream of a worker that acts on text. The fix can't be hoping nobody finds the handle. It has to be structural: strangers can't reach the inbox at all.

the DEFINITION

chan·nel /ˈtʃæn·əl/ noun

1. a runtime capability module — an adapter that reports whether it's configured, sends outbound messages, and runs a supervised inbound poller — registered host-side. Its credential lives in the engine's environment, never in workbook or sandbox content.

The source positions it as the official-API tier — and that phrase carries weight. The Telegram adapter talks to the real Bot API; there is nothing reverse-engineered, no scraping a session out of a logged-in client. A channel is configured exactly when its adapter finds its credential, and only configured channels accept sends and start their pollers. The registry today holds a single entry: telegram. Adding another is a new module plus one line — we'll get there.

why it lives HOST-side

The whole design turns on one boundary you've met before — the Dock, the membrane between the engine and the workload it runs. A channel sits on the engine side of that membrane. The workload never holds the token and never makes the API call. It asks the engine to send; the engine decides whether to, and does it. This is the same posture as secrets-by-reference and the same family as browsing — both are runtime capabilities, not agent tools, for exactly this reason.

Put the two approaches side by side and the difference isn't ergonomic, it's blast radius:

where the token liveswho polls inboundblast radius of a workload compromise
a bot library in your appin the workload — in code that runs untrusted inputthe workload, holding the credentialtoken exfiltrated; the bot is now the attacker's
a channel in the enginehost-side env — never crosses the Docka supervised host process the workload can't seethe workload can request a send; it can't steal the token or read the wire

The route-block comment in the control plane says it plainly: a runtime capability, not an agent tool — creds stay runtime-side, and the control plane just lists, sends, and approves. The workload's reach ends at ask the engine to send this. It cannot become the bot.

config is TOPOLOGY

Here is the part that surprises people. configured?() doesn't just gate whether a send is allowed — it gates whether the inbound poller exists as a running process at all. The credential isn't read by a feature that's always present. The credential's presence is what puts the feature into the running system.

The entire feature flag is one conditional in the supervision tree:

# application.ex — each poller is opt-in by CREDENTIAL (the keeper pattern)
defp channels do
  if Workbooks.Channels.Telegram.configured?(),
    do: [Workbooks.Channels.Telegram],
    else: []
end

# telegram.ex
def configured?, do: token() not in [nil, ""]
def token, do: System.get_env("TELEGRAM_BOT_TOKEN")

Set TELEGRAM_BOT_TOKEN and the poller is in the child list when the engine boots — it joins the supervision tree, starts long-polling, and the routes answer for real. Unset it and that list is empty: the module is inert code, and the routes answer not configured. There is no half-on state, no dormant daemon waiting for a config read. Topology follows the environment.

flowchart TD
  boot["engine boots — building the child list"]
  q{"TELEGRAM_BOT_TOKEN set?"}
  boot --> q
  q -- "yes" --> on["poller joins the supervision tree
· long-polls Telegram
· routes send + approve for real"] q -- "no" --> off["module stays inert code
· no poller in the tree
· routes answer: not configured"] style boot fill:#fbfaf6,stroke:#121316 style on fill:#13d943,stroke:#121316,stroke-width:2.5px style off fill:#d9dbd3,stroke:#121316

This is a house idiom, not a one-off — call it the keeper pattern. Four of the engine's optional children are gated the same way at one line in the application: the keeper by its agent definition, the autopoet by WB_AUTOPOET=1, the groundskeeper by its secret, and channels by the bot token. In every case the rule is the same — unset the credential, the child is excluded. The credential is the feature flag.

the pairing CEREMONY

This is the centerpiece, so let's walk it whole. The DM policy is a pairing-code allowlist: default-deny, with a human in the loop. An unknown sender never reaches the inbox. They get a short code and get parked; a human approves; only then do their messages land.

A stranger messages the bot. The adapter checks the sender and finds them neither approved nor pending, so it mints a code — :crypto.strong_rand_bytes(4) Base32-encoded and cut to six characters, crypto-random, not a counter. It parks them in pending and replies, verbatim:

Workbooks pairing code: J5K2QA
Approve with: wb channels approve telegram J5K2QA

They message again. They get the same code — the check is idempotent: an already-pending peer keeps its existing code instead of minting a new one per message. Still nothing in the inbox. Now the operator runs the approve verb, which moves the peer into approved (de-duplicated) and deletes the code — a pairing code is single-use, consumed on approval. The next message from that peer, now allowed, appends one line to the inbox.

sequenceDiagram
  participant S as stranger
  participant B as the bot
  participant E as the engine
  participant O as operator
  S->>B: hello
  B->>E: check_sender — unknown
  E-->>B: mint J5K2QA, park in pending
  B-->>S: pairing code: J5K2QA
  S->>B: hello again
  B->>E: check_sender — already pending
  E-->>B: same code J5K2QA (idempotent)
  Note over E: still nothing in the inbox
  O->>E: wb channels approve telegram J5K2QA
  E-->>O: ok — peer 5598821, code consumed
  S->>B: deploy status?
  B->>E: check_sender — allowed
  Note over E: one line appended to the inbox
  

Two honest absences are worth stating, because their absence is by design, not oversight. There is no revoke verb — un-approving someone today means editing the JSON file. And there is no expiry or TTL on a pending code: it sits in pending until it's approved or you delete it. The ceremony is small on purpose, and these are the edges of small.

two files on DISK

Depth rung — skippable. The whole policy is two human-greppable files per channel, under <WB_DATA>/channels/. WB_DATA is tmp/data in dev and the mounted volume when deployed, so state is restart-proof either way — approve someone once and they stay approved across reboots.

fileshapewritten byread by
allow-<channel>.json{"approved": [peer], "pending": {code: peer}}check_sender (mint) · approve (move + delete)every inbound message, to decide allowed vs pairing
<channel>-inbox.jsonlone inbound message per lineinbox_append, on an allowed messagenothing yet — this slice's terminus

The allow-file is the policy made literal. Here it is before and after an approval — the pairing code crosses from pending to approved and the code itself disappears:

{"approved": [], "pending": {"J5K2QA": "5598821"}}
{"approved": ["5598821"], "pending": {}}

And one allowed message is one JSONL line. The Telegram record carries a second-resolution timestamp, the channel, the peer (a stringified chat id), the sender's username if any, the text, and Telegram's own update id:

{"ts": 1781234567, "channel": "telegram", "peer": "5598821", "from": "shane", "text": "deploy status?", "update_id": 9}

You can read your entire messaging state with cat. That's not a toy property — it's the same posture as everything else in this ecosystem: plain text you can inspect, diff, and edit by hand when a verb doesn't exist yet.

inside the POLLER

Depth rung — skippable. The Telegram adapter is one module wearing two hats: outbound it's a function that calls the Bot API's sendMessage; inbound it's a supervised, long-running GenServer that calls getUpdates in a loop. Long-poll, not webhooks — which means no inbound HTTP surface to expose. The engine reaches out and holds a connection open; nothing has to reach in. That's what lets a channel work behind a private control plane with nothing published to the internet.

The loop is a few deliberate numbers, and each one has a reason:

constantvaluewhy
server-side holdtimeout: 25getUpdates holds 25s waiting for a message — one request, not a busy poll
re-loop on success0 msgo straight back around; the request itself already held 25 seconds
backoff on error5,000 msa failed call waits before retrying — don't hammer a sick API
transport timeout35,000 msthe HTTP client must outlive the 25s hold, so 35 > 25 by design
offsetmax(update_id) + 1acknowledge what you got; on an empty batch fall back to offset − 1
flowchart TD
  poll["getUpdates — hold up to 25s"]
  got{"updates?"}
  poll --> got
  got -- "yes" --> handle["handle each: allowed → inbox · unknown → pairing reply · non-message → ignore"]
  handle --> off["offset = max(update_id) + 1"]
  off --> poll
  got -- "no / error" --> warn["log warning · wait 5s"]
  warn --> poll
  style poll fill:#13d943,stroke:#121316,stroke-width:2.5px
  style warn fill:#f3c5a3,stroke:#121316
  

Three details earn their place. The token is path material in the API URL, so the adapter is written to keep it out of logs — a leaked log line shouldn't be a leaked credential. Any exception inside the loop is rescued, so a malformed update or a transient fault never kills the poller — it warns, backs off, and goes again; the channel stays up. And non-message updates — edits, joins, the housekeeping Telegram sends — are simply ignored. The poller cares about messages and nothing else.

The transport is the built-in :httpc client, the same idiom the engine uses for its other outbound HTTP, and the HTTP layer is injectable — tests pass a stub so the whole suite runs with zero network and no real token. That's not incidental: it's how the pairing policy and the inbox are proven without ever touching Telegram.

driving IT

Two surfaces drive a channel, and both sit behind the engine's auth — the control plane is authed end to end (the public allowlist, a per-boot desktop token, a shared bearer secret, a JWT, or a dev fallback). The planes lesson is why /api/channels/* lives on the locked control plane and not on any public surface.

verbroutesuccessfailure
wb channels listGET /api/channels{"channels": [{"channel":"telegram","configured":true}]}
wb channels sendPOST /api/channels/send{"ok":true,"sent":{...}}unknown channel → 400 · not configured → unavailable
wb channels approvePOST /api/channels/approve{"ok":true,"peer":...}no pending code → 404

The CLI runs against the connected runtime over RCP — the same target resolution as wb rt: WB_RUNTIME_URL + WB_TOKEN if set, otherwise the local discovery file. The creds and the inbound pollers live there, on the engine, not in the CLI. A real session looks like this:

$ wb channels list
{"channels": [{"channel": "telegram", "configured": true}]}

$ wb channels send telegram 5598821 build green — shipped
{"ok": true, "sent": {"channel": "telegram", "peer": "5598821"}}

And the failure shapes are exactly as honest as you'd want. Send to a channel with no token and you get channel not configured: telegram; send to one that isn't in the registry — the test literally uses carrier-pigeon — and you get unknown channel with a 400. Two error atoms, two clear answers: the registry didn't know it, or the credential wasn't there.

where the slice ENDS

Honesty section, and it's a real boundary, not a hedge. This shipped as Channels slice 1, and the slice stops at the inbox. Approved messages land as JSONL lines — and nothing reads them yet. There's no get-inbox route, no consumer anywhere in the codebase. Routing the inbox to an agent session is deliberately not wired in this slice; the source says so in a comment. The inbox is, in its own words, this slice's terminus.

The rest of the honest list follows from being slice one. There is exactly one adapter — Telegram. The registry is a compile-time map, so adding a channel means a new module satisfying the three-function contract plus one line; it isn't pluggable at runtime. There's no revoke verb and no pending-code expiry, so the allow-file is edited by hand for those today. And the inbox JSONL grows unbounded — nothing rotates it. Long-poll, not webhooks, is a choice with a cost: one held connection per channel, in exchange for needing no public inbound surface.

Here's the frame that matters: the shape is finished; the routing is the frontier. The durable thing this page is selling is the shape — a capability on the engine side of the membrane, gated by its own credential, fronted by a default-deny allowlist with a human in the loop. That shape is done and proven. Where an approved message goes next — into a worker, through dispatch — is the part still being built, and we'd rather point at it than paper over it.

questions people actually ASK

Where does the token live — can a workbook read it?

In the engine's environment, host-side. No — it never crosses the Dock into the sandbox. The workload can ask the engine to send a message; it can't read the token or touch the wire. That's the entire point of making a channel a capability instead of a library you import.

Can anyone DM my bot?

Anyone can send to it — a bot handle is public. But an unknown sender only ever sees a six-character pairing code and a one-line instruction. Their messages don't reach the inbox until a human runs the approve verb. The allowlist is default-deny; the open handle is harmless because the door behind it isn't.

What about group chats?

A group has a chat id, and the policy keys on chat id — the peer is just the stringified chat id, group or direct. So the same pairing-then-approve flow applies to any chat. Nothing in the code distinguishes a group from a one-on-one, and whether groups are an intended use is genuinely open — the mechanism simply doesn't care which kind of chat a peer is.

How do I un-approve someone?

Today, by hand: edit allow-<channel>.json and remove them from approved. There's no revoke verb in this slice. The state is plain JSON under WB_DATA/channels/, so a one-line edit does it — but a proper verb is honest future work, not a shipped feature.

Why polling instead of webhooks?

Because a webhook needs a public inbound HTTP endpoint, and the whole point is to run behind a private control plane with nothing published. Long-poll reverses the direction: the engine holds a connection out to Telegram, so no stranger can reach in. The cost is one held connection per channel — a fair trade for needing zero public surface.

How do I add Slack, Discord, or anything else?

Write a module that satisfies the same three-function contract — configured?, an outbound send, and a supervised inbound poller — and add one line to the registry map. That's the whole extension surface. It's compile-time, so a new channel ships with the engine rather than loading at runtime, but the contract is small and the Telegram adapter is the worked example.

keep GOING

Channels are one capability among several behind the same membrane — start with the parent, then the siblings that share its posture.