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 clients that prefer HTTP/SSE over
Telegram, and node discovery
(/.well-known/metnos.json) for the client side.
Picking a second server (instead of stretching the pairing server or funnelling everything through Telegram) is driven by three constraints: (a) the admin UI must live in a browser tab, so the host doesn't have to open Telegram to manage users, scheduler, introvertiva 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 shouldn't grow.
Default bind: 127.0.0.1:8770. To expose to the LAN, change
the bind in config (Phase 2 plans self-signed cert pinned by fingerprint,
reusing pairing material).
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /agent/health | anonymous | Liveness + uptime + version. |
| GET | /.well-known/metnos.json | anonymous | Discovery: name, channels, capabilities, admin-key fingerprint. |
| POST | /agent/turn | user/admin | Run run_turn. SSE if Accept: text/event-stream, JSON otherwise. |
| GET | /agent/devices/me | user | Caller device info (id, paired_at, autonomy). |
| POST | /agent/register | anonymous | Register a device (pairing wrapper). |
| GET | /admin | admin | Dashboard root (HTML). |
| GET | /admin/proposals | admin | Introvertiva proposals table (filter kind). |
| POST | /admin/proposals/{sig_key}/{approve|reject|defer} | admin | Action on a single proposal. |
| GET | /admin/executors | admin | Catalog with lifecycle. |
| GET | /admin/executors/stats | admin | Counts + daily events for uPlot. |
| GET | /admin/runs | admin | Last N scheduler runs. |
| GET | /admin/safety | admin | Safety signatures (ADR 0071). |
| GET | /admin/turns | admin | Last N turns (jsonl). |
| GET | /admin/users | admin | User table (host + guests). |
| POST | /admin/users | admin | Create user from form (default owner = host). |
| GET | /admin/users/{id} | admin | User detail. |
| POST | /admin/users/{id}/delete | admin | Delete (cascade on user_channels). |
| POST | /admin/users/{id}/autonomy | admin | Change autonomy_level. |
| POST | /admin/users/{id}/channels/{channel}/pair | admin | Issue pairing token + instructions. |
| POST | /admin/users/{id}/channels/{channel}/remove | admin | Unbind channel. |
Collection routes (/admin/proposals,
/admin/executors, /admin/runs,
/admin/safety, /admin/turns,
/admin/users) are negotiation-driven (see
section 4).
Three roles, in increasing privilege order:
anonymous < user < admin.
File ~/.config/metnos/admin.key (mode 0600), 256-bit hex
generated with secrets.token_hex(32) at first server boot.
Logs only show the sha256 fingerprint (first 16 hex). The admin
key also doubles as the master for credential encryption (see
section 7 and ADR 0082).
Web login: POST /admin/login with the admin key in clear;
response set-cookie with TTL 7 days (HttpOnly,
SameSite=Strict). From CLI/curl: header
Authorization: Bearer <admin.key>.
The middleware compares the Bearer with the
public_key_b64 of every paired device (table
devices from pairing). Match → role user.
user, only when no Bearer was presented and failed./agent/health, /agent/register, /.well-known/*. Every other path requires at least user./admin/ requires role admin; otherwise 403.
Collection routes inspect Accept: text/html
→ Jinja2 fragment (htmx-friendly); JSON otherwise. In both cases an
ETag is computed (sha256 of the payload, first 16 hex). If
the caller resends If-None-Match with the same value, the
response is 304 Not Modified with no body. Live smoke test
verified 4 May 2026: ETag/304 round-trip OK.
/agent/turn
When the client sends Accept: text/event-stream,
POST /agent/turn opens a stream and installs an
_SSEProgress (implements
runtime.progress.Progress) that run_turn calls
at every step. Events emitted:
| Event | When | Payload |
|---|---|---|
thinking | Stream open + each ongoing LLM call | {"phase": "planner|intent|vaglio", "step": N} |
progress | User notes (long-op notify) | {"text": "..."} |
tool_call | Phase 2 — not emitted today | {"name", "args"} |
tool_result | Phase 2 — not emitted today | {"name", "ok", "preview"} |
final | Stream close with the final answer | {"final_answer", "n_steps", ...} |
error | Error caught by the turn | {"error_code", "message"} |
run_turn runs in an executor thread (sync) while the main
coroutine pumps events on the loop via
asyncio.run_coroutine_threadsafe.
tool_call and
tool_result are not emitted: the current
Progress interface does not yet have these callbacks. Phase
2 extends it.
/admin (htmx + uPlot)
Frontend stack deliberately minimal: 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 — overview: catalog count, last 5 turns, last 5 paired users, safety signatures count, average latency over 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 run counts per executor.proposals.html — introvertiva proposals table with approve / reject / defer buttons.runs.html — last scheduler runs (cron + recurring user tasks).safety.html — safety signatures (ADR 0071).turns.html — last N turns from the jsonl, credential scrub already applied (ADR 0082).users.html — host + guest users table (see pairing).user_detail.html, user_pair.html — detail + issuance of pairing token.channel (Telegram) and pairingTelegram remains the primary dialogue channel (long-poll, low cost, mobile UX consistency). The HTTP API is the alternative channel for:
/admin dashboard for administration (users, scheduler, introvertiva proposals, safety).POST /agent/turn with SSE.curl -H "Authorization: Bearer ..." http://localhost:8770/agent/turn).
A run_turn invoked via HTTP receives channel="http";
no formal Channel is registered in channels.daemon (the
poll-based model is incompatible with request/response). For
vaglio and
policy the effect is equivalent:
steps go through the same gates, the audit keeps
channel="http" in the turn log.
Multi-user (ADR 0083): the HTTP API is the admin 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 section 6.
Credentials (ADR 0082): the HTTP server admin key is also the
master for symmetric encryption of
~/.config/metnos/credentials/<domain>.json.age.
Whoever controls ~/.config/metnos/admin.key (mode 0600) can
decrypt the credentials. Offline backup recommended.
/agent/turn calls run_turn with channel="http" but does not register a Channel in the daemon. Not blocking in Phase 1.tool_call / tool_result not emitted. Progress interface extension in Phase 2.web.AppKey instead of string keys. Cosmetic refactor, not blocking.~/.local/state/metnos/http_server.lock (same pattern as agent_server).GET /agent/health → 200 {ok:true, version:"1.1", uptime_s:...}.
GET /.well-known/metnos.json → 200 with admin-key fingerprint.
GET /admin (no auth, from loopback) → 403.
GET /admin (Bearer admin key) → 200 HTML dashboard.
If-None-Match round-trip → 304.
Code references: /opt/myclaw/runtime/metnos_http_server.py (entrypoint, factory, lock), http_auth.py (admin key + middleware), http_render.py (Accept negotiation + ETag), http_routes_agent.py (SSE, well-known, devices/me), http_routes_admin.py (read-only collections + actions), runtime/templates/ (8+ Jinja2 files). Tests: runtime/tests/test_http_server.py (12 tests). Reference ADRs: 0078 (HTTP API Phase 1), 0082 (credentials), 0083 (multi-user), 0085 (docs alignment 4/5/2026).