AtlatestRenderedmarkdown
Readme

Courier

Chat and notification channel server for Claude Code sessions, built with Sigil.

Architecture

Courier is an MCP channel server that bridges chat messages (Telegram), desktop notifications, and relay connections (Unix sockets) into a running Claude Code session via the claude/channel protocol.

Courier operates in three modes:

  • Leader mode (default): Telegram + relays + notifications
  • Worker mode (--relay <name>): relay-only, no Telegram
  • Poller mode (--telegram-poller): internal — the leader spawns itself in this mode as a supervised child that does the actual Telegram polling, so a wedged poll can never stall MCP servicing
courier/
├── package.sgl              # Package definition
├── dev-redirects.sgl        # Points deps at local sigil
├── .env-courier             # Telegram token + chat ID (gitignored)
├── courier.yaml             # Allowed senders (optional)
├── src/courier/
│   ├── main.sgl             # Entry point: channel MCP server
│   ├── config.sgl           # Config loading (env + YAML)
│   ├── telegram.sgl         # send-message / send-media tools
│   ├── poller.sgl           # Telegram poller child + supervisor + watchdog
│   ├── relay.sgl            # Unix socket relay communication
│   └── notify.sgl           # Desktop notification tool (notify-send)
└── test/
    └── test-config.sgl

Configuration

Secrets via environment (.env-courier):

COURIER_TELEGRAM_TOKEN=<bot-token-from-botfather>
COURIER_TELEGRAM_CHAT_ID=<chat-id-for-replies>

Relay socket directory (optional, defaults to ~/.courier/relays/):

COURIER_RELAY_DIR=/path/to/relay/sockets

Sender gating via courier.yaml:

allowed-senders:
  - "123456789"

Dependencies

  • sigil-mcp — MCP server framework (channel notifications)
  • sigil-socket — Unix domain socket support (relays)
  • sigil-telegram — Telegram Bot API polling and sending
  • sigil-yaml — Config file parsing
  • sigil-log — Structured logging

Building

Always build within a Guix shell:

guix shell -m ../sigil/manifest.scm -- env CC=gcc \
  ../sigil/build/release/bin/sigil build --redirects dev-redirects.sgl

Testing

guix shell -m ../sigil/manifest.scm -- env CC=gcc \
  ../sigil/build/release/bin/sigil -L build/dev/lib test --redirects dev-redirects.sgl

Running

Leader mode (default)

Courier is designed to be spawned by Claude Code as a channel MCP server:

claude --channels server:courier

Register it in .mcp.json:

{
  "mcpServers": {
    "courier": {
      "command": "./build/dev/bin/courier",
      "env": {}
    }
  }
}

Worker mode

Workers connect to a relay created by the leader:

courier --relay <name> --log logs/courier.log --log-level trace

No .env-courier needed — workers don't use Telegram.

Relays

Relays are Unix domain sockets for bidirectional leader-worker messaging. See DESIGN-relays.md for the full design.

Leader tools: create-relay, close-relay, list-relays

Socket path: ~/.courier/relays/<name>.sock (configurable via COURIER_RELAY_DIR)

Wire protocol: Newline-delimited JSON:

{"text":"message content","sender":"leader"}
{"text":"message content","sender":"worker"}

send-message routing: The to parameter routes by type:

  • Relay name → sends via Unix socket
  • Chat ID → sends via Telegram
  • "leader" (worker mode) → sends to leader via relay

Key Implementation Notes

  • YAML dict keys from yaml-decode are keywords — use keyword->string
  • Struct constructors match the struct name (not make- prefixed)
  • The MCP server loop runs inside with-async so the poller supervisor and relay goroutines can interleave with stdin reads
  • channel-notify! from (sigil mcp channel) sends channel events
  • channel-wait-initialized! waits for MCP handshake before polling/reading
  • Telegram polling happens in a separate child process (see poller.sgl): the underlying TLS reads are blocking natives with no timeout, and a blackholed connection would otherwise freeze the single-threaded scheduler — the t-5ebb alive-but-wedged failure. The leader supervises the child over a stdout JSON event stream (hello/heartbeat/message), drains its stderr into the leader log, and a watchdog kills (SIGTERM + SIGKILL) and respawns it after 30s of silence; the child exits when its stdin reaches EOF (leader gone)
  • make-line-reader from (sigil socket) handles buffered line reading for relays
  • tcp-accept works for Unix domain sockets (generic accept() on any socket fd)