agent-session-router
v0.1.0
Published
Agent-agnostic webhook router that pushes external events into running AI coding CLI sessions (Claude Code, Codex).
Downloads
212
Maintainers
Readme
An agent-agnostic webhook router: external systems (CI, monitoring alerts, chat
platforms, arbitrary webhooks) POST an event to a specific running AI coding
CLI session, and the router delivers it in. Optionally, the session's reply is
POSTed back to a callback_url you provide.
The core (session registry, REST API, webhook ingestion) never knows anything
about a specific agent — it only calls a SessionAdapter interface
(src/adapters/SessionAdapter.ts). v1 ships a fully working Claude Code
adapter (built on Claude Code Channels)
and a Codex CLI adapter stub that returns 501 Not Implemented (see
src/adapters/codex/CodexAdapter.ts for the planned v1.1 approach).
Install
npm install -g agent-session-router
agent-session-router --port 4500Or from a checkout:
npm install
cp .env.example .env # optional, defaults are fine for local use
npm run build
npm start # or `npm run dev` for tsx watch modeagent-session-router --help lists the flags (--port, --host,
--data-dir, --callback-base-url, --channel-health-timeout-ms). Each one
just sets the matching ROUTER_* env var, so precedence is flag > env var >
default and there is only ever one config path.
Running a second instance
Pass both --port and --data-dir:
agent-session-router --port 4501 --data-dir ~/.agent-session-router-bPassing only --port is the mistake to avoid. Both instances would share
~/.agent-session-router/sessions.json, and since each rewrites the whole file
from its own in-memory list, they would quietly erase each other's sessions.
The router takes a lock on its data directory and refuses to start rather than
let that happen, naming the process already holding it. A lock left behind by a
crash is reclaimed automatically — no file to delete by hand.
callback_url routing needs no extra flag: callbackBaseUrl derives from the
port, so channel replies find the right instance.
Platform support
This package is the run-it-on-your-own-machine path — local development and personal self-hosting.
For running it on a Linux box or in a container, deploy/ carries a reference
Dockerfile, a compose file and notes on the two credentials involved. Treat it
as a sketch that has not been run yet: it is traced against the source but
no image has been built from it. deploy/README.md lists exactly what is
unverified.
| Platform | Install |
|---|---|
| macOS (arm64/x64) | Prebuilt, no toolchain needed |
| Windows (arm64/x64) | Prebuilt, no toolchain needed |
| Linux (x64/arm64) | Compiles node-pty from source |
The router needs a real pseudo-terminal, so it depends on node-pty, and the
current release ships no Linux prebuilt binary. On Linux the install therefore
falls back to node-gyp and needs a build toolchain present before you
install — a stock node:*-slim image or a minimal CI runner will fail at
install time, not at runtime:
# Debian/Ubuntu
sudo apt-get install -y python3 build-essentialIf you containerise this yourself, use a glibc base (node:22-slim,
Debian) rather than Alpine — the native addon won't load against musl.
The router binds to
127.0.0.1only. It can spawn agent processes, enumerate the filesystem and hand out live terminal access to a running session, so it is reachable from this machine only unless you explicitly setROUTER_HOST.Exposing it requires a token. Set
--auth-token/ROUTER_AUTH_TOKENand the API, the web UI and the terminal socket all require it — as aAuthorization: Bearer <token>header, or the cookie the browser gets from the/loginform. That includes inboundPOST /webhooks/:id, so CI and monitoring senders need it too. Without a token set, the router refuses to start on a non-loopback interface rather than warning and continuing.Or manage keys from the web UI at
/settings(the gear in the topbar, or⌘K→ "Settings and access keys"): create as many named keys as you like and revoke them one at a time. Keys are stored hashed inROUTER_DATA_DIRand survive restarts; a key's value is shown exactly once, because the router keeps no copy of it.Each key has a scope.
adminis everything;webhookcan post an event to a session and nothing else — no session list, no filesystem, no terminal. Give CI awebhookkey: with a single shared token, a leaked CI credential is an interactive shell on your machine, and with a scoped one it can only send your agent a message.If
ROUTER_AUTH_TOKENis set it wins, and the UI refuses to manage keys at all — the deployment owns that secret, not the app.A 6-digit web UI PIN can be set in the same place. It is a second factor over the browser and the terminal, never a credential: 6 digits is ~20 bits, which a distributed attack exhausts in about an hour, so it is not something to expose a port behind. What it does is re-lock an unattended browser after 30 minutes idle, which a cookie-borne key never does on its own. It never applies to bearer-token calls, so webhook senders are unaffected. Wrong PINs lock out globally after 5 attempts; presenting an admin key as a bearer token clears that, so you can always recover with
curl.A token is defence in depth, not a substitute for an authenticating proxy. If you publish this over a tunnel, put an identity layer (e.g. Cloudflare Access) in front of the whole hostname as well.
Behind a tunnel, a loopback source address means nothing.
cloudflaredconnects from localhost, so every request through it looks local. The router detects proxy headers (CF-Connecting-IP,X-Forwarded-For, …) and, once it has seen one, refuses both creating the first key and disabling authentication — even from localhost — for the rest of the process. If you genuinely need to do either, stop the tunnel, or move~/.agent-session-router/auth.jsonaside and restart.
Requires:
- Node.js 20+
- The
claudeCLI installed and onPATH(or in one of the standard install locations — seesrc/adapters/claude/processManager.ts) - A Claude Pro/Max or Console account with Channels available (research preview feature as of writing)
API
POST /sessions—{ agentType: 'claude' | 'codex', workDir: string, metadata?: object }→201with the createdSessionRecord,501forcodex(not implemented yet),400ifworkDirdoesn't exist, or403if it is outside the permitted roots (seeROUTER_ALLOWED_ROOTS, which defaults to your home directory).GET /sessions— list, optional?agentType=/?status=filters. Each record carries a derivedprojectRoot(the enclosing git repository, else theworkDiritself), which the web UI groups by.GET /sessions/:id—404if missing.POST /sessions/:id/resume— relaunches a stopped session with its conversation intact →202with the record instarting.409if it is already running or has no resumable conversation,501if the agent type doesn't support it. See "Resuming a session after a restart" below.DELETE /sessions/:id— stops the session,204.POST /webhooks/:sessionId—{ content: string, callback_url?: string, meta?: object }→202once the event is handed to the session.404if the session doesn't exist,409if it isn't running,400ifcallback_urlis not an https URL to a public address.GET /sessions/:id/events— recent events/replies for that session (useful when you didn't pass acallback_url).GET /auth/status—{ required, source: 'env' | 'stored' | 'none', canManageKeys }. Public, so the UI can render before it has a credential. Never returns a key or its hash.GET /auth/keys—{ keys, currentKeyId }. Hashes are never included;currentKeyIdmarks the key the caller is using.POST /auth/keys—{ name, scope: 'admin' | 'webhook' }→201with{ token, key }. The only time a key's plaintext is returned.400on an unknown scope,409ifROUTER_AUTH_TOKENowns access,403if creating the first key from off-machine.DELETE /auth/keys/:id— revokes one key,204. Every other key keeps working.409on the last admin key (create another first, or turn auth off),404if unknown.DELETE /auth/token— turns authentication off entirely,204.409if access is env-managed or the router is bound to a non-loopback address.POST /auth/pin—{ pin }, 6 digits →201. Sets or replaces the web UI PIN, invalidating every existing unlock.POST /auth/pin/unlockexchanges the PIN for an unlock cookie (401wrong,429locked out).DELETE /auth/pinremoves it,204.
Any request from a browser (cookie-authenticated) gets 423 Locked while the UI PIN is unlocked; bearer-token calls are never affected.
How the Claude adapter works
MCP servers are spawned by Claude Code, not by an external process, so the router can't attach a "channel" to a session directly. Instead, for each Claude session:
- The router spawns the
claudeCLI itself as a child process, pointed at a generated per-session.mcp.json(~/.agent-session-router/sessions/<id>/.mcp.json). - That config registers
src/adapters/claude/channelServer.tsas an MCP channel server. Claude Code spawns that script itself, over stdio, as normal MCP behavior. channelServer.tsalso runs a small local HTTP listener (port allocated per session) — this is what the router actually talks to:POST /injectto push an event in, and the channel'sreplyMCP tool POSTs replies back to the router's internal/internal/adapters/claude/:sessionId/replyroute.
First-run dialogs are auto-accepted. A real interactive TTY shows two
one-time prompts per session — the workspace-trust check, and the warning from
--dangerously-load-development-channels. processManager.ts watches the pty
output for them and sends \r, since the router runs on behalf of a single
trusted local user. No manual approval is needed.
Testing
npm testUnit tests (test/unit/) cover the registry, webhook validation, the HTTP
API end-to-end against a mock adapter, the Codex stub's 501 behavior, and
the callback client — none of them spawn a real claude process.
Manual end-to-end smoke test (requires a real claude install)
- Start the router:
npm start. - Create a session:
curl -X POST http://127.0.0.1:4500/sessions \ -H 'content-type: application/json' \ -d '{"agentType":"claude","workDir":"/absolute/path/to/some/project"}' - Poll
GET /sessions/:iduntilstatusisrunning. First start in a new folder takes a few seconds — theclaudecold start, the MCP handshake and the channel port bind are all on that path. - Fire a webhook with a callback URL (any endpoint you control that logs
its POST body):
curl -X POST http://127.0.0.1:4500/webhooks/<id> \ -H 'content-type: application/json' \ -d '{"content":"CI build #42 failed on main","callback_url":"https://example.com/echo"}' - Confirm the event shows up as a
<channel source="router-channel" ...>tag in the Claude Code session's output, and that once Claude calls thereplytool, your callback endpoint receives the POST. DELETE /sessions/:idand confirm theclaudeprocess (and its channel subprocess) exit.
What's not built yet (by design, see the plan)
- Codex CLI adapter (stub only — real implementation should bridge to
codex app-server'sturn/steer/thread/inject_items; seeraysonmeng/agent-bridgefor prior art). - Content/header-based routing across multiple candidate sessions — v1 uses
explicit
POST /webhooks/:sessionIdaddressing only. - Reattaching to the original agent process across a router restart. The
router owns the pty, so the process dies with it and any
runningsession in the snapshot is markedstoppedon boot. Sessions can be resumed instead — see below — which restores the conversation but not the process.
Projects
Sessions are grouped in the web UI by the project they sit in — the enclosing
git repository if there is one, otherwise the working directory itself. So a
session at repo/ and one at repo/app/ appear under the same project.
Nothing to configure and nothing to create: the grouping is derived from
workDir on every boot, so existing sessions are grouped retroactively. Each
project header carries a + that starts a session already pointed at that
folder, and Ctrl/Cmd+K lists projects for jumping and for spawning into.
Resuming a session after a restart
POST /sessions/:id/resume relaunches a stopped session's agent with its
conversation intact, keeping the same session id, workDir and metadata — so
webhook senders and callbacks pointed at that id keep working. Responds 202
with the record in starting; poll GET /sessions/:id for the transition.
curl -X POST http://127.0.0.1:4500/sessions/sess_abc123/resumeIn the web UI a Resume button appears in the session header when the session can be resumed.
What does not carry over: the pid, the channel port, the channel token and
the terminal scrollback are all new, and anything the agent was doing mid-turn
when the router stopped is lost. Recorded events/replies are also gone, since
EventStore is in-memory.
The action is only offered when the agent's conversation is actually still on
disk, rechecked on every boot — so a transcript deleted between runs means no
Resume button rather than a button that fails. A session that never received an
event has no conversation to resume. 409 means the session is running, has no
recorded conversation id, or its transcript is gone; 501 means the agent type
doesn't support resuming at all (the Codex stub).
