@justfortytwo/telegram
v0.1.5
Published
Telegram channel adapter for fortytwo: a long-polling bridge that drives a headless `claude` turn loop per chat, surfaces gate approvals as inline keyboards, and gates inbound access behind a login/pairing binding flow.
Downloads
889
Maintainers
Readme
@justfortytwo/telegram
The Telegram channel adapter for fortytwo — a
standalone, always-on bridge that connects a Telegram bot to a headless
claude turn loop.
It does three jobs:
- Turn loop. Long-poll Telegram, and for each authorized inbound message
drive a headless
claude -p --output-format jsonturn (resuming the per-chat session), then transport the assistant's reply back. - Approval UX. When the safety gate defers an external/irreversible tool
call, the turn ends with
stop_reason: tool_deferred. The bridge surfaces an inline-keyboard approval card (Approve / Deny, plus "Allow _N_h" for exact Bash commands). The tap records the decision and resumes the turn so the one-shot allow fires. - Proactive wakes. A scheduler invokes
claude -pat fixed cron-like slots (daily briefing, open-thread sweeps, weekly learn-review).
Pairing / login binding model
The bot is locked down: an unbound sender that does not present a valid
/login gets no response at all. Access is earned through a
channel-agnostic challenge/verify pairing flow:
owner side: adapter.issueChallenge(owner) -> { code, ttl }
channel side: /login <code> -> verify(chatId, code) -> bindingissueChallenge(owner)mints a single-use, short-TTL code (default 6 digits, 5 minutes). The owner relays it out-of-band to the chat they want to bind./login <code>verifies the code and, on success, persists a binding(channelType, channelUserId) -> owner./logoutremoves the binding.- Every inbound message is authorized against persisted bindings ∪ the
optional
ALLOWED_CHAT_IDSbootstrap set. The bootstrap set lets the first owner chat reach the bridge before any binding exists; it is not required once bindings exist.
Pending challenges are persisted in the binding store (not in process
memory), so a code minted by one process — e.g. the fortytwo pair CLI — is
redeemable by the separately-running bridge. They are single-use and short-TTL.
The contract is intentionally channel-agnostic (ChannelAdapter in
src/adapter.ts) so a future Slack / email / SMS adapter implements the same
issueChallenge / verify shape.
Binding store
Authorization must work even when the memory package is absent, so the
binding store is self-owned and does not assume the @justfortytwo/memory
sqlite db exists:
SqliteBindingStore— default; a tinybetter-sqlite3db in its own file (state/telegram-bindings.dbby default) holding bindings + pending challenges, so a CLI-issued pairing code reaches the bridge.MemoryBindingStore— in-process Map, for tests / ephemeral use.BindingStore— the injectable interface. Supply a memory-backed implementation later without touching the login flow.
Cross-repo reconciliation: if fortytwo standardizes a shared identity table, swap the default store for a memory-backed
BindingStore. The login flow is unaffected.
Peer dependencies
This package orchestrates two peer packages — they are referenced, not bundled:
| Peer | Used for | Contract |
| --- | --- | --- |
| @justfortytwo/memory | Memory store, jobs, pending-decision records, embeddings | MEMORY_TOOL_CONTRACT_VERSION (tools mcp__fortytwo-memory__*) |
| @justfortytwo/gate | Bash exact-command allowlist behind "Allow _N_h"; provenance envelopes | POLICY_SCHEMA_VERSION |
Install them alongside this package:
npm install @justfortytwo/telegram @justfortytwo/memory @justfortytwo/gateThe memory data path is wired: the bridge opens + migrates the memory DB at
startup and persists each channel event to @justfortytwo/memory; recall
happens inside the headless claude turn via the Memory MCP tools. If the memory
peer is absent, the bridge degrades to channel-only (messages relay but are
not persisted) rather than refusing to start. The gate "Allow _N_h" path
still uses a local bash-allowlist fallback; wiring it to @justfortytwo/gate's
exact-command allowlist is the remaining seam.
Environment
| Var | Required | Meaning |
| --- | --- | --- |
| TELEGRAM_BOT_TOKEN | yes | Bot token. The bridge is the only long-poller on this bot. |
| ALLOWED_CHAT_IDS | bootstrap | Comma-separated chat IDs allowed before any binding exists. Optional once bindings exist. |
| CLAUDE_BIN | no | Path to the claude binary (default claude). |
| FORTYTWO_TURN_TIMEOUT | no | Hard cap (seconds) on a single turn so a stalled model can't wedge the bridge (default 300). |
| FORTYTWO_BASH_ALLOW_TTL_HOURS | no | Default TTL (hours) for "Allow _N_h" bash approvals (default 8). |
| TELEGRAM_BINDINGS_DB | no | Path to the self-owned bindings db (default state/telegram-bindings.db). |
| DB_PATH | no | Path to the memory DB the bridge opens/migrates at startup (default db/fortytwo.db, relative to FORTYTWO_ROOT). |
| EMBED_MODEL | no | When set, the bridge uses the Ollama embedder for memory (otherwise a deterministic fallback). |
| OLLAMA_BASE_URL | no | Base URL for the Ollama embedder (default http://localhost:11434; may be remote). |
| ASSISTANT_NAME / ASSISTANT_ACTOR / OWNER_ACTOR | no | Display name + journal actor labels. |
| FORTYTWO_ROOT | no | Working root for the headless claude process + state files. |
The historical
FORD_*names (FORD_ROOT,FORD_TURN_TIMEOUT,FORD_BASH_ALLOW_TTL_HOURS) are still honored as deprecated aliases — set theFORTYTWO_*equivalents in new deployments.
Proxy auth for the headless
claudesession is not read here — theclaudeCLI readsANTHROPIC_*from its own config.
Usage
Prerequisites / first run
- The bridge spawns the
claudebinary, so Claude Code'sclaudemust be on yourPATH(or pointCLAUDE_BINat it). - It loads
config/adapters.tomlfrom$FORTYTWO_ROOT/config/. - On first run it requires
TELEGRAM_BOT_TOKENand eitherALLOWED_CHAT_IDSor a pre-existing binding — with no authorized chat to reach, the bridge exits immediately rather than long-polling a bot nobody can talk to.
npm run build
node dist/bridge.jsRun it under a restart loop (tmux/systemd) so it survives transient errors and
crashes. Example launcher (adapted from the monolith's wakeup.sh):
set -a; source .env; set +a
while true; do
node node_modules/@justfortytwo/telegram/dist/bridge.js
echo "[telegram] bridge exited (rc=$?) — restarting in 3s"; sleep 3
doneLicense
MIT © 2026 Enrico Deleo
Created and maintained by Enrico Deleo.
