Skip to content

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

  • 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 (Discord or Mattermost).
inside any session: >share ─► hook writes registry { handle → session }
│
colleague's chat ─► Discord / ◄─ ws ─ agent-tunnel serve ─► fork that
(handle → thread) Mattermost (reads registry) session

Nothing to install. You hand them a handle; they:

  1. Not sure what’s shared? Post !list (or !handles) in the channel to see every available handle.

  2. Post <handle> <question> — e.g. pay-7Q2 how does token refresh work?

  3. The bot opens a thread (named <handle>: <question>) and answers there. They keep asking in that thread — no handle needed again.

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

Installing the package adds a single console command, agent-tunnel — a command group (like aichat) whose subcommands are:

SubcommandWhat it does
agent-tunnel serveRun the chat daemon (Discord and/or Mattermost) — the long-lived bridge that answers questions
agent-tunnel publishedList the sessions you’ve shared (handles in the registry)
agent-tunnel statusShow 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 watchAttach to the private tmux server to watch live forks
agent-tunnel doctorReadiness check (tokens found, channels set, claude/tmux on PATH; Mattermost when configured)
agent-tunnel ask "Q" --handle HAsk one question through the full pipeline, no chat (local test)
agent-tunnel forget --thread T | --allDrop thread bindings and kill their tmux windows
agent-tunnel initWrite a commented sample config
agent-tunnel helpExtensive 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.

Most commands are plain CLI; only two care about tmux:

CommandWhere to run it
agent-tunnel serveAnywhere it stays alive — a tmux pane is handy. It drives its own private tmux server, so it never touches your main one.
agent-tunnel watchBest from a plain terminal tab (outside tmux) to avoid nesting. It works inside tmux too (it warns you), just with awkward prefix keys.
>shareInside the Claude session you want to publish — it’s a hook from the plugin, not a subcommand.
agent-tunnel resumeA normal terminal where you want the Claude session — it execs claude --resume, dropping you into the fork.
ask · published · status · forks · doctor · forget · initAnywhere — tmux context is irrelevant.

agent-tunnel help prints all of this plus every command’s details.

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

CommandEffect
>sharePublish 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 statusShow this session’s handle, if any
>share offStop 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.

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.

One agent-tunnel serve can run Discord, Mattermost, or both. Do Part A for Discord and/or Part A′ for Mattermost.

  1. At the developer portal → New Application.

  2. Left sidebar → Bot. Set its display name. Reset Token → Copy (treat it like a password).

  3. Toggle MESSAGE CONTENT INTENT on — mandatory, or the bot sees blank messages.

  4. 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.

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

  6. 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.

  1. System Console → Integrations → Bot Accounts: enable bot account creation.

  2. System Console → Integrations → Integration Management: enable Personal Access Tokens.

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

  4. 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.

  1. Install the plugin that provides >share (from this repo’s marketplace):

    Terminal window
    claude plugin marketplace add pchalasani/claude-code-tools
    claude plugin install agent-tunnel@cctools-plugins

    Restart Claude Code, then >share works in any session.

  2. 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 /api
    token_file = "~/.config/agent-tunnel/mattermost-token.txt"
    # token_env = "AGENT_TUNNEL_MATTERMOST_TOKEN" # checked before token_file
    channel_ids = ["abcdefghijklmnopqrstuvwxyz"] # 26-char ids
    allowed_user_ids = [] # empty = anyone in the watched channels
    verify_tls = true # false only for a self-signed test server
    default_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"
  3. Check readiness:

    Terminal window
    agent-tunnel doctor
Terminal window
agent-tunnel serve

Keep 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.

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 = 8766
token_file = "~/.config/agent-tunnel/http-token" # any secret string
platform = "the web" # named in the fork's persona

Off unless port is set. Every route requires the shared secret:

Terminal window
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": []}
  • handle is any live published handle; the front-end is not tied to one.
  • thread is 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 --write or --bash handle answers with read tools here), because the caller picks the handle. Custom [claude] allowed_tools and disallowed_tools do not widen them either, permission flags in headless_extra_args/tmux_extra_args are dropped for these forks, they run with --permission-mode dontAsk and --strict-mcp-config whatever the config says, and an all handle does not get --dangerously-skip-permissions here.
  • Fields must be strings within their limits (a question over 8,000 characters is a 413). The [limits] per_user_cooldown_s applies per sender (a second question inside it is a 429). A failed turn is a 502 with the relay’s own error text; ran says 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.

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 file

Each 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:

Terminal window
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.

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:

Terminal window
agent-tunnel watch # run from a plain terminal tab

Each thread is a window named after its handle. Detach with your tmux prefix then d.

  • 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; set 0 to disable).
  • You can also agent-tunnel forget --all, tmux -L agent-tunnel kill-server, or just Ctrl-C serve.

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:

Terminal window
agent-tunnel forks # all forks: handle, asker, last active, turns, id
agent-tunnel forks cmmc # filter to one handle
agent-tunnel resume cmmc # resume cmmc's fork in Claude Code

A 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 9e21ab04

The 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.

Choose how the daemon answers with --backend or [tunnel] backend in the config. The default is headless.

  • headless — a claude -p subprocess 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 interactive claude per thread, each in a window of a private tmux server you can watch live with agent-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 recap

The 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.