Skip to content

REST API Reference

CommHub Server provides a REST API for Dashboard, CLI, and third-party system integration.

Basics

ItemValue
Base URLhttp://YOUR_IP:9200
AuthAuthorization: Bearer <token> (recommended); ?token=<token> URL query kept for SSE / browser EventSource (access-log leak risk — see Security)
Content Typeapplication/json
EncodingUTF-8
Endpoint count30+ across 12 groups: Public 1 · Auth 5 · Network 5 · Data Query 10 · Task Dispatch 2 · MCP 1 · SSE 1 · Token Management 4 · Network Members 6 · Node Rename 3 · Tmux Debug 3 (opt-in) · Legacy 2
Full endpoint sourceserver/src/index.ts:390-1160

Public Endpoints

GET /health

View source ↗

Health check, no authentication required.

bash
curl http://localhost:9200/health
json
{
  "ok": true,
  "version": "0.8.4",
  "api_version": "v3",
  "transport": "streamable-http",
  "sessions_count": 0,
  "sse_connections": 0,
  "sse_sessions": {},
  "auth": "user-token",
  "security": "secured",
  "tmux": "disabled",
  "v3_auth": true,
  "multi_network": true,
  "license": "trial",
  "uptime": 3600
}

The license field is a v0.6 legacy

license: "trial" is a leftover from the v0.6 era 14-day trial mechanism. After the Apache 2.0 OSS transition it is no longer a commercial feature gate (self-hosted has no notion of "expired"). The send_task path still runs the trial check only for backward compatibility (verify server/src/tools.ts:521 where license_expired is still emitted); if you hit it, see troubleshooting. The v0.9.x and v0.10.x scopes did not touch this (Recovery & Observability took priority); full removal is queued for v0.11+ / unscheduled.


Auth Endpoints

POST /api/auth/register

View source ↗

Register a new user. The first user registered automatically becomes admin.

bash
# v0.8+: register is a public endpoint, no master token needed
curl -X POST http://localhost:9200/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice",
    "password": "mypassword2026",
    "email": "alice@example.com",
    "display_name": "Alice"
  }'

Request body:

FieldTypeRequiredDescription
usernamestringUsername (2-50 chars, letters/numbers/underscores/Chinese)
passwordstringPassword (>= 8 chars + not in weak-password dictionary; first bootstrap admin exempt, >= 4 OK)
emailstringEmail
display_namestringDisplay name

Response:

json
{
  "ok": true,
  "user": {
    "user_id": "u_abc123",
    "username": "alice",
    "display_name": "Alice",
    "email": "alice@example.com",
    "role": "admin"
  },
  "token": "utok_xxxxxxxxxxxxxxxx",
  "network_token": "ntok_xxxxxxxxxxxxxxxx",
  "network_id": "net_xxxxxxxx"
}

The user object's 5 fields match server/src/auth.ts:7-13 AuthUser interface (display_name / email may be null); token is the utok_ for CLI/Dashboard; network_token is the ntok_ for agents in the auto-created default network.

Common 4xx errors (verify auth.ts:30-48 register()):

Statuserror valueTrigger
400username must be at least 2 charactersUsername < 2 chars
400username too long (max 50)Username > 50 chars
400username contains invalid charactersContains chars outside a-zA-Z0-9_\- or Chinese
400username already takenDuplicate username
400password must be at least 8 charactersNon-bootstrap user password < 8
400password must be at least 4 charactersFirst user (bootstrap admin) password < 4
400password is too commonHits the weak-password dictionary (password-dict.ts; bootstrap admin is exempt)
429too many requests, try again laterExceeded 30/min IP rate limit (index.ts:430; localhost is exempt — see Security — IP rate limits)

Rate limit: 30 requests/minute per IP.


POST /api/auth/login

View source ↗

User login.

bash
# v0.8+: login is a public endpoint, no master token needed
curl -X POST http://localhost:9200/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice",
    "password": "mypassword2026"
  }'

Response:

json
{
  "ok": true,
  "user": {
    "user_id": "u_abc123",
    "username": "alice",
    "display_name": "Alice",
    "email": "alice@example.com",
    "role": "admin"
  },
  "token": "utok_xxxxxxxxxxxxxxxx",
  "network_id": "net_xxxxxxxx"
}

The user object's 5 fields match the register response (note email may be null); network_id is the default network the user owns (auth.ts:113-115 does ORDER BY role = 'owner' DESC LIMIT 1). Each login issues a brand-new utok_ (existing tokens are not rotated, so multiple devices can log in independently — see auth.ts:102-110).

Common 4xx errors (verify auth.ts:94-100 login()):

Statuserror valueTrigger
401invalid username or passwordUsername doesn't exist or password hash mismatch (auth.ts:99-100 intentionally collapses both into the same message to avoid username enumeration); the server also writes a login_failed audit row
429too many attempts, try again laterExceeded 10/min IP rate limit (index.ts:445; on hit the server writes a login_rate_limited audit row with the client IP)

Rate limit: 10 requests/minute per IP.


GET /api/auth/me

View source ↗

Get current user info.

bash
curl http://localhost:9200/api/auth/me \
  -H "Authorization: Bearer utok_xxx"

Response:

json
{
  "ok": true,
  "user": {
    "user_id": "u_abc123",
    "username": "alice",
    "display_name": "Alice",
    "email": "alice@example.com",
    "role": "admin"
  },
  "networks": [
    { "network_id": "net_xxx", "network_name": "default", "member_role": "owner" },
    { "network_id": "net_yyy", "network_name": "team-prod", "member_role": "member" }
  ],
  "current_network": "net_xxx"
}

networks lists every network the current user belongs to along with their member_role in that network (field name matches GET /api/networks); anet whoami uses this list (combined with the network_id in config.json) to render the "← current" marker. The current_network field is the network the server resolves from the caller's token binding (for utok_ it's the network_id in ~/.anet/config.json; for ntok_ it's the network the token was issued for, which the hub enforces).


PUT /api/auth/me

View source ↗

Update personal info.

bash
curl -X PUT http://localhost:9200/api/auth/me \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"display_name": "Alice Smith", "email": "alice@example.com"}'

Request body:

FieldTypeRequiredDescription
display_namestringDisplay name
emailstringEmail

Only the provided fields are updated (server/src/index.ts:478-479 uses conditional SQL with if (body.X)); username / role / password are not mutable through this endpoint.

Response (success):

json
{
  "ok": true,
  "user": {
    "user_id": "u_abc123",
    "username": "alice",
    "display_name": "Alice Smith",
    "email": "alice@example.com",
    "role": "admin"
  }
}

Common 4xx errors (verify server/src/index.ts:469-491):

Statuserror valueTrigger
400<JSON parse error>Request body is not valid JSON (the catch block echoes the exception message)
401token required / invalid tokenMissing / invalid utok_

Missing fields are not an error

If you supply only display_name and omit email (or omit both), the server does not return 400 — index.ts:478-479 builds the SQL conditionally with if (body.X). When everything is omitted it just re-SELECTs and returns the user as-is. No field-length validation here (the v0.9.x and v0.10.x scopes did not touch this; schema-level checks are queued for v0.11+ / unscheduled).


POST /api/auth/password

View source ↗

Change password.

