Code cites this document by section — SPEC §get_problems, SPEC §impulse-shapes — and each
section names the code it describes as `file@anchor`. Section ids are heading slugs; the rule
and the checks live in DOCLINKS.md.
Agent (Claude Code / Cursor / custom)
│ MCP over stdio (JSON-RPC 2.0)
▼
┌─────────────────────────────────────────┐
│ @metabob/mcp │
│ │
│ Tool Registry (src/tools/index.ts) │
│ │
│ Local tier Substrate tier │
│ ──────────────── ────────────── │
│ cpg-inference-ts concept-db, │
│ LocalStore goal-host, │
│ (in-process) discovery │
└─────────────────────────────────────────┘
stdout carries only JSON-RPC: the server routes console.log, console.info and console.debug to stderr (src/index.ts@stdout-is-transport), so library logging cannot corrupt the transport.
The registry is src/tools/index.ts@TOOL_REGISTRY: every tool below is one entry, validated by its
module's zod InputSchema and served by its module's handle.
Local tier — runs in-process via the vendored CPG library (vendor/cpg-inference, a git submodule re-exported by src/cpg-inference.ts; a fork, absent from package.json dependencies):
- Tree-sitter parsing →
CodePropertyGraph - BFS graph traversal
Co-change comes from git history (src/git-cochange.ts@mineGitCochanges): which files changed in the same commits
as the given ones, over the last 500 commits, skipping commits that touch more than 30 files. It needs a git
checkout and no index.
No workspace content leaves the machine. File content is read only by init_workspace, into the
in-process CPG. Nothing submits files, excerpts or file lists for analysis, and nothing watches the
workspace in the background, so there is no server-side analysis and no backend detections: problems
are what agents record (record_finding), held in the local store and recalled from concept-db.
Substrate tier — requires the attachment: METABOB_API_KEY and a discovery endpoint, given or
derived from the endpoint (§ Configuration). The key is proven against the substrate's identity before
any other keyed call (§ Attachment), and every substrate request carries Authorization: ApiKey <key>. The process is a client of the
substrate only: it makes no discovery registration, runs no libp2p node, and serves its HTTP resolver
(§ Impulse shapes) on 127.0.0.1 for local callers (src/vessel-server.ts@loopback-bind). It sends
no usage telemetry.
The substrate tools (goal dispatch, concept-db, the registry and trace readers) are described in their own sections below.
The initialize result carries MCP instructions (src/server-instructions.ts@SERVER_INSTRUCTIONS),
which MCP clients place in the agent's context at connect time. They state what the server is for,
lead with what to do before editing files other agents or the substrate also work on (get_problems
for those files, then path_activity on them; record_finding for a problem noticed and not fixed,
since a bug mentioned only in a reply is lost; mark_complete for what was fixed), tell the agent to
load the metabob skill before its first call (it holds the sequencing and the
rules), name the first-call set in one sentence (get_analysis_context, get_problems,
record_finding, mark_complete) as the fallback when the skill is not available, so a client that
defers tool schemas loads all four with one tool search, name the ledger and goal loops and the
recent-trace listing, separate the
local tools from the substrate tools, and say that a named cause from a substrate tool is the
attachment's state, diagnosed with metabob-mcp attach --check. They change only with the tool
set: every tool they name exists in the registry, which the W4 skill-contract workflow test holds.
The attachment is two values: an API key and an endpoint. Neither has a default. The substrate
installer writes both into ~/.substrate/config.json (~/.metabob/config.json is a symlink to it),
so a machine it has set up needs no environment and no flags: an MCP client entry of
{ "command": "metabob-mcp" } attaches. The installer writes endpoint, apiKey and
gapStoreEndpoint (which this server does not use) and never a proxy or a CA: those come from the
environment and the OS (§ Network).
A plugin host fills METABOB_API_KEY, DISCOVERY_ENDPOINT and METABOB_ENDPOINT (and the installer's
SUBSTRATE_* names) from its user's settings (README § Agent plugins). A setting left unfilled can reach the server
as the placeholder text itself (${user_config.api_key}): the server removes such a value at start, with one line
naming the variable, and uses the config file as it does for an empty one (src/config.ts@unsubstituted-placeholders),
so a placeholder is never sent as a key or dialled as an endpoint.
Two equivalent ways to supply them:
# Environment (typical .mcp.json / MCP-client setup); SUBSTRATE_API_KEY / SUBSTRATE_ENDPOINT are accepted too
METABOB_API_KEY=mb_live_...
METABOB_ENDPOINT=http://127.0.0.1:18080With the key or the endpoint missing, the server still starts — the local tools work — and logs one
message naming the missing input and this lookup order (src/config.ts@unattachedMessage); /health
and doctor report the same.
metabob.discoveryEndpoint (or DISCOVERY_ENDPOINT) is the preferred, explicit form of the anchor.
metabob.endpoint stays: the installer writes it, and a local one derives discovery (below).
When the server does not attach, metabob-mcp attach --check is the first step (§ attach --check).
Config resolution is directory-aware, like git: a project can pin its own backend with
.substrate/config.json or .metabob/config.json in the directory the server is launched from (MCP
clients spawn stdio servers with cwd = project root). The first file that exists and is this
server's config wins (src/config.ts@config-lookup, src/config.ts@lookupConfig,
src/config.ts@resolveConfigPath):
SUBSTRATE_CONFIG_PATH, thenMETABOB_CONFIG_PATH— an explicit file;./.substrate/config.json, then./.metabob/config.json;~/.substrate/config.json— what the substrate installer writes;~/.metabob/config.json.
A file is this server's config when it is a JSON object with a top-level metabob (or legacy
instance) section. Other tools keep their own .metabob/config.json — metabob-cli's holds base_url
and state_directory — so a file without that section, or one that is not valid JSON, is skipped with a
one-line warning naming it and the reason, and the lookup continues. A file named explicitly by
SUBSTRATE_CONFIG_PATH or METABOB_CONFIG_PATH is read as named and never skipped. When no file
qualifies, ~/.substrate/config.json is the path named as the one to create.
The installer's environment names are accepted alongside this server's: SUBSTRATE_API_KEY and
SUBSTRATE_ENDPOINT fill METABOB_API_KEY and METABOB_ENDPOINT when those are unset
(src/config.ts@substrate-env-aliases). When both names of a pair are set and differ, the METABOB_*
value wins and one warning says so. Every entry point — the server, doctor and attach --check —
reads the configuration the same way (src/config.ts@apply-configuration).
endpoint / METABOB_ENDPOINT is the substrate endpoint. The only address
derived from it is discovery, the anchor (src/config.ts@deriveLocalDiscoveryEndpoint);
every substrate call then resolves a shape through discovery (§ Attachment), so no
other service has a URL to configure or derive.
- Local substrate — an
http:URL whose explicit port ends in80(8080,18080,28080, …) derives discovery at the same block's…100(sohttp://127.0.0.1:18080gives:18100). Deriving discovery this way is census class H1, which CONFORMANCE §c4-anchoring forbids; it is kept as interim debt until the substrate installer writes the discovery endpoint, and the server logs one WARN saying so whenever discovery was derived rather than given (src/config.ts@derivedDiscoveryWarning). The identity endpoint is never derived from the endpoint: it comes from/bootstrap(§ Attachment). - Remote gateway — any other endpoint derives nothing: no path-prefixed
siblings are assumed, so a remote substrate is anchored by an explicit
discoveryEndpoint/DISCOVERY_ENDPOINT.
All keys are optional.
| Key | Purpose |
|---|---|
metabob.apiKey |
API key (also accepted at instance.apiKey) |
metabob.endpoint |
Substrate endpoint — a local one derives discovery (above) |
metabob.vesselId |
Pins VESSEL_ID (the agent-task queue key and finding author) |
metabob.discoveryEndpoint |
The anchor, explicitly: discovery-vessel's URL. Preferred over deriving it from endpoint |
metabob.identityEndpoint |
Advanced: pin the identity endpoint, overriding the one /bootstrap advertises |
Unknown keys are ignored (passthrough). metabob.discoveryEndpoint / DISCOVERY_ENDPOINT
wins over the discovery derived from the same source's endpoint, and
metabob.identityEndpoint / IDENTITY_ENDPOINT over the identity /bootstrap advertises.
Each loader fills only variables that are still unset (src/config.ts@applyConfigDefaults), applied in this order (src/cli.ts@main) — so earlier wins:
- CLI flags —
--api-keyand--endpointare written into the environment before the loaders run - Environment — explicit variables (
METABOB_*, then theSUBSTRATE_*names for whichever is unset), then the discovery derived fromMETABOB_ENDPOINT - The config file (lookup order above) — its explicit keys, then the discovery derived from its
endpoint. WhenMETABOB_ENDPOINTis set, only the file's credentials (apiKey,vesselId) apply, never its endpoints (src/config.ts@withoutEndpoints)
There is no built-in default after these. Consequences worth knowing:
- An MCP client that injects only
METABOB_API_KEYkeeps the config file's endpoint. - An explicit
METABOB_ENDPOINTchooses the substrate: the config file's endpoints never fill a discovery it left underived, so the attachment cannot split across two substrates.
src/attachment.ts@attach runs at startup, in the background (and again after a transient failure, below): startup and the local tools never
wait on it. It takes the anchor {discovery, key} and, in one pass that contacts each address at most
once:
GET {discovery}/bootstrap— public and keyless — readsidentity_endpoint(an empty string means absent).- Chooses the identity endpoint, in this order:
- an explicit
IDENTITY_ENDPOINT/metabob.identityEndpoint, used as given; - the
/bootstrapidentity_endpoint, when a quickGET /health(2 s) answers; - when that address is loopback or a single-label container name on an 8xxx port and does not
answer, the same port on the discovery URL's host at discovery's published-port offset:
port + (discoveryPort − 8100)(node1http://127.0.0.1:8101→:18101; a compose fleet'shttp://substrate-live:8101with discovery on:26100→:26101). This rehost is census class H1 interim debt (src/attachment.ts@rehost-h1): it logs a WARN naming the advertised value and recordsidentity.source: "rehosted-h1"; - otherwise none:
identity_unreachable(advertised=…, tried=[…]).
- an explicit
- Proves the key:
POST {identity}/v1/auth/resolvecarrying the key in both of identity's forms, the nested body{ impulse: { type: "authentication", pointer: { type: "apiKey", apiKey } } }and the flatAuthorization: ApiKey <key>header (src/auth-service.ts@org-check). An identity that serves the nested form answers the tenant with the key's validated scopes (orgId,userId,keyId,accountIdwhen the user has a membership,scopes); one that serves only the flat form reads the header and answersorg_id,key_idwithout scopes, which are then "not reported". The nested body goes only on a direct hop: an inspecting proxy's data-loss scanning logs request bodies where it masks theAuthorizationheader, so through a proxy the check sends the header alone and scopes are not reported. Each check writes a key-session row on identity, so it runs once per attach and on the heartbeat, never per tool call. The scopes are what identity reports for the key; producers enforce only named scopes (verdict:human,operator:direct,admin, …), notreadorwrite, so nothing in the cockpit relies on them as a permission. Whichever address step 2 chose, this check against it is what makes it trusted; for a rehosted address it is also the dial. A 401/403 iskey_rejected— the key belongs to another namespace.
Once authenticated, the same identity is re-resolved every 5 minutes (the key-issuance heartbeat,
src/auth-service.ts@AuthService); a key revoked meanwhile turns the attachment to key_rejected.
A failure that can pass by itself is retried (src/attachment.ts@reattach). A server started before its
substrate answers (alongside the fleet, after a reboot or a power loss) would otherwise stay unattached until it
restarts. After anchor_unreachable, identity_unreachable or blocked_by_portal (a captive portal clears once
the user signs in):
- the attachment attempts again in the background, after 5 s, 15 s, 30 s, 1 min, 2 min, then every 5 min, until it attaches;
- a substrate tool called after the failure attempts again at once, at most every 10 s, so the first call after the substrate comes up succeeds;
- each attempt starts from a fresh state, and the recovery is logged once ("Attached on attempt N, after: ");
- while an attempt runs, readers (
/health, the refusal line, the gate) see the last settled attempt, and calls that arrive meanwhile wait for it, so none reports a half-reset state as its cause. - a cause found more than a minute ago says so in the line tools echo:
… (found 3.1 h ago, at <time>, after N attempts; retrying), so a cause served from an earlier attempt never reads as current.
A rejected key, missing configuration, a proxy's refusal and a non-substrate answer are final. Only the long-running server retries, since it is the one with
the heartbeat; attach --check, doctor, task pull and finding close report what they found once.
State — one typed object (src/attachment.ts@AttachmentState), reported by doctor and in the
loopback GET /health as its attachment field (src/vessel-server.ts@health-attachment), together
with attached (true only once the key is proven) and, when not attached, reason (the cause line,
the missing input, or attaching while pending). Neither carries the key. Before an attachment exists,
/health still reports network.proxy (credentials redacted).
| field | values |
|---|---|
anchor |
{ discovery, source } — explicit, derived-h1, or none |
bootstrap |
{ status, identityEndpoint?, detail? } — ok, unreachable, not_substrate (non-JSON or HTML; when the request went through a proxy and was refused, the detail names the proxy as what may have refused it), or skipped (no anchor) |
identity |
{ endpoint, source, advertised, tried } — source explicit, bootstrap, rehosted-h1, or none; tried lists every address contacted, in order |
auth |
{ status, orgId?, keyId?, accountId?, scopes?, detail? } (never the identity's jwt) — pending, authenticated, key_rejected, identity_unreachable, or not_configured |
cause |
the named cause keyed calls are refused with, or null |
message |
that cause as the one line a substrate tool returns |
advertised |
none (client-only) |
network |
the network diagnosis (src/attachment.ts@network-diagnosis): proxy (HTTPS_PROXY / HTTP_PROXY with credentials redacted, NO_PROXY and the entries in force), anchorRoute ({ via, bypass }: the proxy a request to the anchor takes, or why it goes direct), cause and causeLine (the network cause of a failed attachment, § Network), tls ({ host, authorized, issuer } for an https anchor), osProxy (OS proxy settings the process does not see, named after a direct connection failed), and lastByFamily (the last outcome of the attachment and of each producer family — discovery, trace, concept, goal, other — with its cause) |
startedAt, settledAt, lastCheckedAt |
timestamps; lastCheckedAt moves with each org check, the heartbeat's included |
Named causes — not_configured (no key, or neither a discovery endpoint nor an explicit identity),
anchor_unreachable (discovery does not answer /bootstrap), blocked_by_portal (/bootstrap or
the org check answers non-JSON or HTML, or a proxy answers with its own page — a captive portal or
block page), identity_unreachable, and key_rejected. When the network produced no answer, the line
carries the network cause (§ Network) — anchor_unreachable: discovery https://hub.example did not answer GET /bootstrap — proxy_auth_required(NTLM): … — and network.cause names it. A refusal is
logged once at WARN when the attachment settles, naming the requirement it serves
(CONFORMANCE §c9-fail-visibly, or CONFORMANCE §c3-identity for key_rejected); each refused call after
it is logged at debug.
The gate. Every substrate tool passes one check before it runs, on both dispatch planes — MCP
stdio (src/index.ts) and the loopback resolver (src/vessel-server.ts@resolveImpulseContent):
src/tools/index.ts@substrateToolRefusal. It waits for a pending attachment to settle, then lets the
tool run when authenticated and otherwise returns the cause as one line (isError: true), for example
key_rejected: the substrate's identity (http://127.0.0.1:18101) rejected the API key (HTTP 401); substrate tools are unavailable; local tools still work. The substrate half of the mixed tools
(get_problems, get_analysis_context, record_finding, mark_complete) and answer delivery
(submit_resolution) check the same attachment and report the cause in place of their concept-db or
goal-host output; their local half runs regardless.
The one ingress. resolve(pointer) on the attachment (src/attachment.ts@attachment-resolve) is
the path substrate tools take to the substrate: it posts { pointer } to discovery POST /resolve with
Authorization: ApiKey <key>, and discovery answers its own shapes (vesselCapability,
vesselRegistry, …) or forwards the pointer to the shape's producer and returns the producer's status
and body (CONFORMANCE §c11-attachment-model). Tools reach it through src/attachment.ts@resolveShape.
It refuses with the named cause (AttachmentRefusedError) when the attachment is not authenticated, and
names discovery's own failures in one line (src/attachment.ts@classifyDiscoveryAnswer):
| failure | when |
|---|---|
no_producer(shape) |
discovery answers 404 { error: "Not found", shape }. Discovery sends it when nothing produces the shape, and its peer forward sends the same answer for every non-2xx from a peer's producer — a refusal (a 400 naming missing fields), a missing item (an unknown trace or dispatch) — so through a spoke the answer does not say which. The line keeps the no_producer(shape) token and says that (src/attachment.ts@no-producer-line); a tool that knows what a missing item means for its request names it instead (§ execution_trace, § goal_status, § provide_feedback). A producer's own 404 that reaches this hop with its own body passes through to the tool |
producer_timeout(shape, ms) |
discovery answers 502 forward_failed because it cut the forward at the producer's resolve_timeout_ms, or the request outlives its client cap |
producer_unreachable(shape) |
any other 502 forward_failed |
anchor_unreachable |
discovery itself does not answer; the line carries the network cause (§ Network) |
Each request carries a client cap just above discovery's cut for the producer family
(src/attachment.ts@resolve-timeouts: discovery's own shapes 10 s, traces 15 s, concept-db 35 s,
goal-host 60 s), so no request is held open past 60 s. A read cut at its producer's timeout is retried
twice, after 0.5 s and 1.5 s, except the trace reads (activityExecutionTrace, executionTraceList): activity-api
does not cancel a query discovery cut, so a retry would run the same slow query again beside the first. A write is
never retried.
Every request this process makes to a substrate takes one path, src/net.ts@net-request: the
attachment's /bootstrap, identity dial and org check, discovery POST /resolve, the key-issuance
heartbeat, concept_usage_stats's history read, doctor's HTTPS and TLS probes, and the
certificate-fingerprint self-probe (src/net.ts@open-tls-socket). It is a small HTTP/1.1 client on
node:net and node:tls, not fetch: it behaves the same under Bun and Node, it sees a proxy's own
answer to CONNECT (which fetch hides), and it never follows a redirect, so a captive portal's 302
is seen rather than followed. Proxy selection, trust, the connect budget, retries and error
classification live there and nowhere else (CONFORMANCE §c8-proxy-and-tls-in-the-vessel-role).
The npm bin (dist/cli.js) starts under Node (#!/usr/bin/env node). Which runtime makes the network
calls depends on the command:
The server needs a SQLite for its local store (src/sqlite.ts): bun:sqlite under Bun, node:sqlite under
Node 22.13 / 23.4 and later. One file and one schema serve both, so switching runtime keeps the store.
| launch | the MCP server's calls | doctor, attach --check, task pull |
|---|---|---|
bun src/cli.ts (development) |
Bun | Bun |
node dist/cli.js / npx @metabob/mcp, Node with node:sqlite |
Node: the server runs in-process | Node: they are Node-safe and stay in-process |
| the same, an older Node, Bun on PATH | Bun: the server re-execs into Bun (src/cli.ts) |
Node |
| the same, an older Node, no Bun | none: the server exits 1 with runtime_unsupported, naming both fixes (Node 22.13 or later, or Bun) |
Node |
Each row of the table is checked on every push by .github/workflows/package-smoke.yml, from the packed tarball
installed with npm: on ubuntu, macOS and Windows with Node 22 and no Bun, the server is driven over stdio
(scripts/ci/smoke-mcp.mjs: initialize, tools/list, init_workspace, search_codebase, predict_cochanges,
and stdout carrying only JSON-RPC), and the plugins' own launch command runs from a checkout of this repository
through a one-package registry (scripts/ci/smoke-registry.mjs), waiting for init_workspace as a server started
in its plugin folder does (§ Workspace); on Node 20, the server exits with runtime_unsupported without Bun and
re-execs into it with Bun.
Node 22 prints an ExperimentalWarning when node:sqlite first loads; the server drops that one warning, since
stderr is its log channel, and passes every other. A client that closes the server's stdout is gone, like one that
closes stdin: the write fails with EPIPE and the server shuts down.
So both runtimes make real calls, and the network matrix runs under both
(tests/workflows/networks/matrix.test.ts: Bun in process, Node by spawning attach --check --json
from dist/cli.js, or from a Node bundle of the source). Because src/net.ts uses only node:net and node:tls, a row behaves the same in
either; neither runtime's own HTTP(S)_PROXY handling is involved. attach --check prints the runtime
it ran under, and whether the MCP server runs under it too or re-execs into Bun.
Under Node, a system resolver that drops queries delays the process's exit: attach --check prints its
report within the connect budget, but Node's exit waits for the pending getaddrinfo to give up. The
server is long-lived and unaffected.
The workspace decides which repository the ledger is keyed by (§ Repository identity), where the local store scopes
its findings, and the path the coordination identity is derived from (src/workspace-root.ts). It is decided in this
order (src/local-index.ts@activeWorkspacePath):
- a directory
init_workspacewas given, whether or not it holds files to index (a path that is no existing directory is refused, and binds nothing); WORKSPACE_PATH, when set;- the client's first
file:root (MCProots/list); - the directory the server was launched in, unless that is the plugin's own folder.
A client may start the server outside the project (an Agent Plugins host starts it in the plugin's own folder), so
the server asks a client that declares the roots capability for its roots once it has initialized, and again on
notifications/roots/list_changed; roots of other schemes are not workspaces. A root is taken by its real path, as the launch directory always is, since the workspace id and the identity are hashed from the path: a root sent through a symlink is the same checkout as a launch in it. WORKSPACE_PATH and an init_workspace path are used as given. A tool call waits for the first
answer, at most 2 s, then goes on with steps 2 and 4; a client that declares no roots capability, or headless use
with no MCP client, is not waited for. Once the workspace is decided the store is scoped to it, the attached marker is
written for it and, when it differs, for the launch directory too (the edit check finds the marker by its own working
directory), and the coordination identity is derived from it.
The identity is derived once and kept for the life of the process. A later change of the client's first root moves
the repository, the store's scope and the marker, never the author, so a session's own claims and recorded_by
stay its own; init_workspace with a path wins over the roots for the rest of the session. get_analysis_context
names the workspace, where it came from, any other roots, and the workspace the identity came from when that
differs.
The plugin's own folder is no workspace (src/local-index.ts@workspace-gate). An Agent Plugins client sets
PLUGIN_ROOT and may start the server in it, with no roots and no WORKSPACE_PATH (Codex starts it in its plugin
cache). When the launch directory's real path is PLUGIN_ROOT or inside it, and neither step 2 nor step 3 names a
workspace, the workspace is undetermined: every tool but init_workspace with a workspace_path, on stdio and on
the loopback resolver, answers workspace_undetermined: …, naming that call; the store is not scoped, no marker is
written and no identity is derived. init_workspace { workspace_path } then decides the workspace, and the identity
and the marker are derived from it, as a launch in that directory gives; so are they when roots arrive later. A
path that is the plugin's folder or inside it (a relative path such as . resolves there) is refused with
workspace_undetermined, asking for the project's absolute path, and binds nothing. A plugin folder never gets a
marker. An init_workspace path that arrives on the loopback resolver before the workspace is first decided wins
over steps 2–4 there too.
Chosen per request from the environment (src/net.ts@select-proxy); never from a config file:
- an
https:URL usesHTTPS_PROXY, falling back toHTTP_PROXY; anhttp:URL usesHTTP_PROXYonly (so a plain-http anchor with onlyHTTPS_PROXYset goes direct, andattach --checksays why). Lowercase names win over uppercase. A proxy URL without a scheme ishttp://. NO_PROXYentries:*; a host, which matches its subdomains too (hub.corp,.hub.corp); ahost:port; an IPv4 CIDR (10.0.0.0/8).- Whenever a proxy is set,
localhost,127.0.0.1and::1are added toNO_PROXY, and every loopback host (127.0.0.0/8,::1,*.localhost) goes direct: local substrate traffic never goes through a corporate proxy. - An
https:target is reached withCONNECT host:portby hostname, so the proxy resolves it and the client needs no DNS for it; anhttp:target is sent to the proxy in absolute form (GET http://host:port/path), so a proxy that tunnels only to 443 still carries a plain-http anchor. - Basic credentials go in the proxy URL (
http://user:password@proxy:3128) and are sent asProxy-Authorization: Basic. They are never logged: every proxy URL in a log line,/health,doctor's report and artifact, andattach --checkis redacted tohttp://***@proxy:3128(src/net.ts@redact-credentials). - Only
http://andhttps://proxies are supported; another scheme (socks5://) isproxy_unsupported(socks5).
A certificate is checked against the bundled roots, the OS trust store (read once by
src/tls-trust.ts@systemCaAdditions; METABOB_NO_SYSTEM_CA=1 skips it) and NODE_EXTRA_CA_CERTS,
which src/net.ts@trusted-cas reads itself because Bun does not. The chain and the host name are both
verified before any byte of the request is sent; an untrusted chain is tls_intercepted(<issuer>),
naming the issuer at the top of the chain served. The process-wide undici dispatcher
(src/tls-trust.ts@installSystemTrustStoreSync) covers third-party fetch only: with an undici that
has no EnvHttpProxyAgent it installs a plain Agent and warns, since it cannot choose a proxy per
request.
- Connect budget (
src/net.ts@connect-budget): 5 s (METABOB_CONNECT_TIMEOUT_MSoverrides) for the TCP connect to the first hop, the proxy'sCONNECTanswer and the TLS handshake. A blackholed anchor or proxy fails asconnect_timeoutwithin it, apart from the response budget. - Response budget: the caller's, covering the whole request —
/bootstrapand the org check 5 s, the identity dial 2 s,resolve()its family's cap (§ Attachment, never above 60 s), the history read 3 s. A connected target that does not answer in time isresponse_timeout— slow, not blocked. - Retries: on idempotent requests only (reads, and the org check), within the response budget, at
most twice (after 0.25 s and 0.75 s), for transient failures: a reset connection, a proxy's 5xx to
CONNECT, and — for/bootstrap— a 502/503/504. Never on a write, a 4xx, an auth failure or a classification (blocked_by_portal,tls_intercepted, …).
src/net.ts@net-causes. Each failure is one line that starts with its cause; the attachment's refusal
(§ Attachment), every substrate tool's error text, /health, doctor and attach --check all carry
it.
| cause | what the network did |
|---|---|
proxy_auth_required(<schemes>) |
the proxy answered 407. For Basic without credentials, the line says to put them in the proxy URL; with credentials, that it rejected them. For NTLM / Negotiate, which this process does not speak, the line names the workaround: a local authenticating relay (a px- or cntlm-style proxy) with HTTPS_PROXY pointing at it |
proxy_port_blocked(<port>) |
the proxy refused CONNECT to a port other than 443 (403 / 405) — Squid's SSL_ports default |
proxy_refused |
discovery's /bootstrap, reached through a proxy, answered a non-JSON 4xx: the proxy itself may have refused the request (one that allows only HTTPS, say), so the line names the proxy rather than a portal |
proxy_unsupported(<scheme>) |
the proxy URL is not http:// / https://, or does not parse |
tls_intercepted(<issuer>) |
the chain is signed by an issuer this process does not trust — TLS inspection (TLS_INTERCEPTED), or an untrusted substrate CA |
tls_name_mismatch(<host>) |
a trusted chain for another name |
tls_failed(<host:port>) |
the TLS handshake failed for another reason |
dns_failed(<host>) |
the first hop's name does not resolve — with no proxy, the anchor's |
connect_timeout(<host:port>) |
no connection (or no tunnel) within the connect budget: packets dropped |
connect_refused(<host:port>) |
nothing accepts connections there |
connection_reset(<host>) |
the connection closed before a complete answer — a proxy cutting idle or long requests |
response_timeout(<host>, <ms>) |
connected, but no complete answer within the response budget |
blocked_by_portal |
a proxy or portal answered with its own page: HTML or a body instead of a tunnel, or a non-HTTP answer. /bootstrap and the org check also name a non-JSON or HTML answer this way, and only the org check — which needs the key — makes the anchor trusted |
anchor_unreachable(<host:port>) |
the proxy could not reach the target (a 5xx to CONNECT), or any other failure |
src/net.ts@classify-error names an error from any other source (a scripted fetch in tests, a runtime
error) by its code or message.
metabob-mcp attach --check [--json] (also metabob-mcp doctor --attach) runs the attachment once,
as the server runs it — the same configuration lookup, request path and sequence — then one read,
vesselRegistry, through discovery POST /resolve, and prints each hop (src/attach-check.ts@attachCheck):
config— the file read, the key (last four characters), the anchor and its source;proxy— direct (and why: loopback,NO_PROXY, none configured,HTTP_PROXYunset) or through which proxy, redacted, with theNO_PROXYentries in force;tls— verified (and the issuer), intercepted (and by whom), or plain http;bootstrap,identity(endpoint, source, advertised, tried),org check(orgId, the account, and the key's scopes or "not reported by identity"),resolve;result—attached, or the one line with the named cause.
Exit 0 when attached and the read resolved, 1 otherwise; with nothing configured it prints the one instruction naming the missing input. It writes nothing: no file, and the read is one discovery answers itself. It is Node-safe and runs in the runtime that launched it (§ Runtime).
PAC and WPAD scripts are not evaluated. When a direct connection fails as connect_timeout,
dns_failed or connect_refused and no HTTP(S)_PROXY is set, the OS proxy settings are read
(src/os-proxy.ts@detect-os-proxy: macOS scutil --proxy, the Windows Internet Settings registry key,
GNOME gsettings; best-effort, 1.5 s) and named in the failure — "the OS proxy settings use a PAC
script (…), which this process does not see; set HTTPS_PROXY". With none found, a connect_timeout
still says that a network reachable only through a proxy needs HTTPS_PROXY. doctor reports the same
settings. Nothing here runs at startup or blocks it.
A write is never sent with a key whose validated scopes lack write (or admin)
(src/attachment.ts@read-only-key): every non-read resolve answers key_read_only: this key has scopes [read]; the <shape> write was not sent, and reads are untouched. Producers do not all enforce scopes
yet, and a refusal that came back through a peer forward would read as no_producer, so the cockpit
does not rely on them. record_finding and mark_complete keep their local half and say the shared half
was not sent and other agents will not see it. With no scopes reported (the flat org check, used
through a proxy) nothing is known about the key, and nothing is gated.
metabob-mcp task pull --origin <git url> [--task-dir /task] [--work-dir /work] [--minutes 60] [--trust <vesselId,…>] [--json] (src/task-pull.ts@runTaskPull) is a cell's entrypoint step, run before any harness starts. It takes a task from the ledger, claims it, writes the task and its context into the task directory, and clones the work directory, so every harness gets its work the same way: already mounted and fresh. Both directories must be empty or missing; a task never reuses them.
It is Node-safe and runs in the runtime that launched it (§ Runtime), so a cell that runs npx @metabob/mcp needs no Bun for it; W7 runs the built CLI under Node.
Getting attached. It loads the configuration and attaches as the server does (§ Attachment). Its vessel identity is VESSEL_ID, else derived from the key and the work directory, and its session is SESSION_ID, else a new one. The repository is the origin URL's identity (§ Repository identity), so every clone of one origin shares one ledger.
Choosing a task. Tasks are open findings in category task (§ get_problems) on that repository (src/task-pull.ts@candidateTasks). No checkout exists yet, so the super-repo/vessel aliases (§ Repository identity) come from the origin's identity: a vessel origin also reads tasks the super-repo recorded under repos/<name>/, and task.json names their files as the vessel does (src/repo-identity.ts@vesselAliases). The rule applies to any origin, so a super-repo origin reads its own tasks plus any recorded under repos/<its name>/. It tries them in this order:
- a task this cell already holds, so a restarted cell resumes it;
- then unclaimed tasks, most severe first.
A task someone else holds, a closed one and one with a fix pending are skipped. Each candidate is claimed with claim_finding's rule (§ claim_finding). A held, lost, unsettled or refused claim moves on to the next candidate.
What it writes. On a claim it writes task.json into the task directory:
- the task id, its concept id and finding ids, summary, severity, files and details;
- the origin, repository and ref;
- the integration policy with each value's source;
- the claim (holder, end), the recorder, the time and the session;
- the trust list for conventions and whether it was defaulted;
base, the commit the clone checked out, which the entrypoint passes tofinding closeas<base>..HEAD.
It also writes context.md: the details, the policy, the open non-task findings on the task's files, and the task as recorded. Then it clones the origin into the work directory, at the task's ref: when it declares one.
A ref: must be a branch name git check-ref-format --branch accepts, must not start with -, and is at most 100 characters (src/task-pull.ts@validRef): task details are written by agents. Anything that fails after the claim withdraws it: a refused ref, writing the task directory, or the clone. Left to lapse, one bad ref or origin would block the task for every cell for the claim's length.
The integration policy (src/task-pull.ts@integration-policy) says where the fix goes. Each value comes from the task's details (push_to:, integrate:, conflicts: lines), else from a project_convention concept for the repository by a trusted author, newest first, else the default:
push_to:main, ortask(meaningtask/<task id>); defaultmain;integrate:pushorreview; defaultpush;conflicts:rebase.
A convention steers pushes, so it is untrusted input. It counts only from an author named by --trust or METABOB_TASK_TRUST, and by default only from the task's own recorder, in which case task.json records trust.defaulted: true. A value outside the allowed set is refused whatever its source. Every refusal is logged as a warning and never applied. Authors are declared, not verified (§ Client claim), so the trust list is a convention among cells, not a security boundary.
Exit codes:
- 0: a task was pulled.
- 3: no open task, so the cell is done.
- 4: open tasks exist, but others hold them, or a claim was not settled yet.
--jsongivesretry_after, the earliest claim end, for the entrypoint loop to wait until. - 2: not attached.
- 1: any other failure.
The reply lists what each candidate's claim did.
metabob-mcp finding close (--commits-from <file> | --commits <range>) [--target <branch>] [--work-dir <dir>] [--task <task.json>] [--json] (src/finding-close.ts@runFindingClose) is close by commit. It is Node-safe, like task pull.
Which commits. A cell closes only its own commits. After it fetches and rebases (the rebase conflict rule), and before it pushes, the entrypoint lists them with git rev-list origin/<target>..HEAD into a file, pushes and verifies, then runs finding close --commits-from <file>. A <base>..HEAD range would be wrong after a rebase: it also holds the commits other cells merged since base, and their trailers would be closed under this cell's name. task.json's base records where the clone started, not the range. A person can pass --commits <range> after their own push.
The claim. A commit in the range claims a fix with a Fixes-Finding: agf_… trailer, one per finding (src/finding-close.ts@trailerIds, git's own trailer parse). Only the finding id and the commit sha are sent, never commit text.
The target. The branch the fix must be merged into is --target, else the task's policy (main under review, otherwise its push_to), else main. It is fetched first (git fetch origin <target>). If the fetch fails nothing is written, since a stale view of the origin could misjudge what is merged.
Each finding the range names:
- Must be an open finding of this repository. An unknown id, another repository's, or a closed finding is reported and never written, so a typo can't close someone else's finding.
- Merged means the commit is in
origin/<target>'s history, or a commit there carries the sameFixes-Finding: <id>line anywhere in its message. Merged is judged on the target, so leftover edits in the work tree don't hold the close back: the uncommitted-changes rule of § mark_complete applies to a claim checked against the checkout's ownHEAD, not one checked against a target ref. A squash merge concatenates the source messages into its body, where git's trailer parse doesn't look. The finding is then endorsed throughmark_complete's checks (§ mark_complete), with that commit asfix_commitchecked againstorigin/<target>, plusfix_via: trailer, so the verdict readsfix_claimed: yes. - Reverted: a merged fix that a later commit on the target reverts (git revert's "This reverts commit ") is reported as reverted and not written.
- Pushed but not merged (on some remote branch, as under
review): the verdict isfix pending (awaiting merge)withfix_target:. Recall tags it[fix pending: pushed by <vesselId>, awaiting merge into <target>]. A laterfinding closeafter the merge closes it. - On no remote branch: nothing is written; it is reported as unpushed.
Plain endorse vs close by commit. mark_complete endorse closes a finding once its fix commit is on a remote branch (§ mark_complete). finding close closes it only once the fix is merged into the target. Under the review policy the cell uses finding close, so a fix pushed for review stays pending until merged.
Exit codes: 0 done, with each finding's outcome reported; 2 not attached; 1 any other failure, including a failed fetch.
Known limits:
- A fix that reaches the target without its
Fixes-Finding:line (a cherry-pick that drops it) is not recognised; patch-id matching is not done. - A fix that was reverted and then re-applied still reports as reverted.
Every write the server makes declares who sends it (src/operator-id.ts@clientClaim): the attachment adds
client_claim: { operator, session, client } to the pointer of every resolve that is not a read
(src/attachment.ts@attachment-resolve), unless METABOB_CLIENT_CLAIM=off. Some substrate tables are
schemafull, so a live write smoke checked that every implemented write shape accepts the field. A goal
dispatch also carries operator and a
session:<session> tag, which goal-host stamps into the trace's tags; provide_feedback folds
[operator: …] [session: …] into its label notes, and findings and verdicts already carry
recorded_by / reviewed_by and session in their content.
| Field | Value |
|---|---|
operator |
MB_OPERATOR_ID when set; otherwise this workspace's stable vesselId (metabob-mcp-<hash(apiKey + workspace)>), so distinct workspaces and keys are told apart; metabob-mcp-unidentified with no key |
session |
SESSION_ID when set; otherwise one UUID per launch |
client |
metabob-mcp/<version> |
These are claims, not attribution: the client asserts them, and only a producer that stamps a write
from the credential attributes it; the claim is there so such a stamp can be joined to a session and an
operator. Reads carry no claim. A pointer that already has a client_claim is sent as it is.
Every string a substrate tool sends passes one guard before the tool runs, on stdio and on the
loopback resolver alike (src/outgoing-guard.ts@guard-tool-input). The tools that send nothing
(init_workspace, analyze_impact, predict_cochanges, search_codebase, annotate_component,
get_metrics, assign_git_changes, get_problems, get_analysis_context, pull_assigned_work, and
path_activity, whose paths are matched locally) are exempt; every other tool's string fields are guarded, nested strings included. A field's kind and size
cap come from src/outgoing-guard.ts@guarded-fields (for example a finding's summary is prose with a
2 KB cap, a goal is acted on with 4 KB); a field the table does not list is treated as acted on with a
4 KB cap, so a new field is guarded by default.
- A secret refuses the call, and nothing is sent. Known secrets are the attachment's key
(
METABOB_API_KEY,SUBSTRATE_API_KEY, at least 12 characters) and the values of secret-named variables (KEY,TOKEN,SECRET,PASSWORD,CREDENTIAL) in this process's environment, in the workspace's.env,.env.local,.env.development,.env.production,.env.test,.npmrcand.pypirc, and in the user's~/.netrc,~/.aws/credentials,~/.npmrcand~/.pypirc. These files are read locally, cached by modification time, and never sent. The user's home is this user's for the process's own environment; a caller that passes another environment (a test) names it by that environment'sHOMEorUSERPROFILE, or reads no home file. A value counts only when it is at least 16 characters, not a path or a credential-free URL, not a placeholder, and random enough (≥ 3 bits per character), and a variable named for a location or an identifier (…_PATH,_FILE,_DIR,_ID,_URL,_HOST,_PORT,_NAME,_USER,_REGION) never does. Patterns catch private-key blocks, cloud and service API keys, GitHub, GitLab, npm and Slack tokens, and JWTs; AWS's documented example keys are exempt. Authorization headers, cookies, URLs with credentials and pasted config assignments (DB_PASSWORD=…) count unless the value is a placeholder (YOUR_…,EXAMPLE,<…>,***, a repeated character, an ALL_CAPS name,${VAR}, a literal such astrueorpassword, a number or a duration) or the name uses the word as a qualifier (MAX_TOKENS,TOKEN_TTL,KEY_ROTATION_DAYS); a header or cookie token must also be random enough, while a password counts however weak it is, since weak passwords are the commonest real leak. The refusal is<tool>: refused: outgoing_secret(<kind>, <field>), naming the field and the kind, never the value, and suggests describing it masked (postgres://admin:***@host). - A home path (this user's home directory, or
/home/<user>,/Users/<user>,C:\Users\<user>) is replaced with~in prose fields, and the reply ends with a line saying so. In fields the substrate acts on it refuses the call withoutgoing_local_path(<field>): rewriting a path there would change what the request asks. - Each field has a size cap; a larger field refuses the call with
outgoing_too_large(<field>, <bytes>).
The tool-call log line names each argument and its size, never its value
(src/outgoing-guard.ts@describeArgsForLog). The guard catches accidents, not adversaries: paraphrase,
summaries, re-typed code, an encoded or split key and personal data in prose pass it.
The backstop over what the server composes (src/outgoing-guard.ts@guard-outgoing-pointer). The guard above
checks a tool's input; a write's pointer also carries lines the server builds itself (header blocks, verdicts, author
stamps). Before any write leaves, at the one egress (src/attachment.ts@attachment-resolve), every string in its
pointer is checked once more, on both entry points:
- a known secret value (the attachment's own key, and the values the guard knows) refuses the write with
outgoing_secret(known_secret, <field path>): the <shape> write was not sent, never repeating the value; - a path under this user's home directory is replaced with
~, and a warning names the shape and each field path.
Only exact known values are matched here, not the credential patterns, since a pointer's ids and hashes would read as tokens. Reads are not checked: their text is a tool's guarded input.
With METABOB_CALL_LOG=<path>, the server appends one JSON line per MCP tool call to that file on this machine (src/call-log.ts@logCall). It is opt-in and off by default. It lets the order of an agent's metabob calls be scored for any harness, with or without hooks or a transcript; the cell lab's scorer reads it.
It covers both entry points: MCP stdio, and the loopback resolver's POST /v2/impulses/resolve (a local caller is a harness the lab may score).
Each line holds:
ts,sessionandtool;via:stdioorloopback;args: each argument's name and size, never its value, the same description as the tool-call log line (src/outgoing-guard.ts@describeArgsForLog):string(<length>),array(<length>), or the type;outcome:ok,refused(the outgoing guard),not_attached(the attachment gate) orerror;result, when the tool reports one (src/call-log.ts@reportResult): what its answer did, beyond the call running.claim_findingreports its claim outcome (claimed,renewed,released,held,lost,unsettled,closed,refused,error);mark_completereportsok,pending,not_appliedornot_shared(src/tools/mark-complete.ts@markCompleteResult).ms.
Nothing in it is sent anywhere. The file is created mode 0600, and a write that fails never fails the call. It records only what the server sees, so it can't show whether a call came before an edit; the cell lab gets that from the first-modify times in /work.
The analysis tools. Every handler returns a single text block.
Handler: src/tools/init-workspace.ts@handle
Index the workspace into the local CPG. Indexing is local: no file content leaves the machine, and there is no server-side analysis to wait for.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
workspace_path |
string | the configured workspace (§ Workspace) | Absolute path to the workspace root; it must be an existing directory |
recently_changed_files |
string[] | [] |
Changed files — the output reports how many co-change neighbours they have |
batch_size |
number | 20 |
Accepted (1–20) and ignored |
include |
string[] | [ts,tsx,js,jsx,mjs,cjs,py] |
Extensions to index (with or without the leading dot) |
exclude |
string[] | [node_modules,.git,dist,build,.next,.nuxt,coverage,.turbo,.cache,__pycache__,.venv,venv,vendor] |
Directory names to skip; dot-directories are always skipped |
sync_api |
boolean | true |
Accepted and ignored |
Behaviour
- Scans the workspace (at most 2000 files, each at most 200 KB) and parses it into the local CPG within a 20 s budget; a parse that runs out of budget resumes on the next call and says so.
- Binds the local store to the indexed root, so findings recorded afterwards land in this workspace.
- For
recently_changed_files, counts the files that change with them in git history. - Makes no request:
batch_sizeandsync_apistay in the schema so existing callers validate, and change nothing. The output states that no file content was sent off the machine.
The native modules load on first use (src/local-index.ts@lazy-native). The parser (tree-sitter and its
grammars) are native, and the build keeps them in a chunk of their own that
loads on the first init_workspace, not at startup. A module that cannot load (no prebuilt binary for this
platform, a missing shared library) makes init_workspace, and every local tool after it, answer
local_index_unavailable(<module>: <reason>); the ledger, memory and substrate tools never touch it and work as
usual, and the server always starts.
The index is not persisted (src/local-index.ts@index-not-persisted). The CPG and its components live in the process and are rebuilt by init_workspace after every launch, so analyze_impact and assign_git_changes start cold after a restart. A disk cache with per-file invalidation would be wrong, not just stale: a file's incoming CALLS edges are created while other files are parsed (each call resolves by a whole-graph name lookup), and component ids embed a start line, so invalidating one changed file leaves dependents pointing at absent ids, and the two tools answer wrongly without saying so. Persistence, if it is ever built, must therefore:
- be whole-index and all-or-nothing: on load, stat every stored file, and any missing, modified or resized file discards the entire cache for a full rebuild; it never invalidates part of it;
- carry a cache version, bumped whenever the node or id shape or the format changes, with a mismatch discarding the cache;
- be keyed by the workspace id, written atomically (a temp file, then a rename), and on any load error discard the cache and rebuild, never failing start;
- come with tests for the round trip, for one file's change discarding the whole cache, for a version mismatch, and for a corrupt file.
The first edit voids a whole-index cache, and editing is the main workflow, so the realistic hit is a restart without edits: the cold-start cost is measured before any of this is built.
Handler: src/tools/get-problems.ts@handle
Problems for this codebase, ranked by severity: the findings in the local store (agent-recorded agf_* seeds from record_finding, and any other seeds already on disk), plus findings other agents recorded on this codebase. There is no server-side detection.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
number | 10 |
Max results (max 100) |
severity |
CRITICAL|HIGH|MEDIUM|LOW|INFO[] |
all | Filter by severity |
category |
string[] | all | Filter by category slug |
file_path |
string | — | Substring match on the seed file path |
include_discarded |
boolean | false |
Accepted; a discarded finding is deleted from the local store, so it changes nothing |
Behaviour
- Leads with one count of open findings across both sources (
src/tools/_findings.ts@openFindingsHeadline), e.g.Open findings: 2 (0 in the local store, 2 recorded by agents on this codebase). A finding in both — this session's seed of a finding it shared — counts once and is reported as; N in both: local seeds group by their linked concept id, so a multi-file finding is one finding, and a recalled finding overlaps a group when it is that concept or carries one of itsagf_*ids. Only recalled findings with no verdict count. A recall that did not run (no attachment, or concept-db refused or unreachable) is named rather than counted as zero, and so is an unavailable local store. The local count is after theseverity/category/file_pathfilters; the recall count is the open list the section below shows before its cap of 10. - Lists the open seeds in the local store for this workspace (or, with
file_path, for the matching files), filtered and capped, each with the idannotate_componentandmark_completetake, and an age tag dated from the seeds. If the local store failed to open, the output says so. - States plainly that there is no server-side analysis (
src/tools/_findings.ts@NO_SERVER_ANALYSIS): this server sends no workspace content off the machine, so no backend detections exist. It makes no analysis request. It also says these are agent-recorded findings only: the system's own open gaps on a file arepath_activity's, so none here is not none there. An empty local list readsNo agent-recorded findings …and, withfile_path, names thepath_activitycall for the file. - It then appends a "Findings recorded by agents on this codebase" section recalled from concept-db (
src/tools/_findings.ts@fetchAgentFindings):conceptSearch { shape: "code_finding", limit: 300 }reads in list mode (noquery), relevance first, then newest, kept when the concept belongs to the current repository (itsrepo:header equals the repository identity or the workspace basename, § repository-identity; else itsworkspace:basename) and, whenfile_pathis given, names that file. A full page of 300 asks for the next byoffset, up to four pages; a concept-db that answers an offset with the first page again does not page, and the process reads one page from then on. A last page that is full is reported as truncated; at most 10 open findings are shown.finding_verdictconcepts, read the same way, close findings, which move to a separate "Closed findings" list; a retraction outranks a fix, a fix with a commit one without, and any fix a pending one. A fixed finding with a fix commit shows[FIXED in <sha>], and when the commit is not in this checkout's HEAD (src/repo-identity.ts@commitInHead) it adds— the fix is not in this checkout's HEAD (fixed on <branch>, not pushed when endorsed). A live claim (§ claim_finding) tags an open finding[claimed by <vesselId> until <time>: someone is working on it]. A pending fix keeps the finding in the open list, tagged[fix pending: uncommitted in another checkout (<reviewed_by>) on <branch>; not in this one], orin this checkout …; commit it, then mark_complete endorse againwhen this vessel wrote it. No resolvable repo name skips the recall; concept-db unreachable is non-fatal.
Verdicts past the window (src/tools/_findings.ts@link-verdict, src/tools/_findings.ts@verdict-rows-for). Verdicts and claims outnumber findings, and at the default relevance the newest push the oldest out of the read; a closed finding, credited and so ranked high, would then read as open. So every verdict is linked to its finding when it is written (verdict --derived_from--> finding, best-effort, through conceptLink_write); so is a claim once it is settled as held, and a release. A claim that loses a race, and its withdrawal, are not linked: the link would double the writes a race makes, and settles nothing a later reader needs. When the verdict read is truncated, each matched finding's linked verdicts are read through conceptGraph { direction: "incoming", edge_types: ["derived_from"] }, at most 50 findings, 8 at a time, and 200 verdicts each, and joined with the read's rows. claim_finding reads a finding's claims the same way. Verdicts written before linking are found by the read alone. The findings themselves are read through the window: past four pages, or one page while concept-db does not honour the offset, older findings with no credit fall out of recall.
- The recall runs before the local cache is read, and retires this session's seeds of every finding it shows closed (
src/tools/_findings.ts@retireClosedSeeds), matched by the seed's linked concept id or by theagf_*ids the concept carries: a fixed finding's seeds go stale, a retracted finding's are deleted — the same retirementmark_completeapplies. A verdict another session recorded therefore removes the finding from this session's open list too.
Tasks, listed apart. A finding in category task (its category: line, else the summary's [SEV][task]) is work assigned on the ledger, for example a coordinator's task board, not a problem in the code (src/tools/_findings.ts@task-findings). So an agent checking a file before an edit doesn't take another agent's task for a problem to fix:
- the headline counts findings without tasks, then adds
; N open tasks, listed apart; - the findings sections leave tasks out;
- closed tasks are listed after the open ones as
Closed tasks (N):with[FIXED in <sha>]or[RETRACTED], at most 5, and the header counts them (; N closed), so a coordinator sees completion without filtering for it; - a final Tasks section lists the open tasks from both sources, once each, with any live claim (§ claim_finding) or pending fix, and says to claim one before starting. Claimed tasks are never hidden, since who is on what is the coordination signal. They are ordered so the next move comes first: claimed by this checkout ("yours"), unclaimed, claimed by others, then those with a fix pending. The header counts each group, e.g.
(3 open: 1 yours, 1 unclaimed, 1 claimed by others).
With category including task, tasks are the list itself and there is no separate section. A category filter applies to the recalled findings as it does to the local ones. get_analysis_context counts and lists problems only, and its headline names the open tasks the same way.
Handler: src/tools/record-finding.ts@handle
The write half of agent-side analysis: LLM processing lives on the agent; the server attributes agent-noticed details to code and surfaces them to other agents on the same codebase.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
files |
string[] | required | Files the finding is attributed to (workspace-relative or absolute) |
summary |
string | required | One-line description |
details |
string | — | Longer explanation / suggested fix |
severity |
CRITICAL|HIGH|MEDIUM|LOW|INFO |
required | Severity |
category |
string | required | Free-form slug (security, correctness, design-constraint, …) |
line_start |
number | — | Start line, in the first of files; the other files of a multi-file finding get no line range, and the concept's lines: header names the file it belongs to |
line_end |
number | — | End line |
status |
string | open |
Finding status; anything but open stores the seed already stale |
force |
boolean | false |
Record even when the same finding is already open (below) |
Already recorded. Before writing an open finding without force, it looks for an open finding on one of the same files with the same summary (src/tools/_findings.ts@findOpenDuplicate): a non-stale seed in the local store, then a recalled finding with no verdict (the get_problems recall, repo-scoped; its files: header and the summary after [severity][category] <repo>: ). Summaries match when their normalized text is equal — lowercased, every run of characters other than letters and numbers one space — or when their word sets have a Jaccard index |A ∩ B| / |A ∪ B| of at least 0.8 (src/tools/_findings.ts@duplicate-finding). On a match it writes nothing and answers Already recorded (open): <id> — use mark_complete or add details there., with the existing summary, its files, and where it was found; the id is the local agf_* id when this machine holds a seed, else the concept id. An unreachable recall does not block the write.
Behaviour — (1) upserts one LocalStore seed per file (id agf_<random>, confidence 1) so get_problems surfaces it immediately in-session — repo/workspace attribution resolves via activeWorkspacePath() (§ Workspace), so it does not require init_workspace in the same process; (2) a best-effort concept mint — the concept_create_write shape (src/tools/_findings.ts@postConcept), written once — with shape code_finding, source_type metabob_annotation, summary [severity][category] <basename>: <summary>, and content carrying repo (the repository identity, § repository-identity), repo_name (the workspace basename), workspace, workspace-relative files, lines, severity, category, status, finding_ids and the details — so other agents' sessions recall it org-wide. The minted concept id is stored back on the seeds (refs_json), which is what lets mark_complete fold a verdict into that concept's relevance. The concept content also carries the author: recorded_by: <vesselId> (the stable metabob-mcp-<12 hex> identity, src/vessel-identity.ts@resolveVesselId) and session: <sessionId>. When no vessel identity resolves — no VESSEL_ID, no METABOB_API_KEY — the recorded_by line is omitted entirely rather than filled with a placeholder, so an unattributed finding reads as unattributed instead of as someone's (src/tools/_findings.ts@attributionLines). concept-db unreachable is non-fatal and reported in the output. Close the loop with mark_complete on the returned agf_* id.
The ledger is keyed by repository (src/repo-identity.ts@repoIdentity), read once per workspace path:
- With an
originremote, the identity is its URL fromgit remote get-url origin, normalized (src/repo-identity.ts@normalizeRemoteUrl): scheme, credentials, port, query, a trailing.gitand trailing slashes are dropped, the scp formgit@host:org/reporeads ashost/org/repo, and the result is lowercased — sogit@github.com:Org/Repo.gitandhttps://github.com/org/repoare bothgithub.com/org/repo. Every clone of one repository shares the identity wherever it sits on disk. A workspace below the repository root appends its lowercased subpath (github.com/org/mono/packages/api), so two packages of one monorepo, whose workspace-relative file paths can coincide, keep separate ledgers. - With no
origin(git absent, not a repository, no such remote) the identity is the workspace basename.
A finding carries repo: <identity> and repo_name: <basename>; a verdict carries the same two lines. Recall matches the repo: header against the identity or the basename, case-insensitively, and never against repo_name:. A finding whose repo: header is a basename is therefore recalled by every repository of that basename; one whose header is an origin identity, only by clones of that repository (and the subdirectory workspace it names).
A super-repo and a vessel checkout share one ledger (src/repo-identity.ts@repo-aliases). The substrate's super-repo names a vessel's file repos/<vessel>/<path> under its own identity, while the vessel's own checkout names it <path> under the vessel's. Recall in either reads both, by the rule path_activity maps paths with (a repos/ directory at the root is the super-repo; otherwise the vessel is named by its origin's basename):
- in a vessel checkout, a finding from another repository whose files sit under
repos/<name>/is recalled, with that prefix removed; - in the super-repo, a finding whose
repo:names a vessel underrepos/is recalled, withrepos/<name>/added. Whenrepos/<name>/is its own checkout with anorigin, the finding'srepo:must be that exact identity; otherwise the vessel is matched by name (the last segment ofrepo:).
The file filter, the duplicate check, task pull's related findings, the rendered files: line and mark_complete's fix location (so finding close too) all use the mapped paths: a finding recorded on one side closes from the other once its fix is pushed there. A verdict's own files: line keeps the finding's recorded form, since the verdict's repo: line is the finding's repository. Recording is unchanged, so findings recorded under either identity, before or after, are found from both sides with nothing to migrate. In a vessel checkout the match is by vessel name: a different super-repo with a repos/<name>/ of the same name is read too.
The system's own gaps. get_problems shows findings, not the gap store's gaps, so a finding can describe what an open gap already does. When path_activity's cache is warm (it never reads the store itself), the reply lists, per file, up to three open gaps whose edit site is that file, and says to mark_complete discard the finding if one is the same issue (src/tools/_gap-activity.ts@openGapsOnFilesLines). It does not refuse: a busy file has hundreds of gaps, and one on the same file need not be the same issue.
Handler: src/tools/search-codebase.ts@handle
Search components (local CPG) and problems by keyword.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
query |
string | required | Tokenised (camelCase, snake_case and kebab-case split) and matched by partial token containment |
limit |
number | 10 |
Max results (max 50) |
target |
components|problems|both |
both |
What to search |
file_filter |
string[] | — | Narrow to these file paths (substring) |
Components match on name, file path and node type; problems match on the local store's seeds — brief, category and file path. Both are local, and no request is made; when problems are searched, the output says there is no server-side analysis. Requires init_workspace for component results.
Handler: src/tools/predict-cochanges.ts@handle
Predict which files change together with the given files, from git history (src/git-cochange.ts@mineGitCochanges). Runs locally — no API call — and needs a git checkout, not init_workspace: the workspace is init_workspace's, else the configured one (§ Workspace). Each predicted file carries its confidence, the share of the given files' commits that also touched it, and its commit count; the output caveats that history mining can miss files. Outside a git repository, or with no co-change above the threshold, it says so.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
changed_files |
string[] | required | Files being changed |
limit |
number | 10 |
Max suggestions (max 50) |
confidence_threshold |
number | 0.3 |
Min git co-change confidence (0.0–1.0) |
Findings in the local store for the changed files are appended.
Handler: src/tools/analyze-impact.ts@handle
Components affected by changing the given files, by traversing the local Code Property Graph and the file import graph.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
changed_files |
string[] | required | Files being changed |
diff |
string | — | Accepted for compatibility; the handler does not read it |
direction |
forward|backward|both |
backward |
forward=dependencies, backward=dependents |
max_depth |
number | 5 |
Max traversal depth (max 10) |
include_tests |
boolean | true |
Include test files |
Appends up to 20 findings from the local store for the changed files. When attached, and the workspace maps to the gap store, appends one line per changed file with its open gaps, how many the system is landing now and how many are held (src/tools/_gap-activity.ts@gapSummaryLines) from path_activity's cached read only: it never starts the store read, and without a cached read it adds one line saying the open gaps are not loaded and path_activity shows them; detached, it makes no request. Requires init_workspace.
Handler: src/tools/annotate-component.ts@handle
Structured notes for a problem in the local store.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
problem_id |
string | required | Problem ID from get_problems |
mode |
explain|recommend |
explain |
explain adds why-focused notes, recommend adds how-to-fix notes |
context |
string | — | Additional context to include in the notes |
The id is looked up in the local store, and the notes are built locally from templates — no request is made. The output renders severity/category/file/refs/status plus the why/how notes, so record → annotate → mark_complete works on agent findings. An id the local store does not hold is reported as not found, with the reason: there is no server-side analysis, so only local findings can be annotated.
Handler: src/tools/mark-complete.ts@handle
Mark a detected problem as resolved or dismiss it as a false positive.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
problem_id |
string | required | Problem ID from get_problems — a backend id, an agf_* finding id, or a concept_* id |
action |
endorse|discard |
endorse |
endorse=fixed, discard=false positive |
fix_commit |
string | — | Endorse only: the commit (7–40 hex digits) that fixes the finding, for a fix that touches none of its files or that was committed before the finding was recorded |
Ids are agf_* findings or concept_* ids; any other id is refused with a message saying nothing was recorded, since there are no backend detections for it to name. A concept_* id — the form cross-agent recall hands an agent — is resolved to its local agf_* seed via LocalStore.getSeedByConceptRef. When no local seed exists, the verdict is written from the recalled concept itself (the fleet case: one agent records, another fixes) — keyed on the agf_* id the finding carries, never the concept id — and the output states that nothing local was retired. For agent-recorded findings, endorse marks the LocalStore seed stale (resolved, stops surfacing), discard deletes the local seed only — the org-wide code_finding concept is never retracted — and every seed sharing the finding's concept is retired with it, since a multi-file finding is one finding. A finding_verdict concept referencing the finding id is best-effort recorded to concept-db (non-fatal). Every verdict carries its reviewer and its provenance as two separate content lines: reviewed_by: <vesselId> + session: <sessionId> (the author — omitted when no identity resolves), and origin: local (written against a local seed) or origin: remote (written from a recalled concept). recorded_by never appears on a verdict; it means author-of-finding and nothing else. When the finding carries a linked code_finding concept, the verdict is also folded into that concept's relevance through the same channel as concept_record_usage — endorse → success, discard → failure (conceptUsage_write, src/tools/_findings.ts@recordConceptUsage). A recalled concept is read as the conceptGraph root (src/tools/_findings.ts@fetchConceptById).
Where the fix is. An endorse records where the fix lives, from the checkout's git state (src/repo-identity.ts@fix-location), as verdict lines (src/tools/_findings.ts@fixLines). A fix commit postdates the finding — its earliest seed's time, or the recalled concept's created_at, less two minutes for clocks — since a commit older than the finding cannot be its fix:
- uncommitted changes (modified, staged, untracked or deleted) in the finding's files come first, even with
fix_commit—fix: uncommitted, with the branch andfix_base:HEAD: a claim does not close a finding whose fix is not committed yet (the reply says the claim was not applied); - with
fix_commit: that commit, when it resolves in this checkout, is in HEAD's history and postdates the finding —fix: commit <sha>withfix_claimed: yes, andfix_touches_finding_files: nowhen it touches none of them (the symptom in one file, the fix in another). A named commit older than the finding is its fix when it touches the finding's files (a finding recorded after its fix was committed):fix_predates_finding: yes, and the reply says so; older and touching none of them, it is refused. A commit failing any check writes no verdict and the output names the check; - else the newest commit since the finding touching its files:
fix: commit <sha>, withfix_branch:and, when no remote branch holds it,fix_pushed: no; - else, when other tracked files have uncommitted changes:
fix: uncommittedwithfix_files: outside the finding's files(the fix may be among them); - else no commit since the finding touches its files, and the newest commit since it, if any, is not taken as the fix — a pull of unrelated work would close every finding — but named with the files it touches, with
fix_commit: "<sha>"offered for when it is the fix; no verdict is written; - else nothing in the checkout postdates the finding — or the finding has no recorded time, so no commit can be shown to postdate it — and no verdict is written: nothing is retired, and the output says nothing here can be its fix (endorse where the fix is, or discard it);
- a git checkout where none of the finding's files is known (not on disk, not tracked, and not in its history) is not where the fix is: no verdict is written, the same way. A file the fix deleted is in the history, so its deleting commit is found as above;
- with no
fix_commit, when none of the finding's files is tracked or in the history and every one is ignored (.gitignore):fix: local (ignored files; no commit holds it), and the finding closes; the reply says the fix is local to this checkout and no other checkout can check it. An untracked file that is not ignored is a change still to commit (fix: uncommitted); - outside git:
fix: unknown (not a git checkout), and the finding closes without a commit. - "Outside git" means git answered that this is not a repository, or git is not installed. A git call that times out (20 s) or fails otherwise is never read as "not a git checkout": no verdict is written, and the reply says git did not answer and to try again.
An unpushed fix doesn't close the finding. In a checkout that has a remote, a fix commit no remote branch holds (fix_pushed: no) leaves the finding open: other checkouts can't get the commit, and in a cell it is gone with the work directory. The verdict is verdict: fix pending (unpushed); nothing is retired, no relevance is recorded, and the reply says to push and endorse again. Recall tags the finding [fix pending: committed in another checkout (<reviewed_by>) on <branch> but not pushed; not available here]. A checkout with no remote at all still closes, since no other checkout could have its commits.
A fix only on task branches awaits its merge (src/tools/mark-complete.ts@awaitingMerge). When every remote branch that holds the fix commit is a task branch (<remote>/task/…, which a review merges), the fix is not integrated yet, so the finding stays open. The verdict is verdict: fix pending (awaiting merge) with fix_target: <the origin's default branch>, or its target when the clone records no origin/HEAD, which are the lines finding close writes for the same state. Nothing is retired, no relevance is recorded, and the reply says to fetch and endorse again once it is merged, or to run finding close. Recall tags the finding [fix pending: pushed by <reviewed_by>, awaiting merge into <target>]. A commit on any other remote branch, a development branch included, closes the finding as before.
No path is written: the checkout is named by reviewed_by.
Claims. An endorse of a finding that another vessel holds a live claim on (§ claim_finding) goes ahead, since the claimer may be gone and the fix is what counts. The reply adds claim: claimed by <vesselId> until <time>; this endorse closes it anyway, which ends their claim (src/tools/claim-finding.ts@claimWarning). Closing the finding ends every claim on it, since recall shows no holder on a closed finding. An uncommitted fix does not close the finding, since every other checkout still has the bug: the verdict is verdict: fix pending (uncommitted) with summary [verdict:pending], which a reader knowing only endorsed and discarded parses as no verdict; no local seed is retired, no relevance is recorded, and the output says to commit the fix and endorse again. The endorse that names a commit closes it. Discard records no fix location.
Handler: src/tools/claim-finding.ts@handle
Say an agent is starting work on a finding, so another agent does not start the same work.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
problem_id |
string | required | An agf_* finding id, or the concept_* id recall shows |
action |
claim|renew|release |
claim |
Claim before starting, renew while working, release on stopping without closing |
minutes |
integer | 60 |
How long the claim lasts from now, at most 240 |
What a claim is. A claim is a finding_verdict, keyed on the finding's agf_* id like every verdict (src/tools/_claims.ts@claimLines):
verdict: claimed, withclaim_minutes:,claim_until:and the claimer asreviewed_by:, plussession:;verdict: claim withdrawnfor a release;- the summary is
[verdict:claimed]or[verdict:withdrawn].
A reader that knows only endorsed and discarded parses neither as a verdict, so to it the finding reads open, which it is.
One clock. A claim ends at concept-db's created_at plus claim_minutes. claim_until, from the claimer's clock, is only for people reading the row, and is used only for a row with no claim_minutes. Only "now" comes from the reader's clock: a claim counts as live until two minutes past its end (src/tools/_claims.ts@CLAIM_SKEW_MS), so a reader whose clock runs fast never frees a claim early.
Who holds it. concept-db has no compare-and-set, so two agents can both write a claim. Every reader applies one rule to the same rows (src/tools/_claims.ts@claim-holder):
- A claimer's current claim begins with the first claim it wrote while none of its earlier claims was live. A later claim while one is live renews it, and a withdrawal ends it.
- Of the claimers whose claim is live now, the one whose claim began earliest holds it; on a tie, the lower concept id.
What the tool does:
- It resolves the finding (its local seed and siblings, or the recalled concept and every
agf_*id it carries) and reads the verdicts. - A closed finding is refused. A finding held by someone else is reported with its holder and is not claimed.
- Otherwise it writes the claim, reads the verdicts back and applies the rule. A claim write concept-db gives up on (
no_producerorproducer_timeout, seen under concurrent writes) is retried once after a short random pause; repeating it is safe, since a second claim of one's own renews it. When another claimer holds it, it writes a withdrawal and says who won. A read can miss a concept written moments before, so the read-back counts only once it shows this claim. It is retried after 150 ms, 500 ms and 1.5 s (src/tools/claim-finding.ts@READ_BACK_PAUSES_MS). If it never shows the claim, the reply says the claim is written but not settled and to run the tool again before starting; nothing is reported as held. - Renewing a claim it does not hold, and releasing one it does not hold, are refused.
The read window. Claims are found in the same finding_verdict list read the recall uses: up to 300 rows, ordered by relevance and then newest first. A verdict whose relevance falls below its peers can drop out of the window while it is still live. A read of one finding's verdicts would close that gap, and concept-db's search offers no such filter yet.
Refusals. A claim must name its holder, so with no vessel identity nothing is written. A read-only key writes nothing and says key_read_only. When the verdict scan reached its limit, the finding's linked claims are read too (§ get_problems), and the reply says a claim written before claims were linked may have been missed.
Recall. Recall shows a live claim on an open finding as [claimed by <vesselId> until <time>: someone is working on it], or [claimed by this checkout until <time>] (§ get_problems). A closed finding shows no claim: mark_complete ends it.
Handler: src/tools/get-metrics.ts@handle
This workspace's local analysis state.
Input — none
Computed locally; makes no request. Output — open findings in the local store for this workspace by severity, agent-recorded (agf_*) versus other, the top five categories, the local CPG size, and the statement that there is no server-side analysis.
Handler: src/tools/assign-git-changes.ts@handle
Assign changed files to the CPG components they affect, with the files that change together with them in git history.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
changed_files |
string[] | required | Changed file paths |
diff |
string | — | Accepted for compatibility; the handler does not read it |
include_cochange_predictions |
boolean | true |
Include the git-history co-changes |
prediction_limit |
number | 5 |
Prediction budget per changed file (max 20) |
Returns a file → component map, and the git-history co-changes pooled across the changed files (prediction_limit × 2). Files with findings in the local store show them, and, when attached and the workspace maps to the gap store, each changed file's open gaps and landing leases (as in analyze_impact). Requires init_workspace.
Handler: src/tools/get-analysis-context.ts@handle
It opens with where this session writes and as whom (src/tools/get-analysis-context.ts@attachment-line): Attachment: attached to org <orgId> via discovery <url> (credentials redacted, never the key), attaching (first attempt in progress), or not attached: <named cause>, then as: <vesselId> (session <id>). Agents writing to the shared ledger have no other read that shows it.
The current analysis state in one snapshot, designed to feed an LLM deciding what to do next: CPG index stats, open problems from the local store (with the statement that there is no server-side analysis), the files that change with the recently changed files in git history (confidence at least 0.3, predict_cochanges' default; top 8; no index needed), and a compact top-5 recall of findings recorded by agents on this repo (concept-db code_finding concepts, non-fatal when unreachable). The recall runs first and retires the local seeds of findings it shows closed, as get_problems does. The problems block leads with the same combined count as get_problems (Open findings: …), then the local store's line (local store: N open (c critical, h high)); the suggested next actions name open shared findings alongside local critical and high ones.
Input
| Parameter | Type | Default | Description |
|---|---|---|---|
recently_changed_files |
string[] | [] |
Files whose git-history co-changes are listed |
problem_limit |
number | 10 |
Max open problems to include (max 50) |
scope |
session|project|org |
session |
Accepted for compatibility; the handler does not read it |
The MCP surface drives the running substrate end-to-end — dispatch work, understand it, verify it, inspect it — through tools rather than host access (docker exec, journalctl, raw curl). Targets resolve as follows:
- goal-host-vessel — its shapes
goalDispatchAsync(dispatch),goalWalkState(the dispatch record) andsolicitationResponse_write(an answer), resolved through the attachment (§ Attachment;src/tools/_goal-host.ts@readWalkState,src/tools/_goal-host.ts@dispatchGoal). goal-host keeps dispatch records in memory, about the last 100, andgoalWalkStatecarries the last 60walkLoglines. AgoalWalkStateread is capped at 15 s, since the record is in memory; a dispatch at goal-host's 60 s. - activity-api — traces are read through the attachment as
activityExecutionTrace(§ execution_trace), and oracle labels written asgoal_verification_label_write(§ provide_feedback). - discovery —
registry_queryresolves discovery's own shapes through the attachment (§ Attachment).
Every request goes to discovery POST /resolve with Authorization: ApiKey <METABOB_API_KEY>, which discovery forwards to the producer; none is held open past 60 s. Planes:
| Tool | Plane |
|---|---|
run_goal, run_goal_async |
ACT |
goal_status |
TRACK |
goal_reasoning |
REASON |
provide_feedback |
FEEDBACK |
registry_query, execution_trace, resolve_impulse |
INSPECT |
reached, not status. status (running|completed|failed) is only the template exit status. reached (yes|no) is the goal-reach verdict and the load-bearing signal — printed first by goal_status and goal_reasoning. completed+reached:false (hollow) and failed+reached:true (e.g. a satisfier reach) are both normal; trusting status mis-judges the outcome. For high-stakes work also read the actual diff/output: a change can typecheck and pass the reach-gate yet not do what was asked.
Canonical loop: run_goal_async → goal_status → goal_reasoning → provide_feedback.
Handler: src/tools/run-goal.ts@handle
Goal dispatch that waits for the outcome, for short, one-shot goals.
| Parameter | Type | Default | Description |
|---|---|---|---|
goal |
string | — | Natural-language goal. goal or target_template_id is required |
target_template_id |
string | — | Activity template to execute, bypassing Thompson selection |
variables |
object | — | Seed variables for the execution pool ({{var}} placeholders) |
parent_execution_id |
string | — | Parent execution id for cross-vessel provenance |
composition_chain |
string[] | — | Ancestor execution ids, root-first |
learning_mode |
observe | learn |
goal-host's (learn) | observe traces the walk tagged learning_mode:observe and asks goal-host not to learn from it; goal_status's learning: line says what goal-host recorded all the same (a goal path can be) |
tags |
string[] | — | Tags carried onto the dispatch and its trace |
Dispatches as run_goal_async does (goalDispatchAsync), then polls goalWalkState (src/tools/run-goal.ts@runGoalPollDelayMs) — three polls RUN_GOAL_POLL_MS apart (default 2 s), then pauses doubling to RUN_GOAL_POLL_MAX_MS (default 15 s), because every read makes goal-host re-read the execution's oracle labels from the trace store; each poll is a short request — until the dispatch leaves running or the polling deadline RUN_GOAL_TIMEOUT_MS (default 290 s) passes (src/tools/run-goal.ts@run-goal-poll). No request is held open for the goal. operator is the declared operator (§ Client claim): MB_OPERATOR_ID, else this workspace's stable vesselId; goal-host turns it into the trace's operator: tag, and the dispatch's tags also carry session:<SESSION_ID>. Returns dispatchId, executionId, reached, status, selectedTemplateId, any error and the duration. Past the deadline the goal keeps running on goal-host, and the reply carries its dispatchId and a goal_status hint. A refused dispatch (a 429 from goal-host's spend-rate breaker, a 503 while it drains) returns its status and error.
Handler: src/tools/run-goal-async.ts@handle
Asynchronous goal dispatch — the default for non-trivial goals. Same parameters as run_goal.
Resolves goalDispatchAsync { operator, goal?, targetTemplateId?, variables?, parent_execution_id?, composition_chain?, learning_mode?, tags? } — goal-host enqueues it as POST /run-goal does and answers 202 { dispatchId, status } at once — and returns the dispatchId with a goal_status hint. A dispatch is a write and is never retried; a 429 or 503 from goal-host returns its status and error. learning_mode: "observe" asks goal-host to run the walk without learning from it: the walk is still traced, for audit — activity-api stores a trace tagged learning_mode:observe / reach_observe:true and its learning writers skip it — and goal-host can still record the goal path (a goal_finding concept); goal-host's default is a learning run. The effective mode is in goal_reasoning's walk log (recordGoalPath PRECONDITIONS … learningMode=); goal-host reads a top-level learning_mode on /run-goal, and its goalDispatchAsync path forwards it (goal-host e07f883 and later); an older goal-host drops it on that path and runs the walk as a learning run. The reply states a passed learning_mode as requested, never as applied. What a walk actually taught is reported, not inferred: run_goal, and goal_status / goal_reasoning for a finished dispatch, print a learning: line from the dispatch record's learning consequences (src/tools/_goal-host.ts@learningLines) — goal path recorded, posterior (α/β) changes (a template goal-host lists with Δ0 is not a change), gaps filed, oracle label written — or none recorded, or not reported by goal-host when the record carries none. When observe was requested and the walk recorded a posterior change, a gap or a label anyway, run_goal adds a warning line. A reported goal path alone does not trigger it: goal-host sets goalPathRecorded on its trace branch whether or not it wrote the path, so under observe the flag is not evidence of a write, and run_goal says so. The goal text is forwarded unchanged; goal-host decides the route — a goal naming a real repos/<vessel>/src/… file routes to feature_compose, which drafts, typecheck-verifies and lands a traced commit.
Handler: src/tools/goal-status.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
dispatch_id |
string | required | The dispatchId from run_goal_async |
Resolves goalWalkState { dispatchId } and reads the dispatch record goal-host wraps in it. Prints reached (yes/no/(running)/unknown) first, then status, executionId, operator, selectedTemplateId and error. While the dispatch runs it shows the last 6 entries of the live goal-host walkLog. Once it has finished with an executionId, it hydrates the durable trace through activityExecutionTrace (as execution_trace reads it), except for a satisfier reach, which persists none: then it says so and points at goal_reasoning without a trace read. The trace gives: state-space signature, produced shapes, completion_shapes:/reached: tags, failure mode, Thompson α/β. goal-host keeps dispatch records in memory (about the last 100), so goal-host's 404 means the record was pruned or never existed; through a spoke that 404 arrives as no_producer(goalWalkState), whose line says that either no producer answered or goal-host has no such dispatch. Any other failure is one named line (§ Attachment).
Who dispatched (src/tools/_goal-host.ts@principalLines, shared by goal_status, goal_reasoning and run_goal). goal-host validates the caller and reports it as principal: { kind, operator, tag }: kind is key or node_self, operator is true only for a credential carrying operator:direct or admin, and tag is a non-reversible tag (a key id's hash prefix, or a literal for node-self). The tools print principal: <kind> (<tag>), operator authority: yes (validated) when it holds, the request's claimed operator_label as (claimed, unverified), and, when principal.operator is false, one line: ran without operator authority: an ordinary keyed caller; directed edits and operator priority do not apply for a key, or ran without operator authority (node self-call) for node_self, whose own key never carries operator authority. A record without a principal (an older goal-host, or a record from before goal-host validated callers) prints principal: not reported and is never read as non-operator; its operator is the old claimed value and is marked so. A raw key id is never displayed: a pre-amendment principal.label of the form key:<id> prints as key id withheld.
Handler: src/tools/goal-reasoning.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
dispatch_id |
string | required | The dispatchId from run_goal_async |
The walk's decision-log for a finished dispatch: the same header as goal_status, then the dispatch record's walkLog whenever it has one (numbered, verbatim; goalWalkState carries its last 60 lines), then — when an executionId exists — the per-task trace sequence (#N ✓/✗ resolver/tier inShapes → outShapes, failure mode) and a reach-verdict block (state-space, produced shapes, reach tags, α/β). With neither a walkLog nor trace tasks it says the decision-log is unavailable (typically a direct vessel resolve). While the dispatch runs it returns "still running" and nothing else.
Handler: src/tools/provide-feedback.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
dispatch_id |
string | required | The dispatchId from run_goal_async |
verdict |
reached|not_reached|partial |
required | Operator verdict on whether the goal was achieved |
rationale |
string | required | The evidence behind the verdict; stored as the label notes |
confidence |
number | 0.9 |
Operator confidence, 0..1 |
goal |
string | — | Goal text, when the dispatch record does not carry it |
Reads the dispatch record as goalWalkState, refusing a 404 and a dispatch still running. Takes the goal (or the goal input), executionId and selectedTemplateId from that record. A misrouted or failed walk records no selected template, but its row in the trace listing names the activity that closed it (satisfier:goal-host-walk-failed), so with no template the activity is taken from executionTraceList (the newest 50), and the reply says so; with no activity either way, or no execution, it writes nothing and says where the assessment goes instead (record_finding, category goal-routing). With all three it and resolves goal_verification_label_write (activity-api's resolver) through the attachment, once — a write is never retried: verdict achieved/not_achieved/partial, labeler: "automated", notes <rationale> [operator: <operator>] [session: <session>] (§ Client claim). The tool is driven by an agent's key, so its verdict is never labelled human: goal-host treats a human label as ground truth (it overwrites reached and files a human-reported gap), and a person's verdict comes through the human surface. Until labels carry provenance derived from the writing credential, the principal travels only in notes. The label lands in the oracle corpus (goal_verification_labels), consumed by offline audit rather than by an automated reach-gate. Through a spoke a producer's refusal of the label (a 400 naming missing fields) arrives as no_producer(goal_verification_label_write); the line says that either no producer answered or the producer refused the label, and that no label was written.
Handler: src/tools/path-activity.ts@handle
Before editing shared files, what the substrate has in flight on them.
| Parameter | Type | Default | Description |
|---|---|---|---|
paths |
string[] (1–50) | required | Files or directories, workspace-relative or in the gap store's repos/<vessel>/<path> form |
limit |
integer (1–100) | 10 |
Most gaps listed per path besides those being landed, which are always listed |
include_closed |
boolean | false |
Also list closed gaps that named the paths |
Path mapping (src/tools/_gap-activity.ts@pathMapper). The gap store names files super-repo-relative, repos/<vessel>/<path>, in a gap's classification_metadata.edit_site and in its goal and summary text. In the super-repo (a repos/ directory at the top of the tree) a path maps relative to the root; in a vessel's own checkout it maps to repos/<name>/<path>, where <name> is the basename of the origin remote's path (every super-repo submodule is repos/X → ../X.git); a directory under repos/ that is not a submodule maps by its name. In any other repository the tool says so and checks only paths given in repos/… form. The vessel rule maps any repository with an origin, so a vessel checkout whose repos/<name>/ no open gap names is reported as one this substrate may not mount (no lookup of mounted repositories exists yet), and its paths are not checked: "no open gaps" there would read as "nothing in flight".
Read (src/tools/_gap-activity.ts@readGaps). The store has no path filter and answers the newest limit rows by updated_at, with total counting the page; a file's gaps are mostly old rows, which a recency page drops. So the read is the whole store: substrateGap { status: "open", limit: 1000000, include_held: true } (and status: "closed" with include_closed), a read with the read retries and a 30 s cap. On a busy node the whole open store is about 13 MB, so the read is cached per attachment: fresh for 5 minutes, then served stale for up to 30 minutes while one background read refreshes it, with concurrent callers sharing one read, so agents checking on every edit do not each load the store's holder. When the answer is as large as the limit the output gives no count and says the read may be truncated.
Output, one block per path. It leads with the count of open gaps and how many are being landed by the system now, held and queued (a busy file has hundreds). Every gap being landed is listed; then up to limit more, held ones first and then queued ones, edit site first, then those with an edit site in a given directory, then those that mention it, each newest first, and a line counting the rest. Each gap shows its id, category and route, and one of:
- held —
classification_metadata.operator_hold, a parking disposition (needs_information,needs_info,awaiting_operator_review) or a landing awaiting its verdict:held by operator ruling: <reason>, withreview by <date>andlift: <condition>from the hold record that carries them, orno review date recorded; - being landed — a live landing lease: its holder, attempt and expiry exactly as stored, marked as the holder's own claim (holders are caller-supplied), and, as facts rather than orders, what an edit now does (an edit before the lease ends is lost or conflicts when the change lands) and whose decision it is (the agent's user). The store keeps a lease after its
untilpasses, so a lapsed one is shown as lapsed (when, and who held it) and the gap counts as queued, not being landed; a lease is lapsed only 2 minutes past itsuntil, so a slightly fast local clock never frees a landing early; a lease with nountilis shown as possibly stale and not counted as being landed. Times print with their date and how long ago or ahead they are; - queued — neither: queued for the system.
A path that does not map (in the super-repo, one outside repos/, such as docs/ or README.md) reads no gap names this path. Gaps that carry no edit site are matched to a path only where their goal or summary names it, so when a path in a mounted repository has no gap, the output counts the open gaps with no edit site that could concern it, by category (src/tools/path-activity.ts@unplaced-gaps): for a repos/<v>/… path, those whose remedy names vessel <v> or no vessel, and for a super-repo path outside repos/, those that name no vessel. When any are counted it does not call the path nothing in flight; a repository the substrate may not mount, or one that does not map, gets no such count, since no gap concerns it.
The output ends with what to do, one line for each state present: wait and look again for a gap being landed, prefer letting the system land or dispatching a queued one, a held gap is not open work, and with no gap on a path make the change and record it, as nothing in flight only when no such gap was counted. A substrate that serves no gap store (no_producer(substrateGap)) is not an error to stop on: the tool says there is nothing in flight to check and to go ahead. Any other failed read is one named line. The paths are matched locally and never sent, so the outgoing guard does not check them.
Handler: src/tools/registry-query.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
mode |
shapes|vessels |
required | shapes lists the advertised vocabulary; vessels lists who serves shape |
shape |
string | — | Required in vessels mode; in shapes mode, a case-insensitive substring filter |
Resolves discovery's own shapes through the attachment (§ Attachment), which discovery answers itself. shapes mode: vesselRegistry, whose vessels' shapes, unioned, are the advertised vocabulary (the set discovery's GET /registry/shapes lists), filtered client-side and sorted (src/tools/registry-query.ts@fetchShapeVocabulary). vessels mode: vesselCapability { shape }, listing each vessel's id, endpoint (public_endpoint when present), protocol and resolve endpoint, plus a warning line when the vocabulary and the vesselCapability rows disagree about the shape. A discovery failure is one named line.
The vocabulary is the anchor node's: discovery answers vesselRegistry itself, so on a spoke it lists that node's shapes, while resolves reach peer nodes too. shapes mode ends with a line saying so, and that a peer-served shape is asked for by name in vessels mode. In vessels mode, producers that vesselCapability returns for a shape the anchor's vocabulary does not list get a note naming both readings: the producers are on a peer node, or one view is stale.
Handler: src/tools/execution-trace.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
execution_id |
string | — | The durable trace's execution id. Omitted: list recent traces |
limit |
number | 10 |
With no execution_id: how many recent traces to list (1–50) |
With no execution_id, resolves executionTraceList { limit } (src/tools/_traces.ts@listExecutionTraces) and lists the most recent traces of the caller's org, newest first — execution id, status, duration, template and time, with the page's success and failure counts — ending with how to read one, so an agent holding no id can find a trace. It is a read, and like the trace read it is not retried after a timeout; an empty list says that no trace is recorded. activity-api scopes the listing to the key's account and org; every key on one substrate sees the same set. activity-api's rows carry the stored record id (v_paradigm_execution_traces:⟨exec_…⟩); the listing shows the bare exec_… id, which is the form activityExecutionTrace answers, and a record id passed as execution_id is read by its bare id (src/tools/_traces.ts@bareExecutionId).
With an execution_id, resolves activityExecutionTrace { executionId, format: "json" } through the attachment (§ Attachment) — activity-api's resolver, which answers the stored execution row (src/tools/_traces.ts@readExecutionTrace; its per-task sequence is lifted from execution_trace.tasks). It is a read: cut at activity-api's 10 s, it answers one producer_timeout line and is not retried, since the cut query keeps running in activity-api. A satisfier reach (walk-satisfier-…, or a satisfier: template) persists no task trace — activity-api answers 404 for it, a successful one included — so the listing marks such rows, and reading one answers at once, from the id alone and saying that activity-api was not asked, that its walk is in the dispatch record (goal_reasoning) (src/tools/_traces.ts@isSatisfierExecution). Otherwise it renders status, activity_template_id, vessel_id, composition_chain, the first state_signature: / reached: / completion_shapes: / operator: tags, failure mode, and the per-task sequence. A 404 is reported as expected for a satisfier-only reach, pointing at goal_reasoning. Through a spoke a missing trace arrives as no_producer(activityExecutionTrace), and the line says that either no producer answered or the trace does not exist (a satisfier-only reach persists no trace); goal_status and goal_reasoning use the same line for their trace read. goal_reasoning renders the same trace for a dispatch.
Handler: src/tools/resolve-impulse.ts@handle
The advanced escape hatch: resolve one of the substrate's plain reads (memoryNote, traceAggregateReport, llmSpendSummary, …) or the memory write memoryNote_write. Prefer a dedicated tool when one fits.
| Parameter | Type | Default | Description |
|---|---|---|---|
shape |
string | required | pointer.type to resolve |
pointer |
object | — | Extra pointer fields, merged after type (so pointer.type overrides shape) |
For an allowed shape, the attachment's resolve() (§ Attachment): { pointer } to discovery POST /resolve, which forwards it to the shape's producer. It is sent once and never retried (cap RESOLVE_IMPULSE_TIMEOUT_MS, default 30 s, never above 60 s). Returns the HTTP status and the producer's response body (pretty-printed JSON, truncated at 2048 characters) for any status; a discovery failure is one named line. It adds no operator field.
It is an allowlist. A shape, named by shape or by pointer.type, resolves only when it is one of the plain reads in src/tools/resolve-impulse.ts@escape-hatch-reads (records, reports, statistics, summaries and policies) or the memory write with no dedicated tool, memoryNote_write (src/tools/resolve-impulse.ts@escape-hatch-writes). A shape's name does not tell a read from an action (bash, code_replace_lines, surrealdb_import and llm_completion read like reads), so everything else is refused before any request, unlisted reads included: a shell, file edits, git and GitHub, restarts, cutovers, composes, dispatch, credit, trace deletion. The refusal says to dispatch the work as a goal, so goal-host routes it and its judge grades it, or to use a dedicated tool. The known writes carry their own reason and alternative (src/tools/resolve-impulse.ts@coordination-writes):
| Writes | Why an agent does not make them | Instead |
|---|---|---|
maintenanceLease_write, substrateGapLease_write |
the system's own coordination: maintenance windows, the auto-pick lane, a gap's landing | dispatch the work as a goal, or let the system land the gap; record a finding for a lease that looks stuck or foreign |
activityExecutionTrace_write, execution_write |
the graded record: a trace's tags set its reach | dispatch the work as a goal, so goal-host traces it and its judge grades it |
| activity templates, variants, compositions and feedback; impulse relevance, shape scores, execution and concept sequences, attributions, tool usage, and the system's other learned state | the system learns these from its own graded work; an agent writing them hand-feeds it | dispatch a goal, or record a finding with what was observed |
test_report_write, test_audit_report_write, test_registration_write |
gate evidence, which the gates write themselves | run the check, or dispatch it |
interactor*_write, ui*_write |
the human surface's own records | the person uses that surface as themselves |
fs_write |
writes files on a vessel | a dispatched goal or a reviewed commit |
pushPolicy_write, llmModelPolicy_write, docFixPolicy_write, renderPolicy_write |
system policy is an operator's decision; this key carries no operator authority | ask the person, or record a finding proposing the change |
goal_verification_label_write, poolImpulse_write, substrateGap_write |
the oracle corpus; walk input with no stamped principal; gaps, which the system's detectors file | provide_feedback; dispatch a goal; record_finding |
concept_create_write, conceptLink_write, conceptUsage_write, solicitationResponse_write |
each has a dedicated tool that checks its input and records provenance | concept_create, concept_link, concept_record_usage, submit_resolution |
concept_write, concept_delete_write, concept_retire_write |
overwriting, deleting or retiring the system's memory | concept_create the corrected concept and concept_link it derived_from the wrong one, which keeps the history |
concept_supersede_write |
advertised by concept-db but not implemented there (it throws) | as above |
tests/fixtures/registry-vocabulary.json decides every shape of a live vocabulary, allowed or refused, and a unit test holds the code's allowlist to it, so a refreshed snapshot with a new shape fails the build until someone decides it; at run time an undecided shape is refused. There is no override: sanctioned maintenance runs as a dispatched activity that takes the lease as its own steps. The refusals keep this cockpit from being the convenient route to these writes; they are not a security boundary, which belongs to each producer.
concept-db's graph of concepts, edges and usage, reached through the attachment (§ Attachment) as concept-db's own shapes (src/tools/_concept-db.ts) — never concept, which development-vessel also advertises, so the vessel answering it depends on registry order. Each request is capped at 35 s (discovery cuts concept-db at 30 s); a read cut there is retried with backoff, a write is sent once. A discovery failure, or concept-db's refusal, is returned as one line, never thrown. Reads through these shapes record no load event on the concepts they return; concept_record_usage is the relevance signal. concept-db answers some ids bare and others ⟨⟩-wrapped (or concept:-prefixed); every tool here, and the findings tools, print a concept id in its plain form (concept_…, src/tools/_concept-content.ts@plainConceptId) and accept any of the three forms as input. Bodies render through src/tools/_concept-content.ts@renderConceptContent: preview is the first ~700 characters, cut back to a line boundary with the truncation disclosed; full is the whole body; none omits it.
Handler: src/tools/concept-search.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
concept_id |
string | — | Read exactly this concept (with or without the concept: prefix) with its full body; other filters are ignored |
content |
preview|full|none |
preview |
Body rendering per result |
query |
string | — | Natural-language or shape-name query; omitted = list mode |
source_type |
string | — | Filter by source type |
shape |
string | — | Filter by shape |
min_relevance |
number | — | Relevance threshold, 0..1 |
limit |
number | 10 |
Max results (max 50) |
For concept_id, the root of conceptGraph { concept_id, limit: 1 } — the row GET /concepts/:id returns; otherwise conceptSearch with the filters set (relevance first). Each result shows source type, shape, id, summary, relevance / loaded / succeeded / failed counts, last update and the body.
Handler: src/tools/concept-neighbors.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
concept_id |
string | required | Concept id (no concept: prefix) |
content |
preview|full|none |
preview |
Body rendering per neighbour |
direction |
outgoing|incoming|both |
both |
Edge direction to follow |
edge_types |
string[] | — | Edge-type filter |
limit |
number | 10 |
Max neighbours (max 50) |
conceptGraph { concept_id, direction, edge_types?, limit }, whose edges carry the full edge and neighbour rows; concept-db's 404 for an unknown concept reads as no neighbours. One entry per edge: edge type, shape, neighbour id, summary, edge weight and traversal count, relevance and the body.
Handler: src/tools/concept-usage-stats.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
concept_id |
string | required | Concept id (no concept: prefix) |
include_history |
boolean | true |
Include recent usage rows |
history_limit |
number | 10 |
Max usage rows (max 50) |
conceptUsageStats { concept_id } (total uses, success and failure rates) and, with history, the recent usage rows (outcome, time, trace and activity per row). No concept-db shape serves usage history, so the rows are the one direct REST read the substrate tools make (src/tools/concept-usage-stats.ts@usage-history-direct-edge): concept-db is located through discovery's vesselCapability, through the attachment, and read at GET /concepts/:id/usage?limit=N (3 s) on the attachment's request path (§ Network). An advertised private address (RFC 1918, CGNAT, link-local) on another host than discovery is not dialled, since it is almost always out of reach from the client. A failed or skipped history read still returns the stats, with Recent usage: unavailable — recent rows come from concept-db directly at <address>, …; the aggregate above is complete.
Handler: src/tools/concept-sequence.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
concept_id |
string | required | Concept id (no concept: prefix) |
direction |
next|prev|both |
next |
Direction along the sequence chain |
limit |
number | 10 |
Max neighbours (max 50) |
conceptSequence { concept_id, direction, limit }: the concepts observed to follow (or precede) this one, with weight and traversal count.
Handler: src/tools/concept-link.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
from_concept_id |
string | required | Source concept id |
to_concept_id |
string | required | Target concept id |
edge_type |
related_to|derived_from|resolves_to|sequence_next|sequence_prev|description_of|example_of|contradicts |
required | The typed relationship |
description |
string | — | Rationale |
weight |
number | concept-db's default | Initial edge weight, 0..1 |
Writes conceptLink_write { linkData: { from_concept_id, to_concept_id, edge_type, description?, weight? } } once. concept-db upserts: a new edge is created, and a repeated (from, to, edge_type) moves the existing edge's weight by EMA (the reply then says the edge exists, with its previous weight). Renders the edge id, endpoints, type, weight and description.
Handler: src/tools/concept-create.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
content |
string | required | The concept body (embedded for similarity search) |
summary |
string | — | One-line gist |
shape |
string | required | The impulse shape this concept represents |
source_type |
goal|memo|human_input|search|llm|metabob_annotation|write|read|cpg_embedding|extracted|impulse_signature|vessel_construction_pattern|impulse_activity_pattern |
required | Substrate category |
pointer |
object | { type: <shape> } |
Pointer envelope |
budget |
number | concept-db's default | Token budget for resolution snapshots |
priority |
number | concept-db's default | Initial priority, 0..1 |
The author is stamped (src/tools/concept-create.ts@concept-author-stamp), as record_finding stamps a finding's:
recorded_by: <vesselId> and session: <id> become the content's first lines, so every reader's first match is the
stamp (task pull's convention trust reads the first recorded_by:). recorded_by: and session: lines the agent
wrote in the leading header block (up to the first blank line) are removed, at any indentation and in any case, and
the reply names what it removed; the body after the header block is untouched. They are removed whether or not an
identity resolves. With no vessel identity nothing is stamped: an author is omitted, never faked, and never taken
from what the agent wrote. Readers follow the same rule (src/tools/_concept-content.ts@header-block): the author,
session, repository, files, a verdict and finding ids are read only from the header block, so free text after the
blank line never supplies them. That covers recall, claims, verdicts and task pull's convention trust. Like every author line it is declared, not verified (§ Client claim).
Writes concept_create_write { conceptData } once — the body POST /concepts takes; renders the created concept's id, shape, source type, summary, budget and priority.
Handler: src/tools/concept-record-usage.ts@handle
The direct write into concept-db's relevance model.
| Parameter | Type | Default | Description |
|---|---|---|---|
concept_id |
string | required | The concept the outcome applies to (with or without concept:) |
outcome |
success|failure|neutral |
required | success = it helped; failure = it misled; neutral = loaded only |
trace_id |
string | mcp:<uuid> |
Execution or trace id |
activity_id |
string | — | Activity id |
task_id |
string | — | Task id within the activity |
Writes conceptUsage_write { usageData: { concept_id, trace_id, outcome, activity_id?, task_id? } } once, with the concept id in its plain form. The client computes no relevance: concept-db folds the outcome into its Bayesian posterior mean (times_succeeded+1)/(times_loaded+2) — success credits the concept, failure and neutral grow the load count without credit. mark_complete on a finding writes through this same shape.
A human_input impulse that reaches the loopback resolver (§ Impulse shapes) is durably enqueued
for the agent, and these two tools drain and answer that queue. The session is a client of the
substrate and advertises no shape, so the substrate has no inbound offer to dispatch a task to it
(CONFORMANCE §c11-attachment-model): the queue fills only from a local caller of the resolver.
goal_reasoning reads the substrate's walk; these two tools answer a solicitation.
Tasks and answers persist in ~/.metabob/sidecar.db,
keyed by the vesselId — VESSEL_ID, else metabob-mcp-<hash(apiKey+workspace)>
(src/resolver-identity.ts@getResolverVesselId). A later session in the same
workspace with the same key derives the same id and resumes: it sees
still-pending tasks and, at startup, re-delivers answers a prior session
recorded but could not send (src/agent-delivery.ts@reconcileUndelivered).
Loop: a solicitation is queued → pull_assigned_work → reason → submit_resolution.
Handler: src/tools/pull-assigned-work.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
number | 10 |
Max tasks (max 100) |
Expires tasks past their deadline, then lists pending and claimed tasks (oldest first) and marks each claimed by this session. Returns each task_id, its dispatch_id, the time left to respond, and the question. A claim records the session; it is not exclusive — a claimed task still lists on later pulls until it is resolved or expires. With nothing queued, it says that no task can arrive — the session advertises nothing, so the substrate has no inbound offer to dispatch to (src/tools/pull-assigned-work.ts@NO_INBOUND_OFFER) — rather than reporting an empty queue an agent would keep polling.
Handler: src/tools/submit-resolution.ts@handle
| Parameter | Type | Default | Description |
|---|---|---|---|
task_id |
string | required | A task from pull_assigned_work |
outcome |
answered|declined|insufficient_context |
required | The resolution |
answer |
string | — | Required (non-blank) when outcome is answered |
An unknown task_id is reported as such, adding that the substrate dispatches no task to this session. For a known task it records the answer durably first and marks the task resolved, then delivers it to goal-host as the solicitationResponse_write shape through the attachment (20 s cap; src/agent-delivery.ts@deliverResolution). goal-host's 404 means the solicitation timed out: the answer is marked delivered and never retried. Discovery having no producer for the shape (no_producer) is not terminal; through a spoke a peer goal-host's 404 also arrives as no_producer, so that answer stays queued and is re-sent each session. Any other failure leaves it queued for the next session's startup reconcile; nothing retries within a session.
1. init_workspace(workspace_path, recently_changed_files)
→ builds the local CPG; nothing leaves the machine
2. get_analysis_context(recently_changed_files)
→ snapshot: index stats + open problems + co-change hints
3. get_problems(severity=["HIGH","CRITICAL"])
→ full problem list for triage
4. predict_cochanges(changed_files) / analyze_impact(changed_files)
→ local CPG queries
5. record_finding(files, summary, severity, category)
→ the write half: attribute what you found to the code; returns agf_* ids
and mints a code_finding concept other agents recall org-wide
6. annotate_component(agf_* | concept_*, mode="recommend")
→ structured why/how notes
7. mark_complete(agf_* | concept_*, action="endorse")
→ verdict; on findings it also folds relevance into concept-db
8. run_goal_async(goal, learning_mode?) → goal_status(dispatch_id) → goal_reasoning(dispatch_id)
→ provide_feedback(dispatch_id, verdict, rationale)
→ delegate to the substrate when it mounts the repo; read `reached` and `learning:`,
check the walk, then record your verdict (an `automated` label)
9. pull_assigned_work() / submit_resolution(task_id, outcome, answer)
→ answer a decision queued on this machine; the substrate dispatches
nothing to a client-only session, so the queue is usually empty
10. init_workspace(recently_changed_files=[...files changed since...])
→ parses files added since the last call; files already parsed are not re-read
The MCP client runs the compiled dist/cli.js: after editing src/, run bun run build and reconnect the server for tool changes to take effect.
The vessel accepts POST /v2/impulses/resolve from local callers: the server binds 127.0.0.1 and advertises the resolver nowhere. The body is { impulses: [{ id?, pointer }] } or a single { pointer }; the shape is pointer.type. Mapped shapes run the named tool, subject to the METABOB_TOOLS allowlist (src/vessel-server.ts@resolvePointerToTool):
| Shape | Maps to | Pointer fields forwarded |
|---|---|---|
mcp:tool_call |
any tool by name | tool, input or its alias arguments (what MCP clients call it; default {}; both at once is refused) |
code:cochange_prediction |
predict_cochanges |
changed_files, limit, confidence_threshold |
code:impact_analysis |
analyze_impact |
changed_files, diff, direction, max_depth, include_tests |
code:git_change_assignment |
assign_git_changes |
changed_files, diff, include_cochange_predictions, prediction_limit |
code:analysis_context |
get_analysis_context |
recently_changed_files, problem_limit, scope |
human_input |
queued, not resolved inline (src/vessel-server.ts@QUEUED_SHAPES) |
question_markdown (required), solicitation_id or id, timeout_ms (default 300 000) — durably enqueued for pull_assigned_work / submit_resolution; the POST ACKs { accepted, solicitation_id, will_respond_via } |
Auth: Authorization: ApiKey <key> (a Bearer <key> or bare key is also accepted), matched against METABOB_API_KEY or the identity API key; with no key configured the endpoint fails closed with 401. Per-impulse errors return loaded: false inside an HTTP 200.