agent session router

Route anything into the session that should handle it.

An always-on coding agent you can post to. CI, monitoring, chat and your own keystrokes all land in one specific live session, addressed by id.

npm install -g agent-session-router

In the app

An event arriving in a session.

The router's web UI. A rail on the left groups three running sessions under two projects. The detail pane shows a signal path reading webhook, router, channel port 61139, claude process 18924, and an activity timeline: an inbound CI failure, and the agent's reply naming the file that regressed.
A CI failure posted to a session id. The reply came from the agent already open in that repo.

The problem

A running agent has no inbox.

Your session already knows the repo. Every integration you bolt on starts one that doesn't.

Without a router

A new session, every time

The webhook starts an agent that re-reads what yours already knew.

With one

The session you're already in

The event is addressed by id. It arrives with the context intact.

How it works

Start a session, then post to its id.

  1. Start a session in a folder

    One POST. You get an id back.

  2. Point anything at that id

    CI, monitoring, a cron job, the web UI, a chat bot.

  3. The answer comes back out

    To your callback_url, when the agent replies.

# start a session in a project
curl -X POST localhost:4500/sessions \
  -d '{"agentType":"claude",
       "workDir":"~/code/api"}'

201 { "id": "sess_abc123", … }
# send it your CI failure
curl -X POST localhost:4500/webhooks/sess_abc123 \
  -d '{"content":"build #42 failed on main",
       "callback_url":"https://ci.example.com/hook"}'

202 handed to the session

The other way in

There's a terminal too.

Attach to the session's pty while webhooks keep arriving on the channel.

The Terminal tab of the same session, showing Claude Code's own interface with the routed event and the line 'Replied on the channel', above a row of on-screen keys: Mode, Esc, Ctrl-C, arrows and modifiers.
The same event, in Claude Code's own interface.
The same router on a 390px-wide phone screen: a collapsed session rail behind a menu button, the signal path wrapped onto two lines, and the webhook endpoint with a copy button.
The key bar exists because a phone keyboard has no Esc or Shift+Tab, and Shift+Tab is how you change permission mode.

What's in it

A session manager, not just a webhook sink.

Browser terminal

Attach to any session's pty. Detaching never kills it.

Scoped keys

CI gets a key that can post events and nothing else.

Resume after a restart

Same id, same conversation. Senders keep working.

Grouped by project

Filed under the enclosing git repo. Nothing to create.

Agent-agnostic core

Claude Code ships working. Codex is a stub.

Loopback by default

Binds 127.0.0.1. Refuses a public bind with no key.

Before you expose it

This API spawns processes and hands out a shell.

A mistake here is remote code execution, not a data leak, so the defaults are closed.

Closed by default

Loopback bind, a workDir allowlist, and no public start without a key.

Not a substitute for a proxy

Publishing it? Put Cloudflare Access in front of the hostname too.

Install

Run it on your own machine.

npm install -g agent-session-router
agent-session-router --port 4500

Then open http://127.0.0.1:4500.

FAQ

Common questions.

Which agents does it support?

Claude Code works today. The core — registry, REST API, webhook ingestion — knows nothing about any specific CLI; it calls a SessionAdapter. The Codex CLI adapter is a stub that returns 501.

Installing on Linux, or in Docker, fails at npm install

There's no prebuilt node-pty for your platform, so it falls back to node-gyp and needs a toolchain present before you install — a stock node:*-slim image or a minimal CI runner won't have one. Run apt-get install -y python3 build-essential first, and use a glibc base rather than Alpine: the native addon will not load against musl.

Can I put it on the public internet?

It refuses to start on a non-loopback interface without a key. Add an identity layer such as Cloudflare Access over the whole hostname as well — a key is defence in depth, not a replacement for one. Note that once the router has been reached through a proxy it will no longer mint a first key or let you disable authentication, even from localhost: cloudflared connects from localhost too, so a loopback source address proves nothing about who is calling.

What survives restarting the router?

The session record, its id and its conversation — resume relaunches the agent on the same transcript, so senders and callbacks keep working. Not carried over, and worth knowing: the pid, the channel port and token, terminal scrollback, anything in flight at the moment of the restart, and the event history. This is a relaunch, not a reattach — the router owns the pty, so the old process died with it.

Does my code or my events go anywhere?

No. The router is a process on your machine talking to a CLI on your machine. It has no backend, no telemetry and no account. Outbound traffic happens only when you set a callback_url, and that goes where you point it.

Why a pseudo-terminal, rather than just spawning the CLI?

Claude Code's Channels feature checks for a genuine interactive terminal; a piped child process is treated as non-interactive and errors out. The pty is also what makes the browser terminal possible. The full picture is in architecture.md.