bash
curl -X POST http://localhost:9200/api/auth/password \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "old_password": "oldpass",
    "new_password": "newpass123"
  }'

Response:

json
{
  "ok": true,
  "revoked": 2,
  "token": "utok_xxxxxxxxxxxxxxxx",
  "token_id": "tok_new_session_id"
}

revoked is the number of utok_/atok_ tokens on other devices that were just revoked (it does not include the caller's own token — that one is revoked separately at index.ts L490).

Key side effects (verify auth.ts:267-282 changePassword + revokeOtherUserTokens + index.ts:493-503):

  1. The caller's utok_ (resolved.tokenId) is revoked immediately (index.ts:503 revokeToken(...) explicit delete)
  2. All other devices' utok_ / atok_ are also revoked in one shot (auth.ts:269-270 DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId) — the count is returned in the revoked field
  3. ntok_ tokens are unaffected (revokeOtherUserTokens filters on network_id IS NULL, so agent nodes using ntok_ keep running through a password change; matches the account-system / Change Password narrative)
  4. A fresh utok_ (issued.token) is minted for the caller and returned in this response — the caller must overwrite local storage with the new token right away
  5. Writes audit log: action='password_changed'

Matches the anet passwd CLI behavior (the CLI writes the new token back into ~/.anet/config.json automatically). Other devices' next request returns 401 invalid token and they must anet login again.

Common 4xx errors (verify auth.ts:274-282 changePassword()):

Statuserror valueTrigger
400new password must be at least 8 charactersNew password < 8 chars
400new password is too commonHits the weak-password dictionary (password-dict.ts)
400user not founduser_id doesn't exist (rare; token expired or user deleted by admin)
400incorrect current passwordold_password hash mismatch
401token required / invalid tokenMissing / invalid utok_

Same strength rules as register

Password-strength validation reuses validatePasswordStrength() from register (see POST /api/auth/register 4xx). The bootstrap-admin exemption applies only to the first signup — no exemption for password change.


Network Endpoints

GET /api/networks

View source ↗

Get all networks the user belongs to.

bash
curl http://localhost:9200/api/networks \
  -H "Authorization: Bearer utok_xxx"

Response:

json
{
  "ok": true,
  "networks": [
    {
      "network_id": "net_abc123",
      "network_name": "default",
      "owner_id": "u_abc123",
      "description": "Auto-created default network",
      "settings": null,
      "visibility": "private",
      "max_members": 50,
      "created_at": "2026-04-12 10:00:00",
      "updated_at": "2026-04-12 10:00:00",
      "member_role": "owner"
    }
  ]
}

Each row in networks has 10 fields: the 9 networks table columns (server/src/db.ts:168-177, including the v3 migrations visibility + max_members) plus the joined member_role (auth.ts:382-388 joins network_members). Sort order: owner first, then by created_at (ORDER BY nm.role = 'owner' DESC, n.created_at). settings / description may be null. An ntok_ caller sees only the bound network (not the full list); a utok_ caller sees every network they belong to.


POST /api/networks

View source ↗

Create a new network.

bash
curl -X POST http://localhost:9200/api/networks \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "prod",
    "description": "Production environment network"
  }'

Response (success):

json
{
  "ok": true,
  "network_id": "net_xyz789",
  "network_name": "prod"
}

Common 4xx errors (verify auth.ts:182-206 createNetwork()):

Statuserror valueTrigger
400network name already existsSame owner already has a network with this name (UNIQUE(owner_id, network_name) constraint)
400quota exceeded: max N networks for free planPlan quota gate (auth.ts:184-189; admins are exempt; free plan default max_networks_owned = 2). Note this gate is enforced, unlike the max_members column, which is dormant
401token required / invalid tokenMissing / invalid utok_

GET /api/networks/:id

View source ↗

Get network details (membership check: caller must be a member of the network or a system admin, otherwise 403).

bash
curl http://localhost:9200/api/networks/net_abc123 \
  -H "Authorization: Bearer utok_xxx"

Response:

json
{
  "ok": true,
  "network": {
    "network_id": "net_abc123",
    "network_name": "prod",
    "owner_id": "u_abc123",
    "description": "Production network",
    "settings": null,
    "visibility": "private",
    "max_members": 50,
    "created_at": "2026-04-12 10:00:00",
    "updated_at": "2026-04-12 10:00:00"
  },
  "stats": {
    "nodes": 5,
    "sessions": 4,
    "tasks": [
      { "status": "replied", "count": 42 },
      { "status": "running", "count": 3 }
    ]
  }
}

The network object has 9 fields = SELECT * FROM networks WHERE network_id = ?1 (server/src/index.ts:734), including the v3 migrations visibility + max_members. The settings column is reserved for future per-network JSON config and is currently always null. stats.tasks is aggregated by status (same shape as the nested tasks.by_status in GET /api/stats).


PUT /api/networks/:id

View source ↗

Rename a network (owner only).

bash
curl -X PUT http://localhost:9200/api/networks/net_abc123 \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"name": "development"}'

Request body:

FieldTypeRequiredDescription
namestringNew network name (note the field is name, not network_name; missing returns name required 400)

Response (success):

json
{ "ok": true }

Common 4xx errors:

Statuserror valueTrigger
400name requiredBody missing name (note: not network_name)
400network not foundnetwork_id does not exist
400not your networkCaller is not the owner
400name already takenCaller already owns another network with this name

Writes audit log action='network_renamed'; the detail column records the new name.


DELETE /api/networks/:id

View source ↗

Delete a network (owner only, must have no active sessions).

bash
curl -X DELETE http://localhost:9200/api/networks/net_abc123 \
  -H "Authorization: Bearer utok_xxx"

Response (success):

json
{ "ok": true }

Common 4xx errors:

Statuserror valueTrigger
400network not foundnetwork_id does not exist
400not your networkCaller is not the owner
400network has N active session(s) — stop them firstSome agent sessions still reference this network (run anet node stop <name> on each before deleting)

Writes audit log action='network_deleted'.


Data Query Endpoints

GET /api/status

View source ↗

Get all session statuses.

bash
curl "http://localhost:9200/api/status?network_id=net_xxx" \
  -H "Authorization: Bearer ntok_xxx"

Query parameters:

