MCP tool reference
What the server answers to tools/list, reproduced rather than paraphrased: substrate-spine 1.2.0, MCP protocol 2024-11-05, 23 tools, 9 of them declared read-only (readOnlyHint). Clients see these tools as mcp__substrate__<name> when the server is registered under the name substrate.
Every tool is a JSON-RPC tools/call over stdio. A refusal comes back as a tool error carrying the reason; an empty result is never used to mean failure, and a failure is never returned as an empty result.
- list_plansread-onlyListing plans
- create_planCreating a plan
- archive_planArchiving a plan
- create_taskCreating a task
- get_contextread-onlyReading task context
- list_tasksread-onlyListing tasks
- set_statusSetting task status
- update_taskUpdating a task
- synthesizeread-onlySynthesizing child results
- claim_filesClaiming files
- release_filesReleasing file claims
- file_ownersread-onlyListing file owners
- worktree_ownersread-onlyListing worktree owners
- provision_tasksProvisioning a task tree
- timelineread-onlyReading the task timeline
- open_sessionOpening a session
- session_idsResolving session identity
- close_sessionClosing the session
- reap_sessionsReaping sessions nobody can close
- assign_sessionClaiming a session for work already done
- active_sessionread-onlyReading the active session
- snapshot_overlookRecording an overlook snapshot
- list_overlookread-onlyListing overlook snapshots
list_plansread-only
Listing plans
List plans. Active only unless all=true. Rows carry lastTouchedAt (newest of the plan row and its tasks), staleDays, and a COMPUTED dormant flag — threshold declared per row as dormantAfterDays, never stored — so "active" stops meaning both "being worked" and "nobody has looked in weeks".
| parameter | type | required | description |
|---|---|---|---|
all | boolean | include archived |
create_plan
Creating a plan
Create a plan: named work with a working directory. Pass `project` — the fleet project slug this work belongs to (e.g. substrate, commitwork) — so the plan states its own owner. Without it the owner has to be inferred from cwd at read time, which cannot be done at all for a worktree outside the fleet root. Pass `tasks` (same shape as provision_tasks — tree address -> {goal, dependsOn?, worktree?, branch?, anchor?, ringParent?}) to create the plan AND its whole task tree in this one call, once list_plans has already established a new plan is genuinely needed. One transaction: the plan and every task land together, or neither does — a plan created with no tasks because the batch failed validation would be the same half-provisioned state provision_tasks refuses to produce on its own.
| parameter | type | required | description |
|---|---|---|---|
id | string | yes | short slug, e.g. substrate-v1 |
name | string | yes | human name |
cwd | string | working directory | |
project | string | fleet project slug this plan belongs to | |
tasks | object | optional: tree address -> task spec, e.g. {"1":{"goal":"..."},"1.1":{"goal":"..."}} — creates the plan and this whole tree in one call |
archive_plan
Archiving a plan
Archive a plan. Does not delete it.
| parameter | type | required | description |
|---|---|---|---|
id | string | yes | plan id |
create_task
Creating a task
Add a task. The id is assigned as a TREE ADDRESS ("1", "1.2", "1.2.3") — pass parent to nest. Optionally claim a worktree/branch so the census can tell an owned worktree from an orphan.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
parent | string | parent task id, omit for a root task | |
goal | string | yes | what this task is trying to do |
dependsOn | array of string | task ids that must complete first | |
ringParent | string | the strand/ring this task drifted off — a back-link distinct from the tree parent. Set it when capturing an inner-ring remainder while attention moves on, so closing that ring resurfaces this task instead of orphaning it. Declaration only; never caps or gates work. | |
worktree | string | absolute path to a git worktree this task owns | |
branch | string | branch this task works on | |
anchor | string | commit the work started from |
get_contextread-only
Reading task context
The immediate context for a task: itself, parent, siblings, children, blockers, and — the distinctive read — what abandoned siblings already tried and why it failed. Small however large the plan grows, which is what lets the tree live outside the context window.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
id | string | yes | task id |
list_tasksread-only
Listing tasks
All tasks in a plan, optionally filtered by status.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
status | pending | active | completed | abandoned |
set_status
Setting task status
Move a task to pending | active | completed | abandoned. Abandoning REQUIRES abandonReason: a dead end with no recorded reason gets repeated by the next sibling. Activating a task with unmet dependsOn is REFUSED unless force=true (the override is recorded in state). BATCH: pass `updates` to move several tasks in one call — each entry may set its own status/result or inherit the call-level `status`. A batch is NOT a transaction: entries apply in order and the reply names every id that moved and every one refused, with the reason. Use the single form for one task.
| parameter | type | required | description | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
planId | string | yes | plan id | ||||||||||||||||||||||||
id | string | task id — single form | |||||||||||||||||||||||||
status | pending | active | completed | abandoned | status for the single form, or the default for every batch entry that sets none | |||||||||||||||||||||||||
result | string | human-readable outcome | |||||||||||||||||||||||||
abandonReason | string | why this approach failed — required when abandoning | |||||||||||||||||||||||||
force | boolean | activate despite unmet dependencies; recorded | |||||||||||||||||||||||||
updates | array of object | batch form: [{id, status?, result?, abandonReason?, force?}] — status falls back to the call-level status
|
update_task
Updating a task
Update a task goal, result, notes, structured state, or its worktree claim. Prefer statePatch (shallow merge, null deletes a key) over state (whole replacement) — two writers replacing state wholesale clobber each other.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
id | string | yes | task id |
goal | string | revised goal | |
result | string | outcome so far | |
state | object | REPLACE structured state wholesale | |
statePatch | object | shallow-merge into structured state; a null value deletes that key | |
notes | string | always-mutable scratchpad; result stays the terminal outcome | |
ringParent | string | attach or change the ring back-link (the strand this task drifted off); reporting only, never a gate | |
worktree | string | worktree path | |
branch | string | branch | |
anchor | string | anchor commit |
synthesizeread-only
Synthesizing child results
Read before completing a parent: direct children grouped as completed (with result+state), abandoned (with reason), and open — plus ringChildren, tasks that drifted off this one (ring_parent back-link) and are still open, so closing a strand cannot silently orphan its remainder. A parent completed without this read summarised work it never looked at.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
id | string | yes | parent task id |
claim_files
Claiming files
Declare which files this task is editing. A worktree claim says who owns a CHECKOUT; this says who is editing a PATH, which is the only question that means anything when several sessions share one checkout. REFUSES a path already held by another active task and names the holder; force=true takes it anyway and RECORDS what it took it from. This is a declaration, not a lock — nothing stops a write, but a collision becomes visible while it is happening instead of at commit time.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
id | string | yes | task id |
paths | array of string | yes | paths this task is editing, as you refer to them |
note | string | anything the next session needs to know | |
force | boolean | take a held path anyway; recorded on the claim |
release_files
Releasing file claims
Release claims. Omit paths to release everything this task holds. Completing or abandoning a task releases its claims automatically — a claim held by a dead task reads as contention that is not there.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
id | string | yes | task id |
paths | array of string | omit to release all |
file_ownersread-only
Listing file owners
Every live file claim, the STALE ones whose owning task is no longer active, and — when you supply `dirty` — the paths changed in the working tree that NOBODY claims. Read this before editing a shared file. UNCLAIMED IS NOT FREE: a claim releases when its task completes and the edit outlives it, so a file can read unheld while another session still has work in it. Omitting `dirty` reports joined:false, never an empty list, because "the join did not run" and "nothing is dirty" are different facts. Get the set with `git status --porcelain`.
| parameter | type | required | description |
|---|---|---|---|
paths | array of string | restrict to these paths | |
dirty | array of string | paths changed in the working tree, as YOU measured them — this server does not shell out, and claims are free strings that may be relative to any repository |
worktree_ownersread-only
Listing worktree owners
Every task claiming a worktree. This is the join orchestrator/lib/worktrees.mjs cannot make alone: git knows the worktree exists, the spine knows who owns it, and only the pair tells you an agent left something behind.
No parameters.
provision_tasks
Provisioning a task tree
Create a whole task tree in ONE call, with ids you choose, instead of N create_task calls whose ids you cannot predict. `tasks` maps tree address -> {goal, dependsOn?, worktree?, branch?, anchor?, ringParent?}. All-or-nothing: it refuses rather than half-provisions, because a partially built plan still looks like a plan. Refuses an id that is not a tree address, a goal-less task, an id that already exists, a parent that is in neither the batch nor the plan, and root-level ids on a plan that already has tasks.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
tasks | object | yes | tree address -> task spec, e.g. {"1":{"goal":"..."},"1.1":{"goal":"..."}} |
timelineread-only
Reading the task timeline
Recently-touched tasks for a plan, most recently updated first. What changed lately, when the tree is too big to read whole.
| parameter | type | required | description |
|---|---|---|---|
planId | string | yes | plan id |
limit | number | default 20, max 500 |
open_session
Opening a session
Open a work session, optionally bound to a plan. ONE ACTIVE SESSION PER (OWNER, AGENT), enforced by the storage layer; a second open by the SAME agent is refused and names the incumbent. Concurrent sessions are expected and fine — `owner` answers WHO (defaults to the OS user; do not pass an agent or tool name) and `agent` answers WHICH WORKER, defaulting to this server process, which stdio makes one-per-session. Pass `agent` only to override that.
| parameter | type | required | description |
|---|---|---|---|
planId | string | plan this session works on | |
label | string | what this session is for | |
owner | string | defaults to the OS user | |
agent | string | defaults to this server process — one per agent session |
session_ids
Resolving session identity
Read — and optionally refresh — every knowable identifier for a session: the Claude Code transcript id, the fleet name, the cwd, the VS Code window and the IDE port that holds it. Each value carries HOW it was known (`self`, `presence`, `ppid-walk`, `lockfile`), because a process-tree inference and a file the harness wrote about itself are not the same kind of fact and must not read as one. Ids are resolved at open_session, when every process in the chain is still alive; call this with `refresh` to re-resolve for THIS agent, or with `patch` to contribute what only you can see (a panel knows a window the server does not). Merges, never replaces — a wholesale write erases whatever the writer personally could not observe. An absent key means NOT KNOWN; it is never null-to-mean-nothing.
| parameter | type | required | description |
|---|---|---|---|
sessionId | string | defaults to this agent's active session | |
refresh | boolean | re-resolve from the live process chain (only meaningful for this agent's own session) | |
patch | object | ids to merge in; a null value deletes that key. Include a `how` object naming the evidence class for each key you set |
close_session
Closing the session
Close THIS agent's active session and return its receipt: task counts by status, remaining, overlook snapshot COUNT, and duration. Scoped to the calling agent — it cannot close a concurrent session belonging to another. Refuses when there is none rather than reporting an empty close. `sessionId` is the exception, for retiring an ORPHAN: it closes a named session of the same owner whatever agent opened it, and is REFUSED if that agent's process is still running unless you also pass force. Use active_session with all:true to find the id.
| parameter | type | required | description |
|---|---|---|---|
owner | string | defaults to the OS user | |
agent | string | defaults to this server process | |
sessionId | string | close this NAMED session instead of your own — for orphans | |
force | boolean | close a named session whose process is still alive; recorded in the receipt |
reap_sessions
Reaping sessions nobody can close
Retire sessions nobody can close. Reports what it would take and takes nothing unless apply:true. Two sweeps, and they answer different questions: the PID sweep closes sessions whose holding process is gone, and only ESRCH counts as gone — a live session is never touched, at the cost of missing one whose pid was recycled. `maxIdleSecs` adds the TTL sweep, which is the backstop for exactly that, plus rows predating the pid column. Both write status `reaped`, NEVER `completed` or `abandoned`: the sweep witnessed a dead holder, not an outcome, and either verdict word would assert one. Sessions with no recorded pid are counted and reported separately — they are unknown, not dead, and the PID sweep will not touch them.
| parameter | type | required | description |
|---|---|---|---|
owner | string | defaults to the OS user | |
apply | boolean | actually close them; default is a dry report | |
maxIdleSecs | number | also close anything idle longer than this; omit to run the pid sweep alone |
assign_session
Claiming a session for work already done
Claim a session for work you have ALREADY DONE, when you never called open_session. Starts at THIS SERVER PROCESS's start — an OS fact, not a supplied number — and records `claimed_at` separately, so the stretch the spine did not watch stays visible instead of being reported as observed. Use it when a session did substantive work without opening one (a continued context, a forgotten start); the alternative is losing the attribution or opening a session that pretends the work began now. REFUSED if you already hold an active session — a claim must not overwrite a record that is already running. If you override `agent`, the process start is evidence about somebody else, so the claim covers NO unobserved time unless you pass `startedAt` yourself.
| parameter | type | required | description |
|---|---|---|---|
planId | string | plan this session works on | |
label | string | what this session was for | |
owner | string | defaults to the OS user | |
agent | string | defaults to this server process | |
startedAt | string | ISO override for the start. AVOID: the default is derived from the process and is checkable, this is not, and the record marks which was used |
active_sessionread-only
Reading the active session
This agent's current session, or null. Null means NO SESSION — it is not an error and not an empty one. Pass all:true for every concurrent session this owner is running (a census; you still cannot close another agent's).
| parameter | type | required | description |
|---|---|---|---|
owner | string | defaults to the OS user | |
agent | string | defaults to this server process | |
all | boolean | every active session for this owner, not just yours |
snapshot_overlook
Recording an overlook snapshot
Record a posture/awareness reading against a session. Refuses an unknown session rather than writing an orphan row: a snapshot that cannot say as-of-when or by-whom is not evidence, and storing it anyway manufactures some.
| parameter | type | required | description |
|---|---|---|---|
sessionId | string | yes | session this reading belongs to |
dimensions | object | dimension -> score | |
posture | string | posture label, e.g. cautious | ship | |
note | string | anything the next reader needs |
list_overlookread-only
Listing overlook snapshots
Overlook snapshots for a session, most recent first.
| parameter | type | required | description |
|---|---|---|---|
sessionId | string | yes | session id |
limit | number | default 20, max 500 |