agent-tunnel — Share Your Expert Sessions
A local Claude Code session accumulates large, valuable context over time —
effectively a custom expert agent. agent-tunnel lets you publish a
specific session with >share, hand the resulting handle to teammates,
and have them ask it questions over Discord or Mattermost — each answered
against a private, read-only fork of that session.
In short, it lets colleagues talk to a Claude Code session running on your Mac
— answered either by an interactive session driven through tmux or a headless
claude -p run (details below).
How it works
Section titled “How it works”- Inside any session, you type
>share. A hook reads that session’s own id and folder and mints a short handle (e.g.pay-7Q2). A folder can hold many sessions, so the handle is pinned to the exact session you ran it in — not the folder. - You give the handle to colleagues. In the watched Discord or Mattermost channel, a message that starts with the handle opens a thread bound to that session; follow-ups stay in the thread.
- Each thread is answered against
--resume <session> --fork-session: the fork copies the context, your original session is untouched, and remote turns are hard-restricted to read-only tools (Read/Grep/Glob). - Follow-ups track your session (headless mode): if you keep working in the published session after a thread opened, the thread’s next follow-up is answered from your latest session state, with a short recap of the thread’s earlier Q&A carried over.
- The Discord Gateway and the Mattermost event stream are outbound websockets — no tunnel, no open port, no cloud. Everything runs on your Mac.
- The fork knows it’s in bot mode: its persona says it’s answering
teammates relayed over chat, and each incoming message is prefixed with who
sent it and how — e.g.
Alice (via Discord) says: …— so it can address people by name. The label is the front-end the thread came in on (DiscordorMattermost).
inside any session: >share ─► hook writes registry { handle → session } │ colleague's chat ─► Discord / ◄─ ws ─ agent-tunnel serve ─► fork that (handle → thread) Mattermost (reads registry) sessionFor your colleagues (the simple part)
Section titled “For your colleagues (the simple part)”Nothing to install. You hand them a handle; they:
-
Not sure what’s shared? Post
!list(or!handles) in the channel to see every available handle. -
Post
<handle> <question>— e.g.pay-7Q2 how does token refresh work? -
The bot opens a thread (named
<handle>: <question>) and answers there. They keep asking in that thread — no handle needed again. -
When done, they type
!done(or!close/!end) in the thread to close it.
So the whole colleague vocabulary is just three things: !list to
discover, <handle> <question> to ask, !done to close. They can also
attach a file to a question (a PDF, CSV, image…) and the agent will read
it; on write-enabled handles the agent can hand a generated file back into the
thread. Inside a thread the bot answers every message — except one that
starts with @someone-else (a teammate or role, @everyone on Discord,
@all / @channel / @here on Mattermost), so you can side-chat with a human
without the bot chiming in. On Mattermost the “thread” is the reply thread
under the post that named the handle; the bot answers there. Direct messages
to the bot work only on Discord (and only if the owner enables them).
The agent-tunnel command group
Section titled “The agent-tunnel command group”Installing the package adds a single console command, agent-tunnel — a
command group (like aichat) whose subcommands are:
| Subcommand | What it does |
|---|---|
agent-tunnel serve | Run the chat daemon (Discord and/or Mattermost) — the long-lived bridge that answers questions |
agent-tunnel published | List the sessions you’ve shared (handles in the registry) |
agent-tunnel status | Show active conversation threads and their forks |
agent-tunnel forks [<handle>] | Table of fork sessions (handle, access, asker, last active, turns, live/idle, fork id); --json to script, --manage to select-and-clear |
agent-tunnel resume <handle> | Resume a colleague-accumulated fork in Claude Code (most recent, or --fork <id>) |
agent-tunnel rename <old> <new> | Rename a shared handle (registry + bound forks + live tmux windows) |
agent-tunnel watch | Attach to the private tmux server to watch live forks |
agent-tunnel doctor | Readiness check (tokens found, channels set, claude/tmux on PATH; Mattermost when configured) |
agent-tunnel ask "Q" --handle H | Ask one question through the full pipeline, no chat (local test) |
agent-tunnel forget --thread T | --all | Drop thread bindings and kill their tmux windows |
agent-tunnel init | Write a commented sample config |
agent-tunnel help | Extensive help — overview, where-to-run, and every command’s details |
The one piece that is not a subcommand is >share (and !list /
!done): >share runs inside a Claude session as a hook, and !list /
!done are typed by colleagues in chat.
Where to run each command
Section titled “Where to run each command”Most commands are plain CLI; only two care about tmux:
| Command | Where to run it |
|---|---|
agent-tunnel serve | Anywhere it stays alive — a tmux pane is handy. It drives its own private tmux server, so it never touches your main one. |
agent-tunnel watch | Best from a plain terminal tab (outside tmux) to avoid nesting. It works inside tmux too (it warns you), just with awkward prefix keys. |
>share | Inside the Claude session you want to publish — it’s a hook from the plugin, not a subcommand. |
agent-tunnel resume | A normal terminal where you want the Claude session — it execs claude --resume, dropping you into the fork. |
ask · published · status · forks · doctor · forget · init | Anywhere — tmux context is irrelevant. |
agent-tunnel help prints all of this plus every command’s details.
Publishing & closing
Section titled “Publishing & closing”Type these as a prompt inside the session you want to expose (the prompt is intercepted — it never reaches the model; you just see the handle):
| Command | Effect |
|---|---|
>share | Publish this session; mint/show a handle |
>share <label> | Publish under a friendly handle, e.g. >share payments |
>share --write <label> | Publish with write access (file edits); read-only otherwise |
>share --dangerously-allow-bash <label> | Write plus shell commands (for real PDFs/docx) — trusted people only |
>share --dangerously-skip-permissions <label> | Full access: any tool/MCP (web, browser, shell) with no prompts — needs [claude] allow_skip_permissions; fully-trusted only |
>share --read <label> | Downgrade a handle back to read-only |
>share status | Show this session’s handle, if any |
>share off | Stop sharing (revoke the handle) |
Access is per handle and escalates read ⊂ write ⊂ bash ⊂ all: read-only
by default, --write adds file edits (but still never Bash),
--dangerously-allow-bash additionally allows shell commands, and
--dangerously-skip-permissions (the all level) drops every restriction so
the fork can use any tool or MCP server (web, the browser, shell) with no
prompts. The all level is gated: it does nothing unless the owner also sets
[claude] allow_skip_permissions = true (double opt-in), or the daemon refuses
the turn. Re-sharing without a flag keeps the current level. agent-tunnel published tags writable handles with ✍️, bash with 💥, and full with 🚨.
Re-sharing with a level flag changes a live thread’s access on its next
message (same fork, full context kept), not just new threads: the daemon
re-reads the handle’s access each turn. So >share --write payments upgrades an
in-flight read-only conversation, and >share --read payments pulls it back,
with no need to close the thread (!done) and reopen it. Upgrading to all
still honors the gate: without allow_skip_permissions the next turn is refused
rather than silently granted.
A labeled handle also makes the chat thread and the tmux window readable
(payments-… instead of a cryptic id). Colleagues end a conversation with
!done in their thread; that tears the fork down immediately.
Sharing files both ways (attachments)
Section titled “Sharing files both ways (attachments)”A thread can move files in both directions — hand the expert a document, get a generated deliverable back.
Inbound — a colleague attaches a file (any handle). Drop a PDF, image,
CSV, or markdown file onto a message (with or without text) and the agent reads
it: the bot saves it to a contained per-thread folder and points the fork’s
Read tool at it. Works for every handle, since reading is always allowed.
Per-file size and count caps apply (limits.max_attachment_mb /
max_attachments); anything over is skipped with a note.
Office files (.docx / .pptx / .xlsx) can’t be opened by Read
directly, so they’re best-effort auto-converted — to PDF if LibreOffice is
on the host (highest fidelity), otherwise to Markdown (pandoc) or text. It’s
purely opportunistic: nothing needs installing, and if no converter is present
the bot just asks the colleague to send a PDF. Toggle with [attachments] convert = "auto" | "off". PDF is the universal “attach this and it works”
format — it needs no converter and Read renders its pages visually.
Outbound — the agent hands a file back (write/bash handles). A
write-or-bash fork is told to drop anything it wants to deliver into an
outbox inside the project — .agent-tunnel-out/ — and the bot posts
whatever appears there back into the thread as attachments. That folder carries
a .gitignore of *, so deliverables never show up in your git status.
Multiple Claude config dirs (e.g. work vs personal)
Section titled “Multiple Claude config dirs (e.g. work vs personal)”If you run separate CLAUDE_CONFIG_DIRs — say ~/.claude for personal and
~/.claude-rja for work — agent-tunnel handles it automatically: >share
detects which config dir the session lives under (from its transcript path),
and the daemon forks each session under that config dir. So a work session
forks with your work config and account, a personal one with your personal —
nothing to configure, even though the daemon itself runs under one of them.
agent-tunnel published tags each handle with its config dir
([.claude-rja] vs [.claude]) so you can tell them apart at a glance.
Setup — for developers / self-hosters
Section titled “Setup — for developers / self-hosters”One agent-tunnel serve can run Discord, Mattermost, or both. Do Part A
for Discord and/or Part A′ for Mattermost.
Part A: create the Discord bot (one-time)
Section titled “Part A: create the Discord bot (one-time)”-
At the developer portal → New Application.
-
Left sidebar → Bot. Set its display name. Reset Token → Copy (treat it like a password).
-
Toggle MESSAGE CONTENT INTENT on — mandatory, or the bot sees blank messages.
-
OAuth2 → URL Generator → scope
bot; permissions: View Channels, Send Messages, Create Public Threads, Send Messages in Threads, Attach Files, Add Reactions, Read Message History. Copy the URL. -
Open the URL → pick a server where you have Manage Server (your own, or ask a company admin) → Authorize. The bot appears (offline until you run
serve). -
Make a dedicated (ideally private) channel; on a private channel, add the bot to it. User Settings → Advanced → Developer Mode, then right-click the channel → Copy Channel ID.
Part A′: create the Mattermost bot (one-time)
Section titled “Part A′: create the Mattermost bot (one-time)”These steps need a Mattermost system admin.
-
System Console → Integrations → Bot Accounts: enable bot account creation.
-
System Console → Integrations → Integration Management: enable Personal Access Tokens.
-
Integrations → Bot Accounts → Add Bot Account; copy the access token it shows and save it to a file (e.g.
~/.config/agent-tunnel/mattermost-token.txt). -
Invite the bot to the team and then to the channel it should watch. Copy the channel’s 26-character ID (channel name → View Info).
The bot answers only in the configured channels. Direct messages are not supported on Mattermost.
Part B: install the plugin + configure
Section titled “Part B: install the plugin + configure”-
Install the plugin that provides
>share(from this repo’s marketplace):Terminal window claude plugin marketplace add pchalasani/claude-code-toolsclaude plugin install agent-tunnel@cctools-pluginsRestart Claude Code, then
>shareworks in any session. -
Write the config and add your token + channel:
Terminal window agent-tunnel init # ~/.config/agent-tunnel/config.toml[tunnel]backend = "headless" # default; or "tmux" to watch forks live[discord]# No need to export anything: point at a file holding the token.token_file = "~/Documents/tokens/discord-token.txt"channel_ids = [123456789012345678]# Optional: Mattermost runs too whenever url is set.[mattermost]url = "https://chat.example.com" # no trailing /apitoken_file = "~/.config/agent-tunnel/mattermost-token.txt"# token_env = "AGENT_TUNNEL_MATTERMOST_TOKEN" # checked before token_filechannel_ids = ["abcdefghijklmnopqrstuvwxyz"] # 26-char idsallowed_user_ids = [] # empty = anyone in the watched channelsverify_tls = true # false only for a self-signed test serverdefault_handle = "" # answered when someone just @-mentions the bot# Optional — Office attachments (.docx/.pptx/.xlsx) are auto-converted to a# readable format when a converter (LibreOffice/pandoc) is on PATH:# [attachments]# convert = "auto" # or "off" -
Check readiness:
Terminal window agent-tunnel doctor
Part C: run it
Section titled “Part C: run it”agent-tunnel serveKeep it alive (a dedicated tmux pane is convenient). With token_file set you
don’t export anything. It starts Discord when a Discord token resolves (and
channels or DMs are set) and Mattermost when [mattermost] url is set. If
Mattermost is set but lacks a token or channels, serve logs a warning and
runs Discord alone, and the HTTP front-end when [http] port is set; it
exits with an error only when nothing can run. All front-ends share one
concurrency cap. Your Mac must be awake and online; both
front-ends reconnect after sleep.
The HTTP front-end (for programs)
Section titled “The HTTP front-end (for programs)”Chat is for people. When a program wants an answer from a published session
(a docs site’s “Ask” button, a script, a bot you write yourself), turn on the
HTTP front-end. It runs inside the same serve daemon and goes through the
same relay, so the concurrency cap, per-thread locks, fork refresh and logs
are shared with the chat front-ends.
[http]bind = "127.0.0.1"port = 8766token_file = "~/.config/agent-tunnel/http-token" # any secret stringplatform = "the web" # named in the fork's personaOff unless port is set. Every route requires the shared secret:
curl -s localhost:8766/health -H "X-Ask-Token: $(cat ~/.config/agent-tunnel/http-token)"# {"ok": true, "handles": ["cyberins", "jev-expert"]}
curl -s -X POST localhost:8766/ask \ -H "X-Ask-Token: $(cat ~/.config/agent-tunnel/http-token)" \ -H 'Content-Type: application/json' \ -d '{"handle": "jev-expert", "thread": "tab-1", "sender": "alice@example.com", "question": "In one sentence, what is a Noul?"}'# {"ran": true, "answer": "...", "thread": "tab-1", "handle": "jev-expert",# "fork_session_id": "...", "files": [], "notices": []}handleis any live published handle; the front-end is not tied to one.threadis a caller-chosen id ([A-Za-z0-9_.:-], up to 80 chars). The same handle and thread share one fork, so follow-ups remember earlier turns. A thread that already exists keeps working after>share off, like a chat thread; only a new thread needs a live handle.sender(optional) is shown to the fork as who is asking.metadata(optional) is a flat object of strings kept with the turn in the turn log (below); the fork does not see it.- Forks made over HTTP are read-only whatever the handle’s access (a
>share --writeor--bashhandle answers with read tools here), because the caller picks the handle. Custom[claude] allowed_toolsanddisallowed_toolsdo not widen them either, permission flags inheadless_extra_args/tmux_extra_argsare dropped for these forks, they run with--permission-mode dontAskand--strict-mcp-configwhatever the config says, and anallhandle does not get--dangerously-skip-permissionshere. - Fields must be strings within their limits (a question over 8,000 characters
is a 413). The
[limits] per_user_cooldown_sapplies persender(a second question inside it is a 429). A failed turn is a 502 with the relay’s own error text;ransays whether a turn ran, separately from what it produced.
It binds to loopback. To reach it from elsewhere put a tunnel in front (a
Tailscale Funnel, a Cloudflare Tunnel) and keep the token secret; the caller
should add its own access control for its users. The reference caller is the
Jev wiki’s /api/ask Pages Function, which verifies a Cloudflare Access
identity before forwarding.
Keeping a log of answered turns
Section titled “Keeping a log of answered turns”The daemon’s thread store keeps only a trimmed recap of each conversation. To keep every answered HTTP turn, name a log file:
[http]turn_log = "http-turns.jsonl" # relative paths sit beside the state fileEach answered turn becomes one JSON line: time, handle, thread, question,
answer, and the caller’s metadata. The sender is never written. The fork
is still told who is asking and may repeat it in the answer, so a caller
that shows the log to other people should send a pseudonym as sender.
POST /ask accepts an optional metadata object of flat strings (up to 20
keys, each value up to 4,000 characters); a docs site sends the page and
heading a question came from. GET /turns reads the log back, newest first:
curl -s "localhost:8766/turns?handle=jev-expert&meta.page=/concepts/noul/&limit=50" \ -H "X-Ask-Token: $(cat ~/.config/agent-tunnel/http-token)"# {"ran": true, "skipped": 0, "turns": [{"ts": "...", "handle": "jev-expert",# "thread": "...", "question": "...", "answer": "...", "metadata": {...}}]}Every meta.<key>=<value> parameter must match exactly. With no turn_log
the route is a 404. The /ask response carries logged, which is false
(with a notice) when the turn was answered but could not be written.
Watching live (tmux mode only)
Section titled “Watching live (tmux mode only)”This applies only when you run with --backend tmux. In headless mode
(the default) there are no windows to watch — use the agent-tunnel forks
table and the daemon’s per-question log lines for visibility instead.
The tmux backend runs each fork as a real claude in a window on a
dedicated, private tmux server (tmux -L agent-tunnel) — isolated from
your main tmux (its own file-descriptor budget, out of your normal
tmux ls). Watch colleague Q&A happen live:
agent-tunnel watch # run from a plain terminal tabEach thread is a window named after its handle. Detach with your tmux prefix
then d.
Cleanup model
Section titled “Cleanup model”- Colleagues close their own threads with
!done(immediate teardown). - A backstop reaper kills forks idle longer than
pane_idle_ttl_min(default 3h — only catches abandoned threads; set0to disable). - You can also
agent-tunnel forget --all,tmux -L agent-tunnel kill-server, or just Ctrl-Cserve.
Resuming a fork (pick up colleague context)
Section titled “Resuming a fork (pick up colleague context)”A fork keeps its JSONL transcript after the tmux window is reaped — so the
extra context your colleagues built up lives on, and you can continue it
yourself. The catch: a fork copies the original session’s name, so multiple
forks show the same name in Claude’s /resume picker and you can’t tell
them apart. forks and resume sidestep that entirely by using the exact
fork id the daemon recorded:
agent-tunnel forks # all forks: handle, asker, last active, turns, idagent-tunnel forks cmmc # filter to one handleagent-tunnel resume cmmc # resume cmmc's fork in Claude CodeA handle can have several forks (each colleague’s thread is its own). When
there’s more than one, resume picks the most recently active and lists
the rest so you can choose:
handle 'cmmc' has 3 forks (resuming the most recent, ←): ← alice 2h ago 14 turns fork c75f3a83 bob 1d ago 6 turns fork 9e21ab04 you 3d ago 2 turns fork 1f0b40e6 pick another: agent-tunnel resume cmmc --fork 9e21ab04The turn count is your “how much context” signal. resume runs
claude --resume <fork-id> in that fork’s project dir and config dir (work vs
personal), so you land in the right session — no /resume name ambiguity.
Server modes (headless vs tmux)
Section titled “Server modes (headless vs tmux)”Choose how the daemon answers with --backend or [tunnel] backend in the
config. The default is headless.
headless— aclaude -psubprocess per question, with clean JSON in/out. More reliable (no terminal scraping or submit-timing heuristics) and needs no tmux. Launch:agent-tunnel serve.tmux— a real interactiveclaudeper thread, each in a window of a private tmux server you can watch live withagent-tunnel watch. Launch:agent-tunnel serve --backend tmux.
Both run on your logged-in claude (no API key needed). Do not put
--bare in [claude] headless_extra_args — it skips your user config and
breaks subscription auth. Each new thread replays the published session’s
full context (~its token count per cold question), which is fine for Q&A.
Follow-ups after you keep working (headless only). A thread’s fork is a
snapshot from when the thread opened. If the published session’s transcript
has grown since then, the next follow-up forks your latest session afresh
and prepends a recap of the thread’s earlier Q&A, so the colleague gets your
newest context without losing the conversation. The daemon log marks these
turns follow-up (re-forked from latest session). Tune or disable it:
[tunnel]refresh_forks = true # false = always resume the thread's original fork
[limits]recap_max_chars = 8000 # size budget of the earlier-Q&A recapThe tmux backend always resumes the thread’s own fork. A new
<handle> <question> post always starts from your latest state in either
mode.
The daemon logs one line per question — Q [thread] handle ← asker: "…" on
arrival and A [thread] handle: … in Ns, N chars, fork … on completion (plus
errors) — so tailing the serve output is a live activity feed and a
who-asked-what trail. That’s the primary way to watch a headless daemon
(which has no agent-tunnel watch); pair it with the agent-tunnel forks
table.