# API contract

Base path /api/v1. JSON only. Authorization: Bearer <access_token> on every route except /health, /auth/register, /auth/login. Errors use FastAPI's shape: {"detail": "..."} (validation errors: detail is a list of field errors).

The control panel (web/src/api.js) is written against this file; changing a field here is a breaking change for the panel.

# Auth

# POST /auth/register → 201 TokenOut

{"org_name": "PT Contoh", "email": "[email protected]", "password": "min-8-chars"}

Creates one org and its first user (role: "owner").

# POST /auth/login → 200 TokenOut

{"email": "[email protected]", "password": "..."}

# GET /auth/me → MeOut

{"user": {"id": "...", "email": "...", "role": "owner", "org_id": "..."},
 "org": {"id": "...", "name": "PT Contoh", "slug": "pt-contoh", "plan": "starter"}}

TokenOut = {access_token, token_type: "bearer", user: UserOut, org: OrgOut}.

# Health

# GET /health → 200 (no auth)

{"ok": false, "version": "0.1.0", "db": "up",
 "lxd": {"ok": false, "endpoint": "unix:///var/snap/lxd/common/lxd/unix.socket",
         "project": "rekan", "project_present": true, "image_alias": "rekan-box-ubuntu-24.04",
         "image_present": false, "profile": "rekan-box", "profile_present": false,
         "detail": "LXD profile 'rekan-box' does not exist on the target"}}

ok is true only when the database, the LXD target, the project, the image alias, and the box profile are all present. The panel shows this as the "platform ready" banner.

# Workload catalog

# GET /workloads → {items: [WorkloadOut], total: int}

# GET /workloads/{slug} → WorkloadOut (404 if unknown)

WorkloadOut carries the pack: slug, name, vertical, tagline, persona, capabilities, guardrails, skills, toolsets, model, resources, gateway, version. Unknown keys pass through, so a pack can add fields without an API change.

# Boxes

# POST /boxes → 202 BoxJobOut

{"name": "Finance Copilot", "workload_slug": "finance-cfo",
 "gateway_platform": "telegram", "idempotency_key": "optional-client-key",
 "resources": {"cpu": 2, "memory": "4GiB", "disk": "20GiB"},
 "secrets": {"TELEGRAM_BOT_TOKEN": "...", "NETRARUNTIME_API_KEY": "..."}}

secrets is optional and is pushed to the box, then scrubbed from the job row. 422 for an unknown pack, a duplicate name in the org, or a missing pack. Re-sending the same idempotency_key returns the original box + job.

# GET /boxes?workload=<slug>&state=<STATE>&limit=<n> → BoxListOut

total counts the rows matching the filters (not the whole org).

# GET /boxes/{id} → BoxDetailOut

{box: BoxOut, workload: WorkloadOut|null, latest_job: JobOut|null, events: [EventOut]}

# GET /boxes/{id}/events?limit=50 → {items: [EventOut], total: int}

EventOut: {id, ts, kind, message, data} — append-only audit trail of every state transition.

# GET /boxes/{id}/jobs → {items: [JobOut], total: int}

# POST /boxes/{id}/actions → 202 BoxJobOut

{"action": "start|stop|restart|reapply|delete"}

409 if the box's current state or an in-flight job makes the action impossible. delete cancels pending create jobs for that box first.

BoxOut: {id, name, slug, state, workload_slug, lxd_project, lxd_instance, ipv4, image_alias, resources:{cpu,memory,disk}, gateway_platform, created_at, updated_at, last_error, health:{checked_at, ok, detail}}

# Jobs

# GET /jobs?box_id=&state=&limit=50 → JobListOut

# GET /jobs/{id} → JobOut

JobOut: {id, box_id, kind, state, attempts, error, created_at, started_at, finished_at}

# GET /reconcile → drift report

{"missing_instances": ["rekan-finance-copilot-1a2b3c"], "unknown_instances": [],
 "checked": 3}

Boxes in the DB with no VM, VMs with no box. Read-only: removing an orphaned VM is an operator decision.

# Deploy wizard

The wizard asks exactly three questions (persona, private data, gateway) and hard-sets everything else. docs/deploy-wizard.md has the full contract.

# GET /setup/questions?workload=<slug>[&draft=<json>] → sections + hard_set

# GET /setup/workloads/{slug}/questions → same payload

Each field carries key, label, type, required, default, help and options where the answer is a choice. hard_set is the read-only "set by Rekan.Ai" table (group, label, value, help?).

# GET /setup/gateways → {platforms: [...], notes: [...]}

