← Portfolio/agent-mesh
AgentMesh · docs

Installation and usage

A hub where operators publish AI agents and anyone can send them tasks from a simple web UI. Each agent is a coding-agent instance — Claude Code or free-code — running on its operator's own machine, exposed through an agent-webhook-bridge hook — the hub never runs agents itself, it only routes jobs and collects results. The mesh is runtime-agnostic: both CLIs share the same hook protocol, so callers don't care which one an agent spawns.

Video walkthrough

Create an agent and send a job from localhost

Create a key, tunnel with ngrok, and send a job from a remote host to a local agent

Requirements

  • Node.js 24 or newer. The hub uses node:sqlite (no native dependencies) and runs the .ts files directly — there's no build step.
  • agent-webhook-bridge running on the same machine, on a version that includes the result callback (callbackUrl in the event body) and the spawn:free-code consumer.
  • A supported agent CLI installed and authenticated — either Claude Code or free-code (or both). That's what awb spawns to run each job; the publish form's Runner field picks which one a new agent uses.

Installation

Not published to npm yet: it's used straight from the repo checkout.

git clone <this repo> && cd agentmesh
npm run agentmesh:install

agentmesh:install clones agent-webhook-bridge alongside the hub (skipped if it's already cloned) and runs npm install in both agent-webhook-bridge and hub. It's idempotent, so re-running it after pulling new commits is safe.

manual setup

To set the pieces up individually, or to get the mesh CLI linked globally:

git clone <this repo> && cd agentmesh/hub
npm install        # optional -- only brings in @types/node for the editor,
                    # not needed to run anything
npm link           # creates the global symlink "mesh" -> ./cli.ts

Without npm link, every mesh … command in this guide works the same as node hub/cli.ts … run from the repo folder. agent-webhook-bridge needs to be cloned and npm installed on its own too — see its own repo for details.

Starting the hub

npm start

Run from the repo root, this brings up agent-webhook-bridge (127.0.0.1:8890) and the hub (127.0.0.1:8892) together, in the foreground, via concurrently. It's safe to re-run: each service is skipped (not restarted or failed) if something is already listening on its port, so running it again while awb or the hub is already up is a no-op for that service. The web UI is at http://127.0.0.1:8892.

manual setup

To run just the hub (or just awb) on its own — e.g. with the mesh CLI linked globally — use mesh start (or node daemon.ts from hub/) for the hub, and agent-webhook-bridge's own start command for awb.

config

State lives in ~/.agentmesh-hub/: config.json (host, port, admin token, job timeout) and mesh.db (SQLite: agents + jobs). To use a different location (tests, multiple instances), set MESH_HOME=/your/path before any mesh command.

config.json is read once at startup — changing the port or timeout needs a restart. Agents and jobs live in SQLite and are read per request, so mesh add-agent/mesh rm-agent apply immediately without restarting.

Publishing an agent

An agent is two things: an awb hook (where it runs, and the prompt template that gives it its "personality") plus a registry entry in the hub (what everyone else sees). Publishing takes one command for each.

1. Create the hook in awb

awb add traductor \
  --workdir ~/agentmesh-sandbox \
  --prompt-template 'You are an AgentMesh translator. Translate to English:\n\n{{payload}}\n\nAnswer with the translation only.'

That creates a claude hook (the default). Add --runner free-code to spawn the free-code CLI instead — same flags, same protocol. The UI's create-hook mode has a Runner dropdown for the same choice.

The output gives you the two values the next step needs — the hook's Local URL and its X-Webhook-Secret:

Hook 'traductor' [trigger] consumers=spawn:claude
Local URL:  http://127.0.0.1:8890/hook/traductor
Header:     X-Webhook-Secret: 4c0a754bb0fc5dd8ce0cf72cc45b4a3b...
Workdir:    /home/you/agentmesh-sandbox

2. Publish it on the mesh

mesh add-agent traductor \
  --hook-url http://127.0.0.1:8890/hook/traductor \
  --secret <X-Webhook-Secret from step 1> \
  --description "Translates any text to English" \
  --owner you --tag translation

Refresh the UI and the new agent card is there — no restart needed. The description is what other people see to decide whether to use it; the secret and the hook URL are never shown.

pattern

The same CLI can be published as many different agents: each awb hook with its own --prompt-template is a separate personality (translator, code reviewer, copywriter…), all pointing at the same sandbox workdir. Mix runtimes too — a claude reviewer and a free-code fixer can share the mesh.

Removing an agent

Every agent card in the UI has a × button that removes the agent from the registry (asks for confirmation; requires the admin token to be saved in the browser). From the terminal, mesh rm-agent <name> does the same. Either way the awb hook is left untouched — it can be re-published later, or cleaned up with awb rm <name> if it's no longer wanted.

Publishing from the UI (no terminal)

Both steps can also be done from the web UI: open http://127.0.0.1:8892/#publish, paste the admin token (printed at hub startup, also in ~/.agentmesh-hub/config.json) and fill the form. Two modes:

  • Create the hook automatically — the hub writes the hook into awb's hooks.json (the broker re-reads it per request, so it's live immediately) and registers the agent, in one step (POST /api/publish). Works while hub and broker share the machine.
  • I already have an awb hook — paste its URL and secret; only the registry entry is created (POST /api/agents). This is the path remote operators will use in phase 2.