ParameterDescription
network_idFilter by network (when an ntok_ is bound, this parameter is overridden by the token's network)
statusFilter by status (idle / working / offline)

Response:

json
{
  "ok": true,
  "sessions": [
    {
      "resume_id": "sdk-n_xxx",
      "alias": "coder-1",
      "status": "idle",
      "agent": "agent-node:codex-sdk",
      "model": "your-model-id",
      "task": null,
      "progress": null,
      "last_seen_at": "2026-04-12 10:00:00"
    }
  ],
  "summary": {
    "idle": 7,
    "working": 1,
    "offline": 2,
    "total": 10
  }
}

The summary field is a count aggregated by status (server/src/index.ts:780-787): the working bucket collapses working / blocked / error / waiting_input / running / busy; offline is sessions whose updated_at is older than 10 minutes (the server recomputes this on every GET and writes back to the DB); everything else counts as idle.


GET /api/tasks

View source ↗

Get task list.

bash
curl "http://localhost:9200/api/tasks?status=running&limit=10" \
  -H "Authorization: Bearer ntok_xxx"

Query parameters:

ParameterDescription
network_idFilter by network (when an ntok_ is bound, this parameter is overridden by the token's network)
statusFilter by status; any Task lifecycle state machine state is accepted
to_nameFilter by recipient
from_nameFilter by sender
limitMax items (default 50)

Response:

json
{
  "ok": true,
  "tasks": [
    {
      "task_id": "t_a1b2c3d4",
      "from_node_id": null,
      "from_name": "commander",
      "to_node_id": "node_xxx",
      "to_name": "coder-1",
      "priority": "normal",
      "status": "replied",
      "content": "Write a Python quicksort",
      "result": "Done — quicksort implementation attached",
      "in_reply_to": null,
      "requires_response": "reply",
      "scope": "single",
      "created_at": "2026-04-12 10:00:00",
      "delivered_at": "2026-04-12 10:00:01",
      "started_at": "2026-04-12 10:00:02",
      "completed_at": "2026-04-12 10:00:15",
      "expires_at": "2026-04-12 11:00:00"
    }
  ],
  "count": 1,
  "stats": [
    { "status": "replied", "count": 85 },
    { "status": "running", "count": 5 }
  ]
}

Field mapping to the tasks table schema (server/src/db.ts:87-105) via SELECT *: the primary key is task_id (not message_id); the completion timestamp is completed_at (not replied_at); the TTL field is expires_at (an absolute timestamp), not ttl_secondsttl_seconds is input-only on send_task and converted to expires_at when the row is written. The anet tasks CLI uses from_name / to_name / status / created_at / content to render the table (cli.ts L2810-2817).


GET /api/nodes

View source ↗

Get node list (persistent node info, distinct from session's transient state).

bash
curl http://localhost:9200/api/nodes \
  -H "Authorization: Bearer ntok_xxx"

Query parameters:

ParameterDescription
node_idFilter by node ID
aliasFilter by alias
network_idFilter by network (when an ntok_ is bound, this parameter is overridden)

Response:

json
{
  "ok": true,
  "nodes": [
    {
      "node_id": "node_abc123",
      "node_name": "coder-1",
      "alias": "coder-1",
      "runtime": "claude-agent-sdk",
      "model": "your-model-id",
      "config_path": ".anet/nodes/coder-1/config.json",
      "channels": null,
      "server": "http://localhost:9200",
      "hostname": "dev-machine",
      "network_id": "net_xxxxx",
      "created_at": "2026-04-12 10:00:00",
      "updated_at": "2026-04-12 10:00:00"
    }
  ],
  "count": 1
}

nodes vs sessions

The nodes table is persistent node identity (written at creation, deleted only when the agent is deleted). The sessions table is runtime heartbeat state (written at agent startup; marked offline after 10 minutes of silence). Use GET /api/status to check whether an agent is online; use this endpoint for agent config metadata.


DELETE /api/nodes/:ref

View source ↗

Delete a node from the hub server side — removes the persistent identity row in nodes and the heartbeat row in sessions (same transaction), and pushes a node_deleted SSE event to the alias channel and the network channel so dashboards refresh in real time. Shipped via PR #86 "node delete cascade and node_deleted SSE".

bash
# :ref accepts node_id / node_name / alias (URL-encoded)
curl -X DELETE "http://localhost:9200/api/nodes/n_abc12345" \
  -H "Authorization: Bearer ntok_xxx"

# Non-ASCII aliases need URL-encoding
curl -X DELETE "http://localhost:9200/api/nodes/%E4%BB%A3%E7%A0%811%E5%8F%B7" \
  -H "Authorization: Bearer ntok_xxx"

Path parameter: at server/src/index.ts:1170-1174, the server resolves :ref via node_id = ? OR node_name = ? OR alias = ? (filtered to the network scope, then ordered by updated_at DESC LIMIT 1).

Response (success, 200):

json
{
  "ok": true,
  "deleted": true,
  "node_id": "n_abc12345",
  "node_name": "coder-1",
  "alias": "coder-1",
  "network_id": "net_xxxxx"
}

SSE side effect: after the delete, a node_deleted event is pushed to two SSE channels (server/src/index.ts:1192-1199):

  • The alias's own SSE channel (if any subscribers remain)
  • The user-level SSE channel for the network_id (so every network member sees the deletion immediately)
json
// node_deleted SSE event payload
{ "type": "node_deleted", "node_id": "n_abc12345", "node_name": "coder-1", "alias": "coder-1", "network_id": "net_xxxxx" }

Error responses:

Statuserror valueTrigger
404node not found:ref does not match any nodes row in the current network scope
403permission_deniedCaller is viewer in that network, or the ntok_ is pinned to a different network

Network scope: same as GET /api/nodes — an ntok_ is locked to its token's network; a utok_ can see nodes in every network the user has access to.

Not the same as anet node delete

This REST endpoint only removes the hub-side nodes / sessions rows; it does not delete the local .anet/nodes/<alias>/ config directory and does not auto-revoke the ntok_. Use this endpoint to clear a node identity on the hub. For one-shot client-side cleanup (local dir + tmux + optional ntok_ revoke), use anet node delete <alias> (see CLI — Agent Node Management).


GET /api/servers

View source ↗

Aggregate agents by physical server (hostname + ip) and return live host telemetry — used by the dashboard's "Servers" sidebar. Refs issue #119.

bash
curl http://localhost:9200/api/servers \
  -H "Authorization: Bearer ntok_xxx"

Side effect before returning: same as /api/status — first mark any session idle for over 10 minutes as offline (UPDATE sessions SET status='offline' WHERE updated_at < cutoff), then aggregate. So agent_count reflects every session on that host (including offline); filter by last_seen on the client if you want only currently-online ones.

Response: note that this returns a bare JSON array, not the { ok: true, ... } wrapper used elsewhere in this file (historical choice).

json
[
  {
    "hostname": "dev-machine",
    "ip": "192.168.1.42",
    "agent_count": 7,
    "cpu_load_1min": 0.42,
    "cpu_cores": 8,
    "mem_avail_gb": 12.3,
    "mem_used_gb": 19.7,
    "last_seen": "2026-05-15 11:23:45"
  }
]
FieldSourceNotes
hostnameagent-node os.hostname()Old agents without telemetry render as "unknown"
ipagent-node's first non-internal IPv4Without telemetry: "unknown"
agent_countServer-side +1 per sessionTotal session count on this host (includes offline)
cpu_load_1minLinux /proc/loadavg; macOS/Win os.loadavg() (Windows always [0,0,0] is actively coerced to null)Picks the most recent row for the same hostname+ip
cpu_coresos.cpus().lengthSame
mem_avail_gbLinux /proc/meminfo MemAvailable; macOS/Win os.freemem()GB, 0.1 precision
mem_used_gbmem_total - mem_availGB, 0.1 precision
last_seenCOALESCE(last_seen_at, updated_at)Latest heartbeat for any session on this host

Network scope: same addNetworkScope rule as /api/status — an ntok_ is pinned to its token's network; a utok_ sees every network the user has access to.

Data source

Host telemetry is reported by agent-node on every report_status call (issue #119 step 1, agent-node v2.3.8+). For older agents that don't ship the telemetry fields, SQL returns NULLhostname / ip render as "unknown" and the other fields stay null. The server's schema silently drops unknown keys, so agent and server can be upgraded independently.


GET /api/server/:host/health

source ↗ · v0.10.0 / commhub-server@0.8.2

Returns the current health snapshot of a single physical server plus 24h-bucketed telemetry history. Refs issue #99 (per-server daemon Phase 1 scaffold).

Requires agent-network@2.2.1+

To reach this endpoint via the default anet hub start path, agent-network must be ≥ 2.2.1 (the v0.10.1 hotfix that bumped PINNED_SERVER_VERSION from 0.8.0 to 0.8.2). Older versions (including 2.2.0) still launch commhub-server@0.8.0, where this endpoint does not exist → 404. Workaround: launch the new server manually with bunx --bun @sleep2agi/commhub-server@latest --host 127.0.0.1.

bash
curl http://localhost:9200/api/server/dev-machine/health \
  -H "Authorization: Bearer ntok_xxx"

# host with special characters (an IP like `192.168.1.42` needs no encoding;
# a hostname containing space or `/` must be url-encoded)
curl "http://localhost:9200/api/server/$(python3 -c 'import urllib.parse; print(urllib.parse.quote("my host"))')/health" \
  -H "Authorization: Bearer ntok_xxx"

Path params:

ParamDescription
:hostMatches hostname OR ip (URL-encoded)

Side effect before responding: same as /api/servers — marks sessions with no heartbeat in the last 10 min as offline first, then queries.

Response:

json
{
  "ok": true,
  "host": "dev-machine",
  "hostname": "dev-machine",
  "ip": "192.168.1.42",
  "agent_count": 7,
  "alert_level": "ok",
  "alerts": [],
  "latest": {
    "cpu_load_1min": 0.42,
    "cpu_cores": 8,
    "cpu_pct": 5.3,
    "mem_total_gb": 32.0,
    "mem_used_gb": 19.7,
    "mem_avail_gb": 12.3,
    "disk_total_gb": 500.0,
    "disk_used_gb": 213.5,
    "disk_avail_gb": 286.5,
    "last_seen": "2026-05-16 18:23:45"
  },
  "history": {
    "5m":  [{ "ts": "...", "cpu_pct": 5.1, "mem_used_gb": 19.5, ... }, ...],
    "1h":  [{ "ts": "...", "cpu_pct": 4.8, "mem_used_gb": 18.9, ... }, ...],
    "24h": [{ "ts": "...", "cpu_pct": 4.2, "mem_used_gb": 17.6, ... }, ...]
  }
}
FieldDescription
hostThe host value from the request path
agent_countActive session count on this host (window over the latest row's COUNT(*) OVER ())
alert_levelok / warn / critical (computed by serverAlertLevel(latest); from v0.10.2 onwards, disk_avail_gb < 1 triggers critical and < 5 triggers warn — verify server/src/index.ts:253-258)
alertsActive alert list, non-empty when alert_level != ok
latestMost recent heartbeat instant telemetry (CPU / mem / disk + last_seen)
latest.disk_total_gb / disk_used_gb / disk_avail_gbAvailable from v0.10.2 (agent-node 2.4.1+, host-telemetry.ts readDiskStats()) — sampled via execFileSync('df', ['-k', '/']); the POSIX -k flag shares one parse path across Linux + macOS; on Windows or parse failure, all three fields gracefully fall back to null (the dashboard renders rather than a misleading 0). Older agents (< 2.4.1) emit null for all three.
history.5mLast 5 min, 1 min bucket (from the agent_telemetry history table)
history.1hLast 1 h, 5 min bucket
history.24hLast 24 h, 1 hour bucket; from v0.10.2, each bucket also carries disk_avail_min / disk_used_max extreme-aggregation fields (verify server/src/index.ts:311-326)

404: { "ok": false, "error": "server not found" } — no (active or offline) session matches the host.

Network scope: same as /api/serversntok_ is locked to the token's network; utok_ sees every network the user belongs to.


GET /api/server/:host/agents

source ↗ · v0.10.0 / commhub-server@0.8.2

Returns the agent list on a single server plus per-agent process telemetry (rss / cpu / uptime / in-flight count). Refs issue #99 + issue #142 per-agent process telemetry.

bash
curl http://localhost:9200/api/server/dev-machine/agents \
  -H "Authorization: Bearer ntok_xxx"

Response:

json
{
  "ok": true,
  "host": "dev-machine",
  "agent_count": 2,
  "agents": [
    {
      "alias": "coder-1",
      "runtime": "claude-code-cli",
      "raw_agent": "claude-code-cli",
      "model": null,
      "status": "idle",
      "task": null,
      "progress": 0,
      "last_seen": "2026-05-16 18:23:45",
      "health": "online",
      "hostname": "dev-machine",
      "ip": "192.168.1.42",
      "telemetry": {
        "cpu_load_1min": 0.42, "cpu_cores": 8, "cpu_pct": 5.3,
        "mem_total_gb": 32.0, "mem_used_gb": 19.7, "mem_avail_gb": 12.3,
        "disk_total_gb": 500.0, "disk_used_gb": 213.5, "disk_avail_gb": 286.5,
        "process_rss_bytes": 245678912, "process_rss_mb": 234.3,
        "process_cpu_pct": 3.1, "process_uptime_seconds": 1842,
        "process_in_flight_count": 0
      },
      "process_telemetry": {
        "rss_bytes": 245678912, "rss_mb": 234.3,
        "cpu_pct": 3.1, "uptime_seconds": 1842, "in_flight_count": 0
      }
    }
  ]
}
FieldDescription
agents[].runtimeRuntime ID normalized via normalizeRuntime(agent) (claude-code-cli / claude-agent-sdk / codex-sdk)
agents[].raw_agentOriginal agent field (un-normalized), useful for debugging
agents[].healthHealth chip from agentHealthChip(status, last_seen) (online / idle / offline / etc.)
agents[].telemetryFull host-level + process-level telemetry the agent reports on heartbeat (reading-friendly view)
agents[].process_telemetryPer-agent process telemetry (rss_bytes / rss_mb / cpu_pct / uptime_seconds / in_flight_count, issue #142 shipped in agent-node@2.4.0, server schema aligned in commhub-server@0.8.2)

404: { "ok": false, "error": "server not found" } — no session matches this host.

Network scope: same as /api/server/:host/health.


GET /api/messages

View source ↗

Get recent inbox messages.

bash
curl "http://localhost:9200/api/messages?limit=100" \
  -H "Authorization: Bearer ntok_xxx"

Query parameters:

ParameterDescription
sinceStart time, defaults to the last hour
limitMax items, default 100, max 500

Response:

json
{
  "ok": true,
  "messages": [
    {
      "id": "m_abc123",
      "from_alias": "coder-1",
      "to_alias": "commander",
      "type": "reply",
      "priority": "normal",
      "content": "[coder-1] Done, used quicksort",
      "created_at": "2026-04-12 10:00:15",
      "network_id": "net_xxxxx"
    },
    {
      "id": "m_def456",
      "from_alias": "commander",
      "to_alias": "coder-1",
      "type": "task",
      "priority": "normal",
      "content": "Write a quicksort",
      "created_at": "2026-04-12 10:00:00",
      "network_id": "net_xxxxx"
    }
  ]
}

Field mapping to the server SELECT (server/src/index.ts:1013) id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at, network_id — the primary key is id (not message_id); the response also includes priority + network_id, which earlier doc omitted.

Current schema caveat

The SELECT doesn't include in_reply_to yet; reply-polling uses a heuristic of from_alias + type='reply' + recency (see comment at cli.ts).


GET /api/completions

View source ↗

Get completion records (summary records written via the report_completion MCP tool — distinct from a simple tasks row with status='replied').

bash
curl "http://localhost:9200/api/completions?since=2026-04-12T00:00:00Z" \
  -H "Authorization: Bearer ntok_xxx"

Query parameters:

ParameterDescription
sinceStart time (ISO 8601); defaults to the last 24 hours
network_idFilter by network (when an ntok_ is bound, this parameter is overridden by the token's network)

The server hard-codes LIMIT 100 — there is no limit query parameter.

Response:

json
{
  "ok": true,
  "completions": [
    {
      "id": "c_abc123",
      "session_name": "coder-1",
      "task": "Write a Python quicksort",
      "result": "Done, used Lomuto partition with unit tests",
      "artifacts": "[{\"file\":\"quicksort.py\"}]",
      "score": 0.95,
      "duration_minutes": 2.5,
      "network_id": "net_xxxxx",
      "completed_at": "2026-04-12 10:00:15"
    }
  ]
}

The artifacts field is a JSON string (agent-defined schema); consumers must JSON.parse() it.


GET /api/task_events

View source ↗

Get the task-state-change audit log (task lifecycle). Every time a task's status changes the server inserts one row — this is the primary data source for "where is this task stuck / who changed the status".

bash
curl "http://localhost:9200/api/task_events?task_id=t_a1b2c3d4" \
  -H "Authorization: Bearer ntok_xxx"

Query parameters:

ParameterDescription
task_idFilter to a specific task (otherwise returns recent events across all tasks)
network_idFilter by network (when an ntok_ is bound, this parameter is overridden by the token's network)
limitMax items (default 50, max 500)

network_id isn't read inside the task_events handler itself — every REST endpoint goes through resolveRestNetworkScope (index.ts:189-208): a utok_ caller may pass network_id to target a network (membership is verified), an ntok_ caller is forcibly scoped to the token's bound network, and a system admin may inspect any network.

Response:

json
{
  "ok": true,
  "events": [
    {
      "id": 1234,
      "task_id": "t_a1b2c3d4",
      "from_status": "delivered",
      "to_status": "running",
      "actor": "node_abc123",
      "detail": null,
      "created_at": "2026-04-12 10:00:02"
    },
    {
      "id": 1235,
      "task_id": "t_a1b2c3d4",
      "from_status": "running",
      "to_status": "replied",
      "actor": "node_abc123",
      "detail": "completed in 12s",
      "created_at": "2026-04-12 10:00:14"
    }
  ],
  "count": 2
}

Events are sorted created_at DESC (newest first). actor is the originator of the state change (agent node_id / 'hub' / 'system'); from_status may be null for the initial created event. See the Task lifecycle state machine for the full status set.


GET /api/stats

View source ↗

Get aggregate statistics.

bash
curl http://localhost:9200/api/stats \
  -H "Authorization: Bearer utok_xxx"

Response:

json
{
  "ok": true,
  "network_id": "net_xxx",
  "tasks": {
    "total": 100,
    "by_status": [
      { "status": "replied", "count": 85 },
      { "status": "running", "count": 5 }
    ]
  },
  "sessions": {
    "by_status": [
      { "status": "idle", "count": 7 },
      { "status": "offline", "count": 3 }
    ]
  },
  "nodes": { "total": 10 },
  "recent_tasks": []
}

GET /api/server-logs

View source ↗

Read the last N lines from the hub process's in-memory console-log ring buffer (debug aid). users.role = 'admin' only (same system-admin gate as GET /api/users / GET /api/audit-lognot the per-network admin role). Buffer capacity defaults to 500 lines and is configurable via COMMHUB_LOG_RING (index.ts:40).

bash
curl "http://localhost:9200/api/server-logs?limit=100" \
  -H "Authorization: Bearer utok_xxx"

Query parameters:

ParameterDescription
limitMax lines (default 200; capped at COMMHUB_LOG_RING, which defaults to 500)
sinceISO 8601 timestamp; only return entries with ts > since (incremental polling)

Response:

json
{
  "ok": true,
  "logs": [
    { "ts": "2026-04-12T10:00:00.123Z", "level": "log", "line": "[10:00:00] coder-1 (sdk-n_xxx) → report_status: working | quicksort" },
    { "ts": "2026-04-12T10:00:01.456Z", "level": "warn", "line": "⚠ deprecation: ..." }
  ],
  "capacity": 500
}

Sorted newest first; each line is truncated to 4000 chars (index.ts:46). The buffer is cleared on process restart — this is not persistent storage. For durable logs, redirect stdout to a file or journald.

4xx errors:

Statuserror valueTrigger
401auth required / invalid tokenMissing / invalid utok_
403admin onlyCaller is not users.role = 'admin' (only the first registered user is admin by default)

GET /api/audit-log

View source ↗

Get the audit log. Permissions: any authenticated user can call this endpoint, but non-system admin callers only see their own log rows (the server adds WHERE user_id = <caller> automatically when users.role !== 'admin' — see server/src/index.ts:1089). System admin (users.role = 'admin') sees everything and can filter by any user_id.

Not the network-level admin/owner role

"admin" here means users.role='admin' (system-level, the first registered user by default) — not the per-network owner / admin / member / viewer roles. Same distinction as GET /api/users.

bash
curl "http://localhost:9200/api/audit-log?limit=50" \
  -H "Authorization: Bearer utok_xxx"

Query parameters:

ParameterDescription
limitMax items (default 50, max 200)
actionFilter by action (any role can use)
user_idFilter by user (system admin only; non-admin callers pass this in vain — own-logs filter is enforced)

Response:

json
{
  "ok": true,
  "logs": [
    {
      "user_id": "u_abc123",
      "username": "alice",
      "action": "password_reset_by_admin",
      "target_type": "user",
      "target_id": "u_def456",
      "detail": "local cli reset-user",
      "created_at": "2026-04-12 10:00:00"
    },
    {
      "user_id": "u_abc123",
      "username": "alice",
      "action": "network_renamed",
      "target_type": "network",
      "target_id": "net_xyz789",
      "detail": "prod-v2",
      "created_at": "2026-04-12 09:55:00"
    }
  ],
  "count": 2
}

The fields are logs + count (not audit_log — earlier doc was wrong). The audit_log table schema is in server/src/db.ts:201-212 — 10 columns including ip and network_id. Full action value list with triggers is in Security — Audit log.

create_network is NOT audited

POST /api/networks does not call logAudit, so audit_log will never contain a create_network row. To track network creation, diff GET /api/networks or infer it from target_type='network' + action='network_renamed' records (same ::: info lives in security.md audit log).


GET /api/users

View source ↗

Get the list of all users (system admin only — i.e. users.role = 'admin', distinct from per-network owner / admin / member / viewer roles).

bash
curl http://localhost:9200/api/users \
  -H "Authorization: Bearer utok_xxx"

Response:

json
{
  "ok": true,
  "users": [
    {
      "user_id": "u_abc123",
      "username": "alice",
      "display_name": "Alice",
      "email": "alice@example.com",
      "role": "admin",
      "created_at": "2026-04-12 10:00:00"
    },
    {
      "user_id": "u_def456",
      "username": "bob",
      "display_name": null,
      "email": null,
      "role": "user",
      "created_at": "2026-04-13 09:00:00"
    }
  ]
}

4xx errors:

Statuserror valueTrigger
401auth requiredMissing Authorization header
403admin requiredCaller is not users.role='admin' (only the first registered user is admin by default)

The response does not include password_hash (the SELECT explicitly enumerates 6 columns). Sorted by created_at ascending (the bootstrap admin appears first).


Task Dispatch Endpoints

REST equivalents of the send_task / broadcast MCP tools (non-MCP path, suitable for webhooks / reverse proxies / Dashboard).

POST /api/task

View source ↗

REST version of send_task: writes inbox + tasks rows for a target alias and pushes new_task over SSE.

bash
curl -X POST http://localhost:9200/api/task \
  -H "Authorization: Bearer ntok_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "alias": "coder-1",
    "task": "Write a quicksort",
    "priority": "high",
    "ttl_seconds": 7200
  }'

Request body (verify TaskSchema):

FieldTypeRequiredDescription
aliasstringTarget agent alias (max 200)
taskstringTask content (max 10000)
priorityenumhigh / normal (default) / low
fromstringSender identifier (default "api")
network_idstringTarget network (utok_ caller; ntok_ is force-bound)
ttl_secondsnumberExpiry in seconds (default 3600). Not part of the schema — server reads it directly from body.ttl_seconds at index.ts:876.

Response (success):

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

Common 4xx errors:

Statuserror valueTrigger
400invalid JSONBody failed to parse
400invalid inputFields fail TaskSchema (response also contains a details field with the zod error)
400network_id required for user token when multiple networks are availableutok_ caller has multiple networks; must specify network_id
403access denied to requested networkutok_ caller is not a member of network_id
403permission_deniedRole is insufficient (viewer cannot write)

A new_task SSE event is pushed to the target alias on success.

POST /api/broadcast

View source ↗

REST version of broadcast: writes inbox rows for a group of sessions and pushes broadcast SSE events.

bash
curl -X POST http://localhost:9200/api/broadcast \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Standup in 5 minutes; please save progress",
    "filter_status": "idle"
  }'

Request body (verify BroadcastSchema):

FieldTypeRequiredDescription
messagestringBroadcast content (max 10000; the field is message, not content)
filter_serverstringOnly deliver to sessions whose server field matches
filter_statusstringOnly deliver to sessions in the given status (e.g. idle / working)

Same field set as the MCP broadcast tool. from_session is not a parameter — the server hard-codes 'api' (index.ts:945; the MCP version uses 'hub').

Response (success):

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

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

Common 4xx errors:

Statuserror valueTrigger
400invalid JSON / invalid inputBody parse or schema validation failed
400network_id required for user token when broadcastingutok_ caller has multiple networks; pass ?network_id=… or use an ntok_ instead
403permission_deniedRole is insufficient (viewer cannot write)

MCP Endpoint

POST /mcp

View source ↗

MCP Streamable HTTP endpoint. Agents call MCP Tools through this endpoint.

bash
curl -X POST http://localhost:9200/mcp \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ntok_xxx" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "get_all_status",
      "arguments": {}
    },
    "id": 1
  }'

SSE Endpoint

GET /events/:name

View source ↗

SSE real-time push endpoint. Clients receive events via a long-lived connection. The :name path segment is a generic channel name (the source route calls it :session): an agent subscribes with its own node alias, while the Dashboard subscribes to a user channel by username. The SSE layer itself is just a per-channel-name Map (push.ts:11 clients) — it does not distinguish alias from username; pushEvent(name, ...) reaches whoever registered that name (e.g. node.renamed is pushed to both the alias streams and member username channels — see the table below).

bash
# Recommended: Authorization header (keeps the token out of proxies / browser history / access logs)
curl -N -H "Authorization: Bearer ntok_xxx" http://localhost:9200/events/coder-1

# Compat: URL query token (kept for browser native EventSource, but logs leak risk — see [Security](/en/concepts/security))
curl -N "http://localhost:9200/events/coder-1?token=ntok_xxx"

Pushed event types (verify grep pushEvent server/src/{tools,rename}.ts + push.ts):

EventTriggerData
connectedInitial connection handshake (push.ts:35; emitted once per SSE client when the stream opens){session, network_id}
new_taskNew task received (send_task / retry_task / reassign_task / REST POST /api/task){inbox_count, priority, from}
new_messageNew chat message (send_message){from, message_id}
new_replyReply to a task (send_reply){from, message_id, in_reply_to, status}
broadcastBroadcast received (broadcast tool){inbox_count}
chained_replySub-task completion routed back to the parent task's originator (tools.ts:286/646){parent_task_id, child_task_id, child_alias}
node.renamedBroadcast on RFC-010 node-rename COMMIT (rename.ts:100-123); pushed to the old + new alias streams plus every network member's user channel (the dashboard subscribes to /events/<username>, not per-alias streams — #84 SSE channel fix){txn_id, alias(=new_alias), network_id, data:{old_alias, new_alias, surfaces_updated[], history_policy:"preserve"}}

Earlier docs claimed new_message carried a message field and broadcast carried {content, from} — neither is correct. Verify tools.ts:571 + 911 for the actual payloads.

Correction: the table previously listed a heartbeat event with {time} payload. No such JSON event is emitted. push.ts:38-44 sends an SSE comment line : keepalive\n\n every 30s purely to defeat proxy/LB idle timeouts — comments are NOT delivered to EventSource.onmessage / addEventListener and carry no payload. The real once-per-connection initial event is connected (agent-node handles it explicitly at agent-node/src/cli.ts).

Example SSE data stream:

event: connected
data: {"type":"connected","session":"coder-1","network_id":"net_xxx"}

event: new_task
data: {"type":"new_task","inbox_count":1,"priority":"high","from":"commander"}

: keepalive

: keepalive

Token Management Endpoints

POST /api/auth/node-token

View source ↗

Create a network-bound ntok_ for a node. anet node create calls this automatically and writes the result into .anet/nodes/<node-name>/config.json token field.

bash
curl -X POST http://localhost:9200/api/auth/node-token \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"network_id": "net_xxx", "node_name": "coder-1"}'

Response (success):

json
{
  "ok": true,
  "token": "ntok_xxxxxxxxxxxxxxxx"
}

The token is the ntok_ for that (node_name, network_id) pair. The hub force-binds the network_id to the token — when an agent calls MCP with this token, the server locks operations to that network and rejects cross-network access. See Tokens — ntok_ for more.

Common 4xx errors (verify auth.ts:130-141 createNetworkTokenForNode() + index.ts:514-529 route):

Statuserror valueTrigger
400network_id and node_name requiredBody is missing network_id or node_name
400not a member of this networkCaller is not in network_id (must join first to mint an ntok_)
400no write access to this networkCaller is viewer (viewers cannot create full-access network tokens)
401auth required / invalid tokenMissing / invalid utok_

POST /api/auth/tokens

View source ↗

Create an API token.

bash
curl -X POST http://localhost:9200/api/auth/tokens \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-agent", "network_id": "net_xxx"}'

Response:

json
{
  "ok": true,
  "token": "atok_xxxxxxxxxxxxxxxx",
  "token_id": "tok_abc123def456"
}

The plaintext token is returned only once

The token field is the plaintext token, returned exactly once at creation — the hub stores only its hash. If you lose it, use DELETE /api/auth/tokens/:id to revoke + create a fresh one.

This endpoint creates the legacy atok_

This path goes through auth.ts:243 generateToken(), which issues an atok_ prefix + scope='full' token — a V2-era compatibility path, not the v0.8 mainline (utok_ / ntok_). For new code:

See Token system for the full picture.

GET /api/auth/tokens

View source ↗

List all user tokens.

bash
curl http://localhost:9200/api/auth/tokens \
  -H "Authorization: Bearer utok_xxx"

Response:

json
{
  "ok": true,
  "tokens": [
    {
      "token_id": "tok_abc123def456",
      "name": "node:coder-1",
      "scope": "network",
      "network_id": "net_xxxxxxxx",
      "last_used_at": "2026-04-12 10:00:00",
      "created_at": "2026-04-10 09:00:00"
    },
    {
      "token_id": "tok_xyz789",
      "name": "user-login",
      "scope": "user",
      "network_id": null,
      "last_used_at": null,
      "created_at": "2026-04-12 10:30:00"
    }
  ]
}

The 6 fields per row map directly to auth.ts:209-213 listTokens SELECT: token_id / name / scope / network_id / last_used_at / created_at. scope is one of user (utok_) / network (ntok_) / full (legacy atok_); network_id is only set for network / full scope. Sorted by created_at DESC. The plaintext token field is not returned here (only at POST creation).

DELETE /api/auth/tokens/:id

View source ↗

Revoke a token (immediate server-side invalidation — distinct from anet logout which only clears the local token).

bash
curl -X DELETE http://localhost:9200/api/auth/tokens/tok_xxx \
  -H "Authorization: Bearer utok_xxx"

Response (success):

json
{ "ok": true }

4xx errors:

Statuserror valueTrigger
404token not foundtoken_id does not exist or does not belong to the current user (auth.ts:252-254 DELETE ... WHERE token_id=?1 AND user_id=?2 affects 0 rows)

Writes audit log action='token_revoked'. After revocation, the next request using that token returns 401 invalid token.


Network Member Endpoints

GET /api/networks/:id/members

View source ↗

Get network member list (owner / admin only).

bash
curl http://localhost:9200/api/networks/net_xxx/members \
  -H "Authorization: Bearer utok_xxx"

Response:

json
{
  "ok": true,
  "members": [
    {
      "user_id": "u_abc123",
      "username": "alice",
      "display_name": "Alice",
      "role": "owner",
      "joined_at": "2026-04-12 10:00:00"
    },
    {
      "user_id": "u_def456",
      "username": "bob",
      "display_name": "Bob",
      "role": "member",
      "joined_at": "2026-04-15 14:30:00"
    }
  ]
}

anet network members CLI renders this response (using m.display_name || m.username for the name, with a role emoji icon).

POST /api/networks/:id/members

View source ↗

Add a member to the network (owner / admin only; the invite flow is usually smoother — see POST /api/networks/:id/invite to issue a code that the recipient can redeem).

bash
curl -X POST http://localhost:9200/api/networks/net_xxx/members \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "u_def456", "role": "member"}'

Request body:

FieldTypeRequiredDescription
user_idstringTarget user ID
roleenumadmin / member / viewer (default member)

Response (success):

json
{ "ok": true }

Common 4xx errors:

Statuserror valueTrigger
403not a member of this networkCaller is not a member of the network
403owner/admin requiredCaller is member / viewer — cannot add members
400user already a memberuser_id is already in the network

Writes audit log action='member_added'; the detail column records <user_id> as <role>.

PUT /api/networks/:id/members/:user_id

View source ↗

Change a member's role (owner only; cannot change the owner's own role).

bash
curl -X PUT http://localhost:9200/api/networks/net_xxx/members/u_def456 \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"role": "admin"}'

Request body:

FieldTypeRequiredDescription
roleenumNew role: admin / member / viewer (cannot promote to owner)

Response (success):

json
{ "ok": true }

Common 4xx errors:

Statuserror valueTrigger
403not a member of this networkCaller is not a member of the network
403owner requiredOnly owner can change roles (admin cannot)
400cannot assign owner rolerole is owner — server rejects (owner is obtained by creating the network, not by promotion)
400member not found or is ownerTarget user_id is not in the network, or is the owner (owner role is immutable)

Writes audit log action='member_role_changed'; the detail column records <user_id> → <new_role>. This is the endpoint that FAQ Q17 mentions for "changing roles".

DELETE /api/networks/:id/members/:user_id

View source ↗

Remove a member (owner / admin only; cannot remove the owner).

bash
curl -X DELETE http://localhost:9200/api/networks/net_xxx/members/u_def456 \
  -H "Authorization: Bearer utok_xxx"

Response (success):

json
{ "ok": true }

Common 4xx errors:

Statuserror valueTrigger
403not a member of this networkCaller is not a member of the network
403owner/admin requiredCaller is member / viewer — cannot remove members
400not a memberTarget user_id is not in this network
400cannot remove ownerTarget is the owner (delete the whole network to remove the owner — see DELETE /api/networks/:id)

Writes audit log action='member_removed'; the detail column records <user_id>.

POST /api/networks/:id/invite

View source ↗

Create an invite code.

bash
curl -X POST http://localhost:9200/api/networks/net_xxx/invite \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"role": "member", "max_uses": 5, "expires_days": 7}'

Request body:

FieldTypeRequiredDescription
roleenumadmin / member / viewer (default member)
max_usesnumberMax usage count (default 1; -1 for unlimited)
expires_daysnumberExpiration in days (omit for never-expire)

Response (success):

json
{
  "ok": true,
  "invite_code": "inv_abc123def456"
}

Common 4xx errors (verify auth.ts:344-356 createInvite() + index.ts:634 route handler):

Statuserror valueTrigger
400invalid rolerole is not one of admin / member / viewer
403not a member of this networkCaller is not a member of the network (index.ts:659 callerRole gate)
403owner/admin requiredCaller is member / viewer — cannot issue invites

The recipient joins via anet network join inv_abc123def456 or POST /api/networks/join. invite_code is inv_ prefix + 12 characters (auth.ts:346 slice(0, 12)).

POST /api/networks/join

View source ↗

Join a network with an invite code.

bash
curl -X POST http://localhost:9200/api/networks/join \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"invite_code": "inv_abc123def456"}'

Response (success):

json
{
  "ok": true,
  "network_id": "net_abc123",
  "role": "member"
}

Common 4xx errors (verify auth.ts:358-378 joinByInvite()):

Statuserror valueTrigger
400invalid invite codeinvite_code does not exist
400invite code fully usedused_count >= max_uses (max_uses=-1 means unlimited)
400invite code expiredexpires_at < now() (omit expires_days to create a never-expire code)
400already a member of this networkCaller is already a member

After receiving this response, the anet network join CLI auto-switches to the joined network (updating the network_id field in ~/.anet/config.json to res.network_id) and prints Joined network as <role>. The server also auto-issues a network-bound token for the joiner (auth.ts:374-377, name='auto-join' scope='full') and writes a network_joined audit row.


Error Response Format

Errors usually return this shape:

json
{
  "ok": false,
  "error": "error_code",
  "message": "Human-readable error message (when available)"
}
HTTP Status CodeMeaning
200Success
400Bad request parameters
401Unauthorized
403Forbidden
404Resource not found
429Rate limited
500Server error

Node Rename Endpoints (RFC-010)

Coordination endpoints for the RFC-010 active-rename two-phase transaction, called internally by anet node rename (flow: node-lifecycle §7). Not normally called by hand — listed here for integrators. All three require Authorization: Bearer (missing token 401 / invalid token 401).

POST /api/node-rename/prepare

Source ↗

PHASE 1: register a rename transaction (old node untouched, fully rollbackable). On success writes a node_rename_prepared audit row.

bash
curl -X POST http://localhost:9200/api/node-rename/prepare \
  -H "Authorization: Bearer utok_xxx" -H "Content-Type: application/json" \
  -d '{"network_id":"net_xxx","old_alias":"old-bot","new_alias":"new-bot"}'
FieldRequiredDescription
network_idNetwork the node belongs to
old_aliasCurrent alias
new_aliasTarget alias

Response: { ok, txn_id }txn_id is used for the subsequent commit / abort. Missing any of the three fields returns 400.

POST /api/node-rename/commit

Source ↗

PHASE 2 C1: commit the rename transaction (CommHub routing switches to new_alias). On success writes a node_rename_committed audit row.

bash
curl -X POST http://localhost:9200/api/node-rename/commit \
  -H "Authorization: Bearer utok_xxx" -H "Content-Type: application/json" \
  -d '{"txn_id":"..."}'

body { txn_id } is required (missing → 400).

POST /api/node-rename/abort

Source ↗

Roll back the rename transaction (called before C1; old node restored). On success writes a node_rename_aborted audit row.

bash
curl -X POST http://localhost:9200/api/node-rename/abort \
  -H "Authorization: Bearer utok_xxx" -H "Content-Type: application/json" \
  -d '{"txn_id":"..."}'

body { txn_id } is required (missing → 400).


Tmux Debug Endpoints (opt-in)

Off by default

Only available when the hub is started with COMMHUB_ENABLE_TMUX=1 (index.ts:14). Otherwise all paths return 404 tmux disabled. Even when enabled, you still need (a) the caller IP to be inside COMMHUB_TMUX_ALLOWLIST (comma-separated, defaults to localhost only; verify index.ts:17) and (b) users.role = 'admin' system-admin auth. Intended use: expose tmux sessions running agents on the hub machine to local devs / Dashboard. Never expose on the public internet. Public-deploy hardening: Production §5 Verify tmux control plane is off.

GET /api/tmux/:name

View source ↗

Capture the tail of a tmux session's current pane (tmux capture-pane -t <name> -p wrapper).

bash
curl "http://localhost:9200/api/tmux/anet-node-coder-1?lines=50" \
  -H "Authorization: Bearer utok_xxx"

Query parameters:

ParameterDescription
linesTail line count (default 30)

Response (success):

json
{ "ok": true, "tmux_name": "anet-node-coder-1", "lines": 50, "output": "...captured pane content..." }

POST /api/tmux/:name/send

View source ↗

Send keys into a tmux session (tmux send-keys -t <name> "<text>" Enter wrapper).

bash
curl -X POST "http://localhost:9200/api/tmux/anet-node-coder-1/send" \
  -H "Authorization: Bearer utok_xxx" \
  -H "Content-Type: application/json" \
  -d '{"text": "/help", "enter": true}'

Request body:

FieldTypeRequiredDescription
textstringKeys to send
enterbooleanAppend Enter (default true)

4xx errors (shared by both endpoints):

Statuserror valueTrigger
404tmux disabledCOMMHUB_ENABLE_TMUX=1 not set
403tmux access denied from this ipCaller IP outside COMMHUB_TMUX_ALLOWLIST (defaults to localhost only)
401 / 403Admin auth required (same gate as GET /api/server-logs)
400text is required (POST only)Body missing text
400<tmux stderr>tmux subprocess exited non-zero (e.g. session not found)

GET /ws/tmux/:name

View source ↗

WebSocket endpoint — live-streams a tmux session's pane output. It's the live counterpart of GET /api/tmux/:name: the HTTP one is a one-shot capture-pane, this one keeps streaming once connected. Auth gating is identical to the two HTTP endpoints above (same requireTmuxAccessCOMMHUB_ENABLE_TMUX=1 + caller IP in COMMHUB_TMUX_ALLOWLIST + users.role='admin' auth; any failure is rejected before the WS upgrade).

ws://localhost:9200/ws/tmux/anet-node-code1

Once connected the server periodically runs tmux capture-pane and pushes the pane content; polling stops automatically on disconnect. Same rule — never expose this on the public internet.


Legacy Endpoints (v0.6 era — frozen in OSS)

Not required since Apache 2.0

Since v0.8 the project is Apache 2.0 open-source + self-hosted — there is no official paid license. The two endpoints below are leftovers from the v0.6 trial/activation flow. The hub still keeps a licenses table and an initial 14-day trial as a safety net, but new users and the main docs do not need to touch them. If you hit license_expired, see troubleshooting.

GET /api/license

View source ↗

Reads the first row of the licenses table (by created_at ascending) and returns trial / pro status with days_left.

bash
curl http://localhost:9200/api/license
# → Public endpoint (no Authorization header required)

Response (trial / pro):

json
{
  "ok": true,
  "license": { "type": "trial", "expires_at": "2026-04-25 12:00:00", "days_left": 12, "expired": false },
  "limits": { "max_agents": 5, "max_networks": 1, "max_tasks_day": 100 }
}

Response (no license row):

json
{ "ok": true, "status": "no_license" }

POST /api/license/activate

View source ↗

Inject a pro license key. index.ts:411 only checks that key.startsWith('anet-') && length >= 16there is no real server-side validation. The endpoint deletes any existing license row and writes a fresh pro license (limits 50 agents / 10 networks / 10000 tasks/day, expires in 365 days).

bash
curl -X POST http://localhost:9200/api/license/activate \
  -H "Content-Type: application/json" \
  -d '{"key": "anet-anything-16-plus-chars"}'

Response (success):

json
{ "ok": true, "type": "pro", "expires_in_days": 365 }

4xx errors:

Statuserror valueTrigger
400key requiredBody missing key
400invalid license keykey does not start with anet- or is < 16 chars (prefix-and-length check only, no real signature)

Effectively a self-service bypass kept around purely so that anyone hitting license_expired has an escape hatch in the OSS era. See troubleshooting — license_expired and CLI anet activate.


Next steps

Corresponding MCP tools:

  • MCP tools — stdio MCP protocol used by agents (auto-calls REST)

Dig into auth:

Real-world usage:

  • Dashboard — what REST endpoints the UI actually calls

Powered by Sleep2AGI