REST API Reference
CommHub Server provides a REST API for Dashboard, CLI, and third-party system integration.
Basics
| Item | Value |
|---|---|
| Base URL | http://YOUR_IP:9200 |
| Auth | Authorization: Bearer <token> (recommended); ?token=<token> URL query kept for SSE / browser EventSource (access-log leak risk — see Security) |
| Content Type | application/json |
| Encoding | UTF-8 |
| Endpoint count | 30+ across 13 groups: Public 1 · Auth 5 · Network 5 · Data Query 10 · Task Dispatch 2 · MCP 1 · SSE 1 · Token Management 4 · Network Members 6 · Files 2 · Node Rename 3 · Tmux Debug 3 (opt-in) · Legacy 2 |
| Full endpoint source | server/src/server.ts |
Public Endpoints
GET/health
Health check, no authentication required.
curl http://localhost:9200/health{
"ok": true,
"version": "0.8.8",
"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
}🔴 This sample was captured, not hand-written — on 2026-08-13, from
bunx --bun @sleep2agi/commhub-server@0.8.8in a clean container, as the raw response to an unauthenticatedcurl /health.The two channels return different keys — parse
/healthper channel:🔴
latestmoved from0.8.8to0.9.0-preview.30on 2026-08-27 (contains the redaction fix7bacb729). The text below describes versions, not channels — channels move, versions do not. Check the current pointer withnpm view @sleep2agi/commhub-server dist-tags.
Key 0.8.80.9.0-preview.29sse_sessionsreturned even unauthenticated, and unredacted not returned unauthenticated limitsabsent present The other 13 keys were present on both in this capture — one sample per channel, not a permanent contract.
On 0.8.8, sse_sessions exposes every connected agent to anonymous callers
The sample above shows {} only because that clean container had zero SSE connections. Do not read it as "latest leaks nothing."
/health redaction landed in #473 on 2026-07-29 (7bacb729). commhub-server@0.8.8 was published 2026-06-24 — 35 days earlier, so 0.8.8 does not contain the fix.
On a 0.8.8 hub with live connections, anonymous GET /health returns the per-connection {networkId}:{alias} breakdown. The public-hub audit on 2026-07-30 retrieved the network id plus all 95 agent aliases in one unauthenticated request; see server/src/health-redaction.test.ts.
So the difference between channels is not "key present / key absent":
0.8.8— anonymous callers can read the full live-session breakdown (which is empty only on an idle hub);- preview
0.9.0-preview.22and later — anonymous callers get aggregate counts only; the breakdown moved behind auth atGET /api/stats/sse; - ⚠️ preview
0.9.0-preview.0through.21leak just like0.8.8— published 2026-06-28 … 07-04, i.e. before the fix. Do not treat "preview" as safe wholesale.
Confirm this before exposing a 0.8.8 hub to the public internet.
Code that treats key presence as an authorization signal will also behave differently across the two channels.
Read the anonymous response per channel, per the table and the warning above: on preview the key is absent entirely and anonymous callers get aggregate counts only; on 0.8.8 the key is emitted unredacted — empty on an idle hub, and a full {networkId}:{alias} breakdown as soon as anything connects.
With a valid token, a system admin, legacy master, or DEV_OPEN caller can receive the full map; regular utok_ / ntok_ callers receive only sessions from networks they may access (or an empty object when they belong to none).
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 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
Register a new user. The first user registered automatically becomes admin.
# 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:
| Field | Type | Required | Description |
|---|---|---|---|
username | string | ✓ | Username (2-50 chars, letters/numbers/underscores/hyphens/Chinese) |
password | string | ✓ | Password (>= 8 chars + not in weak-password dictionary; first bootstrap admin exempt, >= 4 OK) |
email | string | ||
display_name | string | Display name |
Response:
{
"ok": true,
"user": {
"user_id": "u_abc123",
"username": "alice",
"display_name": "Alice",
"email": "alice@example.com",
"role": "admin"
},
"token": "utok_xxxxxxxxxxxxxxxx",
"token_id": "tok_xxxxxxxxxxxx",
"network_token": "ntok_xxxxxxxxxxxxxxxx",
"network_id": "net_xxxxxxxx"
}The user object's 5 fields match auth.ts — grep interface AuthUser AuthUser interface (display_name / email may be null); token is the utok_ for CLI/Dashboard; network_token is the ntok_ for agents in the network auto-created at registration.
Common 4xx errors (verify auth.ts register()):
| Status | error value | Trigger |
|---|---|---|
| 400 | username must be at least 2 characters | Username < 2 chars |
| 400 | username too long (max 50) | Username > 50 chars |
| 400 | username contains invalid characters | Contains chars outside a-zA-Z0-9_\- or Chinese |
| 400 | username already taken | Duplicate username |
| 400 | password must be at least 8 characters | Non-bootstrap user password < 8 |
| 400 | password must be at least 4 characters | First user (bootstrap admin) password < 4 |
| 400 | password is too common | Hits the weak-password dictionary (password-dict.ts; bootstrap admin is exempt) |
| 429 | too many requests, try again later | Exceeded 30/min IP rate limit (server.ts; localhost is exempt — see Security — IP rate limits) |
Rate limit: 30 requests/minute per IP.
POST/api/auth/login
User login.
# 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:
{
"ok": true,
"user": {
"user_id": "u_abc123",
"username": "alice",
"display_name": "Alice",
"email": "alice@example.com",
"role": "admin"
},
"token": "utok_xxxxxxxxxxxxxxxx",
"token_id": "tok_xxxxxxxxxxxx",
"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 login() 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 — grep // User token (utok_) — not bound to network, for CLI/Dashboard login). token_id is this token's id in GET /api/auth/sessions, so a client can recognize "this device"; the body may carry an optional client_label (e.g. "macOS · 0.2.150") to name the device. Login tokens expire after 30 idle days — see Signed-in devices.
Common 4xx errors (verify auth.ts login()):
| Status | error value | Trigger |
|---|---|---|
| 401 | invalid username or password | Username doesn't exist or password hash mismatch (auth.ts — grep invalid username or password (2 sites) intentionally collapses both into the same message to avoid username enumeration); the server also writes a login_failed audit row |
| 429 | rate_limited | Exceeded 10/min IP rate limit (server.ts; on hit the server writes a login_rate_limited audit row with the client IP) |
Rate limit: 10 requests/minute per IP.
Full 429 response body (the error field is rate_limited — it is not the prose message):
{ "ok": false, "error": "rate_limited",
"message": "Too many login attempts. Try again later.",
"retry_after_ms": 42000 }A Retry-After header is also returned (in seconds, retry_after_ms rounded up). Match on the error field, not on message.
GET/api/auth/me
Get current user info.
curl http://localhost:9200/api/auth/me \
-H "Authorization: Bearer utok_xxx"Response:
{
"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
Update personal info. Requires a user token. A node token gets 403 user_token_required.
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:
| Field | Type | Required | Description |
|---|---|---|---|
display_name | string | Display name | |
email | string |
Only the provided fields are updated (server/src/server.ts uses conditional SQL with if (body.X)); username / role / password are not mutable through this endpoint.
Response (success):
{
"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/server.ts):
| Status | error value | Trigger |
|---|---|---|
| 400 | <JSON parse error> | Request body is not valid JSON (the catch block echoes the exception message) |
| 401 | token required / invalid token | Missing / 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 — server.ts 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
Change password. Requires a user token. A node token gets 403 user_token_required.
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:
{
"ok": true,
"revoked": 5,
"revoked_login": 2,
"revoked_cli": 3,
"revoked_network": 0,
"kept_network_tokens": [],
"token": "utok_xxxxxxxxxxxxxxxx",
"token_id": "tok_new_session_id"
}Optional request field keep_cli_tokens (boolean, default false): when true, CLI / script tokens are kept and listed in kept_cli_tokens. Without it (older apps, the dashboard) every other network-less token is revoked.
revoked is the total number of other tokens revoked (it does not include the caller's own token — that one is revoked separately by revokeToken(resolved.user.user_id, resolved.tokenId) in the password-change handler in server.ts); revoked_login / revoked_cli split it by kind (revoked = revoked_login + revoked_cli + revoked_network).
Key side effects (verify auth.ts changePassword + revokeTokensAfterPasswordChange + server.ts):
- The caller's
utok_(resolved.tokenId) is revoked immediately (server.tsrevokeToken(...)explicit delete) - Other browser / app login sessions (
api_tokens.kind='login') are revoked; counted inrevoked_login - CLI / script tokens (
kind='cli': issued toanet loginand other anet commands, and network-less named tokens fromPOST /api/auth/tokens) are revoked by default too, counted inrevoked_cli; onlykeep_cli_tokens: truekeeps them, listed one by one inkept_cli_tokens(token_id/name/client_label/created_at/last_used_at, never token values) so they can be revoked individually withDELETE /api/auth/tokens/:token_id. The kind is client-declared and only affects display and this explicit keep path.anet passwdsendskeep_cli_tokens: trueby default;--revoke-cli-tokensomits it - Node credentials remain unchanged. Named network tokens created with
POST /api/auth/tokensare kept by default and listed inkept_network_tokens(token_id,name,network_id,created_at,last_used_at; no credential values). Explicit booleanrevoke_network_tokens: truerevokes the caller’s active named network tokens across their networks and reportsrevoked_network. Onlyscope=full, network-bound tokens without a node binding qualify. The option is independent ofkeep_cli_tokens; other users and node credentials are preserved. Password update and these revocations commit together. - A fresh
utok_(issued.token) of the same kind as the caller's is minted and returned in this response — the caller must overwrite local storage with the new token right away - 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 changePassword()):
| Status | error value | Trigger |
|---|---|---|
| 400 | new password must be at least 8 characters | New password < 8 chars |
| 400 | new password is too common | Hits the weak-password dictionary (password-dict.ts) |
| 400 | user not found | user_id doesn't exist (rare; token expired or user deleted by admin) |
| 400 | incorrect current password | old_password hash mismatch |
| 401 | token required / invalid token | Missing / 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.
Signed-in devices: list / sign out login sessions
Source ↗ — grep
/api/auth/sessions
A login session token is a network-unbound utok_ with scope='user': user-login (issued by login / register), password-change (reissued after a password change), and admin-reset (issued by an admin reset). One row per login — "one sign-in on one device".
Idle expiry (sliding window): a login session token that has been idle for more than COMMHUB_SESSION_IDLE_DAYS days (default 30; 0 disables) stops working. Idle time counts from last_used_at, or created_at if it was never used; every use pushes the window forward (last_used_at is written at most once per hour per token). An expired token gets, on any endpoint:
{ "ok": false, "error": "token_expired", "message": "login session expired after inactivity; sign in again" }with HTTP 401. Clients should prompt for a fresh login on error == "token_expired" (as distinct from invalid token and other 401s).
Not subject to idle expiry: API tokens created explicitly via POST /api/auth/tokens (scope='full') and node / network tokens (ntok_). Those are long-lived credentials for scripts and nodes and are revoked through their own endpoints.
The three endpoints below accept user tokens only; node tokens get 403 user_token_required.
GET /api/auth/sessions — my live login sessions, most recently used first:
{
"ok": true,
"current_token_id": "tok_aaa",
"idle_timeout_days": 30,
"sessions": [
{ "token_id": "tok_aaa", "name": "user-login", "created_at": "2026-09-30 08:00:00",
"last_used_at": "2026-09-30 09:12:00", "client_label": "macOS · 0.2.150",
"user_agent": "Mozilla/5.0 (Macintosh) …", "is_current": true }
]
}client_label is the optional client_label the client sent in the login body (≤ 64 chars); user_agent is the login request's User-Agent header (≤ 256 chars). Tokens issued before the upgrade have null for both.
POST /api/auth/sessions/revoke-others — sign out every other device: revokes all of my login sessions except the current one (expired ones included); API tokens and node tokens are untouched. Response { "ok": true, "revoked": 3, "kept_token_id": "tok_aaa" }. The calling token must itself be a login session, otherwise 403 session_token_required. Audited as sessions_revoked_others (detail = revoked=N).
DELETE /api/auth/sessions/:token_id — revoke one of my login sessions; revoking the current one is a logout. Response { "ok": true, "was_current": false }; 404 session_not_found when the target is not one of my login sessions (someone else's, an API token, a node token, or nonexistent). Audited as session_revoked.
Network Endpoints
GET/api/networks
Get all networks the user belongs to.
curl http://localhost:9200/api/networks \
-H "Authorization: Bearer utok_xxx"Response:
{
"ok": true,
"networks": [
{
"network_id": "net_abc123",
"network_name": "alice",
"owner_id": "u_abc123",
"description": "Auto-created network for alice",
"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 (db.ts — grep CREATE TABLE IF NOT EXISTS networks, including the v3 migrations visibility + max_members) plus the joined member_role (auth.ts getUserNetworks 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
Create a new network. Requires a user token. A node token gets 403 user_token_required.
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):
{
"ok": true,
"network_id": "net_xyz789",
"network_name": "prod"
}Common 4xx errors (verify auth.ts createNetwork()):
| Status | error value | Trigger |
|---|---|---|
| 400 | network name already exists | Same owner already has a network with this name (UNIQUE(owner_id, network_name) constraint) |
| 400 | quota exceeded: max N networks for free plan | What actually rejects a network creation is the plan quota — enforced in auth.ts createNetwork() (the old L184-189 pin has drifted onto token-issuing code, so this now pins the function name; admins are exempt; free plan default max_networks_owned = 2). Note this gate is enforced, unlike the max_members column, which is dormant. |
⚠️ Do not confuse it with the limits block from /api/license (trial defaults max_agents=5 / max_networks=3 / max_tasks_day=500): those are soft limits — the server only stores and returns them and enforces nothing (the CLI prints them as Soft limits). The two networks numbers even differ (3 vs 2); the plan quota is the one that applies | ||
| 401 | token required / invalid token | Missing / invalid utok_ |
GET/api/networks/:id
Get network details (membership check: caller must be a member of the network or a system admin, otherwise 403).
curl http://localhost:9200/api/networks/net_abc123 \
-H "Authorization: Bearer utok_xxx"Response:
{
"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/server.ts), 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
Rename a network (owner only). Requires a user token. A node token gets 403 user_token_required.
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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | ✓ | New network name (note the field is name, not network_name; missing returns name required 400) |
Response (success):
{ "ok": true }Common 4xx errors:
| Status | error value | Trigger |
|---|---|---|
| 400 | name required | Body missing name (note: not network_name) |
| 400 | network not found | network_id does not exist |
| 400 | not your network | Caller is not the owner |
| 400 | name already taken | Caller already owns another network with this name |
Writes audit log action='network_renamed'; the detail column records the new name.
DELETE/api/networks/:id
Delete a network (owner only, must have no active sessions; the network's agent groups, group members and group grants are deleted with it). Requires a user token. A node token gets 403 user_token_required.
curl -X DELETE http://localhost:9200/api/networks/net_abc123 \
-H "Authorization: Bearer utok_xxx"Response (success):
{ "ok": true }Common 4xx errors:
| Status | error value | Trigger |
|---|---|---|
| 400 | network not found | network_id does not exist |
| 400 | not your network | Caller is not the owner |
| 400 | network has N active session(s) — stop them first | Some agent sessions still reference this network (run anet node stop <name> on each before deleting) |
Writes audit log action='network_deleted'.
Hub admin deleting someone else's network (for cleaning up personal networks left by old accounts): when the caller is a Hub admin (user token, role=admin) and not the owner, only an empty network can be deleted — any non-zero count in nodes / sessions / tasks / inbox / user_inbox / requirements / requirement_projects / scheduled_tasks / providers / network_secrets / skillhub_skills / side_chats / agent_groups, in-flight node create/start requests, or uploaded files attributed to the network refuses the request. network_id='default' is always refused. On success the network's members, grants, invites, network-scoped tokens (its node tokens stop working) and request/telemetry history rows are removed in the same transaction; the audit log detail carries "admin_override":true and the original owner.
{ "ok": true, "admin_override": true, "cleaned": { "network_members": 1, "api_tokens": 1 } }| Status | error value | Trigger |
|---|---|---|
| 409 | network is not empty (nodes=1, sessions=1); … | Network is not empty; the response also carries a counts object |
| 409 | the default network cannot be deleted | Target is default |
| 404 | network not found | network_id does not exist |
Error Response Format
Errors usually return this shape:
{
"ok": false,
"error": "error_code",
"message": "Human-readable error message (when available)"
}| HTTP Status Code | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad request parameters |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Resource not found |
| 429 | Rate limited |
| 500 | Server error |
More endpoints
The REST reference is split into three pages:
- This page: basics, public endpoints, auth, networks, error format
- Data, tasks and realtime: data queries, task dispatch, the MCP endpoint, the SSE endpoint
- Administration: token management, network members, files, node rename, tmux debugging, legacy
Next steps
Corresponding MCP tools:
- MCP tools — stdio MCP protocol used by agents (auto-calls REST)
Dig into auth:
- Tokens — utok_ / ntok_ / atok_
- Security design — full auth model
- v0.7 → v0.8 upgrade — RFC-001 Phase 2
Real-world usage:
- Dashboard — what REST endpoints the UI actually calls