npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 4500

Or 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 mode

agent-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-b

Passing 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-essential

If 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.1 only. 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 set ROUTER_HOST.

Exposing it requires a token. Set --auth-token / ROUTER_AUTH_TOKEN and the API, the web UI and the terminal socket all require it — as a Authorization: Bearer <token> header, or the cookie the browser gets from the /login form. That includes inbound POST /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 in ROUTER_DATA_DIR and survive restarts; a key's value is shown exactly once, because the router keeps no copy of it.

Each key has a scope. admin is everything; webhook can post an event to a session and nothing else — no session list, no filesystem, no terminal. Give CI a webhook key: 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_TOKEN is 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. cloudflared connects 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.json aside and restart.

Requires:

  • Node.js 20+
  • The claude CLI installed and on PATH (or in one of the standard install locations — see src/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 }201 with the created SessionRecord, 501 for codex (not implemented yet), 400 if workDir doesn't exist, or 403 if it is outside the permitted roots (see ROUTER_ALLOWED_ROOTS, which defaults to your home directory).
  • GET /sessions — list, optional ?agentType= / ?status= filters. Each record carries a derived projectRoot (the enclosing git repository, else the workDir itself), which the web UI groups by.
  • GET /sessions/:id404 if missing.
  • POST /sessions/:id/resume — relaunches a stopped session with its conversation intact → 202 with the record in starting. 409 if it is already running or has no resumable conversation, 501 if 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 }202 once the event is handed to the session. 404 if the session doesn't exist, 409 if it isn't running, 400 if callback_url is not an https URL to a public address.
  • GET /sessions/:id/events — recent events/replies for that session (useful when you didn't pass a callback_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; currentKeyId marks the key the caller is using.
  • POST /auth/keys{ name, scope: 'admin' | 'webhook' }201 with { token, key }. The only time a key's plaintext is returned. 400 on an unknown scope, 409 if ROUTER_AUTH_TOKEN owns access, 403 if creating the first key from off-machine.
  • DELETE /auth/keys/:id — revokes one key, 204. Every other key keeps working. 409 on the last admin key (create another first, or turn auth off), 404 if unknown.
  • DELETE /auth/token — turns authentication off entirely, 204. 409 if 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/unlock exchanges the PIN for an unlock cookie (401 wrong, 429 locked out). DELETE /auth/pin removes 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:

  1. The router spawns the claude CLI itself as a child process, pointed at a generated per-session .mcp.json (~/.agent-session-router/sessions/<id>/.mcp.json).
  2. That config registers src/adapters/claude/channelServer.ts as an MCP channel server. Claude Code spawns that script itself, over stdio, as normal MCP behavior.
  3. channelServer.ts also runs a small local HTTP listener (port allocated per session) — this is what the router actually talks to: POST /inject to push an event in, and the channel's reply MCP tool POSTs replies back to the router's internal /internal/adapters/claude/:sessionId/reply route.

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 test

Unit 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)

  1. Start the router: npm start.
  2. 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"}'
  3. Poll GET /sessions/:id until status is running. First start in a new folder takes a few seconds — the claude cold start, the MCP handshake and the channel port bind are all on that path.
  4. 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"}'
  5. Confirm the event shows up as a <channel source="router-channel" ...> tag in the Claude Code session's output, and that once Claude calls the reply tool, your callback endpoint receives the POST.
  6. DELETE /sessions/:id and confirm the claude process (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's turn/steer / thread/inject_items; see raysonmeng/agent-bridge for prior art).
  • Content/header-based routing across multiple candidate sessions — v1 uses explicit POST /webhooks/:sessionId addressing only.
  • Reattaching to the original agent process across a router restart. The router owns the pty, so the process dies with it and any running session in the snapshot is marked stopped on 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/resume

In 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).