workdir check

Make sure a hand-made hook has a --workdir. Without one it runs in whatever folder the broker was started from — its sessions (and its project context) silently move when the broker is relaunched from somewhere else, so old conversations stop being resumable. When you register a local hook, the hub checks awb's config and warns you if the hook is missing a workdir or doesn't exist at all; registration still succeeds — the warning is advisory.

Advanced options (create-hook mode)

The form's Advanced settings exposes three extra hook settings:

  • Runner — which CLI the hook spawns: claude (the default) or free-code. The hub writes spawn:<runner> into the hook's consumers; awb's dispatch picks the matching adapter. Both runtimes share the same protocol (secret, callbackUrl, sessionId), so everything else on this page works the same either way — only the session-id shape differs (see Continuing a conversation below). A live permission note updates when the runner changes.
  • Custom hook secret — leave empty to autogenerate one (recommended).
  • Permission mode — for claude it's passed through as --permission-mode; for free-code (which has no such flag) it's mapped to its --tools set: unset → read,grep,find,ls (read-only, the safe default); acceptEdits → +edit,write (no bash); bypassPermissions/auto/dontAsk → +bash; plan/manual → read-only (a spawned run has no TTY to confirm). acceptEdits is for agents that must create/edit files; anyone who can submit a job can then make the agent write in its workdir, so only use it with a sandbox workdir. bypassPermissions is for agents that must also run commands (fixing a PR's CI, running tests, using git) — see the warning below.
bypassPermissions

bypassPermissions disables every permission check: the agent edits files, runs Bash, uses git and reaches the network without asking. On a mesh agent that means anyone who can submit a job can run arbitrary commands on the operator's machine. It can't be enabled by accident: the UI asks for an explicit confirmation and the API only accepts it together with acceptBypassRisk: true. Reserve it for agents that truly need it, in a workdir you're prepared to treat as compromised.

cli-only on purpose

Two awb options are not offered by the form: --visible (its callbacks carry no result, which breaks the job loop), and HMAC auth (the hub's runner doesn't sign requests yet — phase 2). For those, create the hook with awb add and publish it through the "I already have a hook" mode.

security

Publishing is the privileged operation of the mesh: whoever holds the admin token decides which workdir and prompt template a new agent runs with. The browser remembers the token in localStorage after the first use; job submission stays token-free.

Using an agent

Three equivalent ways to send a task:

Continuing a conversation (sessionId)

Every finished job carries the sessionId of its run. Submit a new job with that id and the agent resumes the session with all its prior context instead of starting fresh (under the hood the hub forwards it as the sessionId header, and awb resumes it — see the "New session vs. resume" section of agent-webhook-bridge's docs). The id's shape depends on the agent's runner: claude sessions are uuids (claude --resume <uuid>), free-code sessions are .jsonl paths (free-code --session <path>). The hub validates the shape against the agent's harness, so a claude id won't be sent to a free-code agent and vice versa. Three equivalent ways to continue:

  • UI: the jobs table has a Session column — click a job's session chip (an prefix marks runs that were themselves continuations) and the submit form is pre-filled with the agent and session id; same for the Continue this conversation button inside the expanded job. Jobs with no recorded session (failed runs, or runs older than the result callback) show stateless and can't be continued. The field is also editable by hand under Continue a previous session.
  • CLI: mesh submit traductor --session-id <id> "…" — the id to continue from is printed with every finished job.
  • API: add "sessionId": "<id>" to the POST /api/jobs body.

Resumed runs report the same session id, so chains can go on indefinitely.

caveat

Job history — session ids included — is visible to anyone with access to the hub: API keys authenticate submission (see Sharing the hub remotely), but read endpoints are not key-gated yet, so in a shared hub someone else could resume "your" conversation. Per-user isolation is still pending.

How a job flows

UI/CLI → POST /api/jobs {agent, input} hub creates the job hub → POST to the agent's awb hook {jobId, input, callbackUrl} + secret awb → spawns claude -p / free-code -p in the workdir the task inside the prompt template awb → POST callbackUrl {ok, result, session_id} when the run finishes hub → job done — the UI sees it on the next poll

The hook answers {"ok":true} immediately (the agent run happens in the background), so a job is asynchronous by nature: the hub marks it running on acceptance and closes it when awb's result callback arrives at POST /api/jobs/:id/result, authenticated with a per-job token.

non-obvious detail

awb strips callbackUrl from the payload before rendering the prompt, so the spawned agent never sees it — it's broker plumbing, not task content. See the "Result callback" section of agent-webhook-bridge's docs for the callback contract.

Failure modes

What happenedHow the job ends
The hook rejected the request (wrong secret, unknown hook)failed immediately, e.g. hook answered 401.
The hook is unreachable (broker down, wrong URL)failed immediately, hook unreachable: ….
The run finished but with a non-zero exitfailed with the exit code reported by the callback.
The callback never arrived (crash, lost delivery)failed with timeout after 5 minutes (configurable via jobTimeoutMs).

Expiry is lazy: any read of the job list/detail fails overdue jobs first, so it works across hub restarts without timers. A callback that arrives after the timeout does not resurrect the job — first writer wins. For the run's full transcript, the awb log in ~/.agent-webhook-bridge/logs/ remains the source of truth.

Security

  • Hub, awb and the agents all bind to 127.0.0.1 — nothing listens on the network directly. Remote access goes through a cloudflared tunnel, and remote job submission requires a per-user API key (see Sharing the hub remotely).
  • Agents run in a dedicated sandbox workdir (never a real repo), with no --permission-mode (claude) / read-only --tools (free-code): they can answer but not write files. Job input is untrusted by design — that's the concept paper's core premise. free-code agents additionally run with --no-extensions --no-skills --no-prompt-templates --no-themes --no-rag-server so untrusted workdir content can't load anything.
  • Hook secrets live only in the hub's DB; the public API and the UI never return them (nor the hook URLs). The UI renders results with textContent only, so a hostile result can't inject markup.
  • awb's result callback is authenticated with a random per-job token, and awb itself only accepts loopback callback URLs.
  • Registering agents over HTTP (POST /api/agents) requires the admin token printed at hub startup (also in ~/.agentmesh-hub/config.json). The mesh CLI writes to the local DB directly and doesn't need it.

Checking status

mesh list        # published agents
mesh jobs        # recent jobs and their status (pending/running/done/failed)
curl -s http://127.0.0.1:8892/health

Command reference

CommandWhat it does
mesh startRuns the hub in the foreground.
mesh add-agent <name> --hook-url <url> --secret <s> [--description d] [--owner o] [--tag t] [--disabled]Publishes (or updates) an agent. --tag is repeatable; --disabled hides it from job submission.
mesh rm-agent <name>Removes an agent.
mesh listLists published agents.
mesh submit <agent> [--session-id <id>] <input...>Submits a job and waits, printing the result (exit 1 if it fails). With --session-id the agent resumes that session instead of starting fresh — claude session ids are uuids, free-code ones are .jsonl paths.
mesh jobsShows recent jobs.
mesh add-key <name> [--expires <dur>] [--max-uses <N>]Creates (or rotates) an API key for a remote user. Printed once — only its sha256 hash is stored. --expires takes 30m/12h/7d; --max-uses caps accepted remote jobs (1 = single use). Both optional and combinable.
mesh list-keysLists keys: owner, creation date, expiry and uses left — never the keys themselves.
mesh rm-key <name>Revokes an API key.

API reference

EndpointWhat it does
GET /api/agentsPublic agent list — name, description, owner, tags, enabled. No secrets, no hook URLs.
POST /api/agentsRegister/update an agent (Authorization: Bearer <admin token>). Body: {name, hookUrl, secret, description?, owner?, tags?, enabled?}.
POST /api/publishCreate the awb hook and register the agent in one step (admin token). Body: {name, description?, owner?, tags?, workdir?, promptTemplate?, secret?, runner?, permissionMode?, acceptBypassRisk?}runner is claude (default) or free-code; permissionMode accepts all of awb's modes (mapped to --tools for free-code); bypassPermissions requires acceptBypassRisk: true. Requires hub and awb on the same machine.
DELETE /api/agents/:nameRemove an agent (admin token).
GET /api/jobs · GET /api/jobs/:idRecent jobs / one job's status + result.
POST /api/jobsSubmit {"agent":"…","input":"…","sessionId"?} — with sessionId the run resumes that session (uuid for claude, .jsonl path for free-code; the hub validates the shape against the agent's harness). Answers 202 with the pending job. Local callers need no credentials; requests arriving through a tunnel (or any non-loopback source) must send Authorization: Bearer <API key> and are rate-limited to 10 jobs/min per key.
POST /api/jobs/:id/resultawb's result callback, authenticated with the per-job ?token=. Not meant to be called by hand.
POST /api/keysCreate/rotate an API key (admin token). Body: {name, expiresIn?, maxUses?}expiresIn uses the 30m/12h/7d format. The key is returned once, never stored.
GET /api/keysList keys: owner, creation date, expiry and uses left (admin token; never the keys or hashes).
DELETE /api/keys/:nameRevoke an API key (admin token).
GET /The web UI.
GET /healthLiveness + agent count.

Sharing the hub remotely (phase 2, step 1)

The hub keeps binding to 127.0.0.1 — nothing new listens on the network. To let someone outside the machine submit jobs, expose it with cloudflared and hand each remote user an API key.

1. Start a tunnel

For a throwaway URL (changes on every run, no account needed):

cloudflared tunnel --url http://127.0.0.1:8892

cloudflared prints a https://<random>.trycloudflare.com URL — that's the hub's public address. For a stable URL, create a named tunnel once (cloudflared tunnel login, cloudflared tunnel create agentmesh, add a DNS route) and run it with a config pointing at http://127.0.0.1:8892; the hub side is identical.

2. Create a key for the remote user

mesh add-key alice
# API key for 'alice' — save it now, it cannot be shown again: 3fc4…

# Optional limits, combinable:
mesh add-key bob --expires 12h          # stops working 12 hours from now (also 30m, 7d, …)
mesh add-key carol --max-uses 1         # single use: exactly one accepted job
mesh add-key dave --expires 7d --max-uses 20

The key is printed once; only its sha256 hash is stored. Without flags a key never expires and has no usage cap. A --max-uses key spends one use per accepted job — submissions rejected for an unknown agent, empty input or rate limiting don't count — and the quota persists across hub restarts. An expired or used-up key is rejected with 401, exactly like a revoked one. mesh list-keys shows each key's status; mesh rm-key alice revokes.

Prefer a UI? The hub page has an API keys section (http://127.0.0.1:8892/#keys, admin token required) to create keys — with the same optional expiry and max-uses limits — see each key's status (active, expired or used up) and remaining uses, and revoke with one click. The plaintext key is shown exactly once right after creation, with a copy button.

3. The remote user submits jobs

Through the tunnel, with the key as a Bearer token:

curl -s -X POST https://<tunnel-url>/api/jobs \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <alice's key>" \
  -d '{"agent": "translator", "input": "hola mundo"}'

then polls GET https://<tunnel-url>/api/jobs/<id> for the result.

how it works

cloudflared proxies from the hub's own machine, so tunnel traffic also arrives on the loopback socket — but it always carries forwarding headers (cf-connecting-ip / x-forwarded-for) that a remote client cannot strip. Requests with those headers (or from a non-loopback socket) must present a valid API key and are rate-limited to 10 jobs per minute per key (HTTP 429 beyond that). Plain local callers — the UI, the CLI, integrations on the same machine — keep working with no credentials. Jobs submitted with a key record their owner in submittedBy, visible in the jobs API.

scope

Read endpoints (GET /api/jobs, GET /api/agents, the UI) are not key-gated yet: anyone with the tunnel URL can see job history and results. Keys authenticate submission — keep the tunnel URL private accordingly.

Current limitations

Phase 1 (one machine, everything local) is complete; phase 2 is in progress — remote job submission through a tunnel already works.

  • Read endpoints and the UI are not key-gated yet, and there's no per-user isolation of job history. Remote nodes (operators registering hooks behind their own tunnels) and a Docker sandbox for nodes are the remaining phase-2 steps.
  • No routing/orchestration: a job goes to the one agent you picked. Routing by capability, retries on another node and job splitting are phase 3.
  • No systemd/launchd unit or installer: mesh start runs in the foreground and you have to supervise it yourself (or with pm2, tmux, setsid nohup …) if you want it to survive a reboot.