http_api — admin server + uniform agent channel
The HTTP API is the second Metnos server: listening on
port 8770, separate from the remote-device pairing
server (port 8765). It exposes three things: an admin dashboard
in HTML for the host, a uniform agent channel (POST /agent/turn)
for those who prefer HTTP/SSE over Telegram, and a node discovery
document (/.well-known/metnos.json) for clients.
The choice of a second server (rather than extending the pairing server or routing everything through Telegram) is driven by three constraints: (a) the admin UI must be a browser away without the host having to open Telegram to manage users, scheduler, and introspective proposals; (b) the HTTP agent channel is the foundation for the Rust client and for future integrations; (c) the pairing server is minimal by design (port 8765, register-device only) and must not be weighed down.
Default bind: 127.0.0.1:8770. To expose it on the LAN you
change the bind in the config; to go beyond the LAN you add a TLS
terminator in front of the server (see ch. 8).
The routes are declared as lists of tuples in
http_routes_agent.py (agent side) and
http_routes_admin.py (admin side), mounted together by the
server. There are several dozen in total; here they are grouped by area.
Each path requires at least the role indicated (see ch. 3).
| Method | Path | Purpose |
|---|---|---|
| GET | / | Web chat (HTML + htmx). |
| GET | /agent/health | Liveness + uptime + version. |
| GET | /.well-known/metnos.json | Discovery: name, channels, capabilities, admin key fingerprint. |
| GET | /static/{name} · /manifest.webmanifest · /sw.js | Static assets and PWA support (manifest + service worker). |
| GET | /oauth/callback | OAuth callback for skills (e.g. Google Workspace). |
| GET | /pair/{token} | Consume a pairing token. |
| Method | Path | Purpose |
|---|---|---|
| POST | /agent/turn | Runs run_turn. SSE if Accept: text/event-stream, otherwise JSON. |
| POST | /agent/turn/submit | Starts a turn asynchronously (result via stream/status). |
| GET | /agent/turns/{id}/stream | SSE stream of a turn (re-attachable after a refresh). |
| GET | /agent/turns/{id} | Status/result of a turn (resumable: navigating away is not an error). |
| POST | /agent/turns/{id}/feedback | User feedback (✓ / ✗ / ↺). |
| POST | /agent/turns/{id}/retry | Re-runs the turn. |
| GET | /agent/turns/recent | Most recent turns. |
| Method | Path | Purpose |
|---|---|---|
| GET | /agent/devices/me | Info on the calling device (id, paired_at, autonomy). |
| POST | /agent/session/{register,takeover,ping,revoke} | Device session lifecycle (single active session + takeover). |
| GET | /agent/session/events | SSE stream of session events (e.g. session_revoked). |
| GET/POST | /agent/dialog/{id}/{form,submit,cancel,preview,context} | Interactive dialogs (input request, choice with preview/context). |
| GET | /agent/photos/web · /agent/photos/{turn}/{idx} · /agent/gallery/{turn} | Image serving and a turn's gallery. |
| Method | Path | Purpose |
|---|---|---|
| GET | /admin | Dashboard home. |
| GET/POST | /admin/login · POST /admin/logout | Admin access. |
| GET | /admin/changes + POST /{id}/{accept|reject|stage|rollback|retry} | Unified change lifecycle (change_intent). |
| GET | /admin/proposals (+ /introvertiva, /telos + actions) | Proposal triage (introspective and telos). |
| GET | /admin/promotions · /promotions/review + POST rollback | Executor promotions + review. |
| GET | /admin/executors · /admin/executors/stats | Catalog with lifecycle + counts/events for the uPlot charts. |
| GET | /admin/timers + POST /{name}/{enable|disable|fire} | All system timers: view, enable, disable, run now. |
| GET | /admin/runs · /admin/builds · /admin/safety · /admin/turns | Scheduler runs, builds, safety signatures, recent turns. |
| GET | /admin/users (+POST) · /{id} (+ delete/update/autonomy/channels) | User management (host + guests) and paired channels. |
The collection routes (/admin/executors, /admin/runs,
/admin/safety, /admin/turns, /admin/users,
…) are negotiation-driven: same URL, HTML for the browser or
JSON for curl (see ch. 4). There is no
/agent/register: device registration happens via
/agent/session/register or through the
pairing flow.
Three roles, in ascending order of privilege:
anonymous < user < admin.
File ~/.config/metnos/admin.key (mode 0600), 256-bit hex
generated with secrets.token_hex(32) at the server's first
boot. Logs only ever show the sha256 fingerprint (first 16
hex). The admin key is also the master for credential encryption (see
ch. 7).
Web login: POST /admin/login with the admin key in clear;
the response sets a cookie with a 7-day TTL (HttpOnly, SameSite=Strict).
From CLI/curl: header Authorization: Bearer <admin.key>.
The middleware compares the Bearer with each paired device's
public_key_b64 (the pairing devices table). A
match → user role.
user role by default, only if no Bearer was presented and failed./ (chat), /agent/health, /.well-known/*, the static/PWA assets, /oauth/callback, /pair/{token}. Every other path requires at least user./admin/ requires the admin role; otherwise 403.
The collection routes inspect Accept:
text/html → a Jinja2 fragment (htmx-friendly); otherwise
JSON. In both cases an ETag is computed (sha256 of the
payload, first 16 hex). If the caller re-presents If-None-Match
with the same value, the response is 304 Not Modified with no
body.
/agent/turn
When the client sends Accept: text/event-stream,
POST /agent/turn opens a stream and installs a
_SSEProgress (it implements the
runtime.progress.Progress interface) that run_turn
invokes at every step. Events emitted:
| Event | When | Payload |
|---|---|---|
thinking | Stream open + header of each LLM phase | {"message": "..."} |
progress | Step advancement (operational notes) | {"stage": N, "label": "..."} |
tool_call | At every step, before invoking a tool. Feeds the chat's live breadcrumb. | {"tool", "step_num", "path", "predicted_remaining", "args"} |
final | Stream close with the final answer | {"message": "..."} |
error | Error caught by the turn | {"message": "..."} |
run_turn runs in an executor thread (synchronous) while the
main coroutine pumps the events onto the stream via
asyncio.run_coroutine_threadsafe.
tool_result event. The outcome of
each step does not travel as an event of its own: it is already reflected
in the path of the next tool_call (completed steps
become filled badges in the breadcrumb) and, at the end, in the
final message. This is how the chat draws progress in real
time without a dedicated event.
/admin dashboard (htmx + uPlot)
A deliberately minimal frontend stack: htmx +
Jinja2 + uPlot via CDN, no build step,
no CSS framework. Compact templates (<50 lines each) under
runtime/templates/. Clean system-ui style, palette aligned
with the rest of the canonical docs (navy / sage / bronze).
Main pages (Jinja2 rendering + htmx fragments):
dashboard.html — summary: catalog count, last 5 turns, last 5 paired users, safety signature count, average latency of the last 20 turns.executors.html — catalog table with columns name, kind (handcrafted/synth), lifecycle, uses_30d, last_used.executor_stats.html — uPlot chart with daily execution counts per executor.proposals.html — introspective proposal table with approve / reject / postpone buttons.runs.html — latest scheduler runs (cron + recurring user tasks).safety.html — safety signatures.turns.html — the last N turns from the jsonl, with credential scrubbing already applied.users.html — host + guest user table (see pairing).user_detail.html, user_pair.html — detail + pairing token issuance.channel (Telegram) and pairingTelegram stays the primary channel for conversational dialogue (long-poll, low cost, consistent mobile UX). The HTTP API is the alternative channel for:
/admin dashboard for administration (users, scheduler, introspective proposals, safety).POST /agent/turn with SSE.curl -H "Authorization: Bearer..." http://localhost:8770/agent/turn).
The run_turn invoked over HTTP receives channel="http";
no formal Channel is registered in the channels.daemon
(its poll-based model is incompatible with request/response). For the
vaglio and for
policy the effect is equivalent:
the steps pass through the same gates, and the audit keeps
channel="http" in the turn log.
Multi-user: the HTTP API is the administration point for the
users.db table. Guest pairing via
POST /admin/users/{id}/channels/telegram/pair: the admin gets
a short-lived token, hands it to the guest, the guest sends
/start <token> to the bot. The flow is described in
pairing ch. 6.
Credentials: the HTTP server's admin key is also the master for the
symmetric encryption of
~/.config/metnos/credentials/<domain>.json.age. Whoever
controls ~/.config/metnos/admin.key (mode 0600) can read the
credentials. Offline backup recommended.
~/.local/state/metnos/http_server.lock prevents two instances on the same host.GET /agent/health → 200 {ok:true, version:"...", uptime_s:...}.
GET /.well-known/metnos.json → 200 with the admin key fingerprint.
GET /admin without credentials → 403; with the admin key Bearer → 200 (HTML dashboard).
An If-None-Match round-trip on a collection → 304.
Code references: runtime/metnos_http_server.py (entrypoint, factory, lock), http_auth.py (admin key + middleware), http_render.py (Accept negotiation + ETag), http_routes_agent.py (agent routes, SSE, well-known), http_routes_admin.py (admin collections + actions), runtime/templates/ (Jinja2 templates).