The second way to talk to Metnos: HTTP, browser, curl. An aiohttp server on port 8770 that exposes the agent (web chat + JSON/SSE API) and the admin dashboard. Implementation: runtime/metnos_http_server.py + http_auth.py + http_render.py + http_routes_agent.py + http_routes_admin.py + the Jinja2 templates.
It sits alongside, it does not replace, channel (Telegram stays a primary channel for dialogue). See also pairing (multi-user).
← Documentation index Microdesign › http_api

Metnos

http_api — admin server + uniform agent channel
Microdesign — the HTTP API on port 8770.
Audience: anyone who wants to understand how to talk to Metnos over HTTP/browser/curl.

Reading time: 12 minutes.

Contents

  1. What it does: a second HTTP server
  2. The endpoints
  3. Authentication and roles
  4. Content negotiation + ETag
  5. SSE on /agent/turn
  6. The /admin dashboard (htmx + uPlot)
  7. Relationship with channel (Telegram) and pairing
  8. Known limits

1. What it does: a second HTTP server

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

Request lifecycle — auth → role → route → response HTTP request port 8770 auth_middleware Bearer · cookie · LAN role anon < user < admin route + negotiation Accept + ETag response HTML · JSON SSE stream The middleware denies with 403 before dispatch if the role is not enough; the anonymous whitelist skips the gate. On /agent/turn with Accept: text/event-stream the response becomes an SSE stream (ch. 5).

2. The endpoints

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

Public (anonymous)

MethodPathPurpose
GET/Web chat (HTML + htmx).
GET/agent/healthLiveness + uptime + version.
GET/.well-known/metnos.jsonDiscovery: name, channels, capabilities, admin key fingerprint.
GET/static/{name} · /manifest.webmanifest · /sw.jsStatic assets and PWA support (manifest + service worker).
GET/oauth/callbackOAuth callback for skills (e.g. Google Workspace).
GET/pair/{token}Consume a pairing token.

Agent: turns (user/admin)

MethodPathPurpose
POST/agent/turnRuns run_turn. SSE if Accept: text/event-stream, otherwise JSON.
POST/agent/turn/submitStarts a turn asynchronously (result via stream/status).
GET/agent/turns/{id}/streamSSE 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}/feedbackUser feedback (✓ / ✗ / ↺).
POST/agent/turns/{id}/retryRe-runs the turn.
GET/agent/turns/recentMost recent turns.

Agent: session, dialogs, images (user)

MethodPathPurpose
GET/agent/devices/meInfo 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/eventsSSE 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.

Administration (admin)

MethodPathPurpose
GET/adminDashboard home.
GET/POST/admin/login · POST /admin/logoutAdmin 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 rollbackExecutor promotions + review.
GET/admin/executors · /admin/executors/statsCatalog 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/turnsScheduler 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.

3. Authentication and roles

Three roles, in ascending order of privilege: anonymous < user < admin.

Admin key

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>.

Device bearer token

The middleware compares the Bearer with each paired device's public_key_b64 (the pairing devices table). A match → user role.

Trusted LAN + anonymous whitelist

The «trusted LAN» distinction is a child of the self-hosted model: on the home network the risk is contained, and the host wants curl/htmx without headers. The policy tightens as soon as the bind opens to the WAN (where TLS must be added).

4. Content negotiation + ETag

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.

5. SSE on /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:

EventWhenPayload
thinkingStream open + header of each LLM phase{"message": "..."}
progressStep advancement (operational notes){"stage": N, "label": "..."}
tool_callAt every step, before invoking a tool. Feeds the chat's live breadcrumb.{"tool", "step_num", "path", "predicted_remaining", "args"}
finalStream close with the final answer{"message": "..."}
errorError 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.

No separate 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.

6. The /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):

7. Relationship with channel (Telegram) and pairing

Telegram stays the primary channel for conversational dialogue (long-poll, low cost, consistent mobile UX). The HTTP API is the alternative channel for:

  1. Host browser: the /admin dashboard for administration (users, scheduler, introspective proposals, safety).
  2. Rust client: a remote turn from a paired Windows/Linux PC, via POST /agent/turn with SSE.
  3. curl/scripts: quick home automations (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.

8. Known limits

Quick status check. 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).