@indexlabs/groundwork
v0.1.4
Published
The Groundwork daemon: runs your agents on your own machine, through our harness, on your Claude Code or Codex subscription or any AI SDK provider key, with a packaged signed-out browser.
Readme
@indexlabs/groundwork
The Groundwork daemon runs your agents on your own machine. Convex queues the
work and records it; everything that executes happens here, in our own harness
loop, with our own tools (see docs/agent-platform.md, the authoritative design).
- Models: your Claude Code or Codex subscription (the CLIs, driven as models with every native tool of theirs switched off), or any Vercel AI SDK provider you add an API key for in Settings → Large Language Models. Keys arrive sealed to this device for one job at a time.
- Tools: a signed-out browser for reading the open web and searching, a guarded fetcher for JSON, text and PDFs, a per-task scratch workspace, a sandboxed calculator, the agent's memory, a live plan, delegation, follow-ups, questions to you, notifications, an optional decision model (Jev), and the tools of MCP servers you add on this machine. No built-in tool can click, type, post or act on anything outside your Groundwork account; an MCP tool that is not read-only waits for your approval on every call.
- The daemon is required. Without a paired, running daemon, agents do not run.
npm install -g @indexlabs/groundwork
groundwork login <code> --origin <web app url> # Settings → Devices gives you this whole line
groundwork startCommands
| Command | What it does |
|---|---|
| groundwork login <code> [--origin <url>] | Pair this machine: sends its public key, stores the device token and id in ~/.groundwork/credentials.json (0600). Each --origin (https, or http://localhost / http://127.0.0.1 for a local web app; no path) pins a web app allowed to use the loopback takeover bridge, kept in ~/.groundwork/config.json as appOrigins. The origins are checked before the code is sent; a new --origin replaces the pin, and logout removes it. |
| groundwork start | Run agents. Heartbeats at once and every 30 s, claims jobs as soon as the server's wake subscription says one is queued (polling every 2 min behind it, or every 30 s when the subscription is unavailable; GROUNDWORK_CONVEX_CLOUD_URL names the Convex URL of a self-hosted deployment), reports progress, settles them. Sets up the browser in the background if it is missing. Ctrl-C stops claiming, reports running jobs (and any claim still on its way back) as retryable failures so the server requeues them, and closes the browser. |
| groundwork status | Asks the server whether it still knows this device (a probe heartbeat, which changes nothing on the server, so it is safe beside a running start), and shows models, browser, tools and which web apps may take over the browser. |
| groundwork logout | Forgets this machine's pairing and its web app origin pin. Revoke it in Settings → Devices to remove it from your account. |
| groundwork models | The models each CLI can run, and which are enabled. |
| groundwork install | Set up the browser now (find or download a Chrome). |
| groundwork takeover-key [--rotate] | Print the key that lets the web app on another machine take over this browser. |
| groundwork mcp add <name> [--env NAME]… -- <command> [args…] | Add a local (stdio) MCP server to ~/.groundwork/mcp.json (0600). It gets PATH, HOME and the variables named with --env, nothing else of the daemon's environment. Names: 1–32 of a-z 0-9 _ -, no __, not ending in _. |
| groundwork mcp add <name> --url <https url> | Add a remote (Streamable HTTP) MCP server (plain http only to localhost). |
| groundwork mcp list / mcp remove <name> | Show or remove them. The daemon connects at start; restart it after a change. |
| groundwork doctor | Check pairing (a probe, like status), the device key, each CLI's version and sign-in, each MCP server (starts it, lists its tools, says which ask for approval), the browser, and do one test read in its own browser session (groundwork-doctor-open), never a running daemon's. |
How a job runs
claim ─► parse & check ─► build models ─► build tools ─► harness loop ─► complete | suspend | fail
protocol.ts harness/models harness/tools @indexlabs/groundwork-harness- Claim (
POST /claim). The job is parsed defensively (protocol.ts): the model refs are re-parsed with the daemon's own grammar, model keys must be well-formed, providers must be in the catalog, tool names must look like tool names. A job that fails is reported withfailand the reason; it never runs on a guess. - Models (
harness/models.ts). One harnessModelper entry inrun.models:cli:claudeorcodexthrough the harness adapters, in the empty directory~/.groundwork/run/empty, prompt on stdin. The model key must be one the user enabled on this device (modelFlagFor); there is no fallback list.api:createLanguageModel+apiModelfromsrc/shared/providers.ts. The key is opened with this device's private key (device-key.ts, AADjobId|deviceId), held in memory for the job, and scrubbed from anything reported. A local server that takes no key gets none.api-proxy: a team-shared key that never leaves the server; each step is onePOST /model/step.
- Routing. With
routing: "auto"and a decision model, Jev picks the model for each step among the run's models (harness/router.ts). Confidence below 0.5, or any error, means the default (first) model. Jev sees the task, the step number and one-line summaries of recent steps, never page text. - Tools (
harness/tools.ts): the agent's requested tools intersected with what this machine and job can back.tools: []is finish only (plusdecidewhile Jev's decide use is on); no list means the default set (DEFAULT_TOOL_NAMES: reads, memory, plan, decide,http_fetch,pdf_read,calculate, never a tool that writes to the account or an MCP tool). A requested tool that cannot be offered is named in the run's first activity line. - Run (
@indexlabs/groundwork-harnessrunHarness). Each step is reported to/progressas it lands (the tool, its one-line summary, tokens, the model, and the error text when it failed), the live activity line at most every 1.5 s, and the plan wheneverupdate_planpublishes one. - Settle. An answer (or an exhausted step budget) is
completewith{ text, usage, finished, exhausted }, never the steps. A run that paused issuspend(below). A model or CLI error isfail. A cancel isfail { cancelled: true }.
Suspend and resume (§11.1). ask_human, delegate with wait: true and
an MCP call that needs approval pause the run: the steps (page text included)
are written to ~/.groundwork/checkpoints/<id>.json (0600, a random 32-hex id),
the fast path, and the persisted copy (next paragraph) is stored on the server before
POST /suspend { jobId, attempt, kind, detail, checkpointId, usage }
goes to the server. Only a task can pause. When the wait is answered the server
enqueues a resume job pinned to this machine with request.resume = {
checkpointId, answer }; the daemon loads the checkpoint and continues, the
answer becoming the paused call's result (step numbers continue). For an
approval, "approve" makes the call here first (its result is written into the
checkpoint before the run goes on, so a retried resume never calls twice) and
anything else is "Denied by the user.". The checkpoint is deleted once the
resume settles for good, kept while a retry may still need it, and pruned after
30 days. A resume whose checkpoint is in neither place fails, not retryable.
The audit record (§12, persist/). Every run's context, every step in
full, its end, its workspace (changed files only) and a checkpoint manifest are
persisted to the server: scrubbed first (API keys, the device token, the
takeover key, MCP credentials; a file whose name holds one is left out),
content-addressed and deduplicated per account (/runs/have before any
upload), gzipped, and never able to fail or stall the run (records go in
batches and fall back to the settle outbox). An attempt sends at most 500 step
and context records; past that, or at the server's 429 (a record limit of the
job), it logs once, stops sending steps and sends one truncated marker (the
run's end still goes; the run itself carries on). Such a run can still move
machines: its checkpoint carries exactly the steps the server's records lack
(persist/carried.ts; scrubbed, ≤ 32 MB, outside the record limits), and a
restore fills the gap from them. Only a gap too large to carry, or refused by
the server, keeps a resume on the machine that paused, and the refusal says so. Snapshots run on suspend
(before /suspend), finish, failure, cancel and every 10 steps; one of an
unchanged workspace sends nothing. A resume claimed on a machine without the
local checkpoint rebuilds it from the server's copy (/runs/restore, every
download verified against its sha-256 within a size cap, every path through the
workspace jail) into the task's own workspace directory, and makes that
directory exactly the snapshot: once every file is downloaded and verified,
anything else there (files, folders, links, which are unlinked and never
followed) is removed, inside the workspace root only. So a paused task
continues on any of your machines.
Jev (§11.4), when the job carries a decider and the use is on
(run.decider.uses; each degrades silently to "no Jev"): decide is offered to
every run; routing picks the model per step (a switch is noted); memoryRecall
scores up to 50 recent unpinned notes (/memory/search with an empty query) and
puts the best (≥ 0.6, ≤ 8, ≤ 4k chars) in the system text; finishCheck hands a
clearly incomplete answer back once; stuckCheck nudges every 5 steps when the
agent repeats itself; injectionFlag warns the model about a page
(browser_read, http_fetch, pdf_read) that addresses instructions to an AI;
evalJudge grades an eval answer with no model at all
({"score": 0–1, "pass", "confidence"}). Every decision is a step with
tool: "jev" (and confidence) on /progress.
Attempts. The claim carries attempt (1 for a job's first claim), and every
report about the run carries it back: each /progress event, the /complete
result and the /fail body. The server ignores a report from an attempt it has
moved on from, and answers its progress with cancel: true so that run stops.
At most one run of a job is live in the daemon: if this machine claims a newer
attempt of a job it is still running (the server took it back while the
machine looked gone, then gave it here again), the older run is aborted and
reports nothing (RunningJobs).
Retryable or not. /fail says retryable: false when another attempt
would fail the same way, and the server parks the job at once instead of
requeueing it: the job deadline, a usage or rate limit, a signed-out CLI or a
refused key, a CLI that is missing or too old, a model not enabled on this
machine, a sealed key that will not open, a payload the daemon refuses.
Crashes, the model's service failing (5xx, overload, a dropped connection) and
shutdown stay retryable (modelFailureRetryable).
Cancellation. Each run has an AbortController. It fires when the heartbeat
ack lists the job in cancelJobs or a /progress answer says cancel: true,
and the harness stops the model call and kills the CLI. A job listed in
cancelJobs that no run here holds (its settle was lost, or the daemon
restarted mid-run) is settled at once with fail { cancelled: true }. A job
that runs past the server's reaper deadline is stopped a minute before it and
reported as not retryable.
Lost settles. A complete or fail that still fails after two quick
retries is kept in memory and sent again in the background (5 s, 15 s, 30 s,
then every minute, and at once after a heartbeat gets through) for up to 30
minutes. A restart loses it; the server's reaper is the backstop.
Tools
| Tool | What it does | Offered when |
|---|---|---|
| browser_read | Open one https page, signed out, and return its URL, title and text or links. Action-shaped addresses (logout, delete, checkout, follow, settings…) are refused, and so is anything on this machine or a private network, before navigating and on the page it ends on (see The browser). | the browser is ready |
| web_search | Search a fixed engine (DuckDuckGo's HTML endpoint) through the browser. | the browser is ready |
| memory_search / memory_save / memory_forget | The agent's memory in Convex, scoped by the server to the claimed job's agent. | the job carries run.memory |
| update_plan | Publish a checklist the web app shows live. | always |
| decide | Ask Jev a choice, yes/no or score question. | the decider's decide use is on (offered even when not requested) |
| http_fetch | GET or HEAD one public https URL without Chrome: text, JSON, XML, CSV, markdown. Every connection is resolved and checked (tools/net-guard.ts: loopback, private, link-local, CGNAT, ULA, multicast refused at connect time), redirects followed by hand (≤ 5, each re-checked), fixed UA, no cookies or auth, 5 MB / 20 s, output truncated with a marker. | always |
| pdf_read | Fetch a PDF through the same guard (≤ 20 MB) and extract its text with unpdf in a worker thread (30 s, isEvalSupported: false), optionally a page range. | always |
| workspace_list / workspace_read / workspace_write | Text files in ~/.groundwork/workspaces/<task or job>/: real-path jail, symlinks refused, O_NOFOLLOW, ≤ 200 files and 100 folders, ≤ 50 MB, ≤ 2 MB each, 0600, never executed. publish: true uploads the file (≤ 5 MB; not HTML/SVG/XML/JS) as a run artifact. | always |
| calculate | JavaScript in QuickJS (WASM): no host functions, 32 MB, 2 s; input and rows (CSV via papaparse) are the only values put in. | always |
| delegate / check_tasks | Start a child task (tools ⊆ this run's), optionally waiting for it; check children. | a task |
| schedule_followup | Queue a task for later: an ISO or plain-language time ("tomorrow 9am"), or with repeat a plain repeat ("every weekday at 9am", "every day at 8:30", "every monday", "every hour") or cron; this machine's timezone is sent. At most hourly, 50 runs, 10 active per agent. | a run with an agent, not an eval |
| ask_human | Pause until you answer a question. | a task |
| notify | An in-app notification (≤ 5 per run). | not an eval |
| mcp__<server>__<tool> | A tool of an MCP server in ~/.groundwork/mcp.json, arguments as one arguments_json object checked against its schema. Read-only (readOnlyHint: true) calls run at once; every other call pauses for your approval (outside a task it is denied); an approved call runs at most once, a denied one returns "Denied by the user.". 60 s per call. | the job names it |
| finish | The final answer. Built into the harness loop. | always |
Models
Claude Code and Codex are discovered, not listed in source (models.ts,
capabilities.ts): Codex from codex debug models (or its
models_cache.json), Claude Code from a seed list plus names you add. You choose
which run in Settings → Devices; the choice reaches the daemon on the heartbeat
ack and can only narrow what was discovered. GROUNDWORK_MODELS is a ceiling
on this machine: it narrows that selection (a model the user turned off stays
off), and when none of the selected models is on the list, the listed ones run.
At start the daemon reads claude --help and codex features list once. The
Claude adapter refuses a Claude Code without --tools and --strict-mcp-config
and adds --safe-mode, --no-session-persistence and --disable-slash-commands
when they exist; the Codex adapter disables every denylisted feature this Codex
knows. These facts stay on the machine; they are never sent to or taken from
the server.
A Codex that cannot list its features (an old Codex without the subcommand, or
a first launch slower than the 20 s the probe allows) is not advertised at all,
because the adapter would refuse every step on it: the server sends this
machine no Codex work until it can. groundwork status says why, and the
daemon asks again in the background (after 2 minutes, then backing off to once
an hour), advertising Codex as soon as it answers.
API models need nothing on the machine beyond the daemon: the AI SDK providers ship with it, and the key arrives sealed per job.
The browser
agent-browser (Vercel Labs) is an exact-pinned dependency; its npm package
ships a native binary per platform, and the daemon runs that binary
(browser/driver.ts). The only thing that can be missing is a Chrome:
groundwork start (and groundwork install) prefers one you already have
(Chrome, Chromium or Brave), remembers its path in ~/.groundwork/config.json,
and otherwise runs agent-browser install (Chrome for Testing, about 180 MB,
once), printing its progress. Meanwhile the heartbeat reports the browser as
installing and jobs run without the browser tools.
Reads are headed but invisible: on Linux without a display agent-browser starts
a private Xvfb (install xvfb if groundwork doctor says it is missing); on
macOS the window is minimized. Each read is one agent-browser batch --json
--bail with the commands on stdin, and reads on a session are serialised so
concurrent jobs cannot navigate over each other.
The public web only. Nothing on this machine or its network is read
(loopback, RFC 1918, link-local and cloud metadata, CGNAT, unique-local IPv6,
localhost/.local/.internal names). The URL is checked before navigating,
including what its name resolves to; every batch starts by installing a network
route that aborts all plain-http requests, so a redirect to
http://127.0.0.1:… or http://169.254.169.254/ never leaves the browser; and
the URL the page ended on is checked again, returning nothing if it is private,
not https, or (for search) off the engine's host. A redirect to an https host
on a private network is still fetched before that last check refuses it
(docs/browser-capability.md §2.1).
Isolated from your own agent-browser. Every agent-browser call runs with
--config ~/.groundwork/agent-browser.json (always {}), --auto-connect
false, no AGENT_BROWSER_* variable from your environment, and its working
directory in ~/.groundwork/run/browser. So an autoConnect, cdp,
provider or plugins setting you made for your own agent-browser use can
never make a read use your logged-in Chrome.
Takeover
When a page puts up a challenge, a person can clear it by hand from the web app
(browser/takeover.ts). Convex can request a takeover and can never drive
one:
- Loopback: the bridge listens on
127.0.0.1only, answers only requests addressed to127.0.0.1:<port>(no DNS rebinding), and serves its ticket only to afetch()from an allowed web origin: the ones pinned withlogin --origin, plusGROUNDWORK_APP_ORIGINS(elseDEFAULT_APP_ORIGINS). All of them come from this machine; the server can never add one. A navigation or frame (Sec-Fetch-Mode: navigate, a non-emptySec-Fetch-Dest) and a browser request without anOriginnever get it. - WebRTC for a remote daemon: the signalling is sealed under a key you carry
by hand (
groundwork takeover-key); Convex only brokers, and no frame or input is ever stored there. TURN addresses are pinned in local config. - Input is mouse and key events only (
toInputCommand).
Environment
| Variable | Default | Meaning |
|---|---|---|
| GROUNDWORK_CONVEX_URL | production | The deployment's .convex.site URL. |
| GROUNDWORK_HOME | ~/.groundwork | Credentials, device key, config, mcp.json, browser profiles, workspaces, checkpoints, the empty run dirs, and the empty agent-browser.json every browser call uses. |
| GROUNDWORK_DEVICE_NAME | hostname | This machine's name in the web app. |
| GROUNDWORK_MAX_CONCURRENT_JOBS | 3 | Jobs run at once (1–16). |
| GROUNDWORK_ACTIVE_HOURS | always | Claim only in this UTC window, e.g. 22-6. |
| GROUNDWORK_DAILY_CEILING | none | At most this many jobs per UTC day. |
| GROUNDWORK_MODELS | all enabled | Comma-separated model keys this machine may run: a ceiling on the Settings → Devices selection. Only listed models run; if none of the selected ones is listed, the listed ones do. |
| GROUNDWORK_SKIP_BROWSER_SETUP=1 | off | Never download a browser automatically. |
| GROUNDWORK_BROWSER_EXECUTABLE | found by setup | The Chrome to drive. |
| GROUNDWORK_BROWSER_CLI | packaged binary | A different agent-browser (development). |
| GROUNDWORK_APP_ORIGINS | DEFAULT_APP_ORIGINS in browser/takeover.ts | More web origins allowed to fetch the loopback takeover ticket, added to the ones pinned with login --origin. The baked-in list is the production web app, https://groundwork.indexlabs.dev (npm run dev sets http://localhost:3000). |
| GROUNDWORK_TURN_SERVERS, GROUNDWORK_ICE_SERVERS | STUN only | WebRTC servers for remote takeover. |
| GROUNDWORK_CLI_CLAUDE_CODE, GROUNDWORK_CLI_CODEX | on PATH | A different model CLI executable (a .js runs through Node; used by npm run dev). |
Development
npm run build:daemon # from the repo root: builds @indexlabs/groundwork-harness first, then syncs src/shared and runs tsc
npm run dev:daemon # the same, then: paired → real loop; otherwise an in-process mock
npx turbo run test --filter=@indexlabs/groundwork
GROUNDWORK_LIVE=1 npx vitest run src/harness/live.test.ts # real CLIs, real tokensThe daemon compiles against @indexlabs/groundwork-harness's built dist/, so go
through turbo (above), not npm run … --workspace @indexlabs/groundwork: that
skips the harness build and compiles against a stale one.
npm run dev without credentials runs the in-process MockJobServer
(mock/mock-server.ts, the same contract as Convex, including attempts,
retries, parking, the probe heartbeat, cancel: true for a run it has moved
on from, and the §11 routes: /suspend and resume(jobId, answer), artifacts,
child tasks, schedules and notifications) with the mock CLI
(mock/mock-cli.ts) standing in for claude and codex, and queues one sample
task. The mock CLI plans, saves a memory and searches when those tools are
offered, then finishes; GROUNDWORK_MOCK_STEP_MS=<ms> slows every step down so
a run can be watched or cancelled. It also works against a real deployment:
point GROUNDWORK_CLI_CLAUDE_CODE at dist/mock/mock-cli.js. src/shared/ is generated from packages/llm/src by
scripts/sync-shared.mjs; edit it there.
| File | Role |
|---|---|
| cli.ts, env.ts | Commands and environment knobs. |
| daemon.ts | Heartbeat, claim pool, cancellation, progress, settle. |
| protocol.ts, job-source.ts, http-source.ts | The wire contract, its parsers, and the production transport. |
| harness/run.ts, harness/models.ts, harness/router.ts, harness/tools.ts | From a claimed job to a harness run (resume and the Jev judge included). |
| harness/jev-uses.ts | Jev's advisory uses: recall, finish and stuck checks, the injection screen, the judge. |
| tools/ | The §11 tools: the fetch guard, http_fetch/pdf_read, the workspace, calculate, and the account tools. |
| mcp/ | mcp.json, the schema check, and the bridge (SDK stdio / Streamable HTTP clients). |
| checkpoints.ts | Paused runs' steps on this machine (the fast path; the server holds a portable copy, persist/). |
| persist/ | The audit record (§12): scrubbing, blobs (dedup, gzip, verified downloads), records, workspace snapshots, checkpoint manifests, restore on another machine. |
| capabilities.ts, models.ts, model-settings.ts | CLI detection, model discovery, the user's selection. |
| spawn.ts, index.ts | The reviewed executables and the model-key boundary. |
| device-key.ts, credentials.ts, config.ts | This machine's key pair, pairing, remembered settings. |
| browser/ | Driver, read-only bridge, setup, service, takeover (loopback + WebRTC). |
| mock/ | The mock server, the mock CLI, and the server's sealing routine for tests. |
