@0xmaxma/claude-gateway
v1.8.9
Published
Multi-agent gateway for Claude
Readme
Claude Gateway
A self-hosted multi-agent gateway for Claude Code — with agents that improve themselves, manage their own memory through nightly dreaming, and build a searchable knowledge base from what they learn.
Features
- 🧠 Skill self-improvement — agents learn reusable skills from their own work: after a substantive turn a background reviewer creates or updates a skill, hot-reloaded for the next turn. Provenance-guarded (never overwrites human-written skills), capped per day, and audited to
SKILLS_LEARNED.md. Seegateway.skillLearning - 📚 Knowledge base (two-lane memory) — per-agent SQLite/FTS5 searchable archive exposed through
memory_search/memory_getMCP tools, so agents recall notes that don't fit the always-injected core; chunks carry fail-closed provenance and the index is refreshed off the gateway event loop. Seegateway.knowledge - 🌙 Nightly dreaming — background consolidation of long-term memory: a print-only reviewer proposes ops that a safe applier writes to
MEMORY.md/USER.md(backup, bounded-loss, net-negative when over budget). Deterministic compaction, budget-scaled pruning, and staleness GC keep memory near budget without forgetting — archived entries stay searchable. Seegateway.dreaming - 🤖 Multi-agent — run multiple bots from a single gateway, each with isolated sessions
- 🔌 Multi-channel MCP — modular tool system per channel (Telegram, Discord, LINE, Slack, Cron, Skills, extensible to more)
- 🧩 Agent skills — extensible skill system via SKILL.md files; agents can create, delete, and install skills from URLs at runtime with hot-reload
- 🎭 Agent identity — define personality, tone, and rules via workspace markdown files
- 📡 Live status messages — real-time status updates showing tool usage, thinking, and progress
- ⌨️ Typing indicators — continuous typing animation while the agent is working (Telegram and Discord)
- 🌊 Streaming API — SSE (Server-Sent Events) endpoint for real-time response streaming
- ↪️ Auto-forward — agent text output automatically forwarded to Telegram even without explicit reply tool calls
- ⏰ Heartbeat / scheduled tasks — cron-based proactive messages and recurring tasks via HEARTBEAT.md + REST API; agent jobs deliver output to Telegram, Discord, or both
- 💬 Persistent chat history — two-layer storage: session context (
.jsonl) + permanent SQLite DB with FTS5 full-text search; survives/compactand session eviction - 🧹 Auto-cleanup — configurable retention policy prunes messages and media files older than N days on a daily schedule
- 🗄️ Long-term memory — persistent memory system across sessions
- 🔄 Config auto-migration — automatic schema migration when config format changes
- 🔐 Access control — allowlist, open, or pairing-based Telegram access policies
- 🌐 HTTP API — REST API with key-based auth for external integrations
- 🛍️ App Store — install, update, and host Docker-compose apps on the gateway; apps get a reverse proxy at
/app/:name/:portName/*, optional Unix socket bridge for host scripts, and optional AI agent injection - ⬆️ Self-update — check for newer versions of
claude-gatewayandclaude-codeand trigger an update via a single API call (no SSH or shell access needed), or from the terminal withclaude-gateway update/claude-gateway claude update - 💾 Session persistence — conversation history saved and restored across restarts
- 🖥️ PTY shell (wrap-shell mode) — optional interactive pseudo-terminal backend (
gateway.headless: false) for tools that require a real TTY; includes a live browser viewer (xterm.js) and a/api/v1/sessions/:sessionId/screenendpoint that returns the visible screen as plain text — agents can poll it to detect hang states, menus, or unexpected output without parsing ANSI escape codes; a/clichat command (Telegram/Discord/LINE) opens the same viewer for a single agent, agent-scoped and without an admin key; app-agents always stay headless
Requirements
- Node.js 22+
- Claude Code CLI v2.1.0+ installed and authenticated —
channels modeis required (claude --version)- The gateway must be able to find the
claudeexecutable: either haveclaudeon thePATHof the process that launches the gateway, or setCLAUDE_BINto its full path. WhenCLAUDE_BINis unset, the gateway also probes the native-installer locations (~/.local/bin/claude, then~/.local/share/claude/versions/) and the legacy npm/nvm layout, so a Claude Code installer migration does not break new sessions. If none resolve, setCLAUDE_BINexplicitly (e.g.CLAUDE_BIN=~/.local/bin/claude).
- The gateway must be able to find the
- Bun — runs the MCP server subprocess (
mcp/server.ts) - A bot token per agent — Telegram (from @BotFather) or Discord (from Discord Developer Portal)
- PTY backend only (
claude.headless: false): native build tools required fornode-pty—gcc,python3, andnode-gypmust be available atnpm installtime (pre-built binaries are included for common platforms; build tools are only needed if a pre-built binary is unavailable for your platform)
Quick Start
Install via npm (for users)
1. Install
npm install -g @0xmaxma/claude-gatewayRequires Bun — MCP server dependencies are installed automatically via postinstall.
2. Configure environment (optional)
The gateway auto-loads ~/.claude-gateway/.env on startup:
mkdir -p ~/.claude-gateway
cat > ~/.claude-gateway/.env << 'EOF'
# HTTP port (default: 10850)
# PORT=10850
# Bind address (default: 0.0.0.0 — all interfaces)
# Set to 127.0.0.1 if a host-network reverse proxy (e.g. Traefik) is used
# GATEWAY_BIND=127.0.0.1
# Path to gateway config (default: ~/.claude-gateway/config.json)
# GATEWAY_CONFIG=~/.claude-gateway/config.json
EOFAll variables are optional. Full list: .env.example
3. Start
claude-gateway gateway startclaude-gateway on its own prints help — starting the server is always the explicit
gateway start, so a stray or mistyped command can never leave a gateway listening.
No config file needed — on first run, if ~/.claude-gateway/config.json doesn't exist yet, the gateway creates it automatically with "agents": [] and a fresh random admin API key, and prints that key once:
[gateway] No config found — created one at ~/.claude-gateway/config.json
[gateway] Admin API key (save this now — it will not be shown again):
[gateway] <random-hex-key>
[gateway] The CLI (claude-gateway agents create, etc.) picks this up automatically from ~/.claude-gateway/config.json.Save that key somewhere safe — it isn't shown again (though you can always read it back from config.json on disk). See config.template.json for the full config format (models list, more options) if you want to customize it by hand later.
4. Create an agent
claude-gateway agents createInteractive wizard — describe the agent, Claude generates the workspace files, review and accept them, then optionally connect a Telegram or Discord bot. Hot-reloads immediately, no restart needed. The CLI picks up the admin key from config.json automatically — no need to pass --key. (You can also add an agent entry to config.json by hand instead — same template link as above.)
Run as a service (optional)
To keep the gateway running after you log out or the machine reboots, let the CLI install the
service for you. It shows the exact unit it will write, asks before installing, and verifies
/health afterwards:
claude-gateway service install # systemd *user* unit — no sudo
claude-gateway service install --print # just show what it would install
claude-gateway service status
claude-gateway service uninstall # asks first — this stops a running gatewayInstall and uninstall both prompt before acting; pass --yes in scripts (without it, a
non-interactive run is refused rather than left hanging). Always stop the gateway through
service uninstall or systemctl --user stop claude-gateway.service — a bare kill <pid> bypasses
systemd's own stop tracking, so Restart=always brings it right back regardless of exit code.
install's exit code distinguishes three outcomes: 0 fully healthy, 1 install/enable itself
failed or a validation/confirmation gate refused (nothing was written), 2 install/enable
succeeded but /health never answered within the poll window. A script that only checks the exit
code — not the JSON result on stdout — can still tell "didn't happen" apart from "happened, health
unconfirmed" this way.
install also refuses (rather than just warning) if a claude-gateway.service unit already
exists and is enabled or active at system scope (e.g. one written by provisioning outside this
CLI) — installing a second, independent unit alongside it would race for the port on the next
reboot. It prints the exact sudo systemctl disable --now claude-gateway.service to resolve it;
pass --force to install anyway.
The systemd path writes ~/.config/systemd/user/claude-gateway.service. Run
loginctl enable-linger $USER once if it must keep running while you're logged out.
The unit sets OOMPolicy=continue so that an OOM-killed child process (e.g. a dev server an
agent spawned on its own) doesn't take the whole gateway down with it — only Restart=always
restarting the gateway's own process is intended. Re-running claude-gateway service install
against an already-active unit whose rendered content changed (a newer CLI version, or different
flags) automatically restarts it via systemctl ... restart, so the update takes effect
immediately; re-running with unchanged content leaves the running unit alone.
Prefer PM2? claude-gateway service install --manager pm2 registers
and saves the process instead (run pm2 startup separately for boot-time start).
System-scope installs (for automated/infra provisioning)
Pass --scope system to install a root-owned unit at /etc/systemd/system/claude-gateway.service
instead — for provisioning that needs the gateway to run under a fixed system account rather than
whoever happens to run the install interactively. It requires:
sudo claude-gateway service install --scope system --run-as gwuser --yes- The caller must already be root —
--scope systemnever escalates viasudoon its own, and refuses immediately if it isn't. --run-as <user>is required and becomes the unit'sUser=;WantedBy=ismulti-user.targetinstead ofdefault.target, so it starts at boot regardless of any login session (theloginctl enable-lingerhint is skipped — it's meaningless here).WorkingDirectory=/HOME=/the config path all resolve to--run-as's own home directory (looked up viagetent passwd, not the installing root process's home) — the unit runs as that user, so its paths must be theirs. A~/...in--config/--env-fileexpands against that same home.$GATEWAY_CONFIGfrom the installing (root) process's own environment is not consulted for a system-scope install — only an explicit--configis — since it belongs to root's environment, not--run-as's. If the user's~/.claude-gatewaydoesn't exist yet, the install creates it andchowns it to them; if it already exists but is owned by someone else (e.g. a prior install used a different--run-as), ownership is reassigned to match. An already-correctly-owned directory is left untouched. Refuses if--run-asdoesn't resolve to a real user on this host.- A system-scope install never refuses itself over the system-scope conflict check described above — that check exists to protect a user-scope install from colliding with an externally provisioned system-scope unit, and a system-scope install is that unit.
--after <target1,target2>,--env-file <path>, and--env KEY=VALUE[,KEY=VALUE...]further customize the generated unit (both scopes): extraAfter=ordering targets, anEnvironmentFile=-<path>for feeding secrets in without ever writing them into the unit text, and additional non-secretEnvironment=lines.--envrefuses to overrideHOME,PATH, orGATEWAY_CONFIG(the installer's own reserved names) — use--env-filefor anything sensitive.service status --scope systemandservice uninstall --scope systemwork the same way against the system-scope unit (uninstall also requires root).
Once installed, drive it through the CLI — it detects whichever manager owns the process:
claude-gateway gateway status # manager, URL, health
claude-gateway gateway restart
claude-gateway gateway stop
claude-gateway gateway logs # tail the gateway's own log (works even when it is dead)Managing PM2 directly still works too:
pm2 status # check gateway status
pm2 logs gateway # tail logs
pm2 restart gateway # restart
pm2 stop gateway # stop
pm2 delete gateway # remove from PM2For development
git clone https://github.com/0xMaxMa/claude-gateway
cd claude-gateway
npm install # also runs bun install in mcp/
npm run buildStart the gateway
npm startConfig is auto-loaded from ~/.claude-gateway/config.json — if it doesn't exist yet, npm start creates it automatically with "agents": [] and a fresh admin key (see the Start step in the npm-install path above). Bot tokens are auto-loaded from ~/.claude-gateway/agents/<id>/.env.
Create an agent
The interactive wizard handles everything — workspace files, bot token, and pairing:
claude-gateway agents createSteps:
- Choose an agent id and describe its role — Claude generates workspace files
- Review and accept the generated files
- Optionally connect a channel: Telegram or Discord — paste the bot token, wizard verifies it automatically
- Agent hot-reloads immediately — send any message to the bot, then approve pairing:
claude-gateway channels approve --agent <id> --channel telegram --code <code>
To manage an existing agent — regenerate AGENTS.md, or connect/update/disconnect Telegram, Discord, LINE, or Slack — run claude-gateway agents update.
Workspace Files
Each agent has a workspace directory with markdown files that define its behaviour:
| File | Required | Purpose |
|------|----------|---------|
| AGENTS.md | Yes | Core identity, rules, capabilities |
| IDENTITY.md | No | Agent name, emoji, avatar, personality identity |
| SOUL.md | No | Tone, personality, speaking style |
| USER.md | No | User profile and preferences |
| MEMORY.md | No | Long-term memory (auto-appended by the agent) |
| HEARTBEAT.md | No | Scheduled/proactive tasks |
| skills/ | No | Directory of SKILL.md files — agent-specific skills |
On startup (and on any file change), all files are assembled into CLAUDE.md which the Claude subprocess reads as its system prompt. Do not edit CLAUDE.md directly.
Configuration Reference
Config lives at ~/.claude-gateway/config.json (or set GATEWAY_CONFIG env var / --config flag).
{
"configVersion": "1.0.0",
"gateway": {
"logDir": "~/.claude-gateway/logs",
"logs": {
"level": "info",
"maxFileBytes": 16777216,
"maxFiles": 3,
"retentionDays": 14
},
"timezone": "Asia/Bangkok",
"api": {
"keys": [
{
"key": "${MY_API_KEY}",
"description": "Internal app",
"agents": ["alfred"]
},
{
"key": "${ADMIN_API_KEY}",
"description": "Admin",
"agents": "*"
}
]
}
},
"agents": [
{
"id": "alfred",
"description": "Personal assistant",
"workspace": "~/.claude-gateway/agents/alfred/workspace",
"env": "",
"session": {
"idleTimeoutMinutes": 30,
"maxConcurrent": 20
},
"telegram": {
"botToken": "${ALFRED_BOT_TOKEN}"
},
"claude": {
"model": "claude-sonnet-4-6",
"extraFlags": []
},
"heartbeat": {
"rateLimitMinutes": 30
}
}
]
}gateway.timezone (optional)
IANA timezone, default "UTC". Shared default for the per-feature scheduling
timezones below when they are unset or invalid: gateway.history.cleanupTimezone,
gateway.appBackup.cleanupTimezone, gateway.skillLearning.pruneTimezone,
gateway.dreaming.dreamTimezone, and gateway.knowledge.reflection.timezone. A
valid per-feature field still overrides this shared default for that one feature;
an invalid per-feature value falls through to gateway.timezone rather than being
treated as set. An invalid gateway.timezone itself falls back to "UTC" rather
than crashing that scheduler.
gateway.publicUrl (optional)
The externally reachable gateway base URL. Set it manually to enable short-lived
public file shares used by generate_image reference edits and share_file
(formerly share_image, which still works as a deprecated image-only alias).
The URL must end in /gateway; changing it requires a gateway restart.
{
"gateway": {
"publicUrl": "https://vm.example.com/gateway"
}
}Minted share URLs have the stable form
https://vm.example.com/gateway/shared/TOKEN. When publicUrl is set the mint
response includes this ready-built url; when it is unset the response still
returns the token (the share endpoint stays enabled) and callers with their own
public base — e.g. LINE, which derives its host from the inbound webhook — build
<base>/shared/<token> themselves. HTTP is accepted only for local development
hosts such as http://host.docker.internal:10850/gateway.
gateway.oauthReturnUrl (optional)
Where to send the browser after a connector OAuth sign-in finishes. The gateway is product-agnostic and never hardcodes a downstream app's domain, so this is opt-in.
{
"gateway": {
"oauthReturnUrl": "https://app.example.com/settings/connectors"
}
}Set, the callback issues a real 302 to it on every terminal outcome — success, and
also a denied, expired or failed sign-in, which carries ?connector_oauth_error=<code>.
Unset, the callback renders a plain "Connected — you can close this tab" page instead.
The value is validated once at startup: anything that isn't a well-formed http(s) URL
is logged and ignored rather than injecting a broken redirect into every future callback.
The scheme is part of that check — this value becomes the Location of a redirect sent
to the end user's own browser from a public route, so a javascript: or data: URL
here would be script running on every sign-in, and is refused like any other malformed
value.
gateway.customConnectors (optional)
User-pasted MCP connectors, keyed by a slugified id. Normally written through the API
(POST /api/v1/connectors/custom) rather than by hand.
{
"gateway": {
"customConnectors": {
"firecrawl": {
"label": "Firecrawl",
"config": {
"type": "streamable-http",
"url": "https://mcp.firecrawl.dev/v2/mcp-oauth",
"headers": { "Authorization": "Bearer {access_token}" }
},
"secretNames": ["access_token"],
"credentialOwner": "gateway"
}
}
}
}Each entry is raw mcpServers-entry JSON with {placeholder} tokens standing in for
secrets. credentialOwner records who holds the credential and keeps it valid — none,
static (a pasted value), gateway (this gateway ran the OAuth flow and refreshes the
token itself) or external (a control plane pushes tokens in). It is written by the
route that creates the entry; see API.md. Only the placeholder names are stored here — the values live in
~/.claude-gateway/mcp-token.env (mode 0600), namespaced
CUSTOM__<connectorId>__<placeholderName>, and are substituted in when a session spawns.
Override that file's path with GATEWAY_MCP_TOKEN_ENV_PATH.
Custom connectors are admin-trusted but not code-reviewed — the config is whatever
the admin pasted, and it becomes an MCP server in every agent's session. Per-agent
enablement is opt-out and lives on the agent instead (PATCH /api/v1/agents/:id with
connectors); connecting a connector at all is the security gate. See
API.md for the full model, the OAuth flow, and the refresh
behaviour.
gateway.connectorsDefaultEnabled (optional)
Whether a connected connector is available to an agent that has no explicit entry in its
own connectors map. Defaults to true — opt-out: connecting a connector makes it
available everywhere, and an agent only misses it if explicitly disabled.
{
"gateway": {
"connectorsDefaultEnabled": false
}
}Set it to false on a gateway that hosts agents for more than one person. The default
suits the common single-operator install, but with several owners it hands a credential
connected by one of them to every agent on the box — including agents whose chat users are
not that person. With false, each agent has to be opted in explicitly (PATCH
/api/v1/agents/:id with {"connectors": {"<id>": {"enabled": true}}}).
Changing this affects the next session spawn, like any other connector change.
gateway.logs (optional)
Verbosity, rotation and retention for the files in logDir. The whole block is optional —
omit it and the defaults below apply.
| Field | Default | Description |
|-------|---------|-------------|
| level | "info" | Minimum level written, to both the file and stdout. One of debug, info, warn, error |
| maxFileBytes | 16777216 (16 MiB) | Rotate <name>.log to <name>.log.1 once an append would carry it past this size |
| maxFiles | 3 | Rotated generations kept per stream; the oldest is deleted. Lowering it collects the generations it orphans at the next rotation. 0 = keep none |
| retentionDays | 14 | Delete logs (live and rotated) older than this, at boot and once a day. 0 = keep forever |
level is the one that governs disk usage. Session processes log every stream event at debug,
which on a live host measured 19,995 debug lines to 5 info lines inside a single 217 MB file —
so debug is off by default. Set "level": "debug" when you are actually chasing something, and
expect the directory to grow quickly while it is on. Rotation and retention bound what is kept;
only the level bounds what is written.
Retention is age-based because each session writes its own <agent>:session:<uuid>.log and never
returns to it — maxFiles prunes generations of one stream, so it can never reach them.
This block is hot-reloaded: edit it in config.json and it applies on the next config reload,
no restart. That matters because turning the level up is something you do while chasing a live
problem, and a restart would kill the sessions you are trying to observe.
session
| Field | Default | Description |
|-------|---------|-------------|
| idleTimeoutMinutes | 30 | Kill idle session subprocess after N minutes of inactivity. Inactivity means no incoming message and no subprocess output — a session actively producing output (e.g. a self-paced /loop) is not treated as idle |
| maxConcurrent | 20 | Max simultaneous active sessions per agent; oldest idle is evicted when exceeded |
gateway.history (optional)
Global default retention policy. Can be overridden per-agent with an history key inside the agent config.
{
"gateway": {
"history": {
"retentionDays": 90,
"maxHistoryMessages": 30,
"cleanupHour": 3,
"cleanupTimezone": "Asia/Bangkok"
}
}
}| Field | Default | Description |
|-------|---------|-------------|
| retentionDays | null (keep forever) | Delete messages older than N days on each cleanup cycle |
| maxHistoryMessages | 50 | Max history messages re-injected into a session at spawn. Lower it to shrink the context loaded at session start. 0 = inject no history |
| cleanupHour | 3 | Hour of day to run cleanup (24h, in cleanupTimezone) |
| cleanupTimezone | "UTC" | IANA timezone for the cleanup schedule; falls back to gateway.timezone when unset or invalid |
Per-agent override example:
{
"agents": [
{
"id": "alfred",
"history": { "retentionDays": 30, "maxHistoryMessages": 30 }
}
]
}dmPolicy
Access policy is configured per-channel in the agent's workspace state file, not in config.json:
| File | Path |
|------|------|
| Telegram | ~/.claude-gateway/agents/<id>/workspace/.telegram-state/access.json |
| Discord | ~/.claude-gateway/agents/<id>/workspace/.discord-state/access.json |
| Value | Behaviour |
|-------|-----------|
| allowlist | Only user IDs in allowFrom can DM the agent (default) |
| open | Anyone can DM the agent |
| pairing | New users DM the bot to receive a pairing code; approve with claude-gateway channels approve |
gateway.headless
Controls the Claude subprocess backend for all non-app agents.
| Value | Backend | Description |
|-------|---------|-------------|
| true (default) | Headless (--print) | Stateless invocation, lowest overhead |
| false | PTY shell wrapper | Interactive pseudo-terminal — full TUI support |
App-agents always run headless regardless of this setting.
--dangerously-skip-permissions is always injected by the gateway automatically — there is no per-agent config field for it.
In PTY mode that flag makes Claude Code open a "Bypass Permissions mode" confirmation dialog at startup, which the wrapper accepts on your behalf. How it is accepted depends on the Claude Code build: releases up to 2.1.247 render numbered options (1. No, exit / 2. Yes, I accept) and are accepted with the digit, while 2.1.248 and newer drop the numbers, so the wrapper walks the caret onto the accept row and only then presses Enter. If a future release changes the dialog beyond what the wrapper recognises, it deliberately sends no keystroke and leaves the dialog on screen rather than risk selecting "No, exit" (which would exit Claude Code) — set PTY_SHELL_SKIP_DIALOG_DISMISS=1 to turn the auto-accept off entirely.
{
"gateway": {
"headless": false
}
}This setting is hot-reloadable — new sessions pick it up without a restart.
gateway.selfHealing.autoRecover
Opt-in self-healing for the turn-trace watchdog (Epic #195). When a turn stalls, the gateway always detects it, logs a scrubbed incident, and notifies the affected chat. This flag additionally controls whether the gateway may act on a stall.
| Value | Behaviour |
|-------|-----------|
| false (default) | Detection + incident logging + notification only — no automatic action |
| true | The watchdog may run a whitelisted recovery for a stalled turn: a keystroke into the TUI (esc / enter / arrow / menu selection), a session restart, a reversible safe-mode fallback to the headless backend, and — after a successful unblock — a guarded resend of the last message (only if the turn produced no output, so it is never double-submitted) |
Recovery actions are clamped to a per-stage whitelist and a per-turn budget, and any local triage treats the on-screen text as untrusted data validated against a closed schema. Safe-mode auto-fallback on a hard PTY failure is independent of this flag (it is always reversible and never presses keys). In-memory only — a gateway restart re-reads your real config.
{
"gateway": {
"selfHealing": {
"autoRecover": true
}
}
}gateway.skillLearning
Controls skill self-improvement — agents learning reusable skills from their own work. Telemetry capture is always on; the reviewer/writer/curator honor enabled.
| Field | Default | Description |
|-------|---------|-------------|
| enabled | true | Master switch for the reviewer/writer/curator (telemetry is captured regardless) |
| mode | "auto" | auto writes skills directly; propose queues them for approval instead |
| minToolCalls | 5 | Minimum tool calls in a turn before it's eligible for review |
| reviewModel | claude-haiku-4-5-… | Model used for the background review pass |
| maxAutoSkills | 50 | Cap on the number of non-pinned origin: auto skills kept per agent (pinned skills are never evicted and don't count toward the cap) |
| maxAgeDays | 30 | Curator prunes auto-skills older than this (with too few uses) |
| minUsesToKeep | 2 | Auto-skills used fewer times than this are prune candidates |
| maxReviewsPerDay | 20 | Per-day cap on background review runs |
| pruneHour / pruneTimezone | 3 / UTC | When the daily curator runs; pruneTimezone falls back to gateway.timezone when unset or invalid |
| notify | true | Push a per-write ping to every configured channel (see notifications); the SKILLS_LEARNED.md diary is written regardless |
{
"gateway": {
"skillLearning": {
"enabled": true,
"mode": "auto",
"notify": true
}
}
}Per-agent overrides are supported under the agent's own skillLearning block; unset fields fall back to the gateway default.
gateway.memory
Memory budget discipline. Self-authored memory files (MEMORY.md, USER.md) that exceed a soft char budget get a loud over-budget banner prepended to their CLAUDE.md section at compose time — instead of a silent [TRUNCATED] — nudging the agent to consolidate. The banner reaches the agent on its next spawn (frozen-at-spawn, no restart) and self-heals once the file is back under budget. The banner lives only in the composed CLAUDE.md; the source file on disk is never rewritten with it.
| Field | Default | Description |
|-------|---------|-------------|
| memoryBudgetChars | 8000 | Soft budget for MEMORY.md (0 = disabled) |
| userBudgetChars | 3000 | Soft budget for USER.md (0 = disabled) |
| overBudget | "warn" | Banner severity: warn (⚠️) or error (🛑, stronger wording); an unknown value falls back to warn |
| writeRouting | true | Inject the two-tier write contract into the Memory Rule (MEMORY.md = durable facts; task-log → memory/<topic>.md) and let nightly dreaming route episodic ops out. false = kill-switch (exact pre-routing behavior) |
| episodicArchiveDir | "memory" | Workspace-relative dir episodic notes are written under (validated, path-traversal-guarded) |
{
"gateway": {
"memory": {
"memoryBudgetChars": 8000,
"userBudgetChars": 3000,
"overBudget": "warn",
"writeRouting": true,
"episodicArchiveDir": "memory"
}
}
}The soft budget sits well under the hard per-file limit (still applied as a context safety net); the banner is the primary over-budget signal for memory files.
Write routing (planning-65). MEMORY.md is injected into every prompt, so it should hold only durable semantic facts (preferences, standing rules, identity, lessons). Episodic task-log (completed work, PR/issue status, dated events) belongs in memory/<topic>.md — indexed and retrieved on demand via memory_search, never carried in-prompt. When writeRouting is on, the Memory Rule states this tier contract to the agent, and the nightly dreaming reviewer may emit tier:"episodic" ops that the applier appends to memory/<topic>.md (slug-validated + realpath-confined; a memory-only change ⇒ no session restart). To drain an existing over-budget MEMORY.md, run the one-shot migration node dist/agent/dreaming/migrate-cli.js <workspaceDir> [--apply] — a deterministic terminal sweep (compactor) plus a gated episodic route-out (propose writes .dreaming/migration-plan.md; --apply performs the moves). Pinned sections (## User, ## Feedback, ## Preferences) are never moved, and every relocated entry stays searchable via memory_search (recall preserved). planning-67: with gateway.dreaming.autoRouteOut on (the default), the nightly dream performs this same route-out automatically whenever MEMORY.md is over budget — no manual per-agent run — and every over-budget net-shrink remove now relocates its block to memory/archive/pruned.md (searchable) before cutting it, so no dream op ever silently forgets.
gateway.dreaming
Nightly memory dreaming — background consolidation of an agent's long-term memory. A print-only claude -p reviewer (no tools, no --dangerously-skip-permissions) reads a lookback window of the agent's own session transcripts and proposes memory-consolidation ops. In auto mode (the default) a safe applier writes the ops to MEMORY.md/USER.md (rollback pre-image first; ordered apply with anchor re-resolution; bounded-loss + append-only fallback; net-negative when over budget) — a memory-only change, so no session is restarted. In propose mode the proposals are written only to a DREAMS.md diary + JSONL audit under <workspace>/.dreaming/ — no memory file is modified (set mode: "propose" to keep this dry-run behavior).
| Field | Default | Description |
|-------|---------|-------------|
| enabled | true | Master switch (false ⇒ no scheduler, no run) |
| mode | "auto" | auto = apply ops via the safe applier (backup, bounded-loss, net-negative); propose = diary-only dry-run |
| dreamHour / dreamTimezone | 3 / UTC | When the nightly dream runs (invalid tz → gateway.timezone, then UTC); dreamTimezone falls back to gateway.timezone when unset or invalid |
| dreamMinute | 0 | Minute-of-hour the dream fires at, paired with dreamHour (0–59). Set with staggerWindowMinutes: 0 to fire at an exact HH:MM (e.g. for a controlled re-test) |
| quietMinutes | 30 | Skip a run if a session was active within this window |
| lookbackDays | 3 | How far back to scan sessions |
| maxChangesPerRun | 3 | Cap on proposed ops per run (0 ⇒ no-op) |
| reviewModel | claude-haiku-4-5-… | Cheap model for the reviewer |
| promotionThreshold / minRecallCount | 0.6 / 2 | Scoring thresholds for promoting a fact |
| autoRouteOut | true | planning-67: in auto mode, drain an over-budget MEMORY.md by routing its episodic task-log to memory/<topic>.md automatically each night (archive-safe, pinned excluded, idempotent) instead of a manual per-agent migrate-cli. false = kill-switch |
| staggerWindowMinutes | 30 | planning-68: spread agents' nightly runs across a window (a deterministic per-agent jitter is added to the delay) so they don't all fire at dreamHour:00 together. Clamped [0,55]; 0 = disabled (all fire at dreamHour:00) |
| staleness | (object) | Archive staleness GC sub-config (planning-66) — see below |
Per-agent overrides are supported under the agent's own dreaming block; unset fields fall back to the gateway default. enabled:false or maxChangesPerRun:0 makes a run a no-op.
⚠️ Upgrade note: the default
modefor bothgateway.dreamingandgateway.knowledge.sharedchanged frompropose(dry-run) toauto(configVersion 1.0.24). Once the K4 applier landed (backup + net-negative + bounded-loss + CAS + never-empty; memory-only write ⇒ no session restart),autobecame the intended default: nightly dreaming now applies consolidation toMEMORY.md/USER.mdand promotes durable memories to the shared vault. Like thegateway.bindmigration, the migrator upgrades the retiredproposedefault toautoonce and logs a one-time warning; an explicitmodeyou set at 1.0.24+ is never touched. To keep dry-run, setmode: "propose"explicitly.
Keeping MEMORY.md near budget (auto mode). Two mechanisms stop the on-disk MEMORY.md from growing unbounded while preserving recall:
- Deterministic compaction — before the LLM reviewer, every
autorun moves completed/terminal log entries out ofMEMORY.mdintomemory/archive/completed.md, leaving a one-line pointer. It is domain-agnostic (not just dev): an entry is archived when its lead line carries an explicit done marker — an UPPERCASE status word (DONE,COMPLETED,RESOLVED,CLOSED,CANCELLED,ARCHIVED,MERGED,SUPERSEDED,OBSOLETE,DEPRECATED,EXPIRED,SHIPPED,FINISHED), a checked task box[x], a ✅, or a ~~strikethrough~~ — and it works on both list bullets and###entry headers. The archive lives undermemory/so it is still indexed and searchable viamemory_search— the agent recalls completed work on demand instead of carrying its full changelog in-prompt. It is conservative (uppercase words only, so prose like "Closes #123", "we're not done", or an unchecked[ ]box is never archived), idempotent, and never drops an open/active item. - Budget-scaled pruning — when
MEMORY.mdis over its soft budget, the reviewer is put in an explicit net-shrink mode (propose only length-reducing ops) andmaxChangesPerRunscales up for removals (the add cap stays tight), so an over-budget file converges toward budget instead of trickling at a few edits per night. - Archive staleness GC (
gateway.dreaming.staleness, planning-66) — a deterministic pass that runs next to the compactor (auto mode) to keep the Lane-2 archive's search quality high. This is a search-quality fix, not a prompt-budget one: planning-65 already moved task-log off the injected prompt, so the point here is thatmemory_searchshould keep surfacing current truth instead of stale/superseded facts. Each nightly run soft-invalidates archive entries — superseded ones (a deterministicsupersedes/replaces/obsoletes #Nmatch, which finally populates the previously-inertsupersedes_key) and aged-out ones (idle-since-last-retrieval paststaleTtlDaysand retrieved fewer thanminRetrievalKeeptimes) — by moving them tomemory/archive/stale.mdand stampinginvalid_at. It never deletes: a staled entry stays undermemory/so it is still indexed and searchable (ยุบได้แต่ไม่ลืม). An entry that is retrieved after it was invalidated is promoted back to the active archive (the recall feedback loop — proof we aged it out too soon). Recall is fed by an append-only read-path log (kb_retrieval_log, gated byrecordRetrievals) that the GC folds into each entry'slast_retrieved. High-importance entries (keepImportance) and pinned files (memory/pinned/**) are never aged out; evergreen Lane-1 (MEMORY.md/USER.md) is structurally excluded. Every move is CAS-guarded with a timestamped backup, and — being a memory-only write — drops no live session. One run may soft-invalidate at moststaleness.maxInvalidationsPerRunentries (default50), oldest-idle first, with the remainder resuming on later runs — aging is wall-clock driven, so without a ceiling the first run after anything that widens the GC's visibility (such as backfilling lifecycle rows for previously invisible sources) would relocate every already-expired entry in one night. Restores are never capped. Kill-switches:staleness.enabled:false(GC no-ops),maxInvalidationsPerRun:0(never invalidates, still restores) andrecordRetrievals:false(age falls back to first-seen only).
gateway.knowledge
Two-lane memory — a per-agent searchable knowledge archive so an agent can recall what does not fit in the always-injected core. A SQLite/FTS5 index (agents/<id>/kb.sqlite, built on Node's built-in node:sqlite — no new dependency) covers the agent's memory/*.md notes plus the evergreen MEMORY.md/USER.md. Every chunk is tagged with fail-closed provenance (owner/agent/untrusted/system; unclassified ⇒ untrusted). The index is refreshed by a detached subprocess at session spawn, entirely off the gateway event loop.
Two read-only MCP tools expose it to the agent: memory_search (keyword/FTS5 → ranked snippets with file+line, provenance, importance) and memory_get (bounded, path-traversal-guarded excerpt of a memory-scoped file). When MEMORY.md grows past its gateway.memory soft budget, compose injects a compact auto-generated section index + a pointer to memory_search instead of the truncated full text (core-shrink) — the on-disk file is never modified and its full content stays searchable. Whenever the archive is on, a short --- MEMORY RETRIEVAL --- note is also injected into every agent's system prompt so the tools stay discoverable at all times (not only when the file is over budget).
| Field | Default | Description |
|-------|---------|-------------|
| archive.enabled | true | Master switch (false ⇒ complete no-op, no DB created, no core-shrink) |
| archive.tokenizer | "unicode61" | FTS5 tokenizer ("trigram" for CJK/Thai) |
| archive.chunkTokens | 400 | Target chunk size in ~tokens |
| archive.chunkOverlap | 80 | Overlap between chunks (clamped below chunkTokens) |
| shared.enabled | true | Enable the cross-agent shared KB |
| shared.project | "global" | Sharing partition key (one safe path segment) — agents with the same value share one vault; "global" ⇒ shared-by-default |
| shared.root | ~/.claude-gateway/shared/kb | Shared vault root dir (<root>/<project>/) |
| shared.mode | "auto" | Per-agent→shared promotion mode; auto = promote durable dreamed facts, propose = dry-run |
| shared.graph | false | Compile the memory-wiki graph + dashboards over the shared vault to <vault>/reports/*.md (opt-in). Independent of the dashboard Knowledge base tab, which computes its graph on-demand |
| shared.staleness | (object) | Shared-note TTL lifecycle GC; uses the same fields/defaults as dreaming.staleness (whole notes only; no numeric supersedes #N syntax) |
| reflection.enabled | true | Enable the singleton, per-shared-vault reflection scheduler (daily timer; see cadence note below) |
| reflection.dayOfWeek / hour / minute / timezone | 0 / 4 / 0 / UTC | hour/minute is the daily staleness-GC slot; dayOfWeek selects the weekday that additionally runs LLM consolidation (Sunday 04:00 UTC by default; invalid timezone falls back to gateway.timezone, then UTC) |
| reflection.maxClustersPerRun / reviewModel | 5 / claude-haiku-4-5-… | Hard cap on changed linked-note clusters per consolidation run and the bounded synthesis model |
Shared KB. A shared SQLite/FTS5 vault outside any single agent's workspace lets agents build a common knowledge base. Notes under <root>/<project>/notes/*.md are indexed and reachable via memory_search with corpus:"shared" (the shared vault) or corpus:"all" (this agent's memory + shared, merged by relevance). Concurrent writers are safe without a lock — atomic note writes (temp+rename) plus a cross-process PRAGMA busy_timeout on the index. Per-agent overrides under the agent's own knowledge block. The MCP layer runs under Bun, so the read tools query kb.sqlite via bun:sqlite. Two write paths feed the vault, sharing one freeform-name namespace (issue #386, no agent-id prefix, no ownership scoping): the nightly dreaming promoter (gated by mode:"auto"; it promotes only content that carries a real fact — content that is nothing but MEMORY.md index-pointer bullets is skipped, since those links resolve only inside the promoting agent's own workspace — and names each note after the proposal's topic slug when the reviewer supplied one, falling back to its reason, so a recurring fact updates the same note across nights instead of piling up near-duplicates; a fallback name that reads as an editing instruction rather than the name of a fact is passed over, and the note is named from the fact itself instead — the promotion is only abandoned when nothing nameable remains, and every skip is logged — including a write the note-size cap refuses and an unexpected write failure. A name that doesn't collide is checked against a near-duplicate search, but an unattended merge now also requires real token containment against the candidate — below that bar the fact gets its own note, since two notes are recoverable while two unrelated facts fused into one are not. [[wikilink]]s to related notes use a lower bar than merges, because a link is additive where a merge is destructive — and they are attached whether the fact merges or lands as a new note, so a note below the merge bar is never a disconnected graph node. Containment is scored against each candidate's full body rather than the matched chunk, though against a capped seed — the bar means "half of the fact's leading topic words are already here", not half of the whole fact. Retired stale__* notes are never merge targets; a recurrence of a retired name folds the retired body back in and removes the twin on both the create and the update path, because a retired note stays searchable and a twin beside a live note of the same name would answer every query twice forever. The twin is only dropped once the merged write lands (issue #398)) and the memory_shared_create/memory_shared_get/memory_shared_update/memory_shared_delete MCP tools, which let any agent create, read, update, or delete any note on demand regardless of mode. memory_shared_create warns instead of writing when it finds content-similar existing notes (pass confirm:true to proceed — related notes get [[wikilink]]ed into the new note rather than left disconnected); memory_shared_update warns instead of writing when the edit would drop 50%+ of the existing note's lines (same confirm:true escape hatch). Immediate reindex after every write or delete.
Shared lifecycle + reflection (issues #392, #398). Each shared note receives a stable whole-file lifecycle identity during indexing — including notes whose content has not changed since they were first indexed, which are backfilled from their source mtime so their real age is preserved. Its deterministic TTL GC runs daily, soft-invalidating aged low-recall notes by moving them to notes/stale__<name>.md (never deleting them from the searchable vault); a retrieval after invalidation restores the original active name. Shared memory_search and memory_shared_get reads feed the same append-only retrieval log as personal archive recall. The singleton reflection scheduler runs once per resolved shared-vault root, not once per agent, and fires daily at hour:minute (a fire that lands a hair early re-arms on the next day's slot rather than serving the same one twice): every fire runs the inexpensive TTL GC (no model call), while graph/LLM consolidation runs only on dayOfWeek — and even then is skipped when kb_index_state.revision has not changed since the prior consolidation. Weekly model spend is therefore unchanged, while a note that is retired and then retrieved returns to the active set within a day instead of up to a week. For changed vaults it clusters only active wikilink-connected notes deterministically, then makes at most reflection.maxClustersPerRun bounded reviewer calls to merge genuinely duplicate clusters; related-but-distinct notes remain merely linked.
Knowledge base viewer. The web dashboard's Knowledge base tab renders the shared vault as an Obsidian-style force-directed graph (nodes = notes sized by link degree and coloured by type; edges = [[wiki-links]]; contradicting claims and stale notes are flagged). It is fed by GET /knowledge/graph, which computes the model on-demand from the vault (no dependency on shared.graph or the nightly reindex). When the vault is empty it shows a clearly-labelled demo dataset (with a size selector for scale testing). A source selector switches the graph between the cross-agent Shared KB and any single agent's own Lane-2 memory (workspace/memory), a node search box filters the graph, and clicking a node opens its full note (fetched via GET /knowledge/note) rendered as Markdown below the graph.
Nightly dreaming viewer. A Nightly dreaming tab renders each agent's memory-consolidation audit trail (.dreaming/DREAMS.md + promotions.jsonl) as a newest-first timeline of runs — mode (propose/auto), outcome, the proposed/applied changes with scores + anchors, and per-run token/session counts — fed by GET /knowledge/dreams and filterable by agent. For a propose-mode run you can accept proposals directly from the tab: an Accept button per proposal (and Accept all per run) POSTs to POST /knowledge/dreams/apply, which applies the selected ops to MEMORY.md/USER.md through the same K4 safe applier auto mode uses (backup + bounded-loss + net-negative + CAS; memory-only ⇒ no restart) and — when the shared KB is auto — promotes applied adds to the shared vault. Accepts are idempotent (recorded to .dreaming/accepted.jsonl); applied proposals show ✓ and a proposal whose anchor has since drifted is safely skipped and stays pending for a later retry.
gateway.bind
Network interface the HTTP/WebSocket server binds to. Defaults to 127.0.0.1 (localhost-only), so the dashboard and API are not exposed to the local network out of the box. Set to 0.0.0.0 to listen on all interfaces (for example when a containerized reverse proxy needs to reach the gateway). The GATEWAY_BIND environment variable, when set, takes precedence over this field.
⚠️ Binding to
0.0.0.0? Configure an admin key ingateway.api.keys. The monitoring surface (/status,/processes) and the dashboard require an admin API key (admin: true) or a dashboard session when keys are configured — a scoped or write-only key is rejected (401), because the dashboard grants cross-agent, host-wide power (including PTY keystroke injection into any session). The dashboard prompts for an admin key at/dashboardand stores anHttpOnlysession cookie (issued only to an admin key)./healthstays public but returns only{"status":"ok"}(no agent ids). With no keys configured the gateway fails closed on a non-loopback bind:/status,/processes, and/dashboardreturn503until you setgateway.api.keys(a startup warning is logged); if keys are set but none is admin, the dashboard is inaccessible and a startup warning is logged. On a loopback bind they stay open, so local keyless installs are unaffected. The gateway serves plain HTTP; put TLS in front (reverse proxy) so credentials are not sent in the clear.
{
"gateway": {
"bind": "127.0.0.1"
}
}⚠️ Upgrade note: the default bind changed from
0.0.0.0to127.0.0.1(configVersion 1.0.13). To avoid silently cutting off external access, the config migrator is behavior-preserving: whenever it upgrades a config that never setgateway.bind, it pinsbindto0.0.0.0and logs a one-time warning, so a deployment that was reachable from another host stays reachable. This applies to any upgraded config with nobindkey — including one already stamped1.0.13that never received a bind (an earlier version gated this on< 1.0.13and left such configs stuck on the127.0.0.1default). New installs (no prior config, so no migration runs) keep the secure127.0.0.1default. If you want localhost-only after upgrading, setgateway.bindto127.0.0.1explicitly (or theGATEWAY_BINDenv var).
gateway.publicUrl
Absolute, externally-reachable origin of the gateway (for example https://gateway.example.com, or https://host.example.com/gateway behind an ingress path prefix). The process cannot infer its own public URL — it binds localhost by default and sits behind a reverse proxy — so it must be set explicitly for features that hand out a phone-openable link. Currently that is the /cli terminal viewer; when publicUrl is unset, /cli replies that the viewer is not configured. Leave it blank to keep /cli disabled. A trailing slash is optional. Use an https:// origin — Telegram Mini Apps require HTTPS.
The CLI does not route through this URL when it runs on the gateway's own host: both addresses are the same server, and the public one only adds a reverse-proxy hop that may enforce its own authentication. It talks to the local bind instead, keeping publicUrl as a fallback if that address cannot be reached. Pass --url to exercise the proxy path deliberately. See CLI.md for the full precedence.
{
"gateway": {
"publicUrl": "https://gateway.example.com"
}
}Terminal Viewer — interactive terminal mode
The dashboard's Terminal Viewer opens read-only (a live mirror of the PTY). A toggle in the top-right of the viewer switches it into an interactive terminal: keystrokes typed into the panel — printable characters, Enter, arrows, Ctrl-combos, Esc — are streamed into the live PTY, and the panel title changes to reflect the active mode. This is a per-browser client-side choice (Issue #201); there is no server config flag to enable it.
Because interactive mode turns a read-only view into a remote-write surface, access is protected upstream rather than by a feature flag:
- Authentication — the WebSocket requires a valid dashboard ticket or admin API key. The ticket is minted at
POST /api/v1/pty-stream-ticket, which itself requires an admin API key or a valid dashboard session cookie — so an unauthenticated (or non-admin) caller cannot obtain one. The dashboard gets its session by logging in with an admin key at/dashboard(HttpOnlycookie); no token is embedded in the page. gateway.bind— the gateway binds to127.0.0.1(localhost) by default, so the dashboard is not reachable from the network out of the box. On a non-loopback bind (0.0.0.0), configure an admin key ingateway.api.keysso the dashboard and monitoring endpoints require an admin credential, and prefer a TLS-terminating reverse proxy so credentials are not sent in the clear.
Inbound frames are always bounded (text-only, size-capped) and are dropped for headless sessions (no PTY).
/cli — open the terminal viewer from chat
The /cli command (Telegram, Discord, LINE) opens the same live terminal viewer for one agent, without an admin key. It requires gateway.publicUrl and an agent running with gateway.headless: false. Unlike the admin dashboard, a /cli session is agent-scoped: its cookie and PTY ticket can only reach the originating agent's own sessions — never another agent, the process tree, or a cross-agent stream.
The viewer link is never a credential; unlocking it requires a proof tied to an allowlist-gated chat action:
- Telegram opens a Mini App and the gateway verifies Telegram's signed
initData(HMAC with the agent's own bot token) — nothing secret rides in the URL, and theinitDatauser must match the user who ran/cli. - Discord and LINE send an open-viewer link plus an Approve button; the browser stays locked until you approve in the chat, so a leaked or forwarded link cannot be unlocked by anyone who cannot approve there.
The first browser to open a link owns it (opening the link in a second browser is rejected), the viewer defaults to read-only (toggle for input), and viewer sessions expire (30 min) — send /cli again to reconnect.
gateway.api.keys
Each key has a key string (supports ${ENV_VAR} interpolation), an optional description, and an agents field — either an array of agent IDs or "*" for full access. Keys support both Authorization: Bearer and X-Api-Key headers.
Bot tokens
Tokens are stored per-agent at ~/.claude-gateway/agents/<id>/.env and auto-loaded at startup and before every config reload — so an agent added to config.json while the gateway is running starts without a restart, even though its token only exists in a brand-new .env. Use ${AGENT_BOT_TOKEN} syntax in config to reference them, or set them as shell environment variables. Lines are KEY=value; # comments and blank lines are ignored, and surrounding quotes are stripped, the same as in ~/.claude-gateway/.env.
A variable you exported yourself always wins over the .env file and is never replaced by a reload. A token the gateway did read from a .env is refreshed when that file changes, so rotating a token takes effect on the next config reload rather than at the next restart. Note that only config.json is watched — editing a .env by hand applies on the following reload, while the MCP agent_create / agent_update tools write both files and so take effect immediately. If a ${VAR} cannot be resolved from anywhere, that one agent is skipped — the rest of the gateway starts normally — and the skip is logged to logs/gateway.log with the name of the missing variable.
Architecture
┌─────────────────────────────────────────────────┐
│ Claude Gateway │
│ │
Telegram Bot A ──► TelegramReceiver(A) ──► AgentRunner(A) ─┬─► Session(chat:111) ──► Claude + MCP
├─► Session(chat:222) ──► Claude + MCP
Telegram Bot B ──► TelegramReceiver(B) ──► AgentRunner(B) ──┴─► Session(chat:333) ──► Claude + MCP
│
HTTP Client ──► POST /api/v1/.../messages ────────────────┴─► Session(api:uuid) ──► Claude
(sync JSON or SSE stream)
│ │
│ GatewayRouter (/health, /status, /ui, /api) │
│ CronScheduler (HEARTBEAT.md + REST API) │
│ TypingManager (live status indicators) │
└─────────────────────────────────────────────────┘
┌───────────────────────────────────┐
│ MCP Server (per session) │
│ mcp/server.ts │
│ │
│ telegram_reply │
│ telegram_react │
│ telegram_edit_message │
│ telegram_download_attachment │
│ cron_list / cron_create / ... │
│ skill_create / skill_delete / ... │
└───────────────────────────────────┘Each agent runs a dedicated TelegramReceiver (single poller per bot token) and a session pool of isolated Claude subprocesses — one per chat or API session. Each session gets its own MCP server (mcp/server.ts) exposing channel-specific tools (Telegram reply, react, cron management, skill management). Sessions persist history via SessionStore, so Claude remembers the conversation even after idle restart.
Session Pool
Each agent maintains a session pool — a separate Claude subprocess per chat ID (Telegram) or session UUID (API). Sessions are fully isolated: Claude sees only its own conversation history with no cross-session leakage.
TelegramReceiver (1 per agent, spawned by gateway)
- single long-poll connection per bot token
- handles access control (allowlist / pairing)
- runs as: bun mcp/tools/telegram/receiver-server.ts (RECEIVER_MODE)
- POSTs incoming messages to AgentRunner callback
AgentRunner (session pool manager)
├── SessionProcess(chat:111) ──► Claude subprocess + MCP server (SEND_ONLY)
├── SessionProcess(chat:222) ──► Claude subprocess + MCP server (SEND_ONLY)
└── SessionProcess(api:uuid) ──► Claude subprocess (no MCP — API-only)MCP Tool System
The MCP server (mcp/server.ts) uses a modular multi-channel architecture. Each channel is a separate module implementing ChannelModule or ToolModule interfaces:
| Module | Interface | Tools | Purpose |
|--------|-----------|-------|---------|
| telegram | ChannelModule | telegram_reply, telegram_react, telegram_edit_message, telegram_download_attachment | Send messages, reactions, edit messages in Telegram |
| discord | ChannelModule | discord_reply, discord_react, discord_edit_message | Send messages, reactions, edit messages in Discord |
| cron | ToolModule | cron_list, cron_create, cron_update, cron_delete, cron_run, cron_get_runs | Manage scheduled jobs via gateway REST API |
| skills | ToolModule | skill_create, skill_delete, skill_install | Create, delete, and install agent skills at runtime |
Tools are prefixed by channel name to avoid collisions. Each module controls its own visibility and lifecycle.
Adding a new channel (e.g. Slack) means implementing ChannelModule interface in mcp/tools/slack/module.ts and registering it in server.ts.
Connectors are the other half of the MCP picture: where the modules above are tools the gateway itself implements, a connector is an external MCP server the gateway injects into a session's mcp-config.json. The gateway stores only the connector definition, the per-connector secret (~/.claude-gateway/mcp-token.env) and the per-agent enablement — Claude Code then talks to that server directly. See gateway.customConnectors and API.md.
Process Modes
| Mode | Process | Behaviour |
|------|---------|-----------|
| TELEGRAM_RECEIVER_MODE | receiver-server.ts | Polls Telegram, handles commands, POSTs to callback — no MCP |
| TELEGRAM_SEND_ONLY | server.ts | Exposes MCP tools (telegram_*, cron_*) — no polling |
Receiver lifecycle
Receivers are child processes, so they only stop when the gateway runs its shutdown path. Two mechanisms keep them from outliving it:
SIGTERM,SIGINTandSIGHUPall run the same graceful shutdown.SIGHUPmatters because Node's default action for it terminates the process without running handlers — so before this was wired, closing a tmux pane or dropping an SSH session killed the gateway and left every receiver reparented toinit. Teardown escalatesSIGTERM→SIGKILLafter a short grace period, so a receiver wedged in an in-flight long-poll cannot survive it.A boot-time sweep reclaims leftovers.
SIGKILLand the OOM killer can never be handled in-process, so at startup the gateway terminates anyreceiver-server.tsprocess that was spawned from its own installation and has been reparented toinit(proof that its supervisor is gone), logging how many it reclaimed — and separately warning about any it could not reclaim, since those are still running. Receivers belonging to another checkout on the same host, or to a gateway that is still running, are never touched.On a host where an ancestor is a child subreaper (
systemd --user,docker run --init/tini, s6), orphans reparent to that subreaper instead of toinitand the sweep finds nothing. Clean shutdown still works; what is lost is theSIGKILL/OOM recovery — though such a host usually has a supervisor that reaps the process group itself.
Session Persistence
History is persisted to SessionStore (.jsonl files) after each message. When a session is spawned after an idle restart, history is injected into the initial prompt so Claude resumes the conversation seamlessly.
Live Status Messages
While an agent is working, the gateway sends real-time status updates to Telegram showing what the agent is doing:
☑️ : 🧠 Analyzing the codebase structure...
☑️ : 📖 Reading: src/agent/runner.ts
☑️ : 🔍 Searching for: "sendMessage" in src/
🕐 : ✏️ Editing: mcp/tools/telegram/typing.ts
(elapsed: 2m 30s)- Tool tracking — each tool call is displayed with a descriptive label (e.g.
📖 Reading: config.ts,⚡ Running: npm test) - History — previous steps shown with ☑️, current step with 🕐
- Thinking — agent's reasoning shown with 🧠
- Elapsed time — total time since the agent started working
- Auto-cleanup — status message is deleted when the agent finishes
Status updates are sent every 5-10 seconds (first update at 5s, then every 10s). A single message is edited in place for the whole turn; a tick with nothing new to show issues no update at all, and the message is replaced only if it is deleted or becomes uneditable.
Command Line (CLI)
The claude-gateway binary doubles as a command-line client for a running gateway — a friendlier alternative to hand-built curl calls. It works the same whether the gateway was started with make start, pm2, or systemd (it resolves the target from your config). Run it with no arguments to see what it can do; only gateway start boots the server.
claud