↑ subaud · local-first · MIT

Docs

Install channels, then point it at your servers.

Channels is a pi extension. Install it, declare the servers you want in a config file, and the session spawns each one when it starts. Writing a server of your own is a matter of speaking the protocol below, and the reference server is a working copy of the whole thing. For how one event travels through a session, see the guide.

install

Install with one command.

One command installs channels into pi. It stays quiet until a config file names a server, so nothing connects the moment it is installed.

$ pi install npm:channels.tools

configure

Declare your servers in a config file.

Channels reads ~/.pi/agent/channels.json for servers you want in every session, and <project>/.pi/channels.json for servers scoped to one project; a project entry wins over a global one when both use the same name. Nothing connects until one of these files exists.

{ "channelServers": {
    "muster": { "command": "muster", "args": ["channel"] },
    "file":   { "command": "node",   "args": ["examples/file-channel.js", "inbox.txt"] }
} }

Each entry is a {command, args, env, cwd} the session spawns as a subprocess, so no server is special-cased and a server is any command that speaks the protocol. Point an entry at muster channel or galley channel to connect a tool that already ships one, or at your own command to connect one you wrote.

the protocol

Newline-delimited JSON-RPC over stdio.

A channel server is a subprocess that speaks newline-delimited JSON-RPC 2.0 over stdio: one JSON object per line on stdin and stdout, with no Content-Length framing. It runs on your machine and talks only to the session that spawned it, so anything that can read stdin and write stdout can be one. The exchange has four steps.

1 · handshake The client sends initialize, and the server's result must declare capabilities.experimental["claude/channel"]. The result may also carry an instructions string, injected as guidance for the agent, and the tools the server exposes. A server that does not declare the capability is rejected.
2 · events The server pushes notifications/claude/channel with freeform text and optional meta attributes such as count, thread_id, and from. The client may coalesce pushes, so a server that has already combined several events reports how many one push stands for in meta.count.
3 · tools A server may expose tools, each a name, a description, and a JSON-schema input. The client registers them with the agent under their own names and proxies every invocation back to the server through tools/call.
4 · shutdown The client sends SIGTERM when the session ends, and SIGKILL after a grace period.

Here is that whole exchange on the wire, one JSON object per line — → marks a line the client writes and ← a line the server writes back:

→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}
← {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"experimental":{"claude/channel":{}}},"instructions":"Lines appended to the watched file arrive as channel events."}}
← {"jsonrpc":"2.0","method":"notifications/claude/channel","params":{"content":"deploy finished: api v2.4.1 is live","meta":{"count":1}}}
← {"jsonrpc":"2.0","method":"notifications/claude/channel","params":{"content":"tests passed: 214/214\nreview approved: PR #87","meta":{"count":2}}}
→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"file_channel_status"}}
← {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"watching inbox.txt; 3 line(s) delivered"}]}}

reference server

A working server in about sixty lines.

examples/file-channel.js implements every step above: it watches a file, pushes a channel notification with the contents when the file changes, and registers one tool. Append a line to the watched file and an idle session wakes with it.

The heart of it is three handlers — declare the capability on initialize, push a notification when the watched file grows, and answer the agent's proxied tools/call:

const CAPABILITY = "claude/channel";
const NOTIFICATION = "notifications/claude/channel";

// handshake — declare the capability, with the guidance and tools the agent gets
if (msg.method === "initialize") {
  send({ id: msg.id, result: {
    capabilities: { experimental: { [CAPABILITY]: {} } },
    instructions: `Lines appended to ${watchedPath} arrive as channel events.`,
  } });
  watchFile(watchedPath, { interval: 500 }, drainNewLines);
}

// events — push each new line into the session as one notification
send({ method: NOTIFICATION, params: {
  content: lines.join("\n"),
  meta: { count: lines.length, source_path: watchedPath },
} });

// tools — answer the agent when it calls the registered tool
if (msg.params?.name === "file_channel_status") {
  send({ id: msg.id, result: {
    content: [{ type: "text", text: `watching ${watchedPath}; ${delivered} delivered` }],
  } });
}