MCP Tools Reference
CommHub Server registers 50 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.
Complete tool index
Collaboration — detailed below · 17
| Tool | What it does |
|---|---|
report_status | Report agent status; returns inbox_count |
report_completion | Report task completion with results and artifacts |
get_inbox | Fetch pending commands for this session |
ack_inbox | Acknowledge receipt of a command |
send_task | Dispatch a task into a session's inbox by alias |
send_message | Send a message with no task lifecycle |
send_reply | Reply to a Dashboard-originated task; also the terminal leg for agent-to-agent tasks |
send_ack | Acknowledge a task without entering the inbox |
retry_task | Retry a failed, expired, or cancelled task |
cancel_task | Cancel a delivered/acked/running task |
reassign_task | Reassign a task to a different agent |
get_task | Get task detail, status, and result by task_id |
list_tasks | List tasks with filters |
get_all_status | List every session's status (Hub patrol loop) |
get_session_status | Get one session's detail by alias |
get_completions | Query recent task completions |
broadcast | Send a message to multiple sessions |
Desktop and user messages · 1
| Tool | What it does |
|---|---|
send_desktop_message | Write 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.
// 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
| Tool | What it does |
|---|---|
submit_skill | Submit an immutable SKILL.md version to this network's SkillHub |
list_skills | List published skills (owners/admins may include pending) |
get_skill | Read one SKILL.md (pending content is owner/admin only) |
review_skill | Publish or reject a pending submission (owner/admin) |
Node lifecycle — driven by the anet CLI / Dashboard · 6
| Tool | What it does |
|---|---|
create_node | Create and start a node on a host daemon |
delete_node | Stop child, revoke ntok, delete the hub row (config backed up by default) |
stop_node | Stop the agent-node child, keep the config dir |
start_node | Start a stopped child node through its host daemon |
restart_node | Restart a node without changing its config |
update_node_config | Set a node's desired config (model + flags) and ring its doorbell |
Host-daemon protocol — internal, called by nodes and daemons · 12
| Tool | What it does |
|---|---|
get_config_update | Node pulls its pending config update |
ack_config_update | Node reports the config-update outcome |
read_node_rules_file | Ask 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_file | Ask a node to overwrite its rules file with content; no path argument, 256 KB cap (app#225) |
get_rules_file_result | Poll a rules-file request: pending / in_progress / done / failed / timeout (auto-timeout after 60 s of silence) |
get_rules_file_request | Node pulls its pending rules-file request (network token + alias) |
ack_rules_file_request | Node reports the outcome of a rules-file request (content included for reads) |
list_my_pending_create_requests | Daemon compensates after SSE reconnect by listing pending create-node requests |
list_my_pending_lifecycle_requests | Daemon compensates after SSE reconnect by listing pending stop/delete/start requests |
get_create_request | Daemon pulls a pending create-node request |
ack_create_request | Daemon reports the post-fork start result |
get_stop_request | Daemon pulls a pending stop/delete request |
ack_stop_request | Daemon reports stop/delete completion or failure |
get_start_request | Daemon pulls a pending start request |
ack_start_request | Daemon reports start completion or failure |
list_host_supervisors | List host_supervisor daemons in this network, with online status |
list_my_children | Daemon pulls the list of children it spawned |
Providers & secret vault — owner/admin · 7
| Tool | What it does |
|---|---|
list_providers | List providers and models (never returns secret values) |
upsert_provider | Create or update a provider |
list_network_secrets | List vault key NAMES only, never values (RFC-028) |
upsert_network_secret | Write or replace a vault secret (AES-GCM encrypted) |
probe_provider_model | Dispatch a connectivity probe to a daemon |
get_probe_request | Daemon pulls a pending probe request |
get_probe_results | Query probe history, optionally filtered by provider/model/daemon |
Internal signals — called by agent-node · 3
| Tool | What it does |
|---|---|
send_peer_reply | Atomically finalize one node-owned task and enqueue a no-response result |
mark_tasks_consumed | Internal agent-node signal: which tasks this turn actually consumed |
mark_tasks_runtime_submitted | Internal agent-node signal: which task bodies reached the runtime |
Agent-Side Tools
report_status
View source ↗ —— grep for
"report_status"inserver/src/tools.ts(single registration site)
Report agent status. Also serves as a heartbeat (recommended every 3 minutes).
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
resume_id | string | ✓ | Session unique identifier (max 200 chars) |
alias | string | ✓ | Display name (max 200 chars) |
status | enum | ✓ | working / idle / blocked / error / waiting_input / offline |
task | string | Current task description (max 10000 chars) | |
output | string | Recent output (max 50000 chars, storage truncated to 4000) | |
score | number | Self-rating 0-10 (doc previously said 1-10; schema is actually .min(0).max(10)) | |
progress | number | Progress 0-100 | |
server | string | Server identifier | |
hostname | string | Hostname | |
agent | string | Agent 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_dir | string | Working directory | |
version | string | Agent version | |
tmux_name | string | tmux session name | |
node_id | string | Stable 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_id | string | Runtime session/thread ID | |
config_path | string | Config file path | |
channels | string | Channel list (JSON array string) | |
model | string | AI model name (written to nodes.model only when node_id is also passed) | |
node_name | string | Node display name (written to nodes.node_name only when node_id is also passed) | |
network_id | string | Network ID |
Response:
{
"ok": true,
"resume_id": "sdk-n_a1b2c3d4",
"alias": "coder-1",
"inbox_count": 3
}Example:
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— grepDELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2; cleans up orphans across agent restarts) - When
status="working"with atask, transitions the correspondingtasksrow fromdelivered/ackedtorunning(tools.ts— grepUPDATE tasks SET status = 'running'; see Task lifecycle) - When
node_idis passed, upserts thenodestable (includingmodel/node_name/runtime; see thenode_idrow above)
report_completion
View source ↗ —— grep for
"report_completion"inserver/src/tools.ts(single registration site)
Report task completion. Automatically updates session status to idle.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | ✓ | Session alias |
task | string | ✓ | Completed task description |
result | string | ✓ | Result summary (max 50000 chars) |
artifacts | string[] | Output file paths or URLs (max 50) | |
score | number | Self-rating 0-10 | |
duration_minutes | number | Duration (minutes) | |
network_id | string | Network ID |
Response:
{
"ok": true,
"completion_id": "uuid-xxx"
}Example:
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— grepUPDATE sessions SET status = 'idle'UPDATE sessions SET status='idle', task=NULL, progress=0(matched by alias) - Task state transition:
tools.ts— grepUPDATE tasks SET status = 'replied'(2 sites) moves thetasksrow fromdelivered/acked/runningtoreplied. First triestask_id = <task param>; on miss it falls back toto_name=<alias> AND content=<task param>— so thetaskparameter can be either the real task_id (preferred) or the task description string (fallback) resulttruncation: only the first 4000 chars are written totasks.result(tools.ts— grepresult.slice(0, 4000)(2 sites)); the fullresultstill lands incompletions.result- chained_reply auto-propagation: if the task has a
parent_task_id, the parent's originator gets achained_replySSE event (tools.ts— greptype: "chained_reply"(2 sites); used so subtask replies bubble up to the parent — seetask-lifecycledual-write) task_eventslog: arepliedevent is logged (tools.ts— greplogTaskEvent(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"inserver/src/tools.ts(single registration site)
Fetch pending messages.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | ✓ | Session alias |
limit | number | Max items (default 10, max 100) |
Response:
{
"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"inserver/src/tools.ts(single registration site)
Acknowledge message receipt. After ACK, the message won't be returned by get_inbox.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | ✓ | Session alias |
message_id | string | ✓ | 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. |
response | string | Currently 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_id | string | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) |
Response:
{ "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"inserver/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:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | ✓ | Target agent alias |
task | string | ✓ | Task content (max 10000 chars) |
priority | enum | high / normal (default) / low | |
context | string | Context information (max 10000 chars) | |
from_session | string | Sender identifier (default "hub") | |
ttl_seconds | number | Expiration time (default 3600, max 86400) | |
network_id | string | Network ID | |
parent_task_id | string | Parent task ID; child replies are auto-chained back to the parent task originator | |
meta | object | Structured task metadata; mainly for attachments { attachments: [{ type, path, url, mime, name, size }] }, written to the task's meta_json column |
Response:
{
"ok": true,
"message_id": "uuid-xxx",
"session_status": "idle"
}Example:
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"inserver/src/tools.ts(single registration site)
Send a message (does not trigger AI processing, display only).
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | ✓ | Target agent alias |
message | string | ✓ | Message content (max 10000 chars) |
from_session | string | Sender identifier (default "hub") | |
network_id | string | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) |
Response:
{
"ok": true,
"message_id": "uuid-xxx",
"session_status": "idle"
}send_reply
View source ↗ —— grep for
"send_reply"inserver/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:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | ✓ | Target agent alias |
text | string | ✓ | Reply content (max 10000 chars) |
in_reply_to | string | Original task/message ID | |
status | enum | replied (default) / failed / cancelled | |
from_session | string | Sender identifier (default "hub") | |
network_id | string | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) | |
attachments | array | Attachment 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:
{
"ok": true,
"message_id": "uuid-xxx",
"session_status": "idle"
}send_ack
View source ↗ —— grep for
"send_ack"inserver/src/tools.ts(single registration site)
Acknowledge task receipt (lightweight, does not enter inbox).
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
task_id | string | ✓ | Task ID |
from_session | string | Sender identifier (default "hub") | |
network_id | string | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) |
Response:
{
"ok": true,
"task_id": "uuid-xxx",
"updated": 1
}retry_task
View source ↗ —— grep for
"retry_task"inserver/src/tools.ts(single registration site)
Retry a failed/cancelled/expired task.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
task_id | string | ✓ | Task ID |
from_session | string | Sender identifier | |
network_id | string | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) |
Response:
{
"ok": true,
"task_id": "uuid-xxx",
"retried_to": "coder-1"
}Limitation
- Can only retry tasks with status
failed/expired/cancelled(verifytools.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 hourTTL (hardcoded attools.ts— grep+1 hour) — the original task'sttl_secondsis not preserved task_idis reused; a new inbox row (new UUID) is inserted and anew_taskSSE event is pushed to the target alias
cancel_task
View source ↗ —— grep for
"cancel_task"inserver/src/tools.ts(single registration site)
Cancel a pending task.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
task_id | string | ✓ | Task ID |
reason | string | Cancellation reason (max 1000 chars) | |
from_session | string | Sender identifier | |
network_id | string | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) |
Response:
{
"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"inserver/src/tools.ts(single registration site)
Reassign a task to another agent.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
task_id | string | ✓ | Task ID |
new_alias | string | ✓ | New target agent alias |
from_session | string | Sender identifier | |
network_id | string | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) |
Response:
{
"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) rejectsreplied/failed/cancelled/expiredwith{ok: false, error: "task is terminal (<status>)"}) - The old alias's inbox row is
acked=1(tools.ts— grepUPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1insidereassign_task(2 sites; the other iscancel_task)) so the original agent will not pick it up - Task status resets to
delivered,started_atclears,delivered_atrefreshes to now (tools.ts— grepUPDATE tasks SET to_name = ?1) — arunningtask is interrupted - TTL (
expires_at) is not modified (unlikeretry_taskwhich forces+1 hour); the task keeps its remaining time - The new alias receives a fresh-UUID inbox row + a
new_taskSSE event
Query Tools
get_task
View source ↗ —— grep for
"get_task"inserver/src/tools.ts(single registration site)
Query task details.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
task_id | string | ✓ | Task ID |
Response:
{
"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"inserver/src/tools.ts(single registration site)
Query task list with multi-dimensional filtering.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | Filter by recipient | |
status | string | Filter by status | |
from_name | string | Filter by sender | |
from_node_id | string | Filter by sender node_id (immutable ID, more precise than from_name) | |
network_id | string | Filter by network | |
limit | number | Max items (default 20, max 100) |
Response:
{
"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"inserver/src/tools.ts(single registration site)
Get all session statuses. Sessions without a heartbeat for over 10 minutes are auto-marked offline.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
filter_status | string | Filter by status (idle / working / offline) | |
filter_server | string | Filter by server | |
network_id | string | Filter by network |
Response:
{
"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"
}
],
"summary": [
{ "status": "idle", "count": 5 },
{ "status": "working", "count": 2 },
{ "status": "offline", "count": 1 }
]
}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:
| Reality | What the panel shows | |
|---|---|---|
| ① | Genuinely idle | idle |
| ② | Got a task but never consumed it (wedged / loop not waking) | idle |
| ③ | Running a long task and not reporting mid-way | idle |
🔴 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. send_task stamps the first 200 chars of the task onto the target session inside its own transaction (tools.ts 搜 UPDATE sessions SET task = ?1 inside send_task); report_status can write the same column too (COALESCE — omitting it keeps the old value).
⇒ 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"inserver/src/tools.ts(single registration site)
Get detailed status of a single session, including pending inbox count and recent completions.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | ✓ | Session alias |
Response:
{
"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
sessionisSELECT * FROM sessions(tools.ts— grepSELECT * FROM sessions WHERE alias = ?1) — the full sessions row (same asget_all_status's session row, including themodelcolumn — see the get_all_status note); if the alias doesn't existsessionisnullbutokis stilltruerecent_completionsisSELECT * FROM completions ... LIMIT 5(tools.ts— grepSELECT * 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 bycompleted_atDESC, max 5
get_completions
View source ↗ —— grep for
"get_completions"inserver/src/tools.ts(single registration site)
Get completion records.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
alias | string | Filter by agent | |
since | string | Start time (ISO 8601, default last 24h) | |
network_id | string | Filter by network | |
limit | number | Max items (default 50, max 500) |
Response:
{
"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"inserver/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)):
| Parameter | Type | Required | Description |
|---|---|---|---|
message | string | ✓ | Broadcast content (max 10000 chars) |
filter_server | string | Only deliver to sessions whose server field matches | |
filter_status | string | Only deliver to sessions in the given status (e.g. idle / working) | |
network_id | string | Network 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, notcontent;from_sessionis not a parameter — the server hard-codes it to'hub'.
Response:
{
"ok": true,
"recipients": 10,
"message_ids": ["uuid-xxx-1", "uuid-xxx-2"]
}message_ids.length === recipients — one inbox row per target session.
Common Response Format
All tools return in MCP Content format:
{
"content": [
{
"type": "text",
"text": "{\"ok\": true, ...}"
}
]
}The text field is a JSON string that needs to be parsed.
Error Codes
| Error | Meaning |
|---|---|
network_id_required | The 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_denied | The explicitly supplied network_id names a network the caller is not a member of |
permission_denied | A viewer role attempted a write (viewers are read-only; owner/admin/member can write) |
license_expired | Trial 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 yours | Message doesn't exist or doesn't belong to this agent |
task not found | Task doesn't exist |
task is terminal | Task is in a terminal state, cannot be operated on |
task status is X, not retryable | Only failed/expired/cancelled tasks can be retried |
Next steps
Corresponding REST API:
- REST API — the HTTP endpoints these MCP tools call under the hood
Agent integration:
- Agent Node — how an agent connects to the MCP server
- Runtimes — all runtimes talk to the Hub via MCP
- Channel plugins — how to write a custom MCP channel
Hands-on: