AtlatestRepositorycourier
1# Courier
2
3Chat and notification channel server for Claude Code sessions, built with Sigil.
4
5## Architecture
6
7Courier is an MCP channel server that bridges chat messages (Telegram),
8desktop notifications, and relay connections (Unix sockets) into a running
9Claude Code session via the `claude/channel` protocol.
11Courier operates in three modes:
12- **Leader mode** (default): Telegram + relays + notifications
13- **Worker mode** (`--relay <name>`): relay-only, no Telegram
14- **Poller mode** (`--telegram-poller`): internal — the leader spawns
15 itself in this mode as a supervised child that does the actual
16 Telegram polling, so a wedged poll can never stall MCP servicing
18```
19courier/
20├── package.sgl # Package definition
21├── dev-redirects.sgl # Points deps at local sigil
22├── .env-courier # Telegram token + chat ID (gitignored)
23├── courier.yaml # Allowed senders (optional)
24├── src/courier/
25│ ├── main.sgl # Entry point: channel MCP server
26│ ├── config.sgl # Config loading (env + YAML)
27│ ├── telegram.sgl # send-message / send-media tools
28│ ├── poller.sgl # Telegram poller child + supervisor + watchdog
29│ ├── relay.sgl # Unix socket relay communication
30│ └── notify.sgl # Desktop notification tool (notify-send)
31└── test/
32 └── test-config.sgl
33```
35## Configuration
37Secrets via environment (`.env-courier`):
38```
39COURIER_TELEGRAM_TOKEN=<bot-token-from-botfather>
40COURIER_TELEGRAM_CHAT_ID=<chat-id-for-replies>
41```
43Relay socket directory (optional, defaults to `~/.courier/relays/`):
44```
45COURIER_RELAY_DIR=/path/to/relay/sockets
46```
48Sender gating via `courier.yaml`:
49```yaml
50allowed-senders:
51 - "123456789"
52```
54## Dependencies
56- **sigil-mcp** — MCP server framework (channel notifications)
57- **sigil-socket** — Unix domain socket support (relays)
58- **sigil-telegram** — Telegram Bot API polling and sending
59- **sigil-yaml** — Config file parsing
60- **sigil-log** — Structured logging
62## Building
64Always build within a Guix shell:
66```bash
67guix shell -m ../sigil/manifest.scm -- env CC=gcc \
68 ../sigil/build/release/bin/sigil build --redirects dev-redirects.sgl
69```
71## Testing
73```bash
74guix shell -m ../sigil/manifest.scm -- env CC=gcc \
75 ../sigil/build/release/bin/sigil -L build/dev/lib test --redirects dev-redirects.sgl
76```
78## Running
80### Leader mode (default)
82Courier is designed to be spawned by Claude Code as a channel MCP server:
84```bash
85claude --channels server:courier
86```
88Register it in `.mcp.json`:
89```json
91 "mcpServers": {
92 "courier": {
93 "command": "./build/dev/bin/courier",
94 "env": {}
95 }
96 }
98```
100### Worker mode
102Workers connect to a relay created by the leader:
104```bash
105courier --relay <name> --log logs/courier.log --log-level trace
106```
108No `.env-courier` needed — workers don't use Telegram.
110## Relays
112Relays are Unix domain sockets for bidirectional leader-worker messaging.
113See `DESIGN-relays.md` for the full design.
115**Leader tools**: `create-relay`, `close-relay`, `list-relays`
117**Socket path**: `~/.courier/relays/<name>.sock` (configurable via `COURIER_RELAY_DIR`)
119**Wire protocol**: Newline-delimited JSON:
120```json
121{"text":"message content","sender":"leader"}
122{"text":"message content","sender":"worker"}
123```
125**send-message routing**: The `to` parameter routes by type:
126- Relay name → sends via Unix socket
127- Chat ID → sends via Telegram
128- `"leader"` (worker mode) → sends to leader via relay
130## Key Implementation Notes
132- YAML dict keys from `yaml-decode` are **keywords** — use `keyword->string`
133- Struct constructors match the struct name (not `make-` prefixed)
134- The MCP server loop runs inside `with-async` so the poller supervisor
135 and relay goroutines can interleave with stdin reads
136- `channel-notify!` from `(sigil mcp channel)` sends channel events
137- `channel-wait-initialized!` waits for MCP handshake before polling/reading
138- Telegram polling happens in a **separate child process** (see
139 `poller.sgl`): the underlying TLS reads are blocking natives with no
140 timeout, and a blackholed connection would otherwise freeze the
141 single-threaded scheduler — the t-5ebb alive-but-wedged failure. The
142 leader supervises the child over a stdout JSON event stream
143 (hello/heartbeat/message), drains its stderr into the leader log, and
144 a watchdog kills (SIGTERM + SIGKILL) and respawns it after 30s of
145 silence; the child exits when its stdin reaches EOF (leader gone)
146- `make-line-reader` from `(sigil socket)` handles buffered line reading for relays
147- `tcp-accept` works for Unix domain sockets (generic `accept()` on any socket fd)