Writing a channel
A channel package is a normal npm package with a marker, a manifest, and a start() that owns its transport. Publish it to a GitHub repo and it installs straight into the portal.
The four packages in the repo's channels/ directory are reference implementations of exactly this format — read them alongside this page.
Layout
my-channel/
package.json
index.jspackage.json
The marker is what the loader looks for. Without it the package is ignored, so an ordinary dependency in the same directory is never mistaken for a channel.
{
"name": "pithagoras-channel-my-thing",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"pithagoras": { "channel": true }
}A name starting pithagoras-channel- also counts, but the explicit marker is clearer.
index.js
export const manifest = {
id: "my-thing",
label: "My Thing",
blurb: "One line shown under the name in settings.",
fields: [
{ key: "apiKey", label: "API key", secret: true, required: true },
{ key: "room", label: "Room", hint: "Optional" },
],
};
export async function start(ctx) {
// set up your transport here
return {
async stop() {
// release whatever start() acquired
},
};
}The manifest
| Key | Meaning |
|---|---|
id | Unique across all loaded packages. A clash is rejected at load time. |
label | Shown in settings. |
blurb | One line under the name. |
fields | Generates the configuration form. |
Validation is strict and the error is surfaced in the UI, so a typo shows up as a red row naming your package rather than a channel that silently never appears.
Fields
| Key | Meaning |
|---|---|
key | Property name in ctx.config. Must be a valid identifier. |
label | Shown above the input. |
secret | Stored server-side, never sent to the browser. |
required | The channel cannot be saved without it. |
hint | Small print under the input. |
placeholder | Placeholder text. |
Mark anything credential-shaped as secret. The browser is told only whether a value is set, and a blank secret on save means "keep the stored one" — so a user editing an unrelated field cannot wipe your token.
start(ctx)
Called once when the channel is enabled. Return an object with stop().
ctx | |
|---|---|
config | The configured values, secrets included. |
ask(text, meta) | Send to the agent; resolves with its reply. meta.session is required. |
log(message) | Surfaced in the channel's status. |
signal | AbortSignal, aborted when the channel is disabled or the portal shuts down. |
ask is the entire interface to the agent.
The session key
meta.session is required, and picking it well is your main design decision.
Each distinct key gets its own session — its own conversation, its own memory. Your job is to decide what counts as one conversation on your platform; the portal turns each key into an isolated session and keeps them apart.
const reply = await ctx.ask(text, {
session: `chat:${chatId}`, // one conversation
title: "Engineering", // human label, used the first time only
chatId, // anything else you need to route the reply
});Get it wrong in one direction and a group chat and your DM share a memory; get it wrong in the other and the agent forgets everything between messages. What the builtins settled on:
| Package | Key | Why |
|---|---|---|
| Telegram | chat:<chat id> | A DM and a group have different chat ids, so they separate for free. |
| Slack | channel:<channel> | Per channel, not per thread — a thread is a digression inside one conversation. |
| Discord | channel:<channel id> | A DM is a channel too, so servers and DMs separate for free. |
| Webhook | whatever the caller sends, else default | Only the caller knows what a conversation is. |
Your key is prefixed with the channel's slug before it is stored, so two channels both choosing general stay separate without either knowing about the other. Slugs are stable across a channel being deleted and recreated, which ids are not — see the note.
Relaying progress
A real task takes minutes and pi talks in stretches broken up by tool calls. Pass meta.onReply and each stretch is handed to you as it completes, so the chat shows the agent working instead of going silent:
await ctx.ask(text, {
session: `chat:${chatId}`,
onReply: (body) => send(chatId, body),
});You also get a line for every tool as it starts, so the chat shows what the agent is doing rather than only that it is busy:
⚙ bash · npm test
⚙ read · src/index.tsWhat actually reaches you is the channel's choice, not yours — Progress and Tool activity are toggles on the channel's page in settings. Your package passes onReply and the portal decides what to put through it.
When progress is on, ask resolves with "" — the prose has already been given to you, and returning it too would post everything twice. When it is off, only tool lines are relayed and ask resolves with the whole answer at the end. Both off and nothing is relayed at all. So the safe shape is: send everything onReply gives you, then send the return value if it is non-empty.
Interrupting
A message that is only stop, wait, cancel, abort, halt, hold on or nevermind aborts whatever the agent is doing and answers Stopped. It is checked before the queue, so it takes effect immediately rather than waiting behind the run it is trying to stop.
Matched on the whole message, so stop using the staging bucket is an instruction and reaches the agent intact.
Anything else in meta is yours — it travels with the message so you can route the reply back where it came from.
Honour ctx.signal. A polling loop should check signal.aborted and pass the signal to fetch, or disabling the channel will leave it running.
Do not await ask() in your receive loop
An extension command can block until somebody answers a question — and the answer is the next message, which your loop has to still be receiving. Await ask inside the loop that fetches messages and it can never arrive: the chat hangs until the dialog times out.
Hand each message to an async function and do not await it. Ordering is safe without you: the portal serialises messages per conversation, and an answer to an open question jumps that queue.
You do not handle the channel's instructions. They are configured in the portal and attached on its side of ask(), so your package gets the feature without doing anything.
Three shapes
Polling, from the Telegram package. Outbound only, which is what makes it work on a machine with no inbound route:
while (running && !ctx.signal.aborted) {
const updates = await getUpdates({ offset, timeout: 50 }, ctx.signal);
for (const update of updates) {
const reply = await ctx.ask(update.text, { chatId: update.chatId });
await send(update.chatId, reply);
}
}A socket, from the Slack and Discord packages. WebSocket is a global on Node 22, so this needs no dependency either. Reconnect on close unless you are being stopped, and honour whatever keepalive the service demands — Discord drops a connection that misses its heartbeats:
const connect = () => {
if (stopped || ctx.signal.aborted) return;
const socket = new WebSocket(url);
socket.addEventListener("message", async (frame) => {
const { text, channel } = parse(frame.data);
await send(channel, await ctx.ask(text, { channel }));
});
socket.addEventListener("close", () => {
if (!stopped && !ctx.signal.aborted) setTimeout(connect, 3000);
});
};Listening, from the webhook package. Needs a reachable port, and you should authenticate every request:
const server = createServer(async (req, res) => {
if (!validSecret(req)) return unauthorized(res);
const { message } = JSON.parse(await readBody(req));
const reply = await ctx.ask(message, { from: "webhook" });
res.end(JSON.stringify({ reply }));
});Installing yours
Push it to GitHub, then from Settings → Channels:
user/repoOr via the API:
curl -X POST http://portal:4100/api/channel-packages \
-H 'content-type: application/json' \
-d '{"spec":"user/repo"}'Anything npm understands works: user/repo, github:user/repo#v2, a git URL, an https tarball, or a published npm name. The loader picks it up immediately — a reinstall of the same version is cache-busted, so you do not need a restart to see a change.
Things to know
- Dependencies work.
npm installruns normally, so a package that needs a library gets one. Keep it light — it loads in the portal's process. - Ids must be unique. Two packages claiming
telegramand the second is rejected, named in the UI. - Uninstalling keeps configured channels. Removing a package does not discard credentials; those channels report the missing package until it is reinstalled or deleted.
- A key is mandatory.
askwithoutmeta.sessionthrows rather than quietly lumping everything into one conversation. - Editing configuration restarts your channel.
stop()is called andstart()runs again with the new values, so do not hold state that matters outside them. askwaits for the agent. It can take minutes on a real task; there is a 15 minute ceiling after which it rejects.
Speaking first, and asking natively
start() returns { stop }, and may return two more:
return {
async stop() { /* … */ },
// Optional. Send without being asked — a routine's report, an answer coming
// back later. A transport that can only fill in a response it already has
// open, like a webhook without a callback URL, omits this.
//
// options, when present, are one-tap replies: each carries exactly the
// message that would have been typed. Draw them as buttons if your platform
// has buttons and ignore them if it does not — the text is the protocol
// either way.
async send(target, text, options) { /* [{ label, reply }] */ },
// Optional. Render a question the way this platform renders questions, and
// capture the answer.
async prompt(target, request) {
// request: { id, method: "select" | "confirm" | "input", question, options? }
// return { value } or { cancelled: true }
// return null if this is not something you can present
},
};target is the conversation key your package supplied to ctx.ask — the portal scopes it internally, but hands it back to you as you gave it.
Why prompt returns null
Plain numbered text is the default, not the failure case. The portal asks in words whenever a channel has no prompt, and whenever prompt returns null for a particular question. A free-text question has no keyboard to draw, so returning null there is the correct answer, not a limitation.
The Telegram package shows the shape: an inline keyboard for select and confirm, null for anything else. Because Telegram caps callback_data at 64 bytes, the button carries an index rather than the option itself, and the message is edited afterwards to show what was chosen — so the chat reads as a record of the decision rather than a question nobody answered.
When a channel cannot be spoken to
Omitting send is not a dead end. A message meant for that conversation is held and goes out with the reply to whatever is said next, so an answer is late rather than lost. The portal reports which happened, and tells whoever is waiting.
A webhook can opt into being spoken to by taking a callback URL: the portal POSTs {session, message} there, and the conversation becomes two-way.
Identity
Pass from: { id, name } on ctx.ask — the platform's own id, never a display name. Without it every message is anonymous, which means it cannot be attributed to anybody in the roster and will be refused once a primary user is named.
A webhook can pin its identity in config instead, so the secret is one person's credential rather than a licence to claim any name.
