Skip to content

MCP Tools Reference ​

CommHub Server registers 74 MCP Tools, all called through POST /mcp (Streamable HTTP). The index below is complete; the 17 tools agents use for day-to-day collaboration are documented in full further down — click the name to jump.

Which tools each caller's tools/list shows ​

tools/list depends on the caller: it lists only the tools that token can actually use. Every tool is still registered and tools/call is unchanged, so calling an unlisted tool returns the same error it always did.

CallerNot listedWhy
Node token (agent)projects_create, projects_update, list_node_files, read_node_file, tail_node_logsA node token is always refused these (user_token_required / node_token_cannot_*)
Node token (agent)Protocol tools: get_*_request / ack_*_request (config, rules file, create / stop / start node, probe), get_config_update, ack_config_update, list_my_pending_*, list_my_children, mark_tasks_*agent-node and the daemon call these by name; a model does not need them. They are listed when the request carries X-Anet-Tools: all (or /mcp?tools=all)
Node token in read-only / restricted modeAlso broadcast, the node / skill / probe write tools, and the people-only toolsAlways refused in those modes
Node token with COMMHUB_NODE_PERMISSIONS=enforceAlso the people-only tools (upsert_provider, update_provider, upsert_network_secret, review_skill)Refused once the switch is on; in the default log mode they still work, so they stay listed
User token (person)report_status, org_whoami, schedule_* and all protocol toolsA user token is always refused these (network_token_required / caller_not_a_daemon)

Only tools that are refused regardless of arguments are hidden. Anything that depends on the arguments (who a task goes to, which network) stays listed, and so does everything for restricted members (members who see only granted agents), since they may be allowed in another network. Each tool's class is in server/src/tool-audience.ts; a new tool must be classified or the test fails.

Measured with 74 tools: all tools 71,559 B; node 52 tools 57,632 B (−19%); user 56 tools 54,780 B (−23%).

org_whoami: my Agent team ​

Read-only, no arguments, and available only to node tokens with a resolved node identity. User tokens receive network_token_required; unbound identities receive node_identity_unbound. Caller-supplied aliases, node IDs or network IDs cannot select another identity.

Returns node_id, source (node / none), team {id,name}, nearest-first ancestors, Agent lead, human owner, same-team agents (including self, capped at 50), and truncated. This reads the independent Agent team tree, never the owner's human department. Unassigned nodes receive empty relationships. All references are network-scoped; human owners expose their ID and display name, not username. Membership is re-read on each call and grants no extra permissions.

Complete tool index ​

