API reference — the customer surface¶
This is the API surface a Hivemind install exposes. It is not the full
internal route map of hivemind-server — it is the set of endpoints a
customer-side daemon, automation tool, or integration would realistically
use. The authoritative source is the router assembly in
crates/hivemind-server/src/app.rs; this doc is kept in sync with it
by hand.
Hivemind's API is HTTP + JSON. Authenticated endpoints accept either:
- An
X-API-Keyheader — for daemons + service-to-service callers. The admin API key is theADMIN_API_KEYin your.env, generated byinstall.sh. The server creates the admin user from it at startup, so it is the only admin API key the install ever has. - A session cookie — for browsers, set by
POST /auth/login.
Admin-role enforcement is ON by default¶
require_admin— the guard on the admin surface (/api/v1/admin/*,/api/admin/*,/api/templates, the manager proxy) — enforces by default. An authenticated non-admin principal reaching an admin route receives403 {"error":{"status":403,"message":"admin role required"}}.
HIVEMIND_ENFORCE_ADMIN_ROLEis an opt-OUT, and it is fail-closed: only an explicit falsy value (0/false/no/off, case-insensitive) disables enforcement. Unset, empty, or an unrecognized value all keep it ON.Authentication was never the gap — a key is validated against the database (
is_active, expiry), so an invalid key is rejected with401. The gap was authorization: before this release a valid key's role was never consulted, so issuing an ordinary non-admin service account silently armed a privilege escalation. That is now closed by default.Upgrading from a release before this one — read this first. Earlier versions defaulted to warn-only, so an operator whose users row is
role='user'still reached the admin surface. After upgrading, that operator gets403. Before you upgrade: 1. Confirm every operator principal you rely on isrole='admin'. 2. Upgrade. 3. If you are locked out, setHIVEMIND_ENFORCE_ADMIN_ROLE=falseand restart to restore the previous warn-only behavior, fix the roles, then remove the variable. Prefer fixing roles — the opt-out reopens the escalation above.Tracked as HIVE-581 / HIVE-353 finding 1.
The public chatbot endpoint (POST /api/v1/public/agents/chat/{slug})
is intentionally unauthenticated and rate-limited per client for
unsigned users; this is the surface you'd embed in a marketing-site
chat widget.
Service-to-service access through a reverse proxy¶
If you've put Hivemind behind a reverse proxy with SSO forward-auth
(Authentik / Keycloak / etc., per sso-setup.md),
note that forward-auth intercepts every request before it reaches the
API. A valid X-API-Key is not enough on its own — forward-auth
runs first and bounces the call to the IdP login flow before the
server ever sees the header.
Two supported patterns for service-to-service callers:
- Call from inside the proxy network. Bind your integrator (the
other Seglamater service, your daemon) onto the same Docker network
as
hivemind-server(or onto the host with access to127.0.0.1:8585) and callhttp://hivemind-server:3000/api/v1/*directly. Forward-auth does not run on intra-network traffic. - Configure a forward-auth bypass for
X-API-Key. Add a Caddy / Traefik / nginx route rule that skips forward-auth when theX-API-Keyheader is present. A Caddy snippet:
@api_key header X-API-Key *
handle @api_key {
reverse_proxy hivemind-server:3000
}
handle {
forward_auth authentik:9000 {
# ... your existing forward_auth config ...
}
reverse_proxy hivemind-server:3000
}
The server still validates the key (so a bypass isn't a bypass of auth — just of the forward-auth indirection); without a key, the request goes through the SSO flow as before.
Strip the IdP identity headers on this route. The snippet above is
incomplete: a bypassed request never passes through forward-auth, so any
X-Authentik-* (or equivalent) headers on it are client-supplied and
unverified. If the server trusts those headers to identify a caller, a
request carrying any X-API-Key plus a forged identity header becomes a
confused deputy. Strip them explicitly:
@api_key header X-API-Key *
handle @api_key {
reverse_proxy hivemind-server:3000 {
header_up -X-Authentik-Username
header_up -X-Authentik-Email
header_up -X-Authentik-Groups
header_up -X-Authentik-Uid
header_up -X-Authentik-Name
}
}
Match the header names to your IdP if you are not using Authentik. Apply the
same stripping to every route that bypasses forward-auth, including a keyed
/ws channel if you expose one.
The matcher tests header PRESENCE, not validity (header X-API-Key *).
That is by design — validation is the server's job — but it means this route
is reachable by anyone who sets the header to anything. Because require_admin
now enforces by default (see the top of this page), a valid low-privilege
key is refused at the admin surface with 403 — but only while enforcement
is left on. Do not set HIVEMIND_ENFORCE_ADMIN_ROLE to a falsy value on a
deployment that has issued non-admin keys.
What is not behind authentication¶
This is the complete set, taken from the router's public group plus the auth
and public-agent mounts — if you are hardening a deployment, this is the list
to work from. In the shipped Caddy config these are routed at the proxy before
the forward-auth handler.
| Endpoint | Status |
|---|---|
/api/v1/health |
Unauthenticated. Liveness for your proxy and the container healthcheck. |
/metrics |
Unauthenticated at the application layer — Prometheus cannot do SSO, so the network is the trust boundary. It exposes cost and customer-count data and must never be on a public route. An optional HIVEMIND_METRICS_TOKEN adds a Bearer check. See Metrics. |
/api/v1/public/agents/chat/{slug} |
Intentionally unauthenticated and rate-limited per client (see HIVEMIND_TRUSTED_PROXY_HOPS) — this is the public chatbot widget. |
/auth/* |
The login and registration routes themselves. |
/ws |
The handshake does not require a principal: the handler takes an optional user and then withholds every event that user may not see. Treat it as reachable without credentials. |
/ws/live is gated by default — it carries API-key, session and forward-auth
layers unless HIVEMIND_GATE_WS_LIVE is explicitly set to a falsy value, which
opts it back to anonymous. Leave it at the default.
Everything else under /api/v1/* is in the router group named authenticated,
and the /api/* web mounts carry a blanket API-key requirement.
Base URL is whatever your reverse proxy forwards /api/v1/* to. In the
examples below, $BASE is e.g. https://hivemind.example.com. On the
host directly, it is http://localhost:8585.
Conventions¶
- Content-Type:
application/jsonon writes; reads return JSON. - Errors: JSON of shape
{"error": "<code>", "message": "<text>"}on 4xx / 5xx. The HTTP status carries the category; theerrorcode is stable across versions for programmatic handling. - Pagination (where present): query params
?limit=<n>&offset=<n>, default limit varies per endpoint, no link headers — the response body hastotal/limit/offsetkeys when paginated. - Timestamps: RFC-3339 UTC throughout (e.g.
2026-05-28T13:14:15Z). - IDs: UUIDs unless otherwise noted.
Health + version¶
GET /api/v1/health¶
Unauthenticated. Liveness probe. Returns ok if the server process is serving requests; does not check the database.
GET /api/v1/health/db¶
Unauthenticated. Database reachability + migration state.
curl -fsS $BASE/api/v1/health/db
# {"status":"ok","migrations_applied":34,"latest_migration":"20260528000001_chatalot_integration_verify_tls"}
The combined ok shape is what the managed-update healthcheck waits for
before committing an update; if either returns non-200 within the
grace window, the updater rolls back. See upgrade.md.
Auth¶
POST /auth/register¶
First-time admin bootstrap. It creates your local password account, and it requires two things together:
- no local password account exists yet, and
- your installer's
ADMIN_API_KEY, sent in theX-API-Keyheader.
The key is not optional. Requiring it is deliberate: an account count alone answers "has anyone registered?", never "is this caller the operator" — so without the key the first request to reach the server would win, whoever sent it.
Your ADMIN_API_KEY is in the .env file the installer generated, in the
directory you installed into.
A second registration returns 409. Once you have an account, add further users through the user-management UI rather than this endpoint. With SSO forward-auth enabled this route is closed entirely — your identity provider creates identities instead.
curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "X-API-Key: $ADMIN_API_KEY" \
-d '{"username":"you","email":"you@example.com","password":"<strong password>"}' \
$BASE/auth/register
# {"id":"...","username":"you","email":"you@example.com","role":"admin"}
username, email and password are all required.
POST /auth/login¶
Local-account login. Returns a session cookie (SET-COOKIE: hm_sess=…)
to keep across subsequent browser calls. Daemon callers should use
X-API-Key instead — /auth/login is for the web UI.
curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"<password>"}' \
-c cookies.txt \
$BASE/auth/login
# {"id":"...","username":"you","email":"you@example.com","role":"admin"}
Flat, not wrapped — there is no "ok" key and no nested "user" object.
GET /api/v1/me¶
The currently authenticated user. Useful for daemons to confirm the API key resolves to the expected service account.
curl -fsS -H "X-API-Key: $ADMIN_API_KEY" $BASE/api/v1/me
# {"id":"...","username":"...","email":"...","role":"admin","auth_source":"local","is_active":true,"api_key":"...","created_at":"...","updated_at":"..."}
There is no tier field. Note that this endpoint returns the caller's
own api_key in plaintext — expected for a daemon confirming its
own credential, but worth knowing before piping this response anywhere
it might get logged.
POST /api/v1/me/api-key/rotate and POST /api/v1/admin/users/{user_id}/api-key/rotate¶
Replace an API key. The first rotates the caller's own key. The second rotates another user's key and requires the admin role.
Interactive sessions only. Both routes refuse every bearer credential,
including an X-API-Key caller, whatever role that key carries:
The refusal is deliberate. If a key could rotate keys, whoever held a leaked
key could rotate the owner's key and lock the owner out. A browser-session
request must also pass the same-origin and CSRF check
(api_key_rotation_csrf), and the web UI does not yet offer a rotation
control. To rotate a key today, use the host command in
Troubleshooting → API keys. It
covers every user, including the bootstrap admin and the runner.
On success:
The previous key is refused from the moment the rotation commits. The response
carries Cache-Control: no-store.
Named refusals on the admin route:
| Status | code |
Meaning |
|---|---|---|
| 403 | api_key_rotation_requires_admin |
The caller is not an admin. |
| 409 | api_key_rotation_service_principal |
The target is the runner. Rotate it on the host, which reports the .env step the runner needs. |
| 409 | api_key_rotation_agent_credential |
The target is an agent credential. Use POST /api/v1/agents/{id}/credential/rotate. |
| 404 | api_key_rotation_not_found |
No active user has that id. |
Every attempt is recorded in the api_key_rotations table, including the
refused ones. Key values are never recorded.
Public chatbot¶
POST /api/v1/public/agents/chat/{slug}¶
Unauthenticated. The customer-facing public chat surface — embed this in a marketing site, a help widget, etc. Only profiles at tier 1 ("public") can be invoked through this endpoint. Hivemind applies a full security pipeline to every request: per-client rate limiting, input length cap, prompt-injection regex, PII scrubbing on input, cost-cap check, then the LLM call, then leak detection + PII scrub on the response.
curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"message":"What is Hivemind?","conversation_id":null}' \
$BASE/api/v1/public/agents/chat/support
# {
# "conversation_id": "0199ae…",
# "message": "Hivemind is a self-hosted multi-agent orchestration platform …",
# "tokens": {"input": 42, "output": 156, "cache_read": 0},
# "cost_cents": 0.42,
# "visitor_token": "5f0c…"
# }
Embedders must thread conversation_id and visitor_token back on every turn to keep
conversational context — without it, every call lands as a fresh
conversation and the agent has no memory of prior turns (HIVE-49 was a
client-side bug in an embedder that did exactly this — the Hivemind
server hydrates up to the last 10 messages from the agent_messages
table when a valid conversation_id is supplied, but an embedder that
doesn't echo it back gets a stateless surface). The conversation has a
TTL (configurable per profile). After it, a turn naming the conversation
starts a new one; the expired conversation's messages are kept, not deleted.
Multi-turn example (the second turn must pass conversation_id and
visitor_token from the first turn's response):
# Turn 1: start fresh.
FIRST=$(curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"message":"What is your support email?","conversation_id":null}' \
$BASE/api/v1/public/agents/chat/support)
CONV=$(echo "$FIRST" | jq -r .conversation_id)
TOKEN=$(echo "$FIRST" | jq -r .visitor_token)
# Turn 2: thread the same conversation_id and visitor_token back.
curl -fsS -X POST -H 'Content-Type: application/json' \
-d "{\"message\":\"And what was that email again?\",\"conversation_id\":\"$CONV\",\"visitor_token\":\"$TOKEN\"}" \
$BASE/api/v1/public/agents/chat/support
# {"conversation_id":"<same as turn 1>","message":"As I mentioned, our support email is …", …}
visitor_token is a secret the server returns once, in the response that
starts a conversation. Store it where the visitor's page keeps its
conversation id (for example the widget's session storage), send it on every
later turn, and never log it. The server stores only a hash of it. A turn
that sends a token gets no new one. An empty token counts as none; a token
longer than 128 bytes is refused with 400.
The server validates that the conversation_id belongs to the same
profile + visitor; cross-profile or cross-visitor IDs are silently
ignored (the server starts a fresh conversation), which prevents
history-injection attacks via a guessed UUID. On this route the visitor is
its visitor_token, and nothing else: never its address, which visitors
behind one router, one carrier address or one proxy share. A turn without
a token always starts a new conversation and gets a new visitor_token,
whatever conversation_id it names. A visitor_id in the body is ignored.
The address is used only to count the rate limit (below).
CORS¶
The public chat endpoint is the one route a browser on a different
origin would call directly (e.g. a marketing site at
https://example.com embedding a chat widget that hits
https://hivemind.example.com/api/v1/public/agents/chat/…). To enable
cross-origin browser calls, set the env var
HIVEMIND_CORS_ALLOWED_ORIGINS to a comma-separated list of exact
origins (no wildcards by design — explicit allowlist):
With the env set, the public chat endpoint responds with
Access-Control-Allow-Origin headers matching the request's Origin
when it's on the list, plus the preflight handling browsers need
(OPTIONS requests, Access-Control-Allow-Methods: POST, OPTIONS,
Access-Control-Allow-Headers: content-type). When the env var is
unset or empty, no CORS layer is installed and the behavior is the
pre-HIVE-35 default (same-origin only). This is opt-in by design —
the operator decides which origins they trust.
Scope: CORS is only attached to the public chat router. Admin and
integration routes are server-to-server and don't need (or get) the
CORS headers. The HIVE-35 implementation lives at
crates/hivemind-server/src/cors.rs.
Status codes:
- 200 — response generated.
- 400 — input failed validation (length, JSON shape).
- 429 — rate limited. The body's retry_after is in seconds.
Which forwarding header the proxies write is set by
HIVEMIND_TRUSTED_PROXY_HEADER: x-forwarded-for (each proxy appends to it;
what the install's compose file sets) or x-real-ip (the outermost proxy
sets it). Only the named header is read, never the other. When it is not
set, no forwarding header is trusted, and every visitor counts at the
connection's address, as with HIVEMIND_TRUSTED_PROXY_HOPS at 0. From X-Forwarded-For the limiter reads
exactly the last HIVEMIND_TRUSTED_PROXY_HOPS entries, across every line of
the header; if one of them is not an address, it uses the connection's
address. The server logs the setting it is using at startup.
The limit is counted against what the server observed about the
caller, never against anything in the request body (HIVE-1061). By
default that is the peer address of the connection. An IPv6 caller is
counted by its /64, since one visitor can use any address in it, and an
IPv4 address written in IPv6 form (::ffff:a.b.c.d) counts as that IPv4
address. If a reverse proxy
sits in front — it almost certainly does — set
HIVEMIND_TRUSTED_PROXY_HOPS to the number of proxies that append to
X-Forwarded-For, and the limiter counts that many entries from the
right of the chain. Leave it at 0 and every visitor shares the proxy's
single bucket: still a ceiling, but one shared by your whole audience.
Do not set it higher than the real hop count — that hands the key back
to the caller, which is the bug this replaced. If your proxy adds neither
X-Forwarded-For nor X-Real-IP, Hivemind sees every visitor as the
proxy, whatever this is set to: configure the proxy to add
X-Forwarded-For, then set HIVEMIND_TRUSTED_PROXY_HOPS and
HIVEMIND_TRUSTED_PROXY_HEADER=x-forwarded-for.
- 503 — LLM provider unreachable or the configured agent's tier-cap is hit.
Private chat (authed)¶
POST /api/v1/agent-chat/chat/{slug}¶
Authed. Same shape as the public chat endpoint, but invokable for any profile tier (not just tier 1). Used by service-to-service callers and the admin UI's internal chat console.
A conversation belongs to the key or signed-in user that made the call. An optional
visitor_id in the body separates conversations within that key (for a service
acting for many end users); it can never reach another key's or user's conversation.
The Facet assistant profile cannot be used through this route (403).
curl -fsS -X POST -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"message":"summarize today'"'"'s audit log","conversation_id":null}' \
$BASE/api/v1/agent-chat/chat/ops
Agent profiles¶
Customer admins manage which AI personas (profiles) the install serves. Each profile pins: tier (public / semi-public / internal), default model, system prompt, cost cap, rate limit, and which security filters apply.
GET /api/v1/agent-chat/profiles¶
List all profiles. Administrators only, here and on GET .../profiles/{slug}
(any other key or session gets 403): a profile carries the bot's system prompt.
curl -fsS -H "X-API-Key: $ADMIN_API_KEY" $BASE/api/v1/agent-chat/profiles
# [{"slug":"support","name":"Support","security_tier":1,"model":"claude-haiku-4-5-20251001","cost_cap_daily_cents":500,"rate_limit_rpm":10,"enabled":true,…,"integrations":{}}, …]
A bare array, not {"profiles":[...]}. Every AgentProfile column is
flattened to the top level (there are more than shown above —
system_prompt, max_tokens, temperature, pii_scrub, and others)
plus an additive integrations object (empty unless a chatalot bot
token is configured for that profile).
POST /api/v1/agent-chat/profiles¶
Admin role required, here and on PUT below: a profile sets a bot's system
prompt and its cost cap, so a non-admin key gets 403. (Chat through
POST /api/v1/agent-chat/chat/{slug} is unchanged.)
Create a profile. slug is unique + URL-safe. Note the real field
names: security_tier (not tier), cost_cap_daily_cents (not
cost_cap_usd — this one is real cents, not dollars), and
rate_limit_rpm (not rate_per_min). Unrecognized field names are
rejected (HIVE-1092). They used to be ignored silently, so a request
built against the wrong names returned 201 while quietly keeping every
default — an operator who believed they had set a cost cap had not.
A security_tier outside 1–3 is a 400 rather than a stored value that
matches no tier branch in the request pipeline.
curl -fsS -X POST -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"slug":"qa-helper",
"name":"QA Helper",
"security_tier":2,
"model":"claude-haiku-4-5-20251001",
"system_prompt":"You are a QA assistant…",
"cost_cap_daily_cents":2000,
"rate_limit_rpm":30
}' \
$BASE/api/v1/agent-chat/profiles
PUT /api/v1/agent-chat/profiles/{slug}¶
Update an existing profile (partial — only the fields you send are changed). There is no delete endpoint — retire a profile by disabling it instead, the same control the web UI's pause/resume button uses:
curl -fsS -X PUT -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"enabled": false}' \
$BASE/api/v1/agent-chat/profiles/qa-helper
A disabled profile keeps its cost, usage and audit history. Hard
deletion would cascade-delete that history along with it
(agent_usage.profile_id is ON DELETE CASCADE) — the opposite of
what a billing or audit trail is for, which is why this isn't offered.
Cost + usage reporting¶
GET /api/v1/agent-chat/usage/summary¶
Today's spend across all profiles. Use this to wire a billing dashboard or a budget-warning alert.
curl -fsS -H "X-API-Key: $ADMIN_API_KEY" $BASE/api/v1/agent-chat/usage/summary
# [
# {"profile_slug":"support","profile_name":"Support","today_cost_cents":210,"today_requests":1100,"today_input_tokens":71200,"today_output_tokens":26900,"cap_cents":500,"cap_pct":42.0},
# {"profile_slug":"marketing","profile_name":"Marketing","today_cost_cents":132,"today_requests":140,"today_input_tokens":18221,"today_output_tokens":4304,"cap_cents":2000,"cap_pct":6.6}
# ]
A bare array, one entry per profile — not the nested date/totals/by_profile
shape older drafts of this doc described. Costs are in cents
(today_cost_cents, cap_cents), not dollars.
Per-call raw rows (one row per request, for finer-grained billing than
this daily-per-profile summary) are recorded in the database today but
not yet exposed through the API — there is no /costs endpoint.
Tracked as HIVE-1091.
Audit log¶
GET /api/v1/agent-chat/audit?limit=100¶
Security-relevant events: detected prompt injections, rate-limit
hits, leak-detector triggers, cost-cap blocks. One row per event, newest
first. Administrators only (any other key or session gets 403).
limit defaults to 30 and is capped at 100.
The audit log is append-only in v1 (no delete endpoint). Retention is governed by the install's database — back it up if you need it kept beyond your operational retention.
Conversations¶
GET /api/v1/agent-chat/conversations¶
List recent conversations across every profile and user, newest first.
Administrators only (any other key or session gets 403). limit
defaults to 20 and is capped at 100. A person's own Facet conversations
are listed by GET /api/assistant-user/conversations.
GET /api/v1/agent-chat/conversations/{id}/messages¶
Full message history for one conversation. Order is oldest-first.
Administrators only (any other key or session gets 403).
Tasks¶
Tasks are the unit of work hivemind agents pick up. Service integrators typically create tasks via the API + watch their status transition.
Enums¶
| Enum | Wire values |
|---|---|
TaskStatus |
backlog, todo, in_progress, in_review, done, cancelled |
TaskPriority |
low, medium, high, critical |
There is no urgent priority — critical is the highest. Closed
tasks are done (not closed or complete). A guess at any other
value returns 400.
Agents¶
POST /api/v1/spawn¶
Authed. Ask this instance to start an agent. Returns 201 Created with the
agent record.
This is the endpoint behind the dashboard's own "spawn" control — the same route, the same body.
Before this can work, the instance needs an agent runner that actually
runs. That is a separate, opt-in component with three prerequisites of its own
(a credential, a runner image, and a bind-mounted repository), all covered in
Install. install.sh
checks all three and prints an Agent runner section at the end saying which
are satisfied. If it told you agents are unavailable, this endpoint will accept
your request and nothing will execute it — see
Troubleshooting.
curl -X POST http://localhost:8585/api/v1/spawn \
-H "X-API-Key: $HIVEMIND_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "first-agent",
"role": "builder",
"project_id": "<uuid>"
}'
Required fields. Only these three:
name(string): the agent's name. Validated — letters, digits, dashes and underscores; a malformed name returns400before anything else is checked.role(string, lowercase): one ofcoordinator,builder,reviewer,auditor,security,researcher,tester,deliberator,planner,custom. Sent lowercase;"Builder"is not accepted.project_id(UUID): the project the agent works in. See below for how to get one.
Useful optional fields. All default to absent:
initial_prompt(string): what the agent should do. Without it the agent starts with only its role template.template(string):coordinator,builder, orcustom. Defaults to the role's own template.custom_claude_md(string): rawCLAUDE.mdcontent, used whentemplateiscustom.model(string): e.g.claude-sonnet-4-6. Defaults to the Claude Code default.base_branch(string): branch the agent's worktree starts from. Defaults tomain.persistent(bool): keep the worktree across restarts and resume with--continue.
Getting a project_id¶
An agent works inside a project. List the ones this instance has:
If that returns an empty list, create one. The project is owned by whoever
the key belongs to; an owner_id in the body is ignored. Creating a project
needs the user role or higher (a read-only account is refused), and changing
one later needs its owner or an administrator. Every signed-in account can list
and read every project, but a project's repo_path is shown to administrators
only; for anyone else it reads null:
curl -X POST http://localhost:8585/api/v1/projects \
-H "X-API-Key: $HIVEMIND_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name": "my-project"}'
What authenticates a spawn¶
The same X-API-Key header as every other authed endpoint on this page — the
ADMIN_API_KEY from your .env works, and so does any other active user's key.
A browser session that is already signed in is accepted too, which is how the
dashboard control works without sending a key.
A spawn is a privileged write, in the same class as changing settings or killing an agent. Two ways it refuses, and they are different problems:
means no header arrived. If you are behind a reverse proxy with forward-auth, see Auth — the proxy must be configured to let the header through, or it is stripped before it reaches Hivemind.
means the header arrived and the key is not a live one. Re-read it from .env:
What you see when it works¶
201 Created, with the agent record — including its id and a status. The
agent then appears in the dashboard's agent list and in GET /api/v1/agents.
201 means the request was accepted and the agent row exists. It does not by
itself mean an agent is running. The instance broadcasts the spawn to a
connected runner; if no runner is connected, the row is created and nothing
picks it up. To confirm a runner is actually there before you rely on it:
sudo docker compose --profile runner ps hivemind-runner
sudo docker compose --profile runner logs --tail 20 hivemind-runner
A ready runner logs
[hivemind-daemon] ✓ daemon ready and waiting for spawn requests. If that line
is absent, start with Troubleshooting rather
than with this endpoint — the request is almost certainly fine and the runner
is not there to receive it.
POST /api/v1/tasks¶
Authed. Create a task, in any project. Creating one needs the user role or
higher (a read-only account is refused).
The server records who created it: created_by_user is the account that made
the request, and created_by is the agent that did, which is the agent
your key belongs to. A key that is not an agent's leaves created_by null. A
created_by in the body is taken only from an administrator's key, acting on
an agent's behalf; from anyone else it is ignored.
curl -fsS -X POST -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"project_id":"019e6632-…",
"title":"Investigate the chatalot connector regression",
"description":"Customer reports… (full context)",
"priority":"high"
}' \
$BASE/api/v1/tasks
Fields:
- project_id (required, UUID): the project the task belongs to.
- title (required, string, 1-500 chars).
- description (optional, string).
- priority (required, TaskPriority enum).
- directive_id (optional, UUID): link to a parent directive.
- parent_task_id (optional, UUID): link to a parent task.
- branch_name (optional, string): the git branch the work happens on.
- created_by (optional, UUID): the agent that created this task. Honored
only from an administrator's key (see above).
- metadata (optional, JSON object): freeform.
On FK violation (e.g. created_by referencing a missing agent), the
server now returns 400 with a specific message naming the column +
referent — e.g. created_by must reference an existing agent (not a
user). Hard-to-debug opaque constraint-name errors are gone (HIVE-50).
GET /api/v1/tasks?project_id=<uuid>&status=todo¶
Authed. List tasks with optional filters.
PATCH /api/v1/tasks/{id}¶
Authed. Update a task. Changing a task (this, DELETE, and
POST /api/v1/tasks/{id}/assign) needs authority over that task: the account
that created it, the owner of its project, an agent key whose agent created it
or works in its project, or an administrator. Anyone else gets 403 and the
task is unchanged. Status transitions to in_progress are blocked
if the task has incomplete dependencies — the response message names
the blockers.
No task has dependencies today, so this never holds a task. The dependency
list cannot be populated through the API: neither the task-creation nor the
task-update payload carries a depends_on field, so the list is always empty and
the check has nothing to evaluate. The column and the check both exist — what is
missing is a way to supply a value, which is unbuilt work rather than a setting.
DELETE /api/v1/tasks/{id}¶
Authed. Needs authority over the task, as for PATCH.
MCP servers for agents¶
Giving a launched agent a tool is two steps: register the server, then grant its tools. Registering alone gives the agent a server whose every tool is denied; granting alone names a server that does not exist. Both are admin-only operator state, and nothing an agent or a chat request says can change either.
Worked end to end below with a fictional server, inventory-db.
Step 1 — register the server¶
PUT /api/v1/agents/mcp-servers (admin)¶
Register the set of MCP servers agents may be given. As with grants, the whole
set is replaced by what you send: a server you leave out is removed. Send []
to remove them all.
curl -fsS -X PUT -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '[
{"name":"inventory-db",
"command":"/opt/inventory/mcp-server",
"args":["--port","5432"],
"env":{"INVENTORY_TOKEN_FILE":"/run/secrets/inventory"},
"description":"read-only inventory lookups"},
{"name":"report-store",
"command":"/opt/reports/mcp-server"}
]' \
$BASE/api/v1/agents/mcp-servers
args and env are optional. A disabled server stays registered and is not
given to agents.
enabled when you leave it out: a server whose name is new is created
disabled, and one that already exists keeps its current state. Turn a new
server on deliberately, with "enabled": true or from Content Studio, once you
know it works. Nothing is granted by registering a server.
Credentials go in the secret store, never in env or args. An env value
may be a plain string or a reference to a stored secret:
"env": { "INVENTORY_URL": "https://inventory.internal",
"INVENTORY_TOKEN": { "secret_ref": "inventory_token" } }
Secrets are stored, sealed, from Content Studio, MCP servers, Secrets, and are
never returned by any route, admin included. Only the agent runner receives the
value, when it starts a server that references it. A current runner never writes
the value to a file. It passes the value in the agent's environment, and the
hivemind-mcp-launch launcher in the runner image hands it to that server alone.
An older runner writes it into the MCP configuration file in the agent's working
copy; recreate the runner to take the fix. Either way, a credential given to a
server is readable by the agent that runs it. The secret store keeps credentials
out of the database, out of every API response and out of the configuration
record. It does not hide a server's credential from that server's agent.
What a server's environment holds. A server started by the launcher
inherits only HOME, LOGNAME, PATH, SHELL, TERM, USER, LANG,
LC_*, TZ, TMPDIR, HTTP_PROXY, HTTPS_PROXY, NO_PROXY (either case),
SSL_CERT_FILE, SSL_CERT_DIR and NODE_EXTRA_CA_CERTS. It also gets its own
configured env and its secrets. Nothing else in the agent's environment
reaches it. If a server needs another variable, add it to that server's env.
A key that names a runner variable (HIVEMIND_*, the runner's credentials) is
refused.
HM_MCP_ is reserved for these variables. A literal command, args entry,
env key or env value that contains it is refused when the agent starts. A
runner finds the launcher on its PATH; set HIVEMIND_MCP_LAUNCH_PATH on the
runner to point elsewhere. The secret store needs the
integration encryption key (HIVEMIND_INTEGRATION_ENCRYPTION_KEY_FILE); without
it, secrets cannot be stored.
Optional If-Match. Send the ETag from a previous response as If-Match
and the replace is refused with 409 if the set changed since. Without the
header the replace is unconditional, as before. Content Studio always sends it.
The response is the full stored set, including enabled and description.
Read it rather than round-tripping the GET below: that read is scoped to
enabled servers and returns only what an agent needs, so sending it back would
drop your disabled rows and blank your descriptions.
This adds no new execution capability
The command you register is started on the host the agent runs on. That was already true — the agent has read this configuration since it was introduced, and the only way to write it was direct database access. This endpoint does not widen what can be run; it replaces a database prompt with an admin-gated, validated API, and the validation here is stricter than the database's.
command and args are carried as separate values the whole way and are
never joined into a single string, so shell metacharacters in an argument
are ordinary characters rather than syntax.
What is refused (a 400 naming the value, before anything is written):
- a name that could not later be granted — the rules are the grant rules exactly, so every server you can register is one whose tools you can grant. See What is refused under grants below.
- a blank command, which would register a server with nothing to start.
- a control character in a command, an argument or an env value. These are written into a configuration document the agent reads, and a control character is how such a document comes to mean something other than it reads as.
- an environment variable name outside
[A-Za-z_][A-Za-z0-9_]*, which the process starting the server could not export — storing it would be configuration that silently does nothing. - the same server named twice in one payload.
- a credential in plaintext: an
envvalue under a credential-like name (ending inTOKEN,SECRET,PASSWORD,KEY,AUTHand similar), a value shaped like a well-known token, a key or a URL carrying a password, or an argument that passes one (--token=...,--api-key <value>). Store it as a secret and reference it fromenv. An argument cannot hold a reference, so a credential a server takes as a flag has to be passed through its environment. - a reference to a secret that is not stored.
Every configuration change is recorded (who, when, which servers were added, removed or changed, which secrets are referenced). The record never contains a value.
GET /api/v1/agents/mcp-servers¶
The enabled set, in the shape an agent receives. Launched agents call this themselves.
curl -fsS -H "X-API-Key: $API_KEY" $BASE/api/v1/agents/mcp-servers
# [{"name":"inventory-db","command":"/opt/inventory/mcp-server",
# "args":["--port","5432"],"env":{"INVENTORY_TOKEN_FILE":"[redacted]"}}]
env values are redacted unless you are entitled to them
Only the agent runner receives secret values, resolved from the secret
store, because it needs them to start the server. An admin key sees
plain values and [redacted] for every secret reference: a stored secret is
never read back. Every other key, including a readonly user and any agent,
sees the variable names with every value replaced by [redacted].
If a stored configuration still carries a credential in plaintext, the runner's read is refused (naming the server), and agents do not start until it is moved into the secret store. On upgrade the server does this itself: with an integration key, plaintext credentials are sealed into the secret store. Without one, nothing is deleted: the server is disabled until the key exists, and the credentials are sealed then. A server cannot be enabled while it still holds a plaintext credential.
A whole-set PUT can carry a stored value it cannot see:
{"keep_stored": true} for an env value, and {"keep_stored_arg": <i>}
for an argument, keep what is stored for that server.
The marker is deliberate rather than an empty string: an empty value would be indistinguishable from a variable you had set to empty.
Step 2 — grant its tools¶
Grants are keyed on the server name exactly as you registered it.
GET /api/v1/agents/mcp-grants¶
Read the current grant set. Launched agents call this themselves; you can call it to see what they will get.
curl -fsS -H "X-API-Key: $API_KEY" $BASE/api/v1/agents/mcp-grants
# [{"server_name":"inventory-db","tools":["lookup_item","list_items"],"readonly_roles":false}]
PUT /api/v1/agents/mcp-grants (admin)¶
Write the grant set. The whole set is replaced by what you send — this is a collection PUT, not a partial update. A server you leave out of the body is revoked. That is deliberate: an update-in-place could not express revocation, and a revocation that silently did not take effect is the failure worth preventing on a permission surface.
curl -fsS -X PUT -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '[
{"server_name":"inventory-db",
"tools":["lookup_item","list_items"],
"readonly_roles":false},
{"server_name":"report-store",
"tools":null,
"readonly_roles":true}
]' \
$BASE/api/v1/agents/mcp-grants
To revoke report-store, send the same body without it. To revoke
everything, send [].
tools: three states, and they mean different things¶
| value | meaning |
|---|---|
null |
every tool on that server |
| a list | exactly those tools, and no others |
[] |
no tools — the server is configured and deliberately granted nothing |
null and [] are kept distinguishable on purpose. If they collapsed into one
value, a grant you had deliberately emptied would be indistinguishable from a
server nobody had got to yet.
readonly_roles: an opt-in, defaulting to off¶
Read-only roles — which is every agent started from a chat — receive a grant
only when readonly_roles is true. It defaults to false, so a grant you
wrote with a building agent in mind does not silently widen what a read-only
agent may do. The building and testing roles receive every grant regardless of
this flag.
What is refused¶
Server and tool names must match [A-Za-z0-9_-]+, and a PUT that breaks one of
these rules is a 400 naming the offending value rather than a partial write.
- An asterisk anywhere. The permission grammar treats
*as a wildcard inside a tool name, so an unvalidated name carrying one would silently widen the grant instead of failing. __in a server name. Entries are spelledmcp__<server>__<tool>, so a server nameda__bwould produce an entry the matcher reads as servera, toolb— a grant landing on a server you never named.- A dot in a name. There is no dotted form in the permission grammar, so a
server named
my.dbcannot be granted. Rename it without the dot before granting it. This fails closed: the grant is dropped, not guessed at. - The name
hivemind. That is the agent's own control channel. Its tools are granted per role by the agent's profile, and a grant row could only widen that — handing a read-only agent the tools its role deliberately withholds.
Nothing you send is ever copied verbatim into a permission list. Entries are constructed from validated parts, so a value shaped like a permission rule cannot become one by being echoed into the file.
Confirming an agent received a grant¶
Launch an agent and read the settings file it actually loaded, under the agent's
state directory ($HIVEMIND_AGENT_STATE_DIR/<agent-id>/):
| sandbox | the file the agent loads |
|---|---|
| Landlock | fixed/settings.json |
| bubblewrap | home/.claude/settings.json |
Your entries appear under permissions.allow, spelled mcp__<server> (a whole
server) or mcp__<server>__<tool> (one tool).
Do not check the .claude/settings.json inside the agent's worktree. A
sandboxed agent does not load that file, and it is writable by the agent itself,
so it is neither where grants are delivered nor a trustworthy place to read them
from.
One caveat: tools that reach the host's container runtime¶
If an MCP server's tools work by talking to the host's container runtime socket, whether a grant is meaningful depends on the sandbox:
| where the agent runs | the socket | what a grant means |
|---|---|---|
| the shipped agent runner image | not present | nothing to reach; the grant does nothing |
| under bubblewrap | hidden by the sandbox | the grant does nothing |
| under Landlock, where the account running the agent can already reach the socket | reachable | the grant decides whether the agent may use it |
In that last case the grant is a real capability, not a formality: it gives a
launched agent control of that host's containers. Treat it as you would any other
grant of host access, and note that readonly_roles is what decides whether an
agent started from a chat inherits it.
The general rule: a containment property of one sandbox is not a property of all of them. Decide a grant against the weakest sandbox you actually run.
Health check targets¶
An agent's infra_health tool can check two things you configure: endpoints
(an HTTP request, reporting status code and response time) and TLS domains
(the certificate expiry date). Both are your own services — there is no default
list, and nothing is checked until you set one.
Configure them here, on the instance. They are handed to each agent when it starts.
If you set HIVEMIND_HEALTH_URLS in your environment
Those two variables are read by the tool, which runs in the agent runner — and they are normally set on the instance service, where nothing reads them. If you configured them that way and the tool told you "no targets configured", that is why. Configure them here instead.
Your environment still works as a fallback: while this list is empty, the runner's own variables are used unchanged.
Reading them¶
curl -s https://your-instance.example.com/api/v1/agents/health-targets \
-H "X-API-Key: $HIVEMIND_API_KEY"
{
"endpoints": [
{ "label": "site", "target": "https://www.example.com/" },
{ "label": "api", "target": "https://api.example.com/healthz" }
],
"tls_domains": [
{ "label": "site", "target": "www.example.com" }
]
}
Setting them¶
Admin only, and the whole set is replaced on each call — send the complete list you want, not a change to it. An empty object clears everything.
curl -sX PUT https://your-instance.example.com/api/v1/agents/health-targets \
-H "X-API-Key: $HIVEMIND_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{
"endpoints": [
{ "label": "site", "target": "https://www.example.com/" },
{ "label": "internal-api", "target": "http://192.168.10.20:8080/healthz" }
],
"tls_domains": [
{ "label": "site", "target": "www.example.com" }
]
}'
label is what appears beside the result. Each label is used once per list;
sending the same one twice is refused rather than silently keeping the last.
Internal and private addresses are fine. These are your own services, and whether the runner can actually reach one is a matter of your network — this list does not change what it can reach, only what it is asked to check.
What is refused¶
- An endpoint that is not an
http://orhttps://URL. The tool makes an HTTP request; there is nothing it could do with another scheme. - A TLS domain that is not a bare hostname.
example.com, nothttps://example.com, no port and no path — it is used directly as the host to connect to. - Either one containing a space or any of
&;$()'"`|<>\. These reach a command line on the machine running the check, so they are not storable. This is stricter than the URL standard allows: a query string joining two parameters with&is refused. If you need a target that cannot be expressed without one, tell us — that is a gap to fix here, not to work around.
A refusal names the entry and says which rule it broke.
Confirming an agent received them¶
Start an agent and ask it to run its health check. If the list is configured and the agent still reports no targets, that agent is running an older runner image: the targets are handed over when the agent starts, and a runner from before this feature ignores what it is handed. Upgrading the instance does not replace the runner — see Upgrade, "The agent runner is NOT upgraded by an apply".
Schedules (HIVE-3)¶
Schedules pair a 5-field cron expression with a directive template. A
60-second tick loop in the server sweeps the table each minute; when a
schedule's next_run_at is in the past and enabled is true, the loop
materializes a fresh directive from the template, advances next_run_at
to the cron's next firing, and stamps last_run_at + last_directive_id
on the row.
Use this to run autonomous ops sweeps, nightly backup verifications, weekly bug triage, etc. without an external cron / scheduler. The coordinator pipeline runs as normal against the fired directive.
Cron format¶
5-field standard Unix cron (m h dom mon dow), UTC only in v1.
Examples:
- 0 2 * * * — 02:00 UTC daily.
- 30 6 * * 1 — 06:30 UTC every Monday.
- */15 * * * * — every 15 minutes.
- 0 0 1 * * — midnight UTC on the 1st of every month.
The server validates the expression at create / update time; an invalid
cron returns 400 with a message naming the field count or parse error.
Backfill posture¶
Missed windows are not backfilled. If the server is down for a day
and a daily schedule's next_run_at passes, the next sweep after
restart fires the schedule once, then sets next_run_at to the
next cron firing after NOW(). A weekly-down-then-restart does not
unleash seven scheduled directives.
Disable globally¶
Set HIVEMIND_SCHEDULES_ENABLED=false to disable the tick loop without
deleting any rows. The next start with the env unset / true resumes.
On a compose install this does not reach the server today. The shipped
docker-compose.yml does not declare HIVEMIND_SCHEDULES_ENABLED under the
server service's environment: block, and there is no env_file: directive,
so an undeclared name is not passed through: setting it succeeds at every
visible step and the tick loop keeps running. Confirm with docker inspect
hivemind-server and look for the name in Config.Env. The same check and the
reason behind it are on Feature flags.
POST /api/v1/schedules¶
Authed. Create a schedule.
curl -fsS -X POST -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "Morning ops sweep",
"cron_expression": "0 13 * * *",
"project_id": "019e6632-…",
"directive_template": {
"title": "Morning ops sweep",
"description": "Run infra health + Plane triage + flag CRITICAL findings",
"priority": "high",
"metadata": {"report_via": "telegram"}
},
"enabled": true
}' \
$BASE/api/v1/schedules
# { "id": "0199ae…", "next_run_at": "2026-05-29T13:00:00Z", "enabled": true, … }
Fields:
- name (required, 1-200 chars).
- cron_expression (required, 5-field Unix cron).
- directive_template.title (required), plus optional description,
priority (low|medium|high|critical), metadata (JSON object).
- project_id (optional, UUID): the project directives fire into.
- enabled (optional, default true).
The materialized directive's metadata is the template's metadata
merged with a scheduled_by: {schedule_id, schedule_name, cron_expression}
provenance block — so a directives-table consumer can tell at a glance
which entries were cron-fired and which were human-created.
GET /api/v1/schedules?project_id=…&enabled=true¶
Authed. List schedules, ordered by next_run_at ascending.
GET /api/v1/schedules/{id}¶
Authed. Get one schedule.
PATCH /api/v1/schedules/{id}¶
Authed. Partial update. Sending a new cron_expression triggers
re-validation + recomputation of next_run_at; sending only
enabled: false leaves next_run_at alone (so a temporarily-disabled
schedule resumes its original timing when re-enabled).
curl -fsS -X PATCH -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"enabled": false}' \
$BASE/api/v1/schedules/$SCHEDULE_ID
DELETE /api/v1/schedules/{id}¶
Authed. Returns 204 on success, 404 if not found.
Monitors¶
A monitor checks one URL on a timer and writes one message into the assistant conversation that asked for it when the URL answers with the expected HTTP status. Then it stops. A check makes no AI model call.
Monitors are created only from the assistant chat: the assistant proposes a
watch, and the monitor exists once you confirm that proposal. There is no
create endpoint. In this release only administrators can confirm from chat.
Limits: at most 10 active monitors per user and 200 per installation, at least 60 seconds between checks (default 5 minutes), and at most 7 days of watching (default 24 hours). A monitor that reaches its number of checks or its time limit without the condition being met stops and says so in the conversation. It also stops, before sending any request, if its owner is deactivated, if the owner's role now grants less than it did when the monitor was confirmed, or if its conversation is deleted.
What a check sends: one GET, with no redirects followed, no credentials
and no headers you choose. The response body is never read. Loopback,
link-local (including cloud metadata) and CGNAT addresses are always
refused. Private (RFC 1918 / ULA) addresses are refused unless an
administrator lists the range in HIVEMIND_MONITOR_ALLOWED_CIDRS
(comma-separated CIDRs). The address is resolved again on every check.
Results are posted only into the conversation. Hivemind does not notify you anywhere else yet.
Settings, all read by the server:
| Variable | Default | Meaning |
|---|---|---|
HIVEMIND_MONITORS_ENABLED |
true |
false stops the checker; monitors are kept and the Agents page says they are not being checked |
HIVEMIND_MONITOR_ALLOWED_CIDRS |
empty | private ranges a monitor may reach |
HIVEMIND_MONITORS_MAX_ACTIVE |
200 |
active monitors per installation |
The shipped docker-compose.yml declares all three under the server
service. An update does not rewrite an existing install's compose file, so an
install made before this release must add the three lines to its server
service to change them; until then the defaults apply, and the defaults are
what most installs want.
GET /api/v1/monitors¶
Your monitors, newest active first. An administrator sees every monitor.
POST /api/v1/monitors/{id}/stop¶
Stops one of your monitors (an administrator may stop any). Stopping a monitor that has already ended returns its current state. A monitor that is not yours returns 404, the same as one that does not exist.
Integrations: chatalot¶
Hivemind's chatalot integration is per-agent: each agent (a stored
identity in your agents table) gets its own chatalot bot token,
provisioned on first connect.
GET /api/v1/agents/{agent_id}/chatalot¶
Authed. Get the connection status of an agent's chatalot integration.
curl -fsS -H "X-API-Key: $ADMIN_API_KEY" \
$BASE/api/v1/agents/$AGENT_ID/chatalot
# {"agent_id":"…","instance_url":"https://chat.example.com","verify_tls":true,"bot_user_id":"…","bot_username":"…","token_prefix":"…","status":"active","configured_at":"…","last_used_at":"…","last_error":null}
There is no connected boolean — an agent with no integration
configured gets 404 {"error":"not_configured","detail":"no chatalot
integration configured for this agent"} instead of a 200 with
connected:false. There is no last_connected_at; the closest fields
are configured_at (when the integration was set up) and
last_used_at (when it was last used).
POST /api/v1/agents/{agent_id}/chatalot/connect¶
Authed. Connect this agent to a chatalot instance. Provisions a bot
token + persists it encrypted at rest under the install's
integration_encryption_key.
curl -fsS -X POST -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"instance_url":"https://chat.example.com","admin_token":"<chatalot-admin-bot-token>","verify_tls":true}' \
$BASE/api/v1/agents/$AGENT_ID/chatalot/connect
verify_tls defaults to true (recommended). Set to false for
self-signed / private-CA instances if you cannot supply an additive
CA bundle via HIVEMIND_CHATALOT_CA_BUNDLE. See
the chatalot integration guide in the source repository for the full
pattern (not published on this site).
DELETE /api/v1/agents/{agent_id}/chatalot¶
Authed. Disconnect + revoke the agent's bot token.
Updates (admin)¶
POST /api/v1/admin/updates/check¶
Authed (admin). Force-poll the updates manifest for new releases on the install's channel. Returns the same info the admin UI shows.
curl -fsS -X POST -H "X-API-Key: $ADMIN_API_KEY" \
$BASE/api/v1/admin/updates/check
# {
# "latest_release": {
# "version": "0.2.1", "channel": "stable",
# "image": "registry.seglamater.app/seglamater/hivemind-server:0.2.1",
# "image_digest": "sha256:…", "released_at": "…",
# "breaking_changes": false, "security_advisory": false,
# "release_notes_url": "…", "checked_at": "…"
# },
# "version_relation": "update_available",
# "error": null
# }
POST /api/v1/admin/updates/apply¶
Authed (admin). Trigger an apply of the latest available release. This
is the API equivalent of the Apply button in the admin UI.
target_version is required — a bodyless request fails before the
handler runs, and the server refuses any version that is not newer
than the one currently running.
curl -fsS -X POST -H "X-API-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"target_version":"0.1.73"}' \
$BASE/api/v1/admin/updates/apply
# {"apply_id":"…"}
Poll GET /api/v1/admin/updates/apply/{apply_id} for status.
GET /api/v1/admin/updates/applies¶
Authed (admin). Historical apply attempts (success + rollback).
WebSocket¶
GET /ws¶
Authed — but not by the forward-auth/session/api-key stack the rest of
this reference describes. This socket's handler checks X-API-Key
itself, directly, before the upgrade. A session cookie or a
forward-auth identity does not authenticate this socket — only a
valid API key does. If you're logged into the web UI in a browser and
this connection still 401s, that is expected, not a bug.
The live event stream — agent lifecycle events, message relay, status
changes. Subscribe with {"type":"SubscribeAgent","payload":{"agent_id":"<uuid>"}}
or {"type":"SubscribeProject","payload":{"project_id":"<uuid>"}} after
connecting — there is no category-based {"subscribe":[...]} list;
every subscription names one agent or one project. Subscriptions do
not currently filter anything: every connected client receives every
broadcast event regardless of what it subscribed to. A malformed
command (including the category-list shape above) fails to parse and
comes back as a command_error event on the socket, not a
subscription. There is no server-initiated ping and no pong-timeout
disconnect — the protocol has an optional client-sent Ping the
server logs and otherwise ignores, and that's the entire heartbeat.
GET /ws/live¶
Unauthenticated only if you set HIVEMIND_GATE_WS_LIVE=false.
HIVEMIND_GATE_WS_LIVE defaults ON — unset it stays ON — and while
it's on, this socket requires the same identity the rest of the API
does (a session, a forward-auth identity, or a valid API key); without
one, the upgrade request itself gets a 401, same as any other gated
route. If you're wiring a genuinely public, unauthenticated status
page or health widget, set HIVEMIND_GATE_WS_LIVE=false in .env
deliberately — don't treat a 401 here as a bug to work around, and
don't flip this off as a reflex fix for something else, since it's the
control that stops an anonymous caller reading operator-only status.
When it's off, this is a public-tier socket for status surfaces only
(no message bodies, no user data) — the shape a status page or a
public-facing health UI actually wants.
Stability + versioning¶
/api/v1/*is the customer-stable surface — semver-compatible across minor versions of Hivemind. Breaking changes get av2prefix; we do not silently change shapes.- The non-
v1routes (/api/agents,/api/tasks, etc.) are the web UI's own routes — same handlers, cookie/session-authenticated instead of API-key-authenticated, not a daemon compatibility layer. Use/api/v1/*for anything you write yourself. /auth/*is stable too — registration + login URLs do not move across minor versions.
Bot knowledge¶
Per-bot retrieval-augmented content. The chat handler full-text
searches this corpus at message time and injects the top-N
relevance-ranked snippets into the system prompt. Authed via
X-API-Key (or session cookie).
Snippets are scoped to a single agent profile (the bot) — there is
no global / shared knowledge base. The bot profile's
knowledge_search_enabled flag flips to true automatically on the
first insert.
GET /api/v1/bots/{profile_id}/knowledge¶
List all knowledge snippets for a bot, newest first. profile_id is
the bot's UUID (visible on the bot detail page; not the slug).
Optional query param ?title_contains=<text> filters case-insensitively.
curl -fsS -H "X-API-Key: $HM_KEY" \
$BASE/api/v1/bots/d88aeede-c79a-4e42-a6f0-930dc701fc5f/knowledge | jq .
POST /api/v1/bots/{profile_id}/knowledge¶
Admin role required for POST, PATCH and DELETE here: a snippet is sent
to the model verbatim as the bot's grounding, so writing one is as powerful as
editing the bot's prompt. A non-admin key gets 403. Reads are unchanged.
Create a snippet. Body: {title, content, source?, metadata?}.
| Field | Type | Limit | Notes |
|---|---|---|---|
title |
string | 1–200 chars | Admin-side label |
content |
string | 1–4000 chars | Sent to LLM verbatim |
source |
string | optional | Free-text provenance hint |
metadata |
object | optional | Arbitrary JSON for client-side use |
Returns 201 + the created row including its id. Flips
knowledge_search_enabled = true on the parent bot profile if it was
false.
GET /api/v1/bots/{profile_id}/knowledge/{id}¶
Fetch a single snippet. Returns 404 if the snippet belongs to a different bot — never leaks cross-bot existence.
PATCH /api/v1/bots/{profile_id}/knowledge/{id}¶
Partial update. Body: any subset of {title, content, source, metadata}.
Omitted fields are unchanged.
DELETE /api/v1/bots/{profile_id}/knowledge/{id}¶
Remove a snippet. Returns 204. Same cross-bot 404 protection as GET.
GET /api/v1/bots/{profile_id}/knowledge/search?q=<text>&limit=<n>¶
Preview what the chat handler would retrieve for a given query. Returns
ranked hits ordered by relevance (ts_rank descending). Default
limit=5, clamped to [1, 50]. The chat handler itself uses this
same SQL but does not call this endpoint.
curl -fsS -H "X-API-Key: $HM_KEY" \
"$BASE/api/v1/bots/$BOT_ID/knowledge/search?q=support+hours&limit=3" | jq .
Bot templates¶
Read-mostly catalog of well-tuned agent profile starting points. The admin UI's + New bot modal consumes these; you can also instantiate directly via the API.
GET /api/v1/templates¶
List the bundled templates.
curl -fsS -H "X-API-Key: $HM_KEY" $BASE/api/v1/templates | jq '.[].slug'
# "customer-support"
# "sales-sdr"
# "internal-it-faq"
# "hr-faq"
# "product-docs"
GET /api/v1/templates/{slug}¶
Template detail including its starter knowledge memories. Used by the UI to preview before instantiating.
POST /api/v1/templates/{slug}/instantiate¶
Mint a new bot from a template. Body:
{
"slug": "acme-support",
"name": "Acme Support",
"description": "Optional one-line override",
"system_prompt_override": "Optional; falls back to the template default",
"seed_memories": true,
"security_tier": 1,
"cost_cap_daily_cents": 250,
"rate_limit_rpm": 5,
"rate_limit_rpd": 50
}
The new bot's slug follows the same shape rules as direct profile
creation ([a-z0-9-]+, 1–60 chars, no leading/trailing hyphen).
Duplicate slugs return 409.
The four security settings are optional overrides; omit any of them and
the template's own default is used. Before HIVE-1092 they had no fields
here at all and were accepted-and-discarded, so a bot minted from a
template always carried the template's tier, cap and rate limit no matter
what the caller asked for — including from the + New bot modal, whose
Security tier control did not reach this endpoint. Unrecognized field
names are rejected rather than ignored, and a security_tier outside
1–3, a negative cap, or a rate limit below 1 is a 400.
When seed_memories: true (the default), the template's starter
knowledge snippets are copied into the new bot's bot_knowledge
table and knowledge_search_enabled flips to true. The whole
operation is one transaction — a partial failure leaves no
half-built bot.
Returns 201 with {agent_profile_id, slug, seeded_memory_count}.
Estate knowledge¶
The instance-global, operator-curated document corpus (/api/v1/knowledge/*).
Distinct from Bot knowledge above, which is per-bot-profile customer content
injected into a reply. Estate knowledge is returned verbatim to a calling agent
for its own context. All routes sit behind the standard X-API-Key stack;
writes (/ingest, /vault, DELETE /doc/{id}) are refused for
role = readonly keys.
GET /api/v1/knowledge/search?q=<text>&limit=<n>&scope=<category>&match=<any|all>¶
Ranked full-text retrieval over the corpus.
| param | default | notes |
|---|---|---|
q |
required | free text; lexed by plainto_tsquery |
limit (alias k) |
6 | clamped to 1..=100 |
scope |
none | first path segment, e.g. decisions; vault selects the customer-vault partition |
match |
any |
any OR-s the terms (highest recall); all requires every term |
limit and k are the same parameter. k was the original name and
remains canonical; limit is accepted because the sibling bot-knowledge search
endpoint spells it that way, and an unknown query parameter is silently DROPPED
by the deserializer rather than rejected. That combination produced a
memorable failure: ?limit=100 left k at its default, so every request
returned 6 hits regardless of what was asked for, and 6 identical responses
across unrelated queries read as "search is a stub" rather than "that parameter
name does nothing."
The response reports what was actually applied, so a caller never has to infer it:
{
"query": "gluetun",
"scope": null,
"match_mode": "any",
"count": 20, // hits in THIS page
"total_matches": 57, // TRUE total, ignoring the limit
"limit": 20, // effective limit after clamping
"max_limit": 100,
"limit_clamped": false, // true if you asked for more than max_limit
"truncated": true, // true when total_matches > count
"hits": [ /* ... */ ]
}
count is the page size; total_matches is the corpus truth. A topic with 6
matching chunks and a topic with 600 are only distinguishable by the latter.
Truncation is never silent — if truncated is true, there are matches you
did not receive.
GET /api/v1/knowledge/corpus¶
What the corpus actually contains — the endpoint that makes "migrate → verify → only then delete the local copy" possible.
{
"documents": 412, "chunks": 3180, "token_estimate": 903400,
"vault_documents": 9, "vault_chunks": 24,
"last_ingest_at": "2026-01-15T09:30:00Z",
"by_source_repo": [{"key": "acme/infra-docs", "documents": 412, "chunks": 3180,
"last_ingest_at": "2026-01-15T09:30:00Z"}],
"by_category": [{"key": "runbooks", "documents": 188, "chunks": 1340, "last_ingest_at": "..."}]
}
Customer-vault content (is_vault = true) is counted separately and is
never folded into documents, so vault uploads can never read as estate
coverage.
GET /api/v1/knowledge/corpus/paths?source_repo=<repo>¶
Every stored (source_repo, path) pair. This is the reconciliation primitive:
diff it against a local file listing to get the exact set of documents that are
not yet in Hivemind. A count tells you how many are missing; only this tells
you which, and "which" is what has to be known before deleting a local
original.
curl -sH "X-API-Key: $KEY" \
"$BASE/api/v1/knowledge/corpus/paths?source_repo=acme/infra-docs" | jq -r '.paths[].path' | sort > remote.txt
( cd /path/to/docs && find . -name '*.md' -not -path './.git/*' | sed 's|^\./||' | sort ) > local.txt
comm -23 local.txt remote.txt # present locally, absent in Hivemind
Exclude nested worktree/duplicate checkouts from the local side. Counting a
.worktrees/copy alongside the canonical tree is what turns a complete 364-of-364 migration into an alarming-looking "364 of 624".
GET /api/v1/knowledge/docs?category=<category>¶
Provenance listing: one row per ingested document. Predates /corpus and is
still the way to enumerate documents with their ids, commit shas, and ingest
timestamps.
GET /api/v1/knowledge/doc/{id} · DELETE /api/v1/knowledge/doc/{id}¶
Full document plus its ordered chunks; delete removes the document and cascades
its chunks. Delete is refused for role = readonly.
POST /api/v1/knowledge/ingest¶
Batch upsert, keyed on (source_repo, path). Max 500 documents per batch, max
500,000 characters per document. source_repo = "vault" is reserved.
Every document in the batch is attempted. A document that fails does not abort the batch, and the response reports the batch honestly:
{
"submitted": 50, "documents": 49, "failed": 1, "status": "partial",
"total_chunks": 388,
"failures": [{"path": "services/broken.md", "error": "..."}],
"run_id": "…", "run_recorded": true,
"results": [ /* ... */ ]
}
status is ok | partial | failed. documents is the number stored;
compare it against submitted. Retry exactly the paths named in failures.
GET /api/v1/knowledge/ingest/status?limit=<n>¶
Recent ingest runs, newest first (default 20, max 200), plus
last_incomplete_run — the id of the most recent run whose status was not ok,
surfaced so a consumer does not have to scan the list to notice.
Each run records documents_submitted / documents_succeeded /
documents_failed / chunks_written, the derived status, and per-document
errors. kind is ingest (estate) or vault (customer).
This exists because a partial load used to be invisible: the corpus was simply smaller, and a smaller corpus is indistinguishable from one that was never fully submitted — the failure mode that would let someone delete the last local copy of a document that was never stored.
Vantage cockpit summary¶
A single read endpoint that returns a contract-shaped summary of the Hivemind instance for external dashboards (the Seglamater Vantage cockpit is the primary consumer, but the shape is usable by any operator dashboard).
GET /api/v1/cockpit/summary¶
Returns:
{
"version": "0.2.1",
"projects": [
{"name": "Acme Ops", "tasks": {"open": 3, "in_progress": 0, "done": 12}}
],
"agents": {"configured": 4, "active": 0, "max_concurrent": 0},
"activity": {
"last_task_created": "2026-05-28T22:42:05Z",
"last_task_completed": "2026-05-27T18:10:00Z"
},
"cost": {"window": "30d", "usd": 12.34}
}
Field semantics (consumers should rely on these — they are part of the public contract, locked alongside the endpoint):
version— the server's own version string. Matches/api/v1/health.projects[].tasks.open= task statusesbacklog + todo..in_progress=in_progress + in_review..done=done. Cancelled tasks are excluded — they're abandoned, not done.agents.configured= alive: not incompleted/failed/killed/stopped.agents.active=status = 'running'.agents.max_concurrent= sum of every project'smax_concurrent_agents(theoretical parallelism ceiling).activity.last_task_createdand.last_task_completedare ISO-8601 UTC strings ornullif the table is empty / no tasks have completed.costis always populated.cost.windowis the literal string"30d".cost.usdis the 30-day rolling sum ofagents.total_cost_usd.
The endpoint is cheap (8 small aggregation queries). Reasonable polling cadence is 30s – 5min depending on how live you need the dashboard.
See also¶
- Overview and How it connects — what each component is and where it sits on your host.
self-hosting/install.md— the install path that brings up the API.- The chatalot integration walkthrough, deeper than the API reference above, lives in the source repository and is not published on this site.