agent-hub
Integrates GitHub Copilot as a delegation target, using its built-in GitHub context for github-context tasks and as a fallback for triage, second opinions, and mechanical edits.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@agent-hubDelegate this code review to agy for a second opinion"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
agent-hub
A local MCP server that lets Claude Code
delegate bounded, read-heavy tasks (recon, call-chain tracing, summarizing
large artifacts, second opinions, adversarial review) to other agent CLIs —
Antigravity (agy), opencode, and GitHub copilot — instead of spending
Claude Code's own quota on them. It adds a real preflight ladder (so a job is
never handed to a CLI that is missing, broken, or already circuit-broken), an
append-only event log, and a local dashboard that also shows Claude Code's
own subagents.
Requirements
Node.js >= 20.19.0
At least one of
agy,opencode,copilot, orcodexonPATH, already authenticated with that CLI's own login flow. agent-hub does not manage credentials — it only spawns the CLI you already use.Optional, for the Jules cloud agent: one or more Jules API keys (generate them at jules.google.com/settings), added as accounts in the dashboard or given as
JULES_API_KEY. There is no binary to install — Jules is a REST API. See Cloud delegation (Jules).Optional, to see agent activity in a Windows notch: Quota Arc.
Layout
src/
index.mjs MCP bootstrap (stdio) + --selftest/--version
config.mjs paths, TTLs, model registry, timeouts, adaptive-timeout and breaker constants
schemas.mjs shared zod contracts (tool outputSchema + dashboard types); browser-safe
eventlog.mjs appendEvent() (one atomic append per line) / readTail()
fsutil.mjs writeJsonAtomic()/updateJsonLocked() (lock + tmp + rename) for state shared by two processes
jobstore.mjs runs/<jobId>/{prompt.txt,stdout.log,response.txt,result.json}
process.mjs spawn argv, SIGTERM->SIGKILL ladder, runCommand()
jobrunner.mjs ties process+jobstore+worktree+timeouts+learnings+readguard into startJob/cancelJob
preflight.mjs L0-L3 ladder, TTL cache, circuit breaker
preflight-cli.mjs `agent-hub preflight` table printer
discovery.mjs CLI discovery (binPath/version/models), startup + on-demand
overrides.mjs manual per-pair hold / breaker-reset overrides
startup.mjs non-blocking startup discovery scheduler
router.mjs delegation map + availability filtering, applies accepted proposals
worktree.mjs write-mode gate (secondary git worktree) + single-writer lock
metrics.mjs job-history aggregation (success rate, p50/p95, tokens) per agent/model/mode/taskType
timeouts.mjs effective timeout: explicit > adaptive (p95 x 1.5) > static default
proposals.mjs Wilson-bound chain-reorder proposals, human-accepted before they apply
cloud/
jules/client.mjs Jules v1alpha REST client (injectable fetch, 30s deadline, JulesApiError)
jules/adapter.mjs tolerant parsing of the alpha session/activity shapes
gitContext.mjs infers sources/github/{owner}/{repo} + branch from a checkout
poller.mjs the remote session poll loop (dedup by activity identity, backoff)
runner.mjs startRemoteJob/finishRemoteJob/resumeRemoteJobs
check.mjs one-shot live read of a session, no poller required
learnings.mjs curated pending/approved gotchas, sanitized and injected into root turns
readguard.mjs git before/after snapshot for read jobs -> read_mode_violation
hook.mjs SubagentStart/SubagentStop -> events
dashboard.mjs node:http dashboard: serves dashboard/dist, SSE /events, JSON API
adapters/{base,agy,opencode,copilot,index}.mjs
tools/{agents,jobs,insights,learnings}.mjs
dashboard/ React 19 + TS + Vite + Tailwind v4 + shadcn/Base UI workspace, builds dist/
scripts/ build-dashboard.mjs — the `prepare` hook (never fails npm install)
bin/agent-hub dispatch: mcp | hook | dashboard | preflight | selftest
skills/ multi-agent-orchestrator and agy-delegate skills (see Install)
test/ node --test; fixtures/ has real+synthetic CLI output;
live/ is real-CLI, gated by AGENT_HUB_LIVE=1
systemd/agent-hub-dashboard.service NOT installed — copy it yourself if wantedRuntime state (never committed) lives in AGENT_HUB_HOME, default
~/.local/share/agent-hub/: events.jsonl, preflight-cache.json,
discovery.json, overrides.json, proposals.json, learnings.json,
runs/<jobId>/, runs/.locks/.
Related MCP server: agent-bridge-mcp
Install
git clone https://github.com/jalejandrov93/agent-hub.git ~/.claude/mcp-servers/agent-hub
cd ~/.claude/mcp-servers/agent-hub
npm install
npm test
node bin/agent-hub selftestnpm install also builds the dashboard workspace through the prepare
script: scripts/build-dashboard.mjs runs npm run -w dashboard build and
always exits 0. The MCP server does not need the bundle, so a failed build
only prints a hint and never fails the install; run npm run build to rebuild
it on demand. When dashboard/dist/ is missing, the dashboard server answers
/ with a 503 page naming the repo path and the npm run build command.
Rebuilds are picked up without restarting the dashboard process (it re-reads
the built index.html when its mtime changes).
Keep the checkout outside ~/.claude (optional)
Cloning straight into ~/.claude/mcp-servers/agent-hub is the quickest path,
but that directory then holds the whole development tree (node_modules,
tests and the dashboard sources — a few hundred MB). To keep the checkout
wherever you develop and leave only the runtime there:
git clone https://github.com/jalejandrov93/agent-hub.git ~/Desarrollo/agent-hub
cd ~/Desarrollo/agent-hub
npm install
npm run install:local -- --restartnpm run install:local builds the dashboard, then copies bin/, src/,
skills/, systemd/ and dashboard/dist/ into the install directory
(--target <dir>, or AGENT_HUB_INSTALL_DIR, default
~/.claude/mcp-servers/agent-hub), writes a runtime package.json with no
workspaces, dev dependencies or prepare hook, installs production
dependencies there, and records INSTALL.json with the version and commit it
came from. --restart also restarts the dashboard unit. Because the install
directory keeps its usual path, the MCP registration, the hooks, the systemd
unit and the skill symlinks below need no changes; re-run the command after
every git pull and restart Claude Code. Runtime state stays in
AGENT_HUB_HOME and is never touched. --skip-build reuses the current
dashboard/dist/.
Register the MCP server
Use an absolute node path, not a bare node / #!/usr/bin/env node: on a
machine where node comes from an ephemeral version-manager shim (for
example fnm's per-shell multishell directory), that path only exists inside a
shell that ran the manager's use/env step, and Claude Code's own process
environment may not have it on PATH. Pin to the manager's stable,
version-pinned alias instead — for fnm that is
~/.local/share/fnm/aliases/default/bin/node:
claude mcp add --scope user agent-hub -- \
/path/to/node /path/to/agent-hub/bin/agent-hub mcpclaude mcp add [options] <name> <commandOrUrl> [args...] defaults to a
stdio transport, so no --transport flag is needed for this. --scope user
registers it for every project; use --scope local (the default) to
register it only in the current project instead.
Install the skills
Two skills ship in skills/ and are picked up by Claude Code once symlinked
into its skills directory:
ln -s /path/to/agent-hub/skills/multi-agent-orchestrator ~/.claude/skills/multi-agent-orchestrator
ln -s /path/to/agent-hub/skills/agy-delegate ~/.claude/skills/agy-delegatemulti-agent-orchestrator documents the full delegation loop
(agents_status → route → delegate → job_wait/job_status → job_result → synthesize) and the break-even rule for when delegating is worth it.
agy-delegate is a thin pointer to it plus a no-MCP fallback script
(scripts/agy-run.sh) for delegating directly to agy when the
MCP server is not registered in the current session.
Optional: Claude Code hooks
SubagentStart and SubagentStop both run the same binary, using the same
absolute node path as above, to record Claude Code's own subagents into the
same event log the dashboard reads:
/path/to/node /path/to/agent-hub/bin/agent-hub hookWire these through the update-config skill (or by hand in
~/.claude/settings.json) — agent-hub does not register its own hooks. The
hook reads the event JSON from stdin, enriches SubagentStop with the
model/description from the agent's meta.json and summed token usage from
its own transcript, and always exits 0.
Optional: run the dashboard as a systemd --user unit
mkdir -p ~/.config/systemd/user
cp /path/to/agent-hub/systemd/agent-hub-dashboard.service ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now agent-hub-dashboardGive the unit the same PATH as your shell. systemd --user starts services
with a minimal PATH that usually lacks ~/.local/bin, ~/.opencode/bin or a
Node version manager's bin directory. Without this step the dashboard cannot find
agy, opencode or copilot, and a Revalidate click would record them as
unavailable in the cache the MCP server also reads. The unit loads an optional
environment file for this:
mkdir -p ~/.config/agent-hub
# Absolute paths only: systemd does not expand $HOME or %h inside this file.
# List stable directories; do not paste a PATH containing per-shell dirs such
# as fnm_multishells, which disappear when that shell exits.
printf 'PATH=%s\n' "$HOME/.local/bin:$HOME/.opencode/bin:/usr/local/bin:/usr/bin:/bin" \
> ~/.config/agent-hub/env
systemctl --user restart agent-hub-dashboardThe Config panel shows the PATH and resolved CLI binaries the dashboard
process actually sees. If a CLI is missing there, the Agents panel shows a
warning and disables Revalidate and Ping for it, and the API returns a
skipped result instead of recording that agent as unavailable in the state
the MCP server reads.
Its ExecStart uses the same absolute node-path reasoning as above — a
systemd --user unit does not inherit a shell's version-manager PATH
either.
How it works
Preflight ladder
Every agent+model pair is checked through an ordered ladder before a job is
routed to it. Each rung short-circuits on failure, and results are cached
for PREFLIGHT_TTL_MS (15 minutes):
Level | Check | When it runs |
L0 |
|
|
L1 | The requested model appears in the CLI's own model catalog |
|
L2 | Quota signal + circuit-breaker state |
|
L3 | A real | Only on an explicit dashboard "Ping" action — never at startup, never in bulk |
Startup discovery
scheduleStartupDiscovery() runs in the background via setImmediate right
after the MCP process starts, so it never delays the stdio handshake with
Claude Code. It runs L0 + L1 only (never L3) for every CLI referenced in the
delegation map, prunes preflight-cache rows for agent:model pairs no longer
reachable from the map, and writes the merged result to discovery.json
({ [agent]: {agent, cmd, binPath, version, models, checkedAt, error, note?} }). It is TTL-gated the same 15 minutes as the preflight cache, and
can be disabled entirely with AGENT_HUB_DISABLE_STARTUP_DISCOVERY=1.
State writes (preflight cache, discovery, overrides) use atomic tmp+rename writes because the MCP process and the dashboard process both write the same files. The model list is fetched once per agent, not once per agent+model pair; the three agents are probed in parallel, but pairs within one agent are probed serially to avoid more than one live CLI process per agent at a time.
Delegation map
route({taskType}) looks up an ordered candidate chain in
DELEGATION_MAP (src/router.mjs) and returns the first candidate that
passes availability filtering, plus the rest as fallbacks:
Task type | Primary | Fallbacks | Why |
| agy gemini-3.8-flash-low | opencode muse-spark-1.3 → claude haiku | proven context compression, cheap refreshable quota |
| agy gemini-3.8-flash-high | opencode nemotron-3-ultra → claude sonnet | needs multi-hop reasoning, 1M ctx |
| opencode muse-spark-1.3 | opencode mimo-v2.5 → agy gemini-3.8-flash-medium | zero cost, 1M ctx |
| opencode muse-spark-1.3 | copilot auto → codex default | lowest latency |
| agy gemini-3.1-pro-high | copilot auto | different model lineage than Claude Code |
| agy claude-sonnet-4-6 (parallel with copilot auto) | agy claude-opus-4-6-thinking | dual blind review off the Claude Code quota |
| copilot auto | — | built-in GitHub MCP; cheap models keep premium quota |
| opencode deepseek-v4-flash (write) | copilot auto (write) → codex default (write) | cheap write-capable; single writer |
| claude sonnet | — | only Claude Code loads CLAUDE.md + skills + hooks |
| claude opus | agy claude-opus-4-6-thinking | highest reasoning |
| claude haiku | — | cheapest Claude tier |
Codex is only ever a last fallback, for small bounded tasks: its plan quota is
limited, and every call carries a baseline of about 17,000 input tokens (its own
system prompt), even for a one-word reply. Batch questions into one task. Model
default means the CLI's own default model, so no model name is guessed.
A {agent: 'claude', model: 'haiku'|'sonnet'|'opus'} candidate is a Claude
Code subagent tier, run by the caller through its own Agent tool — it is
never CLI-preflighted or breaker-checked. A candidate is filtered out of the
chain (and reported in the result's skipped list, with a reason) when it
is manually held, its CLI was not found on PATH, its cached preflight is
unavailable, or its circuit breaker is open. route() is advisory: it
never blocks the caller from delegating to a skipped pair directly.
Cloud delegation (Jules)
Every other agent here is a local CLI: agent-hub spawns it, streams its stdout
and reaps it. Jules is not. It is a REST API
(v1alpha, alpha — shapes may change) and the work runs on Google's
servers, against a GitHub repository you connected in the Jules web UI. The
result is a pull request, never a change to your cwd. That difference drives
every design decision below.
Jules is reachable only through its own tools (jules_delegate,
jules_sources, jules_check, jules_sessions, jules_interact,
jules_wait, jules_supervise). It is deliberately absent
from the delegation map, so route never picks it and delegate cannot reach
it — you get a cloud session only when you ask for one.
It survives your machine being off. This is the point of the feature: hand over a task, close the laptop, come back later. Because the MCP server is a per-session stdio process and the dashboard is a local service, both die with the machine while the Jules session keeps going, so polling can never be the only way to learn the outcome:
While the server is up, a poll loop streams Jules' activity into the job's
stdout.log, sojob_resultand the dashboard show progress live. Activities are deduplicated by identity, not by page token, and the interval backs off to 60s when nothing moves.On startup the server resumes polling any job still marked
running. If its deadline elapsed while nothing was watching, it does one final read before deciding — a session that finished overnight lands assucceededwith its pull-request link, not as a timeout.Whenever you want,
jules_checkanswers "did it finish, and on which branch" with a single live read and no poll loop at all. If the session ended while the machine was off, it finalizes the local job sojob_resultreturns the real answer.jules_sessionslists what Jules has even when this machine has no record of it, so a reinstall or a session started elsewhere is still recoverable.
Cancel is local only. The Jules API exposes no cancel endpoint — and no
pause/resume endpoints either. job_cancel
marks the job canceled and stops this server's polling; the session keeps
running on Google's side. The tool says so.
Waiting is a state, not a stall. A Jules session can sit in
AWAITING_PLAN_APPROVAL, AWAITING_USER_FEEDBACK or PAUSED indefinitely,
and the hub no longer polls those blindly: the poll interval is semantic per
state (QUEUED/PLANNING 5s, IN_PROGRESS 5–15s backoff, AWAITING_*
30–60s, PAUSED 5min), a waiting tick skips re-draining listActivities,
and the job record carries pollingStoppedReason: 'awaiting_interaction'
until new activity resumes it. jules_check now also answers
attentionRequired / attentionReason (user_feedback | plan_approval |
paused) / recommendedAction (send_message | approve_plan) /
canAutoResolve / attempts, and jules_interact (reply |
approve_plan) is the interaction API — pause/resume/cancel remotes
are explicitly rejected because the backend does not offer them. See
src/cloud/poller.mjs (STATE_INTERVALS, intervalForState),
src/cloud/jules/adapter.mjs (ALL_JULES_STATES, isWaitingState) and
src/cloud/check.mjs (computeAttention).
Model A: interacting never restarts polling. After jules_interact
(or job_reply on a Jules parent) nothing observes the session again on its
own — the local record freezes at running until you observe it. So the
three observation tools have distinct jobs, and mixing them up loses
completions:
job_wait— waits on the local record: returns on terminal, or immediately ondone+waitingwith attention fields. It does not poll Jules itself, so after an interaction it will not see the session finish.jules_wait— actively observes one session for a bounded local budget (timeoutS≤600); never a watch daemon.jules_check— one punctual inspection plus reconciliation (finalizes the local job if the session already ended).
Post-interaction rule: jules_interact → observe with jules_wait (or
jules_check), never job_wait alone. Continuous supervision belongs to
jules_supervise (B4), which owns observation through a
remote.watch = {owner, generation} lease so two watchers never drive the
same session: it loops observe → decide → interact → resume-observation,
auto-replies only through 6 hard gates with maxAutoReplies=2 counted on
autoReplyCount (never turnDepth), and escalates anything else as
REQUEST_USER. PAUSED is never approved/replied.
Several accounts. Quotas are per Jules account, so agent-hub can hold more
than one. Add them in the dashboard; they live in accounts.json under
AGENT_HUB_HOME, written with mode 0600, and no API response ever returns a
raw key. A policy (round_robin, least_used or priority) picks the account
for each new session, a 429 fails over to the next eligible account, and a job
keeps the account that started it for its whole life. Account health is judged
by GET /sessions, never GET /sources: a valid key can be refused /sources
with a 401 while working normally. With no accounts configured, JULES_API_KEY
is used as before. All key resolution lives in src/cloud/credentials.mjs.
Recurring tasks. The Jules API has no scheduling, so agent-hub owns it. Schedules run inside the dashboard service, the only long-lived process here, and fire at most one run at a time per schedule. With the machine off, no schedule fires; sessions already started keep running on Google's side.
The key never leaves this process. JULES_API_KEY travels only in the
X-Goog-Api-Key header. It is never written to a job record, an event, a log
line or a response, and it is stripped from the environment handed to the local
agent CLIs, which are third-party programs agent-hub does not control.
Sources are read-only. Repositories are connected to Jules through its
GitHub App in the web UI. The API can list them (jules_sources) but cannot add
one. jules_delegate accepts an explicit source, or infers it from cwd via
the origin remote.
Quota state before delegating
agent-hub can show how much of each agent's usage limit is left before you
delegate, read from a local CodexBar
server (codexbar serve, default http://127.0.0.1:8787, override with
AGENT_HUB_CODEXBAR_URL). It appears as a quota field on agents_status
rows and on route()'s primary and fallbacks, in the agents_quota tool, at
GET /api/quota, and in the dashboard's Agents view.
It never decides anything. Quota data does not choose, skip, reorder or
block an agent, and route() returns the same chain with or without it — a test
pins that. An exhausted agent stays in its place; the point is that you see it
before a job fails, and decide.
Each pair maps to the CodexBar windows that actually limit it. agy splits by
model family: gemini-* models read the Gemini windows, while claude-* and
gpt-* models read the shared Claude/GPT windows, which are exhausted
independently. copilot and codex read their own provider, opencode-go/*
models read OpenCode Go, and the free opencode models and deepseek/* are not
metered by CodexBar. A window CodexBar reports with usageKnown: false is shown
as unknown, never as 0%. Readings are cached for five minutes, requested one
provider at a time (/usage?provider=all probes about 69 providers and is
slow), and a missing CodexBar never blocks or slows a delegation.
Circuit breaker
A per-agent+model breaker opens after failureThreshold (2) matching
failures (quota, canceled, billing) within a 30-minute window, or
immediately on a single billing failure (a billing failure such as an
insufficient-balance error does not clear on retry). A manual
breakerReset override (dashboard "Reset breaker" button, or POST /api/overrides) makes earlier failures at or before that instant stop
counting.
Per-class breakers extend this without replacing it: src/policy/taxonomy.mjs
classifies failures (billing, auth, quota, timeout, transport,
crash, quality), CIRCUIT_BREAKER_BY_CLASS in src/config.mjs sets a
window/threshold per class, and src/breakers.mjs evaluates them
(circuitBreakerOpenByClass, matchingFailuresByClass, breakerStatus).
src/policy/registry.mjs (POLICY_TABLE, policyFor) then maps each class
to an explicit {retry, resume, fallback, escalation} policy executed by
executeWithPolicy() in src/policy/executor.mjs — one declarative loop,
not a chain of if/else. The table covers every taxonomy category
(billing/auth never retry and escalate to human; quota retries bounded
then falls back; crash gets one retry then an alternate adapter), and
ctx.recoveryOrder lets a remote adapter run resume/reconcile before any
retry that could duplicate a session (escalation always stays last).
Execution contract
docs/execution-contract.md is the normative spec for job execution
(deadline, cancellation, retry/resume/fallback/escalation, idempotency, read
purity, write ownership, local-vs-remote state). The two invariants to
remember: a local deadline is never a remote failure (a timed-out watcher
stops polling with pollingStoppedReason: local_deadline; only the remote
session's own state finalizes the job), and local cancel always wins
while remote cancel is never guaranteed. Dispatch idempotency
(dispatchKey = sha256(task + cwd + taskType + workflowStep),
executionId/attempt/parentExecutionId/rootExecutionId) is separate
from remote-creation reconciliation (fingerprint repo/branch/task/title/window
plus a listSessions search before creating).
Workflow-ready job records
JobRecord carries nullable workflow columns from the start (workflow_id,
step_id, parent_execution_id, root_execution_id, attempt,
remote_state, quality_score, verified, judge_verdict) so the later
workflow engine never needs a breaking migration.
C0-real: SQLite as coordination state
better-sqlite3 is a runtime dependency; initDb() runs at MCP and
dashboard startup (AGENT_HUB_HOME/agent-hub.db, WAL, singleton per state
dir). createJob/updateResult dual-write: runs/<jobId>/result.json
stays the content artifact and compat path, while SQLite (jobs, leases,
workflows, workflow_nodes) is the authority for coordination state —
this is what lets two processes update the same job without lost updates,
and what C1's DAG will build on. If the native module is ever unavailable,
the mirror is skipped with a one-time warning and the JSON path keeps
working alone. Crash/recovery (kill → restart → coherent state) and
concurrent-writer tests pin the guarantee (test/storage-c0real.test.mjs).
Event watcher (C0.5)
bin/agent-hub watch [--once|--follow] [--sink console|file|webhook]
tails events.jsonl (src/notify/watch.mjs, same offset-tracked pattern as
the dashboard SSE) and routes first-level events (job.finished,
job.failed, jules.waiting, jules.attention_required,
workflow.completed) to console / <AGENT_HUB_HOME>/notifications.jsonl /
webhook adapters (src/notify/adapters.mjs, never throws, never logs
secrets). It runs as its own process — never inside the MCP stdio lifecycle.
C1 workflow DAG (src/workflow/)
runWorkflow({workflow, ctx}) executes delegate/fanout/fanin/notify nodes in
parallel waves (Promise.all per wave), persisting every transition
(pending → ready → running → succeeded | failed | skipped | canceled) to
SQLite so runWorkflow({workflowId}) resumes after a restart without
re-running succeeded nodes. Dependencies resolve via dependsOn +
condition (safe expression over sibling results) with onFailure skip
propagation by default; fanout fans N children with distinct dispatchKeys
and fanin aggregates them. Per-node maxAttempts (backoff) and timeoutS;
concurrent schedulers can't double-claim a node (claimed_by CAS in
claimWorkflowNode). No quality/cost/adaptive scoring yet — nodes carry
onSuccess/onFailure hooks for the future judge (C4). Example:
examples/software-pipeline.mjs (research → implementation → review).
C1.1 execution hardening (src/workflow/dsl.mjs, execution.mjs)
dispatch()also returns anExecutionHandle {jobId, sessionId, abort()}; the engine awaits real completion viawaitExecution()— a node succeeds only on terminal job state, never on dispatch return. Timeout aborts first (cancelJoblocally, stop-wait remotely), then retries.Nodes understand
waiting(user_feedback|plan_approval|external_event):running→waiting→running→succeeded, resumed without duplicating execution.Conditions and fan-out
itemsuse a small safe DSL (==,!=,===,!==,AND, OR,NOT,existsoversteps.*) — nonew Function/evalanywhere insrc/.All mutations go through
claimNode()(soleready→running) andtransitionNode()(assertValidTransitionalways); resume only revives arunningnode whose lease expired and whose owner is dead, else it re-adopts. Afork()-based test pins cross-process single execution. A dispatch result carrying ajobIdis always waited on (pendingJobHandle; a bare pending record never counts as success), anddispatchKeyis scoped byworkflowIdso identical steps in different runs never share a job.
C1.2 workflow↔supervisor link (src/workflow/resume.mjs)
A waiting node resumes only via resumeWorkflowNodeFromExecution(jobId):
it reads the record's workflow_id/step_id (C0), no-ops unless the node is
WAITING (not-waiting:<estado>), and CASes WAITING→RUNNING without
stealing a live owner's claim (owned-elsewhere otherwise). Both
jules_supervise and jules_interact call it best-effort after every
successful interaction — the engine never polls Jules itself. Harness
session origin (_meta.sessionId → harness_origins, mapping only,
supportsWake:false) is recorded for the future lifecycle bridge.
Harness profiles (src/harness/)
delegate() starts a job and returns — but harnesses behave differently
after an MCP tool call (Claude Code usually continues with job_wait;
OpenCode may end the turn with the job still running). Profiles model the
orchestration strategy explicitly without coupling the engine to any
harness: generic (waitMode: none), claude-code (attention),
opencode (attention). Priority: explicit waitMode/harness arg >
AGENT_HUB_HARNESS env > MCP clientInfo hint > generic (hints never
decide security or critical logic). waitMode semantics: none returns at
creation; attention returns on terminal or waiting/attention; terminal
only on terminal. delegate() is unchanged (generic never waits by
default). job.started/finished/failed events carry harness + waitMode
for later analysis.
Write-mode gate
A delegate()/job_reply() call with mode: 'write' requires cwd to be
a secondary git worktree add checkout (see src/worktree.mjs), plus a
per-cwd single-writer lock — this keeps an agent CLI from editing the same
working tree Claude Code (or another job) is using, and keeps two write jobs
from racing on the same worktree.
Metrics and adaptive timeouts
computeMetrics() reads the job records under runs/, drops non-terminal jobs
and the operational errorKinds (locked, worktree_denied, orphaned,
canceled_by_user), and aggregates the rest per
(agent, model, mode, taskType): sample count, succeeded/failed/canceled,
success rate, p50/p95 latency over succeeded runs, an errorKind
histogram and token totals. It backs the agents_metrics tool and
GET /api/metrics.
resolveEffectiveTimeoutS() picks the timeout for a job: an explicit
timeoutS always wins (source: 'explicit'). Otherwise the static default
from config.mjs is raised, never lowered — to ceil(p95s x 1.5), capped
at 3600 s — once a row has at least METRICS_MIN_SAMPLES (10) samples; thin
taskType data falls back to the general (taskType: null) row. The job
record keeps the value used and its timeoutSource (adaptive or default).
Routing proposals
refreshProposals() compares a task type's current primary CLI candidate
against the other CLI candidates in its chain. It proposes promoting the
candidate with the strictly better 95% Wilson score lower bound — its lower
bound beats the primary's upper bound — once both have at least 10 samples.
New proposals stay pending: nothing changes until a human accepts one at
#/approvals?tab=proposals. Accepting one supersedes any other accepted
proposal for the same task type, and a stored proposal whose chain no longer
matches the delegation map (its chain hash changed) is marked superseded.
After a rejection, no new proposal for that task type for 7 days. route()
applies an accepted proposal to the chain and returns it as appliedProposal.
Learnings
learning_propose records a short gotcha about an agent, model or task type as
pending; a human approves or rejects it in the dashboard. Approved learnings
that match a job are prepended, most specific first, to the prompt of a
root turn only — at most 3, each up to 300 characters — as a
<hub-learnings> advisory block; a reply turn continues a conversation that
already has it. Text is sanitized on write and again on every read (control
characters, backticks and <hub-learnings> tags stripped, whitespace
collapsed) before it can reach another model's prompt.
Read-mode guard
Before a read-mode job starts, the hub snapshots the git-visible state of
cwd (git status --porcelain=v1 -z plus HEAD) and re-snapshots it when the
job reaches a terminal state. If the tree changed, a job the CLI reported as
succeeded is failed with errorKind: read_mode_violation naming the changed
paths; the response text and tokens are still kept. The guard exists because
read mode is not actually enforced by the CLIs: verified, agy --mode plan
writes files with or without --dangerously-skip-permissions (opencode's plan
agent did respect read mode in testing). A cwd outside a git work tree
produces no snapshot and is reported unverifiable rather than clean, and
because the guard is a tree diff it also flags changes other processes make in
the same cwd while the job runs. Files ignored by git (for example .env or
build output) are outside the snapshot, so edits to them are not detected: run
read jobs from a disposable worktree when that matters.
MCP tools
Tool | Input | Notes |
|
| L0-L2 for every pair in the delegation map. Never pings. Rows include |
|
| Skips unavailable/breaker-open/held pairs; returns |
|
| Returns |
|
| Atomic decide+execute: revalidates preflight/breaker at execution time (closes the |
|
| Polls until terminal ( |
|
| Current status, no waiting. |
|
| Head of the response (default 20 lines) plus extra |
|
| Kills the whole process group; marks |
|
| Starts a new turn in a terminal agy/opencode job's conversation, using its recorded |
|
| Each delegation pair's quota state, read from a local CodexBar server: every applicable window with used percent and reset time, |
|
| Success rate, p50/p95 latency, error kinds and tokens per group (default: all four dimensions) from job history. |
|
| Starts a Jules cloud session. Needs |
|
| The GitHub repos connected to the Jules account. Connect new ones in the Jules web UI — the API cannot add them. |
|
| One live read of a session: |
|
| Talk to a live Jules session: |
|
| Local orchestration only (the API has no wait endpoint). Waits until terminal ( |
|
| Lists sessions straight from the Jules API, newest first, each with the local |
|
| The configured Jules accounts, read-only: masked keys ( |
|
| The recurring Jules tasks, read-only, with their next run and last result. Schedules are created and edited in the dashboard. |
|
| Supervised autonomy for a Jules session: acquires the |
|
| Records a gotcha as pending; a human must approve it in the dashboard before it is injected into a prompt. Returns |
Every tool also declares a zod outputSchema and returns the same payload as
structuredContent for clients that want typed output (the text content stays
JSON for compatibility).
Resources
URI | Contents |
| A job's full |
| A job's |
Prompts
Prompt | Args | Purpose |
|
| Delegate a bounded, read-only recon task off Claude quota |
|
| Get a second, independently-hosted opinion in parallel with a third CLI |
|
| Plan → review → execute → delivery-review a non-trivial write, staying in one session via |
Dashboard
node bin/agent-hub dashboard --port 7777
# open http://127.0.0.1:7777Binds to 127.0.0.1 only. The page is a sidebar app with deep-linkable hash
routes, so a link can open the exact filtered view:
Group | Route | Shows |
Monitor |
| What needs attention: unhealthy agents, open breakers, failures in the last 24h, unresolved CLIs, recent activity |
Monitor |
| Agents grouped by CLI; filter, free-text search, row menu, detail panel |
Monitor |
| Running and queued jobs with live elapsed time and Cancel |
Monitor |
| Terminal jobs; |
Monitor |
| Success-rate chart and per-pair table; |
Activity |
| Claude Code subagent runs recorded by the hooks |
Activity |
| Last 200 events over SSE, filtered by source and free text |
System |
| Routing proposals and learnings awaiting a human accept/reject |
System |
| MCP tool inventory ( |
System |
| Delegation map, process PATH and CLIs, breaker and TTL, overrides, paths |
Sidebar badges show unhealthy agents, running jobs, failures in the last 24h,
unseen timeline events and unresolved CLIs. Agent row actions are Revalidate,
Ping (an L3 round-trip for that one agent+model), Hold / Release and Reset
breaker; the Agents header adds Revalidate all and Rediscover CLIs. Ping,
Reset breaker and Cancel job ask for confirmation first. The theme follows the
system by default and can be set to light or dark. preflight events
(phase: discovery|agent|ping) stream over the same SSE feed as job events.
The UI is a React 19 + TypeScript + Vite + Tailwind v4 app in the dashboard/
workspace, using shadcn/ui components on Base UI, with TanStack Router (hash
history) and TanStack Query. It is built to dashboard/dist/ and served
read-only from there — see Install for the build step.
Route | Method | Body | Notes |
| GET | — | Built app ( |
| GET | — |
|
| GET | — |
|
| GET | — | Same rows as |
| GET | — |
|
| POST | — | Recompute proposals from current metrics and store new pending ones |
| POST | — | Accept a pending proposal (supersedes the previous accepted one for that task type) |
| POST | — | Reject a pending proposal (7-day cooldown before the same task type is proposed again) |
| GET | — |
|
| POST |
| Creates a pending learning (201) |
| POST | — | Approves a learning so matching root turns get it |
| POST | — | Rejects a learning |
| DELETE | — | Deletes a learning |
| GET | — | SSE stream of |
| POST | — | Cancels a running job |
| GET | — | Same payload as |
| POST |
| No body = all pairs, L0-L2. |
| POST | — | Forces a fresh discovery pass, ignoring the TTL |
| POST |
| Sets a manual hold and/or clears breaker history |
| DELETE | — |
|
Security: the dashboard binds only to 127.0.0.1 and is meant for the
local user. It also defends against a hostile web page open in the same
browser, because that browser connects from loopback too:
Every request must carry a loopback
Hostheader (127.0.0.1,localhostor[::1], any port). This blocks DNS rebinding. Otherwise 403.Every state-changing request must come from a loopback address and use
Content-Type: application/json, so a cross-origin browser request needs a CORS preflight the server never grants (415 otherwise). When anOriginheader is present it must be the dashboard's own loopback origin (403).Request bodies are capped at 64 KiB (413) and must be valid JSON (400).
Pages and assets send
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:with no inline script or style, plusX-Content-Type-Options: nosniff.style-src 'self'is still enforced: the app runs Base UI withCSPProvider disableStyleElements, and chart colors come from CSS variables instead of injected styles.
There is no authentication: any local process can call the API. Do not expose the port beyond loopback (no reverse proxy, no port-forward to a shared network).
Quota Arc and CodexBar
Quota Arc is a small notch pinned to a screen edge that shows how much of each coding assistant's quota is used. On Windows it can also show an Agent Hub cell, read from this dashboard: jobs running and queued, open circuit breakers and human holds. When the assistants live inside WSL, Quota Arc reads their quotas from CodexBar running there.
Nothing needs configuring on the agent-hub side beyond running the dashboard service (Optional: run the dashboard as a systemd --user unit). Setup on the other two sides lives in Quota Arc's WSL remote mode guide, which starts with a quick start. In short:
# inside WSL, from a Quota-Arc checkout
./wsl/install.sh # CodexBar: prints the sudo commands for its systemd unit
systemctl --user enable --now agent-hub-dashboard# on Windows
curl.exe http://127.0.0.1:8787/health # CodexBar
curl.exe http://127.0.0.1:7777/api/state # agent-hub dashboard
setx QUOTAARC_CODEXBAR_URL http://127.0.0.1:8787Always use 127.0.0.1, never localhost, from Windows. Windows resolves
localhost to ::1 first, and both this dashboard and CodexBar listen on IPv4
only, so a localhost request hangs until it times out rather than falling back.
Configuration
Env var | Effect |
| Overrides the state directory (default |
|
|
| Read by |
|
|
State file (under | Contents |
| Append-only event log: jobs, preflight, hook-recorded subagents |
| L0-L2 results per agent:model pair, TTL-gated |
| CLI binPath/version/model catalog per agent, from startup + on-demand discovery |
| Manual per-pair |
| Routing proposals ( |
| Curated agent/model/taskType learnings ( |
|
|
| Per-cwd single-writer locks for write-mode jobs |
Comparison with ai-dispatch
ai-dispatch is a Rust,
CLI-first tool in the same family — dispatching bounded tasks to other agent
CLIs — with an embedded aid mcp server as one entry point among several.
agent-hub is MCP-first: it has no standalone CLI dispatch mode, only the MCP
tools above plus the agent-hub preflight/dashboard/selftest
subcommands. Functional differences: agent-hub adds task-type fallback
chains (a delegation map with an ordered chain per task type, not a single
target), a per-pair circuit breaker, session resume for follow-up turns
(job_reply), and a worktree-based write-mode gate. Ideas borrowed from
ai-dispatch: a two-tier discovery/preflight cache, pruning stale state
before rendering it, and externally inspectable hold markers (overrides.json
here, read and writable by hand, not only through the dashboard).
Testing
npm test # server, node --test; fast, hermetic, no real CLI calls
npm run -w dashboard test # dashboard, Vitest + Testing Library
npm run -w dashboard typecheck # dashboard, tsc --noEmit
AGENT_HUB_LIVE=1 npm run test:live # one real PONG per adapter — uses real quotaSee test/fixtures/README.md for exactly which adapter fixtures are real CLI
output versus hand-built synthetic shapes, and why.
Versioning
This project follows Semantic Versioning and Conventional Commits. See CHANGELOG.md for release history.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
Build and supervise fleets of agents from Claude Code, Codex or Cursor. Connects over OAuth.
Build agents to automate any background task. Works with your ChatGPT/Claude subscription.
Cross-agent artifact workspace with provenance across Claude Code, Codex, Cursor, LangGraph.
Supervised API-write gateway for AI agents with policy, human approval and execution receipts.
Related MCP Servers
- AlicenseAqualityAmaintenanceEnables AI assistants to delegate specific tasks to specialized sub-agents (e.g., test-writer, code-reviewer). Supports both Cursor and Claude Code with custom agent definitions.1217 npm98MIT
- AlicenseNot gradedqualityBmaintenanceEnables controlled delegation of tasks to local coding-agent CLIs and the Manus API, with strict sandboxing, approval tracking, and remote-egress safeguards.5 npmMIT
- AlicenseAqualityCmaintenanceEnables Claude Code to delegate agent tasks to local LLMs via llama.cpp or vLLM servers on your own hardware, reducing API usage while maintaining full agent capabilities.1110MIT
- AlicenseAqualityAmaintenanceEnables Claude Code and Claude Desktop to delegate token-heavy tasks to Antigravity headless subagents, offloading file edits, test runs, and exploration while preserving Claude's context window.1225 npm1MIT