Unofficial, community-built client β not affiliated with or endorsed by Anthropic. "Claude" is a trademark of Anthropic.
βββ Bash / Filesystem
βββ Playwright / LibreOffice
βββ Python kernel / DuckDB
ResearchMesh-Router βββββββ€
βββ workstation ββ ResearchMesh (Agent)
βββ gpu-box ββββββ ResearchMesh (Agent)
βββ scraper ββββββ ResearchMesh (Agent)
The same toolset as ResearchMesh, plus the ability to drive any number of ResearchMesh agents on other machines, without the tool-name conflicts that combination normally causes.
Two kinds of tool, in one list:
- Local β
bash,python,computer,memory,browser_navigate, β¦ run here, immediately. - Worker β
gpu-box__delegate,scraper__delegate, β¦ run on another machine, over MCP.
Ask for something and Claude picks the machine. Independent work on different workers runs at the same time.
Not an MCP server, by design. It connects out to ResearchMesh workers or other MCP servers; nothing connects in, which keeps tool names unambiguous. Claude Code can reach each worker directly but cannot drive the whole mesh through one endpoint, and this router cannot be a worker in someone else's fleet.
Less restrictive than Claude Code. No approval prompts, no permission model, no context compaction. It runs any program, command or script your user can run, on this machine and on every worker. That is the point, and the risk.
Scope each worker like a role-scoped employee account, not one all-access
account. No approval gating exists anywhere in this fleet, locally or on any
worker. The mitigation is OS-level access matched to the machine's job: a
dedicated non-admin account, file and directory permissions, GPOs or
Configuration Profiles (see
ResearchMesh's README for the Linux,
Windows and Mac mechanisms). A misrouted or hallucinated request then fails at
the OS layer: a "Graphic Designer" worker asked to modify a production database
cannot, because its account has no database access. Scope each worker's account
to its description in config.toml, not to whatever is convenient to set up.
Workers as employees. Beyond OS scoping, give each worker its own email address, let it work with people and other AIs in Teams or Slack, and route its work through the systems everyone else uses: a CRM/CMDB such as ServiceNow or ConnectWise as the system of record, change tickets for anything that touches production. None of this is built into the 26 local tools; Adding workers connects a worker to an email, Teams/Slack or CMDB MCP server, and it participates through the same front doors a new hire would. "No approval gating" means no y/n dialog in this software, not that nothing gates a risky change: a maintenance request can be submitted instantly, but whether it runs depends on the same Change Advisory Board approval a human's request needs, because that gate lives in the change-management process, not in this client.
26 local tools, plus one per connected worker:
| Tool | For |
|---|---|
bash |
Shell commands as your user via /bin/bash by default. Stateless: a fresh subprocess each call. [bash] in config.toml selects a different shell (e.g. zsh) |
str_replace_based_edit_tool |
View, create, and edit files |
web_search Β· web_fetch |
Anthropic's server-side search and page fetch |
memory |
A /memories store that persists across sessions β the only state that outlives the process |
computer |
Screenshots plus mouse/keyboard control, on X11 (pyautogui) or Wayland (xdg-desktop-portal remote control; needs dbus-next and spectacle or grim) |
desktop_window |
List windows, and focus, move, resize, full-screen, minimize or restore one, on a KDE desktop (KWin scripting; needs dbus-next), so keystrokes reach the right window |
screen_find |
Find on-screen text (text) or button-like blocks (buttons: true), limited to a region if given, by OCR, and return click coordinates in computer's space; reads text on coloured buttons that plain OCR misses (needs tesseract) |
browser_navigate Β· _links Β· _click Β· _fill Β· _extract Β· _back Β· _tab |
Playwright DOM browsing: renders JavaScript, follows links and new tabs, fills forms, saves downloads to ~/Downloads. _navigate takes mode and profile (see below), _tab lists, switches and closes tabs, and _fill takes a pass vault entry (value_secret) without the value appearing in the conversation, or submit to press Enter afterwards |
document_convert |
LibreOffice + pandoc. Markdown β .docx/.odt/.pdf, or any office format to any other |
python |
Persistent IPython kernel β variables survive between calls |
bash_session |
Persistent shell β cd/env/venvs/background jobs survive between calls |
interactive_run |
Commands that prompt: passwords, [y/N], ssh host keys, installers |
config_edit |
Edit YAML/TOML/JSON without destroying your comments |
sql_query |
DuckDB straight against CSV/Parquet/JSON β no import step |
trash |
Recoverable deletes instead of rm |
text_embeddings |
Vector embeddings from an HTTP embedding server you configure, self-hosted or a paid API. See [embeddings] in config.toml for worked examples |
vision_query |
Ask a question about an image via your own vision-capable chat server, instead of sending it to Anthropic's API. See [vision] in config.toml for worked examples |
speak Β· listen |
Local text-to-speech (Piper) and speech-to-text (faster-whisper) through your own speaker and mic; no cloud audio API. Both return not_configured until [speak] and [listen] are set in config.toml, which also covers first-time device setup |
<worker>__delegate |
Hand a whole task to a ResearchMesh agent on another machine |
Browser modes. browser_navigate takes mode and profile:
headless(default): no window; installed Chrome if present, else bundled Chromium.headed: a visible window on your desktop (headed: trueis an alias).virtual: Chrome on a hidden display (Xvfb); no window appears.real: your installed Chrome started normally and attached over CDP, the least detectable mode. It opens a window you can click in and is closed when the client exits.virtualandrealneed Google Chrome (google-chromeorgoogle-chrome-stableonPATH);virtualalso needsxvfb.headedandrealneedDISPLAYorWAYLAND_DISPLAYand return an error without one.profilenames a persistent profile (1-40 letters, digits,-,_; cookies and logins survive restarts) under~/.cache/researchmesh/browser-profiles, mode 700. Without one the session's profile is deleted when it closes. Changing mode or profile restarts the browser.- A report carries a
Human check:line when a Cloudflare check appears. A fresh default-mode visit that a check stops is reopened once invirtualmode; if it still says pending, userealor click the check yourself. - Downloads are saved to
~/Downloads(RESEARCHMESH_DOWNLOAD_DIRoverrides) under a unique name, so an existing file is never overwritten, and are listed asDownloaded:lines in the result.
Every machine has its own copy of all this. The python kernel here is not a
worker's kernel, and /memories here is not a worker's memory store. Same names,
different computers, no shared state.
- One request can fan out into many tool calls, local and worker alike (capped at 200 per turn).
- A down worker and a mistyped worker name look the same to
/workersand/dagent./workersreports only what is reachable right now (a cached listing could report a machine that went down ten minutes ago), so a rejected name could be either. /dagentexists because a localbashis instant, while adelegatetakes minutes and has to be written as an outcome. Left alone, Claude prefers the local tool and does a worker's job on the wrong machine./dagentremoves the local tools from the request.- If it keeps returning 400s, run
/clear. Two failures persist for the life of the process, an unansweredtool_useblock and a conversation past the context window, and both fail every later turn the same way. The error report names which one you hit;/clearrecovers from either and keeps your worker connections. ruff check .andmypy .should both pass.- Run
python smoke_test.pybefore you commit. It needs no API key, network or running workers. It builds a fleet of fakes and checks what breaks silently: two workers exposing the same tool name get two distinct, API-legal names; the namespacing round-trips; a dead worker is skipped; groups fan out while one worker's calls stay serial; everytool_useblock gets exactly onetool_result, in order;/dagentwithholds every local schema; and the docs match the code (README's tool count, CLAUDE.md's module list). - Two checks sit outside the gates because they spend real tokens.
python e2e_test.py(~15s, needsANTHROPIC_API_KEY) launches two workers over real stdio MCP and checks that the duplicate-name 400 is the API's actual behavior, that namespacing survives a real transport, and that a real model issues both calls in one turn.python test_model_compat_live.py(real API, ~9 requests) checks the per-model tool-compatibility handler against Anthropic's actual error wording.
You need Linux, Python 3.11+, and an Anthropic API key. This is an API client, so a Claude subscription won't work.
sudo apt install python3 python3-venv python3-dev build-essential \
libreoffice pandoc python3-tk scrot pulseaudio-utils \
tesseract-ocr xvfb
python3 -m venv ~/researchmesh-router
source ~/researchmesh-router/bin/activate
pip install -r requirements.txtlibreoffice and pandoc back document_convert; python3-tk and scrot back
computer (step 3); tesseract-ocr backs screen_find; xvfb backs the
browser's virtual mode and the nested X server in step 3. pulseaudio-utils
backs speak and listen (paplay, parecord), which call it directly with no
fallback, so a missing package is a raw subprocess failure, not a tool that
declares itself unavailable. A desktop with PipeWire usually has it; a headless
server or WSL does not.
The router needs the same backings as
ResearchMesh because it runs the same
local tools. Every per-tool package in requirements.txt is required, and each
is imported lazily, when its tool first runs.
playwright install chromium # the browser binary β pip installs the package, not this
sudo playwright install-deps chromium # OS librariespip install pyautogui succeeds on its own, so a missing-package failure here is
misleading: computer reports pyautogui as missing when it is really one of two
apt packages. python3-tk: pyautogui pulls in mouseinfo, which imports
tkinter at module level. scrot: pyscreeze's screenshot path on X11.
computer works on X11 (pyautogui) and on Wayland (echo $XDG_SESSION_TYPE). On Wayland it goes through xdg-desktop-portal: the desktop
may ask for approval when a session starts, and while it lasts KDE shows a
"Remote Control" tray icon whose End entry stops it (the next action starts a
new session). It needs dbus-next (in requirements.txt) and spectacle or
grim for screenshots. The screen is one monitor: the leftmost one shared in the
dialog, or CLAUDE_COMPUTER_MONITOR=<index>. Share every monitor in the dialog.
With only some shared, the screenshot scale is estimated (exact when they span the
desktop's width or height) and a warning is printed. Typing goes through keysyms;
on Plasma 6 capitals and symbols arrive as written. The pointer position is not
readable from Wayland: cursor_position returns an error until the pointer has
moved once. On KDE, desktop_window focuses the window that should receive
keystrokes and screen_find returns click coordinates by OCR.
CLAUDE_DISPLAY_SIZE=WxH sets the logical display size declared to the model
(default 1280x800). Screenshots go to the model, as for any use of this tool.
To use X11/XTEST on an XWayland-only setup or inside a nested X server instead:
xvfb-run -s '-screen 0 1280x800x24' python main.py # nested X server
export CLAUDE_COMPUTER_FORCE=1 # XWayland-only setupexport ANTHROPIC_API_KEY=sk-ant-... # add to ~/.bashrc to keep it
export CLAUDE_MEMORY_DIR=~/.router-memories # else it writes into this repoIf you also use Claude Code with a subscription, add this alias too (same file) so the API key doesn't shadow your subscription auth:
alias claude='env -u ANTHROPIC_API_KEY claude'Set CLAUDE_MEMORY_DIR explicitly. The default is ./memories, relative to
the working directory, and ResearchMesh uses the same default. Run both from
adjacent checkouts and they write to two different ./memories paths that only
look related. Point the router at its own path (~/.router-memories or similar).
RESEARCHMESH_DOWNLOAD_DIR changes where browser downloads land (default
~/Downloads).
python main.pyconfig.toml ships with [mcp].enabled = false, so a fresh clone runs on the 26
local tools alone. Set it to true once you have added workers.
Just type. At the > prompt:
<anything> |
ordinary turn β local tools and workers are offered |
/workers |
list the workers that are up |
/dagent <task> |
delegate-only: the local tools are withheld for this turn |
/dagent <worker> <task> |
the same, pinned to one machine |
/think <anything> |
give Claude longer to reason |
/clear |
drop the conversation, keep the workers connected |
/model |
list the ROUTER's own models, /model swap <name|index> to swap for this session |
/model <worker> |
list a CONNECTED WORKER's models, /model <worker> swap <name|index> to swap them remotely |
/voice [on|off] |
toggle whether Claude's replies also get spoken aloud (speak, local Piper TTS) |
/listen [N] |
record N seconds from your mic (or [listen].default_duration_seconds), transcribe locally (faster-whisper), and auto-submit it as your next turn β no Enter press needed, works the same whether /voice is on or off |
/voice and /listen need [speak] and [listen] set in config.toml first
(see the tools table above and that file's inline setup comments). Both ship
fully commented out, like [vision] and [embeddings]. Without that, /voice
still toggles but has nothing to speak, and /listen reports not_configured or
disabled instead of opening the mic.
/model lists the models in config.toml's [claude] claude_models, each
with an index. /model swap <name or index> swaps the model for this session
only; it never edits config.toml, so a new session starts on the first entry.
The list is a live-refreshed cache, not hand-typed: about once a day
(model_scan_ttl_hours, default 24) it re-scans Anthropic's /v1/models and
rewrites claude_models to one entry per model family, newest first, sonnet
first when present. A failed scan (offline, bad key) changes nothing on disk.
This affects only the router's own reasoning model. A connected worker's
model is set in that worker's own config.toml.
Haiku 4.5 has no computer tool. It rejects it, so the client drops the tool
for Haiku after one rejected request (a [model compat] line is printed) and
every other tool keeps working. If computer was used earlier in the
conversation on another model, /model swap to Haiku fails every turn with a 400
(toolset_name 'computer' on a tool_use block is not the family of a declared toolset entry (no toolset entry is declared)): swap back, or /clear.
/model <worker> lists a connected worker's models instead, e.g.
/model gpu-box, from that worker's own model MCP tool (a sibling of
delegate): no agent turn is spent and no Anthropic API call is made.
/model <worker> swap <name or index> swaps that worker's model immediately,
for every later delegate call to it from any session, until changed again or
that worker process restarts. Every worker response is printed with a
[worker: <name>] prefix, never a bare [model: ...], so it cannot be mistaken
for the router's own /model output. The worker owns its own TTL and live-scan
cache; the router adds no TTL logic to a remote call. The slash command is not
required: the router's own Claude can see and call a connected worker's model
tool during a normal turn, since it is namespaced into the tool list like
delegate, so asking in plain language ("swap gpu-box to opus") works too.
Ctrl-C exits and shuts everything down cleanly.
Each prompt below is meant to be pasted into the CLI as is.
a) Build your own persistent memory of this machine β do this one first, always.
Before we do anything else, I want you to build yourself some persistent memory about
this machine, since /memories is the only state that survives a session reset or a
restart β everything else (the Python kernel, the browser page, the DuckDB connection)
resets every time. Figure out what Linux distro and version this actually is first
(don't assume β check `/etc/os-release`, `uname -a`, etc.), then scan this machine's
real hardware (CPU, RAM, GPU, disks) and what's actually installed: CLI tools on PATH
via `command -v`, packages via whichever package manager this distro actually uses
(`dpkg`/`apt` on Debian/Ubuntu, `rpm`/`dnf` on Fedora, `pacman` on Arch, `zypper` on
openSUSE, etc. β check which one applies here rather than guessing), plus snap/flatpak
if either is present. Then write two files: 01_environment_notes.md (hardware specs,
the distro/OS version you actually found, disk layout, and any quirks or behaviors you
run into along the way β display server, privilege model, which package manager(s) are
in play) and 01_system_tool_inventory.md (a categorized inventory of what's already
installed β GUI apps, CLI tools, dev-assistant tools, reusable scripts you find lying
around β so you reach for a real local tool instead of writing something from scratch
every time). In both files, add a short instruction near the top telling your future
self to re-scan and refresh the file's contents the next time you're asked to read them,
rather than trusting old data blindly β so this stays accurate as things change on this
machine over time.
NOTE: this is the most useful prompt on the list. Do it once and every later session starts already knowing your machine. It builds this machine's own memory; a worker you add later builds its own.
b) List its own slash commands.
List all your custom commands and their options.
c) Understand why any of this is worth doing.
Now that you've looked at what's installed on my machine, explain in plain terms why
it's worth installing extra local command-line tools β like ripgrep, fd, jq, ffmpeg,
ImageMagick β instead of just having you write a one-off script from scratch every
time I ask for something similar. What's actually being saved by doing this?
d) Install the recommended tools, one at a time.
Look at the "Recommended local tools" section further down in this project's
README.md, and install every tool listed there via apt/snap/flatpak/rustup β one at
a time. Wait for each install to fully finish and tell me whether it succeeded or
failed before starting the next one. Don't batch them together.
NOTE: this installs on this machine only (the router). Repeat it on each worker.
e) Mouse/keyboard GUI control.
Open a text editor (gedit, kate, or whatever opens by default), type "Hello, I am
controlling your mouse and keyboard," save it to my Desktop, then export that same
file as a PDF, also saved to my Desktop.
TIP: don't touch your mouse or keyboard while it runs; fighting it for control makes the task harder. On Wayland the desktop may ask for approval (step 3).
f) Headless, DOM-based web browsing.
Go to news.ycombinator.com using DOM-based browsing β not a visible browser window β
open the #1 story on the front page, and give me a short summary of it.
NOTE: this reads and surfs the web without opening a window or touching your mouse and keyboard.
g) Write a document, then convert it.
Write a short one-page markdown file about the history of the QWERTY keyboard layout,
then convert it to a PDF and save both the markdown and the PDF to my Desktop.
h) What is the airspeed velocity of an unladen swallow?
All of the above run on this machine and need no worker. Once you have added one
(see Adding workers), try /workers and /dagent.
interactive_run answers a command's prompts (sudo, ssh, git, anything that asks
for a password) from a vault on your machine. The model supplies only the name of
an entry; the value is decrypted locally and never appears in the conversation.
browser_fill takes the same vault entries for web logins (value_secret). Set
up the vault once (below). After that, whenever a command needs a credential, the
agent asks you to pick from the names you saved.
Name check. An entry is decrypted only if you typed its name in one of your
own messages this session, so the model cannot pick one on its own. When it needs
a credential it lists the real entry names and waits for you to name one. A typed
name stays confirmed for the rest of the session and for any use. The match is on
the whole name anywhere in your message, so a passing mention ("push it to github"
with an entry named github) also confirms it. A task delegated to a worker never
counts as your message there, so a delegated task cannot unlock the worker's
vault entries.
What is and is not protected:
- The value goes from
pass showto the child process over a pty and is never in a tool call. The transcript returned to the model has the value scrubbed, along with its percent, form, HTML, JSON, hex and base64 encodings. A reversed or otherwise transformed copy that the child prints is not caught and would reach Anthropic. - sudo's password feedback (asterisks) shows the password's length in the transcript, not its text.
send_envtakes the NAME of an environment variable and is scrubbed the same way, withoutpass.browser_filltypes a confirmed entry into whatever page is open. A malicious page that talks the model into filling its login form receives the real value, and scrubbing does not help, because the value never returns through the model. Name an entry only when you want it used, and watch which site the browser is on.- Only the first line of a
passentry is used. - A GPG passphrase prompt (
pinentry) appears on your screen, not in the conversation. If the key is not cached and nobody answers,pass showtimes out after 30 s and its whole process group (passand thegpgit started) is killed; unlock the key once in your own terminal first. computerhas no vault option: type a password into a native window yourself.- A one-time code (authenticator, SMS, email) is not a vault secret. Paste it in
the chat and the agent enters it at once with
browser_fillsubmit: true.
Full pass vault setup, walkthrough + reference charts (click to expand)
One-time pass setup β install first:
sudo apt install pass pinentry-curses
SETUP SEQUENCE SETTING UP A VAULT FROM SCRATCH
ββββββββββββββββββββββββββββββββββββββββββββββ
Step 1: gpg --full-generate-key
You type: Name, Email, Passphrase
Purpose: Creates your encryption key (a public/private key pair)
Step 2: gpg --list-secret-keys
You type: Nothing β just run it
Purpose: Shows you the Key ID (long hex string) you'll need next
Step 3: pass init <key-id>
You type: The Key ID from step 2
Purpose: Tells pass "encrypt my whole vault using this key"
Step 4: pass insert <entry-name>
You type: A name you choose, then the secret value to store
Purpose: Encrypts and saves one password under that name
Step 5: pass show <entry-name>
You type: Nothing β just the entry name
Purpose: Decrypts and prints that password (needs your passphrase
the first time; gpg-agent caches it for a while after)
EXPLANATION FOR SETTING UP A VAULT FROM SCRATCH AND ADDING YOUR GITHUB PERSONAL ACCESS TOKEN (PAT) TO IT AS AN EXAMPLE
Using a PAT specifically, not a password, because GitHub doesn't accept account passwords for git/API operations at all anymore β a PAT is what actually goes in that prompt. Generate one at github.com β Settings β Developer settings β Personal access tokens.
Thing Where it comes from What it's actually for
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Name / Email You type it when you run The vault never reads this
(= "User ID") `gpg --full-generate-key` β but YOU will. It's the
to create your key only human-readable label
you'll see when running
`gpg --list-keys` later.
Pick something you'll
recognize (e.g. name:
"pass-vault"), not
garbage β you're the one
who has to remember it,
not the software.
Passphrase You type it when you run This passphrase allows
`gpg --full-generate-key`, you to get into your
same command as above vault.
Key ID GPG generates this on its An ID number you give to
(long hex own, shown to you after `pass init` one time, to
string) you run `gpg --list-secret- tell your (still-empty)
keys` vault which key to use.
Public key Generated automatically Locks up new passwords
alongside the key, same you save β used the
command as above moment you run
`pass insert github`.
Private key Generated automatically Unlocks passwords so you
alongside the key, same can read them β used the
command as above moment you run
`pass show github` (once
the passphrase has
unlocked the key itself).
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Your Actual You type it when you run THIS is your actual
GitHub `pass insert github` β pass GitHub PAT β the real
Personal then asks you for it on its credential git sends to
Access Token OWN separate line, AFTER you GitHub over HTTPS. Lives
(PAT) run that command INSIDE the vault,
encrypted. Retrieved
with `pass show github`.
GitHub sees THIS, never
the passphrase. NOT the
same as, and unrelated
to, the passphrase
above. NOT your GitHub
account password either
β GitHub no longer
accepts that for git/API
use at all.
Once set up, a tool call looks like:
{"expect": "Password for", "send_secret": "github"}Note: git's prompt says "Password for ..." although the PAT belongs there; the
expect regex has to match what git actually prints.
The model only ever sees the word "github", never your real PAT.
BELOW IS HOW YOU BLOW THE WHOLE VAULT AWAY IF YOU WANT START OVER
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
gpgconf --kill gpg-agent
rm -rf ~/.password-store
Example run βββββββββββ
$ python main.py
[mcp] disabled in config.toml β no workers
> please run sudo whoami
Response:
please select the cred name I need to use:
super_secret_admin_password
> super_secret_admin_password
Response:
`sudo whoami` returned **`root`** β the `super_secret_admin_password` credential authenticated successfully.
The transcript the model receives shows the password as ***.
Non-secret settings live in config.toml. Secrets stay in the environment; the app
does not read a .env file. The file ships with [mcp].enabled = false, so a
fresh clone runs on the 26 local tools alone.
[router]
max_parallel = 8 # how many workers may be busy at once
timeout_seconds = 900 # default deadline for one call to a worker
[mcp]
enabled = true
servers = [
{ name = "gpu-box",
url = "http://192.168.2.31:8100/mcp/",
token_env = "GPU_BOX_MCP_TOKEN",
description = "Headless Linux, no display β GUI tasks fail here. CUDA stack and the datasets in /data.",
timeout_seconds = 3600 },
]A worker that is unreachable prints a warning and is skipped, so one being down
doesn't stop the app. config.toml is committed and never holds a token, only the
name of the variable that holds one. Generate a token with
python -c "import secrets; print(secrets.token_urlsafe(32))".
See Adding workers for what each field does and how to bring a worker online.
| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY |
Read from the shell. The app does not load a .env. |
CLAUDE_MEMORY_DIR |
Where memory stores /memories. Defaults to ./memories relative to the working directory β set it (see Setup step 4). |
CLAUDE_SHOW_USAGE=1 |
Per-request token and prompt-cache counters. |
CLAUDE_KERNEL_ENCRYPTION |
auto (default) tries CurveZMQ-encrypted TCP, then IPC, then plaintext TCP, printing why each tier fell through; required fails the tool rather than running unencrypted; off skips encryption. Covers this machine's kernel only; a worker's kernel reads the variable from the worker's own environment. |
CLAUDE_DISPLAY_SIZE |
WxH, the logical display size computer declares to the model. Default 1280x800. |
CLAUDE_COMPUTER_FORCE=1 |
Use X11/XTEST for computer on a Wayland session (XWayland-only setups, nested X servers). |
CLAUDE_COMPUTER_MONITOR |
Monitor index, counted left to right, for computer on Wayland. Default: the leftmost shared one. |
RESEARCHMESH_DOWNLOAD_DIR |
Where browser downloads land. Default ~/Downloads. |
PASSWORD_STORE_DIR |
The pass store whose entry names interactive_run and browser_fill offer. Default ~/.password-store. |
| (per worker) | Each token_env names the variable holding that worker's bearer token. No token_env means unauthenticated. |
| (embeddings server) | Whatever [embeddings].api_key_env names, if your server needs auth. |
| (vision server) | Whatever [vision].api_key_env names, if your server needs auth. |
Any MCP server works; ResearchMesh is what the router was built and tested
against. Each worker's tools are prefixed with its name, so identical machines
never collide.
On each worker machine, run ResearchMesh as a server:
export RESEARCHMESH_MCP_TOKEN=...
python mcp_server.py --transport streamable-http --host 0.0.0.0 --port 8100Then add it to config.toml here (full example under
Configuration). Four fields deserve a second look.
name becomes the tool prefix (gpu-box__delegate). Keep it short. Letters,
digits, _ and - pass through unchanged, and anything else is replaced with
_, so gpu box and gpu.box would collide (gpu-box needs no substitution).
A collision gets a numeric suffix instead of silently shadowing one worker's tool
with the other's.
description is strongly recommended. ResearchMesh hardcodes one description
constant, so every worker describes itself identically unless you add your own.
Write what is true of that machine: its OS and session type, what is installed
or attached, what data is on it, what it must not be used for. It is prepended to
that worker's tools as [worker: name] ... and makes routing much more reliable.
timeout_seconds matters more than it looks. The MCP SDK defaults to 300s. A
worker driving a GUI runs longer than that, and when the timeout fires the work
is already done on the far side and lost. The router default is 900s; raise it
per worker for long compute. The connect timeout stays at 15s, so a machine
that is switched off fails in seconds instead of hanging the turn.
url can be https://. The router adds no certificate logic; it uses the
HTTP client's normal trust configuration (the OS trust store). A company CA or
private certificate works only if that CA is already trusted on the client
machine, or if SSL_CERT_FILE=/path/ca.pem or SSL_CERT_DIR=/path/to/certs is
set for that process. The certificate itself belongs on the worker
(mcp_server.py --ssl-certfile/--ssl-keyfile). Over plain http:// the bearer
token and every task and result cross the network in the clear: acceptable on a
trusted LAN, not on a corporate one.
How work is distributed. Worker calls are grouped by machine. Groups run
concurrently and calls within a group run in order, because a ResearchMesh worker
has one mouse, one browser page and one kernel, and serialises delegate behind
a lock. So max_parallel is really how many machines at once. Local tools run in
order. A turn calling three workers takes as long as the slowest, not the sum.
Project layout and extending
main.py entrypoint β loads config, connects the fleet, runs the REPL
mcp_client.py MCP client (stdio / SSE / Streamable HTTP)
config.toml the fleet, and router behaviour
smoke_test.py the offline gate
e2e_test.py live check against real workers (costs tokens, not a gate)
e2e_worker.py the stand-in worker e2e_test.py launches
test_model_compat_live.py live check of the per-model tool handler (costs tokens, not a gate)
test_*.py behavioural tests for individual tools (no API; see CLAUDE.md, Commands)
core/
chat.py the agentic loop, routing prompt, /dagent
claude.py Anthropic SDK wrapper
cli.py prompt_toolkit REPL
tools.py namespacing, worker identity, fan-out β the reason this exists
local_tools.py registry β the one place a local tool is wired in
browser.py browser_session.py computer.py wayland_input.py kernel.py bash_session.py memory.py data.py
documents.py processes.py config_edit.py files.py output.py
claude_learned_schemas.py text_embeddings.py vision.py speak.py
listen.py process_reaper.py desktop_window.py screen_find.py dbus_loop.py
Adding a worker is a config edit, no code. Adding a local tool is one
module exposing TOOLS/handles()/execute(), plus a line in local_tools.py.
The app starts without these, but Claude works faster and cheaper with them: it
reaches for a purpose-built local binary through bash instead of spending tokens
re-implementing the job in python, or reading whole files through the editor to
search them. Install them on the router and on each worker. Everything below is
apt, snap or flatpak (Rust uses the official rustup installer), and the
commands are Debian/Ubuntu-specific; on another distro the tool names are the
same, so use your own package manager. The apt and flatpak lines include -y
because Claude may run them itself through bash, which has no terminal to prompt
on; drop it if you want to review each one by hand.
This is the router's own machine only. Each worker is a separate ResearchMesh
install with its own bash, filesystem and set of tools; installing something
here does not make it available on gpu-box or scraper. Repeat the installs on
each worker.
# --- Search, text & structured data -----------------------------------------------
sudo apt install -y ripgrep # rg β recursive search, instead of reading whole files to grep them
sudo apt install -y fd-find # fd β fast, .gitignore-aware find. NOTE: the binary is `fdfind`,
# not `fd` (Debian name clash with an unrelated package)
sudo apt install -y bat # cat with syntax highlighting + line numbers. NOTE: the binary is
# `batcat`, not `bat` (same kind of Debian name clash as fd-find)
sudo apt install -y jq # jq β query/reshape JSON from the shell
sudo apt install -y yq # yq, but for YAML. NOTE: Debian's `yq` is the OLD Python
# jq-wrapper-for-YAML (`yq '.filter' file.yaml`), NOT the popular
# Go-based mikefarah/yq most online docs assume (`yq e '.path' file`)
sudo apt install -y miller # mlr β CSV/TSV/JSON reshape/filter/stats from the shell
sudo apt install -y fzf # fuzzy finder; use `--filter` for non-interactive/scripted matching
# --- File search & disk usage ------------------------------------------------------
sudo apt install -y plocate # modern `locate` β instant filename search across the whole disk,
# from a background-updated index (run `sudo updatedb` once first)
sudo apt install -y tree # directory-structure dumps
sudo apt install -y ncdu # interactive, curses-based disk usage β see what's eating space
sudo snap install dust # fast, visual `du` β not in the default apt repos, snap only
sudo apt install -y duf # nicer `df`, disk-space-by-volume at a glance
# --- Archives & binary inspection ---------------------------------------------------
# tar, gzip, zip, unzip and xz-utils are already on a standard Ubuntu install, which
# covers nearly every format. The gaps:
sudo apt install -y unrar # RAR extraction (RAR is proprietary; nothing built in)
# 7-Zip's .7z format; add it only if you receive .7z files:
sudo apt install -y 7zip # provides `7z`; current Ubuntu has `7zip`, not `p7zip-full`
sudo apt install -y hexyl # colorized hex+ASCII dump, e.g. for raw SysEx/firmware bytes
sudo apt install -y binwalk # scans a binary for embedded file signatures/firmware images β
# the closest apt-packaged equivalent to a deep file-type identifier
# --- Git / GitHub / diffing ---------------------------------------------------------
sudo apt install -y gh # GitHub CLI β PRs/issues/releases from the shell
sudo apt install -y git-delta # syntax-highlighted, side-by-side git diff pager. NOTE: the plain
# `delta` apt package is a DIFFERENT, unrelated 2006 tool and
# installs no `delta` binary at all β `git-delta` is the one that
# actually provides the `delta` command
# --- HTTP / API testing --------------------------------------------------------------
sudo apt install -y httpie # much more readable than raw curl for poking at APIs. NOTE: the
# request-sending command is `http`, not `httpie` β the bare
# `httpie` command is a separate plugin-manager subcommand
# --- C / C++ / Rust toolchains --------------------------------------------------------
# gcc/g++/make (build-essential) come from Setup step 1. clang is an alternative compiler:
sudo apt install -y clang # self-contained C/C++ compiler, alternative to gcc
sudo apt install -y cmake # build system generator
sudo apt install -y ninja-build # fast build backend, pairs with cmake
# Rust: use the official rustup installer; apt's rustc/cargo lag well behind upstream
# and cannot be updated separately from the system:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# --- System diagnostics ---------------------------------------------------------------
# strace and lsof ship with a standard Ubuntu install (`ubuntu-standard`); nothing to add.
# `strace <cmd>` traces syscalls (first move for "why is this hanging"); `lsof` shows
# what has a file or port open.
sudo apt install -y htop # interactive process viewer, nicer than plain `top`
sudo apt install -y procs # modern `ps` replacement, colorized/tree-aware output
sudo apt install -y hyperfine # benchmarking β compare two commands' real run time
# --- Audio production & media metadata ------------------------------------------------
sudo apt install -y ffmpeg # ffmpeg/ffprobe β audio/video transcoding and inspection
sudo apt install -y sox # CLI audio conversion/trim/resample, complements ffmpeg
sudo apt install -y mediainfo # instant codec/bitrate/duration metadata
sudo apt install -y libimage-exiftool-perl # exiftool β metadata on images/audio/PDFs/almost anything
# (package name differs from the `exiftool` command it installs)
# --- Images & graphic design -----------------------------------------------------------
sudo apt install -y imagemagick # convert/mogrify/compare β image conversion & editing from the shell
sudo apt install -y krita # digital painting/illustration, distinct from GIMP (raster) and
# Inkscape (vector)
sudo apt install -y webp # cwebp/dwebp β encode/decode the WebP image format from the shell
# --- Video editing -----------------------------------------------------------------------
sudo apt install -y handbrake-cli # video transcoding with sane presets, complements ffmpeg
sudo flatpak install -y flathub org.shotcut.Shotcut # free timeline-based video editor, not
# reliably in the default apt repos
# DaVinci Resolve (another free NLE) has no apt/snap/flatpak package; Blackmagic
# distributes it by manual download after a free signup.
# --- Documents & writing -----------------------------------------------------------------
sudo apt install -y poppler-utils # pdftotext/pdftoppm/pdfinfo/pdfimages β pull just the pages you
# need out of a PDF as text, without going through LibreOffice
sudo apt install -y calibre # ebook-convert (CLI) β epub/mobi/azw3/etc., more formats than
# document_convert reaches
sudo apt install -y hunspell # command-line spell-checkingThe CLI shell, Anthropic wrapper and MCP client began as copies from
ResearchMesh (same author, MIT), and
the tool modules are copies kept identical in code. Docstrings and comments here
are shorter, so compare the parsed code with docstrings stripped, not the bytes.
The files in core/ that differ in code are browser.py, chat.py, cli.py,
computer.py, local_tools.py and tools.py; browser_session.py,
dbus_loop.py, desktop_window.py, screen_find.py and wayland_input.py
exist only here, and midi1.py exists only in ResearchMesh. Anything else that
differs in code is drift. A fix to a tool in either repo should be a copy of the
code.
It exists because a plain MCP bridge passes tool names through verbatim, so three
ResearchMesh workers all advertising delegate get rejected outright
(400 ... Tool names must be unique). core/tools.py is the fix.
If Claude Code is your front end you don't need any of this: it already
namespaces MCP tools as mcp__<server>__<tool>.
- No loop protection. Nothing stops a worker's config pointing back here.
MIT β use it, fork it, ship it. No warranty; see the file for the full text.