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.sglConfiguration
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.sglTesting
guix shell -m ../sigil/manifest.scm -- env CC=gcc \
../sigil/build/release/bin/sigil -L build/dev/lib test --redirects dev-redirects.sglRunning
Leader mode (default)
Courier is designed to be spawned by Claude Code as a channel MCP server:
claude --channels server:courierRegister 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 traceNo .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-decodeare keywords — usekeyword->string - Struct constructors match the struct name (not
make-prefixed) - The MCP server loop runs inside
with-asyncso the poller supervisor and relay goroutines can interleave with stdin reads channel-notify!from(sigil mcp channel)sends channel eventschannel-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-readerfrom(sigil socket)handles buffered line reading for relaystcp-acceptworks for Unix domain sockets (genericaccept()on any socket fd)