Daemon Provider management (#906, development / not released)

ToolWhat it does
daemon_provider_snapshotNetwork owner/admin requests scoped refresh, reads a request with read, saves a versioned Provider with put, or explicitly confirms single-node apply. Every call specifies network_id; id is the daemon ID for mutations and the request ID for readback. Secrets are never returned.
pull_daemon_provider_snapshotsOnly the token-bound target daemon pulls its pending requests. Writes and applications require explicit accept_writes / accept_applications capabilities. Internal protocol, not listed to models by default.
ack_daemon_provider_snapshotTarget daemon submits a validated scoped snapshot or exact application receipt, or a fixed failure. Wrong scope, secret-bearing payloads and expired acknowledgments cannot overwrite the result.

An application receipt is distinct from a saved configuration. The first slice supports API-key selection for one existing managed Codex TUI co-presence node, preserving its thread. Official account switching is not implemented by this path. Disconnecting the client stops observation, not the remote operation; a timeout does not mean rollback.

Agent teams

ToolWhat it does
org_whoamiRead the authenticated node's team, ancestors, lead, owner and teammates

Collaboration — detailed below · 17

ToolWhat it does
report_statusReport agent status; returns inbox_count
report_completionReport task completion with results and artifacts
get_inboxFetch pending commands for this session
ack_inboxAcknowledge receipt of a command
send_taskDispatch a task into a session's inbox by alias
send_messageSend a message with no task lifecycle
send_replyReply to a Dashboard-originated task; also the terminal leg for agent-to-agent tasks
send_ackAcknowledge a task without entering the inbox
retry_taskRetry a failed, expired, or cancelled task
cancel_taskCancel a delivered/acked/running task
reassign_taskReassign a task to a different agent
get_taskGet task detail, status, and result by task_id
list_tasksList tasks with filters
get_all_statusList every session's status (Hub patrol loop)
get_session_statusGet one session's detail by alias
get_completionsQuery recent task completions
broadcastSend a message to multiple sessions

Desktop and user messages · 1

ToolWhat it does
send_desktop_messageWrite a pushable message into a desktop user's inbox

send_desktop_message — the recipient is a user identity, not a node alias

This is the one thing that is easy to get wrong, and it is the fundamental difference from send_task:

  • send_task(alias=…) goes to a session/node (something you can look up in the roster);
  • send_desktop_message(to_user_id=… | to_username=…) goes to a logged-in user, and is pushed to their currently active Desktop / web clients.

A logged-in user does not necessarily have a corresponding session alias. Passing an alias as the recipient — or the reverse — delivers to the wrong party, and both calls return success, because each argument is valid under its own meaning.

jsonc
// Minimal call: supply either to_user_id or to_username
{
  "to_username": "alice",          // or "to_user_id": "u_9f2c1b7ae4d0"
  "title": "Build finished",       // optional, <=200
  "message": "v0.9.0-preview.43 is published to npm.",   // required, 1-10000
  "severity": "success",           // info | success | warning | error, default info
  "kind": "agent_message"          // default agent_message; clients use it to categorise
}

Supply at least one of to_user_id / to_username; if both are given they are checked against each other and a mismatch is rejected. You normally do not pass network_id (single-network user tokens auto-resolve; an ntok stays bound to its own network).

SkillHub · 4

ToolWhat it does
submit_skillSubmit an immutable SKILL.md version to this network's SkillHub
list_skillsList published skills (owners/admins may include pending)
get_skillRead one SKILL.md (pending content is owner/admin only)
review_skillPublish or reject a pending submission (owner/admin)

Schedules (an Agent's own, #733) · 8

ToolWhat it does
schedule_listList schedules in this network that target you or that you created
schedule_getRead one of them
schedule_createCreate one; target_node_id defaults to you, another target needs send_task access to it right now; at most 20 open per node
schedule_updateChange task / timing / target, pause or resume via status; edit, cancel and run only your own
schedule_batch_intervalChange the interval of multiple self-created interval schedules, with per-ID results
schedule_cancelCancel
schedule_run_nowRun once now
schedule_runsRecent runs

Node tokens only; people use the app or /api/scheduled-tasks. Permissions and quota: Schedules.

Node lifecycle — driven by the anet CLI / Dashboard · 7

ToolWhat it does
create_nodeCreate and start a node on a host daemon
clone_nodeClone an existing daemon child into a new node on that same daemon: new alias, new ntok_, source token not copied
delete_nodeStop child, revoke ntok, delete the hub row (config backed up by default)
stop_nodeStop the agent-node child, keep the config dir
start_nodeStart a stopped child node through its host daemon
restart_nodeRestart a node without changing its config
update_node_configSet a node's desired config (model + flags) and ring its doorbell

Host-daemon protocol — internal, called by nodes and daemons · 13

ToolWhat it does
get_config_updateNode pulls its pending config update
ack_config_updateNode reports the config-update outcome
read_node_rules_fileAsk a node to send back the rules file in its working directory (claude → CLAUDE.md, others → AGENTS.md); no path argument, poll get_rules_file_result (app#225)
write_node_rules_fileAsk a node to overwrite its rules file with content; no path argument, 256 KB cap (app#225)
list_node_skillsAsk a node to list the skills its runtime actually loads ({name, scope, path_rel, description}, read-only); directories are chosen by the node per runtime, no path argument, poll get_rules_file_result
read_node_skillAsk a node to send back one skill's SKILL.md (read-only, 256 KB cap); the only input is the skill name ([A-Za-z0-9._-]{1,64}), never a path
list_node_filesAsk a node to list one directory level of its work dir (project folder, read-only): path is relative to the work dir (default: root); absolute paths and .. are refused. Entries are {name, type, size, mtime}; secret-looking files are listed by name only (hidden_reason: "secret"), node_modules / .git are not descended, 1000-entry cap. User logins only
read_node_fileAsk a node for one text file under its work dir (read-only, 256 KB cap; binary / oversized → size only; .env, keys, auth.json and other credential files → no content). The node realpath-contains the path in its work dir, so symlinks cannot escape. Only the token that asked can read the result
tail_node_logsAsk a node for the tail of its own agent-node run log (read-only): lines (default 500, max 2000), optional level (info/warn/error, exact match), grep (case-insensitive, matched after redaction) and since_ts (follow mode). No path argument — the node reads only its own log directory, and masks tokens, Bearer/Authorization values and credential-named assignments before answering. User logins only, and only the node's owner or a network owner/admin. The result is handed out once to the token that asked, then purged (unread results after 5 minutes). Nodes advertise logs_capable
get_rules_file_resultPoll a rules-file request: pending / in_progress / done / failed / timeout (auto-timeout after 60 s of silence). File contents are kept only briefly: purged 60 s after the first terminal read, or 24 h after the request regardless; a later poll returns the status/metadata with content_purged: true instead of content. Rows are deleted after 30 days
get_rules_file_requestNode pulls its pending rules-file request (network token + alias)
ack_rules_file_requestNode reports the outcome of a rules-file request (content included for reads)
list_my_pending_create_requestsDaemon compensates after SSE reconnect by listing pending create-node requests
list_my_pending_lifecycle_requestsDaemon compensates after SSE reconnect by listing pending stop/delete/start requests
get_create_requestDaemon pulls a pending create-node request
ack_create_requestDaemon reports the post-fork start result
get_stop_requestDaemon pulls a pending stop/delete request
ack_stop_requestDaemon reports stop/delete completion or failure
get_start_requestDaemon pulls a pending start request
ack_start_requestDaemon reports start completion or failure
list_host_supervisorsList host_supervisor daemons in this network, with online status
list_my_childrenDaemon pulls the list of children it spawned
request_adopt_nodeHuman requests adoption with node_id, daemon_node_id, workdir. Requires network write access plus node ownership or network owner/admin. Daemon must be online, same network/hostname, and advertise daemon_capabilities.adopt_capable. Creates a pending binding, without signalling processes
get_adopt_requestOnly the target daemon's node token may pull the pending request; the daemon performs local path/process verification
ack_adopt_requestTarget daemon reports adopted / refused, optionally error. Only acknowledgement activates a binding; revoked requests cannot reactivate
unadopt_nodeOwner or network owner/admin revokes pending/active authority without stopping the node. Refused while stop/start is in flight; retry after completion
report_token_usageNodes add their own LLM usage into UTC hour buckets per model (input / output / cache_read kept apart — cache never folds into total). Counts only: no prompt, no message id, no credential. Lands in node_token_usage; humans read GET /api/token-usage

Adopted children appear in list_my_children with managed: "adopted", including stopped children. They cannot be deleted (adopted_node_delete_unsupported). Stop/start require a creation record or active binding; an explicit daemon id alone is not authority. Old daemons without the capability cannot adopt; this Hub protocol does not itself implement daemon-local verification or process control.

The existing Hub startup creates node_daemon_bindings via server/src/db.ts on SQLite/PG. No new service, port, tunnel, environment variable, or credential source is introduced. Use the existing Hub deployment process; verify request→ack→revoke against an isolated database, not production nodes. Revoke bindings before rollback (wait for any in-flight lifecycle operation); older Hubs ignore this table. Bindings and audit rows require database backups, not just a clone. Daemon-local registry state requires the host backup, or fresh local adoption verification before controlling a process; a restored Hub row alone is insufficient.

Providers & secret vault — owner/admin · 9

ToolWhat it does
list_providersList providers and models (never returns secret values)
upsert_providerCreate or update a provider
update_providerEdit an existing provider's name / base_url / models / enabled (at least one; vendor, secret and network are immutable); admin+
list_network_secretsList vault key NAMES only, never values (RFC-028)
upsert_network_secretWrite or replace a vault secret (AES-GCM encrypted)
probe_provider_modelDispatch a connectivity probe to a daemon
get_probe_requestDaemon pulls a pending probe request
ack_probe_requestDaemon reports a probe result (strict whitelist: exactly 4 fields, any extra one is -32602)
get_probe_resultsQuery probe history, optionally filtered by provider/model/daemon

OpenCode V2 catalog (network members) · 4

ToolWhat it does
read_opencode_catalogQueue a native catalog read for one owned OpenCode V2 child. ok with status: pending means authorized and stored in memory. It is not a snapshot and does not mean a daemon received the doorbell. Does not start a server. Rejects url, authorization, directory, settings, headers, and body
select_opencode_session_modelEntry point for an OpenCode V2 session model change; currently unavailable: it always reports unavailable, writes nothing and restarts nothing. Change models with update_node_config
get_opencode_catalogPoll that read. pending is not a snapshot. Only ready returns a whitelist snapshot, still completeness unknown and invocationVerified false
get_opencode_catalog_requestDaemon pulls one authorized read. Scope only: no URL, authorization, settings, headers, or body
ack_opencode_catalogDaemon stores a whitelist snapshot or a closed error code. Extra fields are rejected. A stored failure is status failed, not a transport error

Internal signals — called by agent-node · 3

ToolWhat it does
send_peer_replyAtomically finalize one node-owned task and enqueue a no-response result
mark_tasks_consumedInternal agent-node signal: which tasks this turn actually consumed
mark_tasks_runtime_submittedInternal agent-node signal: which task bodies reached the runtime

Requirements / task board (usable by agents, see below) · 12

ToolWhat it does
requirements_listList tasks in your network, newest first; filter by status / project_id / owner / agent_owner / tag (exact, case-sensitive) / updated_since / external_ref / parent_id / top_level / q; archived ones hidden by default. MCP defaults to view: "summary" and 50 per page (summary drops description and checklist bodies, about 0.5–0.8 KB per task, so a page is typically 30–40 KB); no last_event by default, pass include_last_event: true to get each row's newest activity entry, comments included (about 150 B more per task); page with has_more + next_cursor (pass it as cursor), limit ≤ 1000. For full text use requirements_get for one task, or pass view: "full" (several KB per task; keep limit small). Parameters are strict: an unknown one (say tags instead of tag) fails with -32602 listing every valid parameter, instead of silently returning the whole table. changes: true + updated_since returns only tasks changed since then (archived included) plus deleted and server_time (Hub ≥ preview.75); with include_last_event: true it also returns tasks that got a newer activity entry (a comment, say). The REST GET /api/requirements defaults are unchanged (full, 500)
requirements_peopleLook up the people in your network and the Agents they own, to fill task person fields: each row has user_id, username, display_name ("" if not set), role, department ({id, name} or null), agents: [{node_id, alias}]; Agents with no owner are in agents_without_owner (first page only). q filters by username / display name / department / Agent alias (case-insensitive substring); 50 people per page (limit ≤ 200), page with has_more + next_offset (pass offset). Agents you are not granted are left out. Strict parameters (unknown ones → -32602)
requirements_getGet one task by id (description, checklist, owners, project, external_ref)
requirements_createCreate a task; status goes in column (pool / doing / done), and status is accepted as an alias (both sent and different → 400 status_conflicts_with_column); a duplicate external_ref in the network returns 409 external_ref_exists + existing_id
requirements_updatePatch a task, omitted fields kept; status = column (as above). participants replaces the whole list (anyone left out is removed, [] clears it); to add / remove a few people use participants_add / participants_remove: applied to the current list without touching anyone else, adding someone already there or removing someone absent changes nothing; cannot be combined with participants (400 participants_conflict), the same person in both → 400 participants_add_remove_overlap; archived: true archives it (agents cannot delete)
requirements_checklist_toggleSet one checklist item done / not done; only that item is written
requirements_upsert_by_external_refIdempotent sync by external_ref (e.g. github:owner/repo#123): create if missing, patch if present; also accepts the status alias
departmentsRead and write the org structure (department tree): action: "list" returns the tree (each department with its agent count and children); create / update / delete manage departments (name, parent, head, sort); assign_agent moves one Agent into a department (department_id: null removes it). Writes are limited to the network owner/admin (a plain member gets forbidden); every write is audited. Departments are shared network-wide, not per person
projects_listList the network's projects (id, name, color, sort, archived). Node (agent) tokens can only read projects
projects_createCreate a project (name 1–40 characters, unique in the network; optional color / sort), same as the app's 管理项目 → 新建; needs a person's token: node (agent) tokens can only read projects and get 403 user_token_required here; task-scoped members and viewers get 403 too
projects_updateRename / recolour / reorder a project, or archived: true / false to archive / unarchive; only the fields you pass change; same permissions
requirement_tagsTags of this network. action: "list" returns the tree: name, parent_id (null = root), depth, colour, usage counts, can_manage. The same tool writes (rename / merge / delete / color / reparent) and only the owner/admin may; a node token calling a write gets 403. reparent only moves the tree edge (no card changes, no renames): unknown parent → 404 tag_parent_not_found; a cycle → 400 tag_parent_cycle; deeper than 4 after the move (the check is the new parent depth plus the tag's own subtree height) → 400 tag_depth_exceeded; omitting the parent key → 400, while null / "" moves it to the top level. A refusal changes nothing in the database. Hub ≥ preview.156
requirements_eventsRead the task activity timeline (field-level changes: who, when, old → new; and comments, kind: "comment" with the text in new.text; newest first); requirement_id (req_… or "#N") for one task (limit default 200); without requirement_id it is the whole network, 50 per page by default on MCP, pass next_cursor as cursor for older events (limit ≤ 500; the REST default is still 200); since for only newer ones; same visibility as requirements_list; strict parameters (unknown ones → -32602)
requirements_commentAdd a comment / progress note to a task ({id, text}, markdown, 1–4000 characters): append-only, never rewrites the description, shows in the timeline (kind: "comment") under the caller (person or agent). Anyone who can see the task and is not read-only in the network may comment; can't see it → 404, viewer → 403. Hub ≥ preview.90

Agent-Side Tools ​

report_status ​

View source ↗ —— grep for "report_status" in server/src/tools.ts (single registration site)

Report agent status. Also serves as a heartbeat (recommended every 3 minutes).

Parameters:

ParameterTypeRequiredDescription
resume_idstring✓Session unique identifier (max 200 chars)
aliasstring✓Display name (max 200 chars)
statusenum✓working / idle / blocked / error / waiting_input / offline
taskstringCurrent task description (max 10000 chars)
outputstringRecent output (max 50000 chars, storage truncated to 4000)
scorenumberSelf-rating 0-10 (doc previously said 1-10; schema is actually .min(0).max(10))
progressnumberProgress 0-100
serverstringServer identifier
hostnamestringHostname
agentstringAgent type (free-form string for audit; agent-node actually sends agent-node:<runtime> — e.g. agent-node:claude-agent-sdk / agent-node:codex-sdk / agent-node:claude-code-cli; the Claude Code MCP wrapper sends claude-code; other clients fill freely)
project_dirstringWorking directory
versionstringAgent version
tmux_namestringtmux session name
node_idstringStable node identifier. Note: passing node_id is required to upsert model / node_name / runtime (parsed from the agent field) into the nodes table (tools.ts — grep upsertNodeWithSec1Guard (the call inside report_status's if (node_id), plus the helper of the same name defined after registerTools)). The model parameter itself does not depend on node_id — report_status's sessions upsert unconditionally writes sessions.model = COALESCE(model, old) (tools.ts — grep INSERT INTO sessions inside report_status (these columns are written by report_status, not by the tool documented in this section) and model = COALESCE(?20, sessions.model)); only node_name has no sessions column and must go through the nodes table via node_id.
session_idstringRuntime session/thread ID
config_pathstringConfig file path
channelsstringChannel list (JSON array string)
modelstringAI model name (written to nodes.model only when node_id is also passed)
node_namestringNode display name (written to nodes.node_name only when node_id is also passed)
network_idstringNetwork ID

Response:

json
{
  "ok": true,
  "resume_id": "sdk-n_a1b2c3d4",
  "alias": "coder-1",
  "inbox_count": 3
}

Example:

typescript
report_status({
  resume_id: "sdk-n_a1b2c3d4",
  alias: "coder-1",
  status: "working",
  task: "Writing sorting algorithm",
  progress: 50,
  model: "your-model-id",
  agent: "agent-node:codex"
})

Authentication required

This tool only accepts a ntok_ (network-scoped) token. Calling with utok_ (user-scoped) returns {ok: false, error: "network_token_required"} (tools.ts — grep "network_token_required" (3 sites)). This is a hard constraint after RFC-001 in v0.8 — agent heartbeats must be bound to a network.

Side effects beyond the sessions table:

  • Automatically DELETEs any older session row with the same network + alias + a different resume_id (tools.ts — grep DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2; cleans up orphans across agent restarts)
  • When status="working" with a task, transitions the corresponding tasks row from delivered/acked to running (tools.ts — grep UPDATE tasks SET status = 'running'; see Task lifecycle)
  • When node_id is passed, upserts the nodes table (including model / node_name / runtime; see the node_id row above)

report_completion ​

View source ↗ —— grep for "report_completion" in server/src/tools.ts (single registration site)

Report task completion. Automatically updates session status to idle.

Parameters:

ParameterTypeRequiredDescription
aliasstring✓Session alias
taskstring✓Completed task description
resultstring✓Result summary (max 50000 chars)
artifactsstring[]Output file paths or URLs (max 50)
scorenumberSelf-rating 0-10
duration_minutesnumberDuration (minutes)
network_idstringNetwork ID

Response:

json
{
  "ok": true,
  "completion_id": "uuid-xxx"
}

Example:

typescript
report_completion({
  alias: "coder-1",
  task: "Write sorting algorithm",
  result: "Implemented with quicksort, O(n log n) time complexity",
  artifacts: ["/tmp/sort.py"],
  score: 8,
  duration_minutes: 2
})

Side effects (beyond the completions INSERT)

  • Session state flip: tools.ts — grep UPDATE sessions SET status = 'idle' UPDATE sessions SET status='idle', task=NULL, progress=0 (matched by alias)
  • Task state transition: tools.ts — grep UPDATE tasks SET status = 'replied' (2 sites) moves the tasks row from delivered/acked/running to replied. First tries task_id = <task param>; on miss it falls back to to_name=<alias> AND content=<task param> — so the task parameter can be either the real task_id (preferred) or the task description string (fallback)
  • result truncation: only the first 4000 chars are written to tasks.result (tools.ts — grep result.slice(0, 4000) (2 sites)); the full result still lands in completions.result
  • chained_reply auto-propagation: if the task has a parent_task_id, the parent's originator gets a chained_reply SSE event (tools.ts — grep type: "chained_reply" (2 sites); used so subtask replies bubble up to the parent — see task-lifecycle dual-write)
  • task_events log: a replied event is logged (tools.ts — grep logTaskEvent(updatedTaskId, null, "replied")

Compared to send_reply: send_reply is a hub tool that requires an explicit task_id; report_completion is an agent tool that can fall back by content match.


get_inbox ​

View source ↗ —— grep for "get_inbox" in server/src/tools.ts (single registration site)

Fetch pending messages.

Parameters:

ParameterTypeRequiredDescription
aliasstring✓Session alias
limitnumberMax items (default 10, max 100)

Response:

json
{
  "ok": true,
  "messages": [
    {
      "id": "uuid-xxx",
      "task_id": "task-uuid-xxx",
      "type": "task",
      "priority": "high",
      "content": "Write sorting algorithm",
      "context": null,
      "from_session": "commander",
      "created_at": "2026-04-12 10:00:00",
      "network_id": "net_xxx"
    }
  ]
}

For task messages, id identifies this inbox delivery row while task_id is the stable logical task identity across retry and reassignment. For legacy rows without a separate task ID, the Hub returns task_id = id. Task consumers should pass the returned task_id to ack_inbox.message_id; non-task consumers should continue to pass id.

Messages are sorted by priority: high > normal > low, then by time within the same priority.


ack_inbox ​

View source ↗ —— grep for "ack_inbox" in server/src/tools.ts (single registration site)

Acknowledge message receipt. After ACK, the message won't be returned by get_inbox.

Parameters:

ParameterTypeRequiredDescription
aliasstring✓Session alias
message_idstring✓The inbox delivery-row id, or a task message's logical task_id. Task consumers should prefer the task_id returned by get_inbox; use id for non-task messages.
responsestringCurrently a no-op: the handler accepts this parameter but never writes it to the database (tools.ts — grep "ack_inbox" does not read response). The schema is kept for forward-compat / to avoid breaking existing callers; if you want to actually reply, use send_reply.
network_idstringNetwork scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517)

Response:

json
{ "ok": true }

Errors: no pending delivery owned by the alias → message not found or already acknowledged; the resolved delivery becomes unwritable after lookup → message not found or not yours.

Side effect: tasks-table state machine

The Hub first resolves the current unacknowledged inbox row by id = message_id, or for a task message by task_id = message_id, and ACKs only that row. For task messages it then uses the row's resolved stable logical task_id to UPDATE the matching tasks row from status='delivered' to 'acked' (tools.ts — grep UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2). Retry/reassign deliveries can therefore have a new inbox id while still ACKing the original task; legacy callers that pass an inbox id remain compatible. The task transition only accepts delivered, unlike the hub-side send_ack, which also accepts created — see Task lifecycle — the created state.


Task Management Tools ​

send_task ​

View source ↗ —— grep for "send_task" in server/src/tools.ts (single registration site)

Dispatch a task to a specified agent's inbox. send_task triggers AI processing on the receiver (same as broadcast; send_message / send_reply / send_ack do not — see Task lifecycle — Message types).

Parameters:

ParameterTypeRequiredDescription
aliasstring✓Target agent alias
taskstring✓Task content (max 10000 chars)
priorityenumhigh / normal (default) / low
contextstringContext information (max 10000 chars)
from_sessionstringSender identifier (default "hub")
ttl_secondsnumberExpiration time (default 3600, max 86400)
network_idstringNetwork ID
parent_task_idstringParent task ID; child replies are auto-chained back to the parent task originator
metaobjectStructured task metadata; mainly for attachments { attachments: [{ type, path, url, mime, name, size }] }, written to the task's meta_json column
forcebooleanDispatch even when the target is degraded (otherwise node_degraded). Honoured for user tokens only; a node token is still refused. Hub 0.9.0-preview.86+, see health and degraded refusal

Response:

json
{
  "ok": true,
  "message_id": "uuid-xxx",
  "session_status": "idle"
}

Queue info (#500): successful and offline-queued (alias_offline) responses also carry the fields below. They are advice only — the send is never refused because of them, and older clients that do not know them keep working. Idempotent replays (idempotent_replay) do not carry them.

FieldTypeMeaning
queue_aheadnumberOpen tasks already on the target before this one: tasks rows with status ∈ created / delivered / acked / running, dispatched in the last 24 hours, same network, any sender. Messages (send_message / broadcast) never write tasks, so they do not count; terminal rows (replied / failed / cancelled / expired) do not count; rows older than 24 hours that never reached a terminal state are treated as abandoned and do not count
target_busybooleanThe target has a started open task (acked / running, or a started_at / consumed_at stamp), or its session status is working / busy / running
est_wait_minutesnumber | nullRough estimate: median duration of the target's replied tasks in the last 24 hours × queue_ahead, rounded, capped at 1440. Nothing ahead → 0; fewer than 3 samples → null (unknown). Durations start at started_at and fall back to the dispatch time, so the estimate usually runs high
warningstringPresent only when queue_ahead ≥ 3 or est_wait_minutes > 30: one sentence suggesting an idle node instead (status=idle, queue_depth=0 in get_all_status), merging asks, or a longer ttl_seconds, and not to resend the same task
json
{
  "ok": true,
  "message_id": "uuid-xxx",
  "actual_to": { "alias": "coder-1", "to_node_id": "node_xxx", "network_id": "net_xxx" },
  "session_status": "working",
  "queue_ahead": 3,
  "target_busy": true,
  "est_wait_minutes": 60,
  "warning": "coder-1 already has 3 open task(s) ahead of this one (rough wait ~60 min, busy now). Your task is queued, not refused. It may expire before it starts (ttl 60 min). Consider an idle node instead (get_all_status: status=idle, queue_depth=0), merging your asks, or a longer ttl_seconds. Do not resend the same task."
}

Example:

typescript
send_task({
  alias: "coder-1",
  task: "Write a Python quicksort algorithm with comments",
  priority: "high",
  from_session: "commander",
  ttl_seconds: 7200
})

Permission Requirements

  • viewer role cannot send tasks
  • Cannot send tasks after trial expires

send_message ​

View source ↗ —— grep for "send_message" in server/src/tools.ts (single registration site)

Send a message (does not trigger AI processing, display only).

Parameters:

ParameterTypeRequiredDescription
aliasstring✓Target agent alias
messagestring✓Message content (max 10000 chars)
from_sessionstringSender identifier (default "hub")
network_idstringNetwork scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517)

Response:

json
{
  "ok": true,
  "message_id": "uuid-xxx",
  "session_status": "idle"
}

send_reply ​

View source ↗ —— grep for "send_reply" in server/src/tools.ts (single registration site)

Reply to a task. Links to the original task_id and does not trigger the recipient's AI processing.

Parameters:

ParameterTypeRequiredDescription
aliasstring✓Target agent alias
textstring✓Reply content (max 10000 chars)
in_reply_tostringOriginal task/message ID
statusenumreplied (default) / failed / cancelled
from_sessionstringSender identifier (default "hub")
network_idstringNetwork scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517)
attachmentsarrayAttachment array; parity with send_task's meta.attachments. Each item { type:"file", file_id, name?, mime?, size? }, persisted into tasks.meta_json and inbox.meta_json. file_id comes from commhub_upload_file (#507)

Response:

json
{
  "ok": true,
  "message_id": "uuid-xxx",
  "session_status": "idle"
}

send_ack ​

View source ↗ —— grep for "send_ack" in server/src/tools.ts (single registration site)

Acknowledge task receipt (lightweight, does not enter inbox).

Parameters:

ParameterTypeRequiredDescription
task_idstring✓Task ID
from_sessionstringSender identifier (default "hub")
network_idstringNetwork scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517)

Response:

json
{
  "ok": true,
  "task_id": "uuid-xxx",
  "updated": 1
}

retry_task ​

View source ↗ —— grep for "retry_task" in server/src/tools.ts (single registration site)

Retry a failed/cancelled/expired task.

Parameters:

ParameterTypeRequiredDescription
task_idstring✓Task ID
from_sessionstringSender identifier
network_idstringNetwork scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517)
forcebooleanDispatch even when the target is degraded (otherwise node_degraded). Honoured for user tokens only; a node token is still refused. Hub 0.9.0-preview.86+, see health and degraded refusal

Response:

json
{
  "ok": true,
  "task_id": "uuid-xxx",
  "retried_to": "coder-1"
}

Limitation

  • Can only retry tasks with status failed / expired / cancelled (verify tools.ts — grep ["failed", "expired", "cancelled"].includes(task.status)); other statuses return {ok: false, error: "task status is <X>, not retryable"}
  • Retry gives the task a fresh +1 hour TTL (hardcoded at tools.ts — grep +1 hour) — the original task's ttl_seconds is not preserved
  • task_id is reused; a new inbox row (new UUID) is inserted and a new_task SSE event is pushed to the target alias

cancel_task ​

View source ↗ —— grep for "cancel_task" in server/src/tools.ts (single registration site)

Cancel a pending task.

Parameters:

ParameterTypeRequiredDescription
task_idstring✓Task ID
reasonstringCancellation reason (max 1000 chars)
from_sessionstringSender identifier
network_idstringNetwork scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517)

Response:

json
{
  "ok": true,
  "task_id": "uuid-xxx",
  "cancelled": true
}

Constraint

Only cancellable from these 4 source statuses: created / delivered / acked / running (verify the WHERE clause at tools.ts — grep status IN ('created', 'delivered', 'acked', 'running') inside cancel_task (2 sites; the other is send_message)). Calling on a terminal status (replied / failed / cancelled / expired) returns {ok: false, cancelled: false}.

created is only the DB column default; the normal API path never produces a row in that state (see Task lifecycle — the created state).


reassign_task ​

View source ↗ —— grep for "reassign_task" in server/src/tools.ts (single registration site)

Reassign a task to another agent.

Parameters:

ParameterTypeRequiredDescription
task_idstring✓Task ID
new_aliasstring✓New target agent alias
from_sessionstringSender identifier
network_idstringNetwork scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517)
forcebooleanDispatch even when the target is degraded (otherwise node_degraded). Honoured for user tokens only; a node token is still refused. Hub 0.9.0-preview.86+, see health and degraded refusal

Response:

json
{
  "ok": true,
  "task_id": "uuid-xxx",
  "reassigned_from": "coder-1",
  "reassigned_to": "coder-2"
}

Constraint

  • Reassign works only on non-terminal tasks: created / delivered / acked / running (tools.ts — grep ["replied", "failed", "cancelled", "expired"].includes(task.status) (unique) rejects replied / failed / cancelled / expired with {ok: false, error: "task is terminal (<status>)"})
  • The old alias's inbox row is acked=1 (tools.ts — grep UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 inside reassign_task (2 sites; the other is cancel_task)) so the original agent will not pick it up
  • Task status resets to delivered, started_at clears, delivered_at refreshes to now (tools.ts — grep UPDATE tasks SET to_name = ?1) — a running task is interrupted
  • TTL (expires_at) is not modified (unlike retry_task which forces +1 hour); the task keeps its remaining time
  • The new alias receives a fresh-UUID inbox row + a new_task SSE event

Query Tools ​

get_task ​

View source ↗ —— grep for "get_task" in server/src/tools.ts (single registration site)

Query task details.

Parameters:

ParameterTypeRequiredDescription
task_idstring✓Task ID

Response:

json
{
  "ok": true,
  "task": {
    "task_id": "uuid-xxx",
    "from_name": "commander",
    "to_name": "coder-1",
    "priority": "normal",
    "status": "replied",
    "content": "Write sorting algorithm",
    "result": "Implemented with quicksort...",
    "created_at": "2026-04-12 10:00:00",
    "delivered_at": "2026-04-12 10:00:01",
    "started_at": "2026-04-12 10:00:03",
    "completed_at": "2026-04-12 10:00:15",
    "expires_at": "2026-04-12 11:00:00",
    "network_id": "net_xxx"
  }
}

get_task does SELECT * FROM tasks (tools.ts — grep SELECT * FROM tasks WHERE task_id = ?1 inside get_task (3 sites; the others are retry_task / reassign_task)) and returns the full row (the example above shows sample fields; the actual row also includes requires_response / parent_task_id and every other column). When the task doesn't exist it returns {ok: false, error: "task not found"}.


list_tasks ​

View source ↗ —— grep for "list_tasks" in server/src/tools.ts (single registration site)

Query task list with multi-dimensional filtering.

Parameters:

ParameterTypeRequiredDescription
aliasstringFilter by recipient
statusstringFilter by status
from_namestringFilter by sender
from_node_idstringFilter by sender node_id (immutable ID, more precise than from_name)
network_idstringFilter by network
limitnumberMax items (default 20, max 100)

Response:

json
{
  "ok": true,
  "tasks": [
    {
      "task_id": "uuid-xxx",
      "from_name": "commander",
      "to_name": "coder-1",
      "priority": "normal",
      "status": "replied",
      "content": "Write a sorting algorithm",
      "result": "Implemented with quicksort...",
    "created_at": "2026-04-12 10:00:00",
    "runtime_submitted_at": "2026-04-12 10:00:03",
    "consumed_at": "2026-04-12 10:00:04",
    "completed_at": "2026-04-12 10:00:15"
    }
  ],
  "count": 1,
  "stats": [
    { "status": "replied", "count": 42 },
    { "status": "running", "count": 3 },
    { "status": "delivered", "count": 1 }
  ]
}

list_tasks rows are a subset of get_task

Each list_tasks row includes task identity, sender/recipient, status, content/result, and summary timestamps. runtime_submitted_at means the body was handed to the vendor runtime; consumed_at additionally requires attributable turn-start/activity evidence. It does not include delivered_at / started_at / expires_at / network_id / requires_response / parent_task_id — use get_task (SELECT *) for those. count is the number of rows returned this call (≤ limit); stats is the status-grouped count for the entire scope (not affected by the filters). See Task lifecycle for the distinction from legacy fields.


get_all_status ​

View source ↗ —— grep for "get_all_status" in server/src/tools.ts (single registration site)

Get all session statuses. Sessions without a heartbeat for over 10 minutes are auto-marked offline.

Parameters:

ParameterTypeRequiredDescription
filter_statusstringFilter by status (idle / working / offline)
filter_serverstringFilter by server
network_idstringFilter by network

Response:

json
{
  "ok": true,
  "sessions": [
    {
      "resume_id": "sdk-n_xxx",
      "alias": "coder-1",
      "status": "idle",
      "agent": "agent-node:codex",
      "node_id": "n_a1b2c3d4",
      "last_seen_at": "2026-04-12 10:00:00",
      "network_id": "net_xxx",
      "queue_depth": 2
    }
  ],
  "summary": [
    { "status": "idle", "count": 5 },
    { "status": "working", "count": 2 },
    { "status": "offline", "count": 1 }
  ]
}

Each row also carries queue_depth (#500): the number of open tasks on that node (same definition as queue_ahead in the send_task response: created / delivered / acked / running, last 24 hours, same network; messages do not count). One GROUP BY covers every node — not one query per row. To pick a node that can start right away, queue_depth=0 is more reliable than status alone.

The sessions row has no model field

get_all_status runs SELECT * FROM sessions (tools.ts — grep SELECT * FROM sessions WHERE 1=1, no JOIN). The sessions table schema (db.ts — grep CREATE TABLE IF NOT EXISTS sessions + V2 migration db.ts — grep ALTER TABLE sessions ADD COLUMN) has a model column — the V2 migration runs ALTER TABLE sessions ADD COLUMN model, and report_status's sessions upsert unconditionally writes sessions.model = COALESCE(model, old) (tools.ts — grep INSERT INTO sessions inside report_status (these columns are written by report_status, not by the tool documented in this section) and model = COALESCE(?20, sessions.model)). So get_all_status returns each session's model directly (null if the agent never passed a model parameter). The nodes table also keeps a copy of model (synced by report_status when node_id is passed) as the more durable source. summary is the status-grouped count over the entire scope (same as list_tasks's stats).

🔴 status answers "what it last reported", not "is it working right now"

sessions.status / progress are updated only at the moment a node calls report_status (tools.ts 搜 INSERT INTO sessions inside report_status), and report_completion resets them to idle (tools.ts 搜 UPDATE sessions SET status = 'idle', task = NULL, progress = 0 inside report_completion).

Nothing in between updates it on the node's behalf. So status: "idle" covers at least three different realities:

RealityWhat the panel shows
①Genuinely idleidle
②Got a task but never consumed it (wedged / loop not waking)idle
③Running a long task and not reporting mid-wayidle

🔴 Measured 2026-08-18: a node showed status=idle / progress=100 with a heartbeat under 2.5 minutes old for 75 straight minutes, and its own reply said it had been running a long sync the whole time — case ③.

task is even easier to misread: it may be something the sender wrote. When the target session is not working, send_task stamps the first 200 chars of the task onto it inside its own transaction; when the target is already working, the column is left alone so it keeps the task in flight (tools.ts 搜 sessionTaskOnDispatch("?1") inside send_task; the condition is in session-task-on-dispatch.ts 搜 CASE WHEN status = 'working' THEN task ELSE). report_status writes the same column too (COALESCE — omitting it keeps the old value): when agent-node starts a task it reports the whole text, the hub compares that text with tasks.content and, on an exact match, marks the row running and sets started_at — but it still stores only the first 200 chars in task (tools.ts 搜 sessionTaskPreview(task); // board668-session-preview inside report_status). So task is at most 200 chars and the full text lives only on the task row — task is readable through get_all_status and the full /api/status, a wider audience than the task row.

⇒ Seeing your own text in task proves the hub recorded that you sent it. It does not prove the node read it. That is the echo of your own action.

There is exactly one structurally sound way to tell whether a node is working: send something that requires a reply, and see whether it replies. The status panel cannot answer this — not because the data is stale, but because of when these columns are written.


get_session_status ​

View source ↗ —— grep for "get_session_status" in server/src/tools.ts (single registration site)

Get detailed status of a single session, including pending inbox count and recent completions.

Parameters:

ParameterTypeRequiredDescription
aliasstring✓Session alias

Response:

json
{
  "ok": true,
  "session": {
    "resume_id": "sdk-n_xxx", "alias": "coder-1", "status": "idle",
    "agent": "agent-node:codex", "node_id": "n_a1b2c3d4",
    "last_seen_at": "2026-04-12 10:00:00", "network_id": "net_xxx"
  },
  "inbox_pending": 2,
  "recent_completions": [
    {
      "id": "uuid-xxx",
      "session_name": "coder-1",
      "task": "Write sorting algorithm",
      "result": "Done",
      "artifacts": null,
      "score": 8,
      "duration_minutes": 2,
      "network_id": "net_xxx",
      "completed_at": "2026-04-12 10:00:15"
    }
  ]
}

Response shape

  • session is SELECT * FROM sessions (tools.ts — grep SELECT * FROM sessions WHERE alias = ?1) — the full sessions row (same as get_all_status's session row, including the model column — see the get_all_status note); if the alias doesn't exist session is null but ok is still true
  • recent_completions is SELECT * FROM completions ... LIMIT 5 (tools.ts — grep SELECT * FROM completions WHERE session_name = ?1) — the full 9-column completion row (id / session_name / task / result / artifacts / score / duration_minutes / network_id / completed_at), ordered by completed_at DESC, max 5

get_completions ​

View source ↗ —— grep for "get_completions" in server/src/tools.ts (single registration site)

Get completion records.

Parameters:

ParameterTypeRequiredDescription
aliasstringFilter by agent
sincestringStart time (ISO 8601, default last 24h)
network_idstringFilter by network
limitnumberMax items (default 50, max 500)

Response:

json
{
  "ok": true,
  "completions": [
    {
      "id": "uuid-xxx",
      "session_name": "coder-1",
      "task": "Write sorting algorithm",
      "result": "Implemented with quicksort...",
      "artifacts": "[\"/tmp/sort.py\"]",
      "score": 8,
      "duration_minutes": 2,
      "network_id": "net_xxx",
      "completed_at": "2026-04-12 10:00:15"
    }
  ]
}

completions is SELECT * FROM completions WHERE completed_at >= <cutoff> (tools.ts — grep SELECT * FROM completions WHERE completed_at >= ?1) — the full 9-column row, ordered by completed_at DESC. artifacts is a JSON-array string (not a parsed array — report_completion JSON.stringifys it on the way in). When since is omitted the cutoff defaults to 24 hours ago.


Broadcast Tools ​

broadcast ​

View source ↗ —— grep for "broadcast" in server/src/tools.ts (single registration site)

Broadcast a message to all online agents. broadcast triggers AI processing on receivers, the same as task (agent-node/src/cli.ts thinks only on task and broadcast types; reply / message / ack are display-only). If you just want a notification without an AI reply, loop send_message instead. Full message-type table: Task lifecycle — Message types.

Parameters (verify tools.ts — grep "Send a message to multiple sessions." (broadcast's registration description, unique; the parameter schema follows it)):

ParameterTypeRequiredDescription
messagestring✓Broadcast content (max 10000 chars)
filter_serverstringOnly deliver to sessions whose server field matches
filter_statusstringOnly deliver to sessions in the given status (e.g. idle / working)
network_idstringNetwork ID (broadcast within this network only; can be supplied with a utok_, but ntok_ callers are pinned to their bound network)

The field is message, not content; from_session is not a parameter — the server hard-codes it to 'hub'.

Response:

json
{
  "ok": true,
  "recipients": 10,
  "message_ids": ["uuid-xxx-1", "uuid-xxx-2"]
}

message_ids.length === recipients — one inbox row per target session.


Requirements / task board ​

Tasks (requirement cards) live on the Hub; the app's 任务 page and agents read and write the same records. The MCP tools and REST go through the same handler, so permissions, validation and the "who did it" record exist once.

Permissions

  • User tokens: same as the app (viewers are read-only).
  • Node tokens (agents): read, create, patch, toggle checklist items, comment, upsert and list projects in the network the token is bound to only. Other networks' tasks are invisible (404); writes always land in the token's own network — a network_id in the body cannot move them.
  • Delete is human-only (REST DELETE /api/requirements/{id}; node tokens get 403 user_token_required); agents archive with archived: true. Creating / editing / deleting projects is human-only too.
  • Every task records created_by / updated_by: {kind: "user" | "node", id}; a node token is recorded as its bound node_id.

Fields: name, column (pool / doing / done / abandoned. abandoned = 废弃, a closed state like done: not open, never overdue, no due reminders, no completion time; it can be moved back to pool / doing; supported by hubs whose list capabilities include column_abandoned, older hubs answer 400 invalid_column. Old-client compatibility: a REST caller that does not declare it gets abandoned projected to done in responses (desktop ≤ 0.2.220 files unknown values under pool), status=done also matches abandoned cards, and echoing column: "done" back does not turn an abandoned card into a done one; declare with the header X-Anet-Accept-Columns: abandoned or the query parameter accept_columns=abandoned; MCP always gets the real value. The event log records the real value), priority (high / normal / low / lowest, shown in the app as P0 highest / P1 normal / P2 low / P3 lowest; lowest is supported by hubs whose list response capabilities include priority_lowest, older hubs answer 400 invalid_priority), due (YYYY-MM-DD all day, or an ISO instant with Z / ±HH:MM, stored as UTC seconds), start (optional start, same shapes as due, used by the Gantt view; supported by hubs whose list capabilities include start_date, invalid values are 400 invalid_start; older hubs ignore it), description (markdown, ≤ 20000 chars), checklist ([{id, text, done}], ≤ 100 items; replaced as a whole), owner (负责人, {kind:"user"} only, written as {kind:"user", id} or {kind:"user", username}; for old clients a node owner sent without agent_owner is stored as agent_owner and the response carries owner_coerced_to_agent_owner: true), agent_owner (负责 Agent, {kind:"node"} only, written as {kind:"node", id} or {kind:"node", alias}), participants (either kind, also by name; names are resolved only in the task's own network and stored as {kind, id}, and an id, when given, wins; an unknown name is 400 person_not_in_network, an alias matching several Agents is 400 person_ambiguous, both with field and a hint pointing at requirements_people), project_id, parent_id (sub-requirements: same network, no cycles, at most 5 levels; a parent returns children: {total, done}; deleting a parent keeps its children and makes them top level), external_ref (unique per network, e.g. github:owner/repo#123), external_url (http(s) link), archived, tags (string array, at most 10, each at most 20 Unicode characters; omit to keep, [] to clear; invalid values are 400 invalid_tags).

Short number seq (task ID #N): besides its primary key id (req_<uuid>) every task has a read-only seq — counted from 1 per network and shown in the app as #N. The Hub assigns it inside the create transaction (concurrent creates never share a number); it never changes afterwards, and numbers of deleted or archived tasks are never reused. Tasks created before the upgrade are numbered per network at Hub startup in created_at order (then id). Supported by hubs whose list capabilities include requirement_seq; older hubs have no such field and older apps ignore it.

Everywhere an {id} is accepted (REST GET / PATCH / DELETE / checklist toggle; the id of MCP requirements_get / requirements_update / requirements_checklist_toggle) #N works too (percent-encode it in a REST path: /api/requirements/%2342). Only the #-prefixed form is a short number; a bare number is still looked up as an id. Old req_… ids keep working. A short number is unique within one network only: when the caller's scope spans several networks and more than one of them has #N, the answer is 409 {error:"ambiguous_seq", networks, message} — retry with ?network_id= (MCP: pass network_id). The list can filter by number as well: GET /api/requirements?seq=N (seq on MCP requirements_list).

HTTP endpoints (Authorization: Bearer <token>; multi-network user tokens add ?network_id=)

MethodPathNotes
GET/api/requirementsList; filters seq (short number, positive integer), status, project_id (none = no project), owner / agent_owner (user:<id> / node:<id> / none), updated_since (ISO), external_ref, tag (only tasks carrying this label, exact and case-sensitive; invalid → 400 invalid_tag), parent_id (none = top level) / top_level=1, department_id (cards whose owner, or whose Agent's owner, is in that department or below; see Department heads); excludes archived by default, include_archived=1 includes all, archived=true returns only archived (takes precedence over include_archived); q server-side search (title, description, owner / agent owner / participant display names, project name, tags; space-separated terms must all match; names of agents the caller cannot see are not matched; the whole query is also matched as a task id: #N / N against the short number seq, a full id or an 8+ character prefix against the id, full-width # counts as #); paging with limit (default 500, max 1000) + cursor, the response carries has_more / next_cursor, newest first by created_at then id; the response carries capabilities. Every row carries last_event (capability last_event): the card's newest activity entry, comments included, {type, field, actor: {id, kind, display_name}, at, summary?}, or null when it has none; same visibility as the row, and an Agent the caller is not granted shows as actor: null; last_event=0 leaves the field out (what MCP requirements_list sends by default), any other value is 400 invalid_last_event. The incremental read changes=1 + updated_since (when rows carry last_event) also returns cards with a newer activity entry, a comment for instance: comments do not touch updated_at, and updatedAt keeps its meaning
GET/api/requirements/{id}One task; {id} may also be %23N (short number #N)
POST/api/requirementsCreate; duplicate external_ref → 409 {error:"external_ref_exists", existing_id}
POST/api/requirements/upsertCreate or patch by external_ref (omitted fields, including status, are kept); returns {requirement, created}
PATCH/api/requirements/{id}Patch (omitted fields kept); participants replaces the list, participants_add / participants_remove change it in place (as in MCP). REST does not take the status alias (still empty_patch, with a hint to use column)
PATCH/api/requirements/{id}/checklist/{itemId}{done: true|false}; only that item
POST/api/requirements/{id}/comments{text} (1–4000 characters, trimmed) adds one comment, append-only; returns 201 {event} (kind: "comment", new.text). Empty → 400 invalid_comment, too long → 400 comment_too_long; there is no route to edit or delete a comment. Comments are kept 180 days, like field changes
DELETE/api/requirements/{id}Delete (humans only); children are kept and detached
GET/api/requirements/projectsProjects (creating / editing / deleting projects is human-only)
GET/api/requirements/tagsTags that appear in the current network (deduplicated and sorted, including tags left only on archived tasks). A node token may read them. A Hub with the tag_ops capability also returns counts / colors / can_manage; a Hub with tag_tree also returns nodes (the tag tree, zero-task tags included — see Task tags)
POST/api/requirements/tags/opsRename / merge / delete a tag across the network, or set its color (humans only; scoped members get 403). See Task tags
POST/api/requirements/tags/createCreate a tag, optionally with parent_id (at most 4 levels); a duplicate returns 409 tag_exists, a missing parent 404 tag_parent_not_found (never falls back to the root). See Task tags

Sync example (GitHub issues → tasks): call requirements_upsert_by_external_ref once per issue with external_ref = github:<owner>/<repo>#<number> and external_url = the issue link; re-syncing updates the same task instead of creating a duplicate. Use updated_since on requirements_list for incremental sync.

Common Response Format ​

All tools return in MCP Content format:

json
{
  "content": [
    {
      "type": "text",
      "text": "{\"ok\": true, ...}"
    }
  ]
}

The text field is a JSON string that needs to be parsed.

Error Codes ​

ErrorMeaning
network_id_requiredThe write could not be scoped to a network: the utok_ caller has 0 or ≥2 network memberships and passed no explicit network_id. With exactly 1 membership the hub auto-resolves — no parameter needed. The message states which case applies (no memberships vs. multiple networks)
access_deniedThe explicitly supplied network_id names a network the caller is not a member of
permission_deniedA viewer role attempted a write (viewers are read-only; owner/admin/member can write)
license_expiredTrial period expired (v0.6 legacy path; not needed after Apache 2.0 OSS — when hit, follow the license_expired section in troubleshooting to clear the SQLite licenses table)
message not found or not yoursMessage doesn't exist or doesn't belong to this agent
task not foundTask doesn't exist
task is terminalTask is in a terminal state, cannot be operated on
task status is X, not retryableOnly failed/expired/cancelled tasks can be retried
node_permission_deniedA node token was refused by the node's own permissions: the node is in read-only/restricted mode, or it went beyond its owner's permissions while the Hub switch is COMMHUB_NODE_PERMISSIONS=enforce. The body carries reason, route and hint; see A node's own permissions

Next steps ​

Corresponding REST API:

  • REST API — the HTTP endpoints these MCP tools call under the hood

Agent integration:

Hands-on:

Powered by Sleep2AGI

Download the app