2 Getting started with dvergr
(ns notebooks.getting-started
"Live Clay notebook: a from-zero tour of the dvergr.discourse kernel —
rooms, participants, tagged messages, capability subscriptions, and the
fork-and-merge proposal. Runs inline; no API keys, no network."
(:require [notebooks.support :refer [settle]]
[org.replikativ.spindel.spin.cps :refer [spin]]
[dvergr.discourse :as d :refer [with-room]]
[scicloj.kindly.v4.kind :as kind]))authors: Christian Weilbach
dvergr is a Clojure framework for continuous-time collaborative multi-agent rooms. Agents are reactive processes; humans, LLMs, and scripts are participants of the same shape; everything composes through tagged messages on a small pub/sub kernel.
This notebook builds that kernel up from nothing — no LLM required. Each form runs live; the outputs you see are produced by evaluating the code.
A room
A Room is a continuous-time message namespace with its own execution context (:ctx — a forkable spindel runtime + a Datahike database). Every operation on a room runs inside that context; we bind it once per block with with-ctx (the dvergr.clients.client surface binds it for you — here we work with the raw algebra to see the kernel directly).
(def room (d/room :hello))A participant
A Participant has an :id (its routing endpoint) and an :on-message that returns a spin yielding a reply-spec {:to … :content …} (or nil to stay silent). This one just echoes — swap it for dvergr.discourse.llm/llm-agent and the shape is identical.
(defn echo-agent [id]
(d/participant
{:id id
:on-message (fn [_p msg]
(spin {:to (:from msg) :content (str "you said: " (:content msg))}))}))(kind/hidden
(with-room room
(d/join room (echo-agent :sage))
nil))Post a message
d/message builds [from to content]; d/post! routes it. The agent’s spin runs asynchronously, so we let the engine settle before reading the log.
(with-room room
(d/post! room (d/message :you :sage "hello, dvergr"))){:id #uuid "7cd06ef1-32bd-418c-89a3-f6068e755aa4",
:from :you,
:to :sage,
:content "hello, dvergr",
:ts 1781309549779,
:in-reply-to nil,
:metadata nil,
:type :user/message}(settle)nilRead the log
Every post is recorded. The agent’s reply is addressed back to :you.
(kind/table
(mapv #(select-keys % [:from :to :content]) (d/log room)))| from | to | content |
|---|---|---|
| you | sage | hello, dvergr |
| sage | you | you said: hello, dvergr |
Tagged routing — address a capability, not a name
Messages carry a :type. A participant can subscribe to a tag in addition to its inbox, so it receives messages it was never directly addressed in. This is how cross-cutting concerns (auditors, budget policies) compose without the sender knowing who handles them.
(def audit-log (atom []))(kind/hidden
(with-room room
(let [auditor (d/join room (d/participant
{:id :auditor
:on-message (fn [_p msg]
(spin (swap! audit-log conj
(select-keys msg [:type :from]))
nil))}))]
;; subscribe the auditor to ALL :metric/cost messages, regardless of :to
(d/subscribe! room auditor [:type :metric/cost]))
nil))A message tagged :metric/cost, addressed to nobody in particular, still reaches the auditor via its tag subscription:
(with-room room
(d/post! room {:type :metric/cost :from :sage :payload {:usd 0.02}})){:type :metric/cost, :from :sage, :payload {:usd 0.02}}(settle)nil(kind/table @audit-log)| type | from |
|---|---|
| cost | sage |
| cost | sage |
The auditor saw a message addressed to no one — pure tag routing.
Where to go next
Humans + agents, fork & merge — humans as participants, background tasks, and the propose → accept/reject pattern (
humans_and_agents).Auditor — capability subscriptions in depth (
auditor).Escalation — budget escalation via tagged messages + a bus-level policy handler (
escalation).Streaming — per-consumer buffer/SLA policy on a token stream (
streaming).LLM agent — drive a real model with tools (
llm_agent).
(kind/hidden (d/close-room! room) nil)