The gateway catalog: one entry per platform, with slug, label, hermes_platform (the name the box's Hermes uses — feishu for lark), route_field / route_label / route_help / route_example, and credentials + extras, each field carrying env, label, help, required, secret, kind, default and choices where the platform fixes the vocabulary. The deploy wizard's platform picker and credential sub-step render from this; the catalog holds no credential value, so nothing in the response needs masking. docs/gateway-setup.md is the reference.

# POST /setup/preview → 200

{"box_yaml": "# Generated by …", "secrets": {"TELEGRAM_BOT_TOKEN": "••••••••"},
 "secret_names": ["TELEGRAM_BOT_TOKEN"], "hard_set": [...],
 "create_request": {"name": "Rani", "workload_slug": "finance-cfo", "secrets": {"…": "••••••••"}},
 "fingerprint": "637c89f7…", "warnings": []}

Credential values are masked, never echoed. 422 uses {"detail": {"message", "section", "errors": {field: sentence}}} with section ∈ persona | private_data | gateway | workload.

# POST /setup/draft/verify → {fingerprint, matches, summary, idempotency_key}

409 when the answers changed since the preview was issued.

# GET /setup/draft/{fingerprint}?draft=<json> → {fingerprint, stateless, summary}

Drafts are held by the client; this verifies one and never returns a credential.

# POST /setup/deploy → 202 BoxJobOut (alias: POST /setup)

Same shape and semantics as POST /boxes, with the answers on the create job.

# Box dashboard (Hermes web UI)

Each box runs the Hermes dashboard bound to its own loopback; the control plane reaches it through an SSH tunnel and proxies it behind the panel's JWT or the box-scoped ticket cookie. docs/dashboard-in-panel.md has the full design and docs/v0.4.0-verification.md the measurements.

# GET /boxes/{id}/hermes/summary → the five casual answers

{"box": {"id": "...", "name": "Sari", "state": "READY", "gateway_platform": "telegram"},
 "dashboard": {"reachable": true, "version": "0.21.3", "advanced": true},
 "status":  {"ok": true, "data": {"ok": true, "version": "0.21.3"}},
 "sessions":{"ok": true, "items": [{"id": "...", "title": "...", "messages": 12}], "count": 1},
 "usage":   {"ok": true, "summary": {"prompt_tokens": 1200, "completion_tokens": 340,
                                     "total_tokens": 1540, "cost_usd": 0.00042}},
 "files":   {"ok": true, "items": [{"name": "laporan.pdf", "path": "outbox/laporan.pdf"}], "count": 1},
 "skills":  {"ok": true, "items": [{"name": "xlsx", "enabled": true}], "count": 1}}

Every group carries ok; a failing group adds error (a sentence) and the rest of the page still renders. 409 for a deleted/deleting box, 404 for another org's.

# ANY /boxes/{id}/hermes/dash/{path} → allow-listed pass-through

The Advanced view. Allowed: the pages /, /chat, /sessions, /analytics, /files, /skills, /models, /channels, /cron, /docs, their /api/... counterparts, and the dashboard's static assets (/assets/, /fonts/, /fonts-terminal/, /favicon.ico, /dashboard-plugins/). 404 with an explanation for everything else — notably /env, /config, /system, /logs, /mcp, /webhooks, /plugins, /profiles, /pairing. 503 when the box's dashboard is not answering (it starts with the box).

Every proxied request carries X-Forwarded-Prefix: /api/v1/boxes/{id}/hermes/dash, so the dashboard rewrites its own shell and its SPA builds its API and WebSocket URLs under that prefix. The header is always ours: a client-supplied one is dropped.

# GET /assets/{path} → the dashboard's chunks, which only exist at the root

The Hermes bundle's chunk preloader is generated with the origin as its base (Xt = e => "/" + e over __vite__mapDeps, resolved by import.meta.resolve), so a lazily imported page chunk is always fetched from <origin>/assets/... — under the panel, the panel's own /assets. This route passes such a request to the box named by the ticket cookie; the panel's own file wins whenever one exists at that path, so the panel SPA is never shadowed. Without it the dashboard shell renders and every lazy chunk 404s.

# POST /boxes/{id}/hermes/ticket → the cookie the embedded dashboard uses

Mints a short-lived, box-scoped ticket and sets it as the rekan_agent_ticket cookie (HttpOnly, SameSite=Lax, Path=/, 1 h). The panel calls it before it loads the dashboard frame, because a navigation cannot carry an Authorization header. 409 for a deleted/deleting box.

{"expires_in": 3600, "box_id": "40204c7f..."}

The cookie is accepted by /hermes/dash/{path}, by the root-level /api/{path} and /assets/{path} pass-throughs, and by the live sockets below. It names one box and expires; it is not a session.

# WS /api/{pty,chat,ws,events} → live sockets for chat and the terminal

An allow list of the dashboard's live paths, nothing else (any other path closes 1008). Send the ticket cookie; a socket with no valid ticket closes 1008, and so does a box that is deleted or deleting. The control plane opens the upstream socket itself and presents the box's derived dashboard credential — as ?token= and a bearer header, because the dashboard's WebSocket gate accepts no header on a loopback bind (see docs/v0.4.0-verification.md). A client-supplied token/ticket/internal parameter is dropped, never forwarded.

The same bridge is reachable under the per-box prefix, which is the shape the embedded SPA actually builds (window.__HERMES_BASE_PATH__):

# WS /boxes/{id}/hermes/dash/api/{pty,chat,ws,events} → the same sockets, prefixed

Identical rules, plus one: the ticket must name the box in the path. A ticket for box A closes 1008 on box B's prefixed socket. The path is ours, not the box's — the upstream socket is dialled at the box's bare /api/pty.

# Enums

  • Box state: REQUESTED, VALIDATING, PROVISIONING, BOOTING, CONFIGURING, HEALTH_CHECKING, READY, STOPPED, FAILED, DELETING, DELETED
  • Job kind: CREATE_BOX, DELETE_BOX, START_BOX, STOP_BOX, RESTART_BOX, REAPPLY_CONFIG
  • Job state: PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED