Skip to content

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.js

package.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.

json
{
  "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

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

KeyMeaning
idUnique across all loaded packages. A clash is rejected at load time.
labelShown in settings.
blurbOne line under the name.
fieldsGenerates 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

KeyMeaning
keyProperty name in ctx.config. Must be a valid identifier.
labelShown above the input.
secretStored server-side, never sent to the browser.
requiredThe channel cannot be saved without it.
hintSmall print under the input.
placeholderPlaceholder 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
configThe 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.
signalAbortSignal, 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.

js
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:

PackageKeyWhy
Telegramchat:<chat id>A DM and a group have different chat ids, so they separate for free.
Slackchannel:<channel>Per channel, not per thread — a thread is a digression inside one conversation.
Discordchannel:<channel id>A DM is a channel too, so servers and DMs separate for free.
Webhookwhatever the caller sends, else defaultOnly 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:

js
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.ts

What 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:

js
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:

js
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:

js
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/repo

Or via the API:

bash
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 install runs 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 telegram and 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. ask without meta.session throws rather than quietly lumping everything into one conversation.
  • Editing configuration restarts your channel. stop() is called and start() runs again with the new values, so do not hold state that matters outside them.
  • ask waits 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:

js
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.

Give it a task, close the browser, come back later.