One http-kit server (dvergr.web.server) serves three things over the daemon:
app/ directory served as a static site under /apps/<room>/;/api/v1/, one route per operation of dvergr.ops, the same
operations MCP serves (doc/mcp.md).The server is optional. The daemon runs without it, and the TUI, Telegram, MCP and nREPL are other frontends over the same daemon.
clojure -M:cli --web # daemon + nREPL + TUI + web on 127.0.0.1:17880
clojure -M:cli --no-tui --web # server box: no TUI
clojure -M:cli --web --web-port 9090 # another port
Like Jupyter, it prints a login URL, [dvergr] web dashboard: http://127.0.0.1:17880/?token=…:
open it once and the page reloads, logged in, without the token. The token is the daemon's
local token, $DVERGR_HOME/local-token (a home from before keeps the value of its
mcp/http-token, which therefore now opens the web and TCP too). The login page also takes
the token in a form (a POST, so it stays out of the browser's history; the printed URL does
not). (:login-url (dvergr.web.server/start! …)) returns the URL to a REPL; the server does
not keep it.
A daemon started any other way (as a library, daemon/start!) starts the server when its
config has :http:
:http {:port 17880 :ip "127.0.0.1"
:allowed-hosts ["dvergr.example.com"] ; names a reverse proxy sends
:allowed-origins ["https://dvergr.example.com"]} ; origins whose writes are accepted
The JSON API needs reitit, which is not a dependency of the dvergr library. The :cli,
:dev and :test aliases bring it, and the :web alias adds it to any other command
(clojure -M:local:web). Without it the daemon logs daemon/web-server-skipped and runs
headless. The interactive Swagger UI at /api/v1/docs also needs
metosin/reitit-swagger-ui, which no alias includes; /api/v1/swagger.json works without it.
From a REPL: (dvergr.web.server/start! daemon :port 17880) and (dvergr.web.server/stop!).
One server runs per process.
Every request is authenticated through the daemon's :authenticate seam (transport :web;
doc/mcp.md describes the seams and the principal), from the Authorization: Bearer header
alone: the local token, or a browser session's id. No credential ever travels in a cookie:
cookies are not scoped by port, so every server on this host (an agent's dev server on
127.0.0.1:3000, say) would receive one and could replay it. Where a request comes from
grants nothing, loopback included. A run token (an agent's, doc/mcp.md) is for MCP only and
is refused here.
GET <path>?token=<token> (or the login page's POST to /login) with a valid token opens
a session and answers 303 to <path>#sid=<id>. A fragment is never sent to a server nor
in a Referer. The page script (/static/dvergr.js) keeps the id in IndexedDB, which is
this origin's alone (this port's) and shared by its tabs (it survives a browser restart,
for the session's 12 h), and registers a service worker (/dvergr-sw.js, scope /)
that adds Authorization: Bearer <id> to requests of this origin, room apps excepted:
navigations, form posts, htmx, fetch, new tabs, back and forward. So pages render and
forms submit natively. A navigation reaches the worker whoever started it, so the worker
gives the session to a request that changes something (any method but GET/HEAD) only when
its referrer is this origin; a cross-site form, or one that strips its referrer, gets none
(fail closed). GETs get it (bookmarks, new tabs, links from elsewhere), so no GET route
changes anything: the pages, the htmx fragments under /api/ (rooms, agents, system,
schedules, a room's stats, messages and context), the JSON API's reads, /api/health,
room apps, /static/, the worker; minting an app link is a POST. As a backstop the server
refuses a session's change that lacks X-Dvergr-Request: 1, which the worker adds only
after that check (an outdated worker without it never adds it); a script with the local
token needs no such header. The worker re-issues a request with its own integrity,
keepalive and abort signal. A navigation without a session gets
the login page (200); one with a session the server refuses (it ended: a restart, 12 h, a
new local token, eviction) gets it with 401, and its script drops that session instead of
retrying it; an htmx request refused with 401 logs the page out. Sessions last 12 h, at
most 1000 are kept, a new local token ends them all, and POST /logout (the page's "log
out") ends the session for every tab of the browser. A wrong token is 401;Authorization: Bearer $(cat $DVERGR_HOME/local-token).A cross-site page cannot send the header, so it needs no CSRF token; the guard below stands
besides. A service worker needs a secure context: http://127.0.0.1/localhost, or HTTPS (a
proxy with TLS); a dashboard opened over plain HTTP on another address cannot keep a session
(scripts can still send the header). The pages run only this origin's scripts (vendored
htmx, /static/dvergr.js), with no inline script or handler: Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'. A proxied or hosted deployment configures an authenticator (its own credentials) and
an :authorize seam. What a principal may do:
dvergr.web.ops) run each op for the
request's principal, authorized like every other surface (403 with {"error": …} when
denied; listings filtered);/api/app-link?room=… →
/apps/<room>/~<capability>/, an hour, that room only, Referrer-Policy: no-referrer):
the service worker gives apps no session. An app is agent-written, so every
response under /apps/ carries Content-Security-Policy: sandbox allow-scripts: its
scripts run in an opaque origin and their requests say Origin: null, which this server
refuses outside /apps/ (403);/api/health answers anyone authenticated.Every response outside /apps/ refuses to be framed (Content-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY), and none is content-sniffed
(X-Content-Type-Options: nosniff). dvergr.web.guard checks every request before it is
routed:
:allowed-hosts entry. A DNS name rebound to 127.0.0.1 fails here, reads included.Origin, or Referer without it) must name this server's own or an :allowed-origins
entry. A client such as curl sends neither and passes.application/json (else 415), which a cross-site page can
send only after a CORS preflight, and the server grants none.Requests are limited to 1 MiB. Every route that changes state is a POST.
/ (or /dashboard) has four sections, each polled: System (/api/system), Agents
(/api/agents), Rooms (a fork tree, /api/rooms, with fork, merge, discard and delete
buttons) and Schedules (/api/schedules). /rooms/<slug> is a room's page: its messages,
stats and per-agent context bars, a box to post as the user (POST /rooms/<slug>/post) and a
microphone that posts a voice message (POST /rooms/<slug>/voice, transcribed by
dvergr.audio.stt; doc/media.md). /agents/<id>/config edits an agent; its form uses the
same field spec (dvergr.agent.fields) as the agent_create/agent_update operations.
The dashboard's room and agent actions call dvergr.ops through dvergr.web.ops, so they act
exactly as the MCP tools of the same name do. The HTML fragment routes under /api/ (without
v1) are for the dashboard, not a stable interface.
/apps/<room-slug>/ serves the files under app/ in the room's workspace (its Geschichte
repository), for example app/index.html an agent wrote. A fork has its own workspace, so a
fork's app previews the fork's files; merging the fork publishes them. Serving is static and
GET-only: no code runs on the server, paths cannot leave app/ (symlinks included), and
dotfiles are not served.
dvergr.web.api derives every route from dvergr.ops/specification. An operation
:room/messages is the route /api/v1/room_messages, the same name as its MCP tool. A read is
a GET with its arguments as query parameters; a write is a POST with a JSON body. The
operation's malli schema coerces the request (a request that does not match is a 400); the
result is the operation's data as JSON.
curl -s 'http://127.0.0.1:17880/api/v1/room_list'
curl -s 'http://127.0.0.1:17880/api/v1/room_messages?room=general&limit=10'
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"room":"general","text":"hello"}' http://127.0.0.1:17880/api/v1/room_post
curl -s 'http://127.0.0.1:17880/api/v1/swagger.json' # the OpenAPI description
An operation that fails returns its status (4xx when the request was wrong, as
dvergr.ops/error-status maps it) and {"error": …, "op": …}; without a daemon the API answers
503. /api/health reports the daemon status and the number of online agents.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |