Skip to content

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-Key header — for daemons + service-to-service callers. The admin API key is the ADMIN_API_KEY in your .env, generated by install.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 receives 403 {"error":{"status":403,"message":"admin role required"}}.

HIVEMIND_ENFORCE_ADMIN_ROLE is 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 with 401. 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 gets 403. Before you upgrade: 1. Confirm every operator principal you rely on is role='admin'. 2. Upgrade. 3. If you are locked out, set HIVEMIND_ENFORCE_ADMIN_ROLE=false and 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:

  1. 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 to 127.0.0.1:8585) and call http://hivemind-server:3000/api/v1/* directly. Forward-auth does not run on intra-network traffic.
  2. Configure a forward-auth bypass for X-API-Key. Add a Caddy / Traefik / nginx route rule that skips forward-auth when the X-API-Key header 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/json on writes; reads return JSON.
  • Errors: JSON of shape {"error": "<code>", "message": "<text>"} on 4xx / 5xx. The HTTP status carries the category; the error code 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 has total/limit/offset keys 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.

curl -fsS $BASE/api/v1/health
# {"status":"ok","version":"0.2.1"}

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:

  1. no local password account exists yet, and
  2. your installer's ADMIN_API_KEY, sent in the X-API-Key header.

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:

403 {"error":{"code":"api_key_rotation_requires_interactive_session", ...}}

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:

{"user_id":"...","username":"...","api_key":"hm-rk-...","rotated_at":"...","message":"..."}

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):

HIVEMIND_CORS_ALLOWED_ORIGINS=https://seglamater.com,https://www.seglamater.com

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.

curl -fsS -H "X-API-Key: $ADMIN_API_KEY" \
  "$BASE/api/v1/agent-chat/audit?limit=20"

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 returns 400 before anything else is checked.
  • role (string, lowercase): one of coordinator, 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, or custom. Defaults to the role's own template.
  • custom_claude_md (string): raw CLAUDE.md content, used when template is custom.
  • 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 to main.
  • 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:

curl -H "X-API-Key: $HIVEMIND_API_KEY" \
     http://localhost:8585/api/v1/projects

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:

{"error": {"status": 401, "message": "missing X-API-Key header"}}

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.

{"error": {"status": 401, "message": "invalid or inactive API key"}}

means the header arrived and the key is not a live one. Re-read it from .env:

grep ^ADMIN_API_KEY= .env | cut -d= -f2-

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 env value under a credential-like name (ending in TOKEN, SECRET, PASSWORD, KEY, AUTH and 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 from env. 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 spelled mcp__<server>__<tool>, so a server named a__b would produce an entry the matcher reads as server a, tool b — 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.db cannot 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:// or https:// 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, not https://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 a v2 prefix; we do not silently change shapes.
  • The non-v1 routes (/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."

curl -sH "X-API-Key: $KEY" "$BASE/api/v1/knowledge/search?q=gluetun&limit=20" | jq .

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

curl -fsS -H "X-API-Key: $HM_KEY" $BASE/api/v1/cockpit/summary | jq .

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 statuses backlog + todo. .in_progress = in_progress + in_review. .done = done. Cancelled tasks are excluded — they're abandoned, not done.
  • agents.configured = alive: not in completed/failed/killed/stopped.
  • agents.active = status = 'running'.
  • agents.max_concurrent = sum of every project's max_concurrent_agents (theoretical parallelism ceiling).
  • activity.last_task_created and .last_task_completed are ISO-8601 UTC strings or null if the table is empty / no tasks have completed.
  • cost is always populated. cost.window is the literal string "30d". cost.usd is the 30-day rolling sum of agents.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.