#1. Abstract
The HAAK agent runner is a Rust daemon that governs AI agent execution through a constitutional policy layer backed by a content-addressed ledger. It implements the OpenClaw Agent Client Protocol (ACP) wire interface over WebSocket, wrapping Anthropic API calls with mandatory governance: policy-gated tool access, mandate injection, mailbox-driven inter-agent messaging, and BLAKE3/JCS hash-chained audit records. The system's novel contribution is the ConstitutionalRuntime<R> wrapper pattern, which makes governance transparent to both the inner backend and the session manager while producing a cryptographically-structured audit trail designed for zero-migration evolution toward signed attestations and ZK-STARK proofs.
#2. System Overview
Five layers compose the runtime. Data flows top-to-bottom on ingress, bottom-to-top on egress.
Layer 1 Axum Gateway port 18789, WebSocket upgrade at /ws
JSON-RPC 2.0 five methods: session.init, turn.run,
session.cancel, session.close, session.status
Layer 2 SessionManager per-session serial queue (KeyedQueue)
DashMap cache, SQLite WAL session store
TTL eviction (domain mode), standing preload
Layer 3 ConstitutionalRuntime<R>
mandate injection, mailbox injection
tool gating via PolicyEngine
ledger entry emission per event
Layer 4 AgentRuntime trait AnthropicBackend (reqwest + SSE)
ClaudeCodeBackend (subprocess)
ErrorBackend (fallback stub)
Layer 5 Ledger BLAKE3 over JCS-canonical JSON (RFC 8785)
LedgerWriter: SQLite, INSERT OR IGNORE
Proof enum: Null | Attestation | ZkStark
A turn's lifecycle: the gateway receives a turn.run JSON-RPC frame, the SessionManager enqueues it on the session's serial worker, the ConstitutionalRuntime gates tools and injects mailbox/mandate before delegating to the inner AgentRuntime, events stream back through the chain, and on Done the post-turn wrapper writes a hash-chained Turn ledger entry.
#3. Content-Addressed Ledger
#3.1 Cid derivation
A Cid is a 64-character lowercase hex string: the BLAKE3 digest of the JCS-canonical serialization (RFC 8785) of all entry fields except cid itself. JCS canonicalization sorts object keys alphabetically at all nesting levels and delegates scalar serialization to the JSON specification. Implementation: ledger/hash.rs:entrycid() computes canonicaljson(pre_image) then blake3::hash().
The canonical serializer (ledger/hash.rs:canonicaljson) handles objects by sorting (key, value) pairs lexicographically by key, arrays by recursive descent, and scalars via serdejson::to_string. Five unit tests verify insertion-order independence and collision resistance.
#3.2 LedgerEntry schema
pub struct LedgerEntry {
pub cid: Cid, // computed by LedgerEntryBuilder::build()
pub entity_id: String, // agent session key
pub target: String, // session id or tool id
pub quality: EntryQuality, // discriminant (see 3.3)
pub timestamp: DateTime<Utc>,
pub source: String, // session key of producing agent
pub actor: String, // agent roster id
pub parents: Vec<Cid>, // hash chain linkage
pub tags: Vec<String>, // free-form metadata
pub payload: Value, // JCS canonical; skill trace for Turn
pub proof: Option<Proof>, // null (v0), see 3.5
pub envelope: Option<Envelope>, // null (v0/v1.0), see 3.5
}
LedgerEntryBuilder assembles entries via a builder pattern. Callers never supply cid -- it is computed deterministically by build(), which constructs a serdejson::json! object with all fields in alphabetical key order, passes it through entrycid(), and attaches the result.
#3.3 EntryQuality variants
| Variant | Written when |
|---|---|
Turn | A turn completes (Done event). Payload carries the skill-trace format: inputshash, outputshash, timestamp, actor. |
ToolCall | The model requests a tool invocation. |
ToolResult | A tool returns its result. |
PolicyVerdict | The policy engine gates a tool (allowed or blocked). Payload includes constitution_hash. |
MailboxInject | An unread mailbox message is injected into the turn history. |
SessionLifecycle | A session opens or closes. |
#3.4 Hash chain
Within a session, each Turn entry's parents field contains the cid of the previous completed turn. The first turn has parents: []. The chain is reconstructed by SessionStore::lastturn(), which retrieves the most recent completed turn for prevcid population. Integration test testledgerhashchain verifies: turn 0's prevcid is NULL, turn 1's prev_cid equals turn 0's id.
#3.5 Proof and Envelope
pub enum Proof {
Null, // v0: no proof
Attestation { rules_checked, predicate_results, signature }, // v1.0
ZkStark { stark_proof, public_inputs }, // v2.0
}
pub struct Envelope {
pub audience_label: String,
pub encrypted_key: String,
pub ciphertext: String,
}
Proof::Null serializes as JSON null. Both Attestation and ZkStark variants are typed and tested for roundtrip serialization from day one (ledger/proof.rs), though only Null is populated in v0. Envelope is null until v1.5 (KDF audience-keyed encryption on agent departure).
#3.6 SQLite storage
LedgerWriter (ledger/writer.rs) wraps a SQLite database path. All blocking operations dispatch via tokio::task::spawn_blocking. The ledger table has cid TEXT PRIMARY KEY; appends use INSERT OR IGNORE for idempotency. parents, tags, and payload are stored as JSON text. proof and envelope are nullable text (serialized as "null" for None).
#4. Constitutional Runtime
#4.1 Wrapper pattern
ConstitutionalRuntime<R: AgentRuntime> implements AgentRuntime by wrapping any inner R. The session manager and gateway interact with it through the same trait interface -- the constitutional layer is invisible to them. The inner runtime never sees blocked tools.
pub struct ConstitutionalRuntime<R: AgentRuntime> {
inner: Arc<R>,
identity: AgentIdentity,
manifest: AgentManifest,
policy: Arc<PolicyEngine>,
ledger: Arc<LedgerWriter>,
board: BoardClient,
mailbox: MailboxClient,
}
#4.2 Pre-turn sequence
On run_turn(), before the inner runtime sees anything:
- Tool gating --
policy.gate_all(&tools, &trust)evaluates every tool. Blocked tools are removed frominput.tools. APolicyGateledger entry andRuntimeEvent::PolicyGateevent are emitted for each tool (allowed or blocked).
- System prompt assembly --
manifest.systemprompt()constructs: constitutional preamble (trust level, externalization mandate, human authority statement) + mandate text (from roster or default) + board excerpt (last N lines) + available tool list +[constitution: <blake3hash>]footer. This is prepended to any existing system prompt.
- Mailbox injection --
MailboxClient::unreadfor(agentid)reads unread messages. Each is appended as aMailboxInjectledger entry and emitted as aRuntimeEvent::MailboxInjectedevent. The messages are formatted asContentBlock::Textand inserted at position 0 ofinput.messagesas aUserrole message.
- Stream composition -- Pre-events (policy gates + mailbox injections) are chained before the inner runtime's stream via
stream::iter(preevents).chain(innerstream).
#4.3 Policy engine
PolicyEngine (constitution/policy.rs) loads constitution.yaml and the prose constitution.
YAML sidecar format:
tool_rules:
- name: "rule_name"
condition:
agent_trust: "unknown" # optional
tool_name_matches: ["bash", "shell"] # optional
verdict: "blocked"
reason: "explanation"
default_mandate: |
Injected system prompt for unknown agents.
Evaluation: Rules are checked in declaration order. Conditions are ANDed: both agenttrust and toolname_matches must hold if both are present. An empty condition {} matches everything. First match wins. If no rule matches, the engine returns Blocked("no matching policy rule") -- fail-closed semantics.
constitutionhash: blake3hex(constitutionmarkdownbytes), computed once at PolicyEngine::load() and carried in every PolicyGateResult.
#4.4 Trust levels
| Level | Determination | Tool access |
|---|---|---|
Unknown | Not in agent-roster.jsonl, or state == "dead" | Read, search, send_message only. Shell, write, network blocked. |
Registered | In roster with state != "dead" | All tools allowed by default. |
Standing | In roster with kind == "role" and state == "live" | All tools. Session never evicted. Heartbeat-triggered. |
Trust is resolved at session creation by AgentManifest::load() scanning data/agent-roster.jsonl line by line, matching on session_id field.
#4.5 Mandate injection
Standing agents may have a mandate field in their roster entry pointing to a markdown file. AgentManifest::load() reads this file and uses it as the mandate text. Unknown agents receive default_mandate from constitution.yaml. The mandate is embedded in the system prompt alongside the board excerpt and constitution hash.
#5. Session Management
#5.1 Session key format
<agentid>:<channel>:<peer> -- e.g., reed:cli:local, openclaw-agent:ws:abc123. The gateway derives a key automatically if the client does not supply one: <agentid>:ws:<uuid>.
#5.2 Session ID derivation
blake3hex(format!("{agentid}:{sessionkey}:{createdat_rfc3339}")). Deterministic from identity components, not a random UUID. This is a prerequisite for keypair-anchored session identity in v1.0.
#5.3 KeyedQueue
session/queue.rs. A DashMap<String, SessionWorker> where each SessionWorker is a Tokio task with a bounded mpsc::channel<TurnRequest>(8). enqueue() returns a BoxStream<RuntimeEvent> backed by a response channel. The worker task (worker_task) drains requests serially: each turn runs to completion before the next begins. Different session keys run on different workers, achieving cross-session concurrency. Unit tests verify serial execution within a session and concurrent execution across sessions (timing-based: 30ms per turn, two concurrent sessions complete in <100ms).
#5.4 SessionStore
session/store.rs. SQLite with PRAGMA journal_mode = WAL. Four tables:
- sessions:
id TEXT PRIMARY KEY(BLAKE3),sessionkey TEXT UNIQUE,agentid,backend,model,mode(JSON enum),state,pubkey(null v0),lastactivity,createdat. - turns:
id TEXT PRIMARY KEY(BLAKE3),sessionidFK,seq INTEGER,prevcid TEXT(null for first turn),inputhash,outputhash,stopreason,usage(JSON),startedat,completedat,proof TEXT(null v0). Index on(sessionid, seq). - history:
id TEXT PRIMARY KEY,sessionidFK,turnidFK,seq,role,content(JCS JSON),createdat. Index on(sessionid, seq). - ledger: Same schema as
LedgerWriterDDL.
All operations are async via tokio::task::spawnblocking. Reads open short-lived connections (openro); writes go through openrw with PRAGMA foreignkeys = ON.
#5.5 TTL eviction
session/eviction.rs. EvictionTask runs on a configurable interval (default 60s check cycle). Only Domain mode sessions are eligible. Persistent sessions are never evicted. Oneshot sessions are closed by the gateway after each turn.
Mark-and-defer: A Running session past its TTL is not killed mid-turn. Instead, evictonidle is set to true. The postturnstream wrapper in session/mod.rs checks this flag after Done and calls close(IdleEvicted) if set. Idle sessions past TTL are evicted immediately.
#5.6 Standing agent preload
On startup (with --preload-standing), SessionManager::preloadstandingagents() reads data/agent-roster.jsonl, filters for kind == "role" and state == "live", and calls ensuresession for each with mode: Persistent and sessionkey: {agent_id}:cli:local.
#6. Wire Protocol
#6.1 Frame format
ACP JSON-RPC 2.0 over WebSocket at ws://localhost:18789/ws.
Request: { "id": <any>, "method": <string>, "params": <object> }
Response (non-streaming): { "id": <same>, "result": <object> }
Streaming event: { "id": <same>, "event": { "type": <string>, "seq": <u64>, ... } }
Terminal result: { "id": <same>, "result": { "status": "complete" } }
Error: { "id": <same>, "error": { "code": <string>, "message": <string> } }
#6.2 Methods
| Method | Params | Result | Streaming | |||
|---|---|---|---|---|---|---|
session.init | agentid (required), sessionkey (optional), model (optional), mode (optional, default "domain") | { sessionkey, sessionid } | No | |||
turn.run | session_key (required), message or messages (required), tools (optional) | { status: "complete" } | Yes | |||
session.cancel | session_key | { ok: true } | No | |||
session.close | session_key, reason (optional) | { ok: true } | No | |||
session.status | session_key | `{ state: "idle" | "running" | "cancelled" | "closed" }` | No |
#6.3 Event types
ACP-compatible: textdelta, toolcall, toolcallupdate, toolresult, usageupdate, done, error.
HAAK extensions (ignored by OpenClaw clients): reasoningdelta, policygate, mailboxinjected, ledgerappend.
All events carry a monotonically increasing seq: u64 -- prerequisite for stream signing in v1.0. RuntimeEvent is #[serde(tag = "type", renameall = "snakecase")], so serialization produces the correct wire shape directly.
#6.4 OpenClaw compatibility
An OpenClaw agent connects by setting "url": "ws://localhost:18789" in its ACP backend config. The gateway implements the full RPC surface. Unknown agents receive the constitutional bind (restricted tools, default mandate). The agent cannot negotiate or opt out.
#6.5 Tool loop
handleturnrun in server/routes.rs implements the agentic tool loop. When a turn ends with stopreason: ToolUse, the gateway executes tools via ToolExecutor, appends assistant and tool-result messages to the history, and calls runturn again. The loop continues until EndTurn, MaxTokens, or StopSequence.
When the client provides no tools, the gateway auto-injects the standard tool set from ToolExecutor::tool_definitions(), ensuring standing agents triggered via heartbeat always have tool access. The constitutional policy gate still filters to permitted tools.
#7. Tool Executor
#7.1 Standard tool set
Eight tools, implemented in server/tools.rs:
| Tool | Params | Notes |
|---|---|---|
read_file | path (required) | 50KB truncation. Path-safe. |
list_files | path, pattern | Glob-based, 200 entry limit. |
search | query (required), path, glob | Grep with -rn --include, 100 match limit. |
send_message | to, subject, body (all required) | Appends to agent-mailbox.jsonl. |
read_mailbox | (none) | Returns unread messages for the session's agent. |
read_board | lines (default 20) | Tail of board.md. |
post_board | content (required) | Appends [agent_id] content to board.md. |
spawn_subagent | prompt (required), working_dir | claude --print subprocess, 120s timeout. |
#7.2 Path safety
saferesolve(root, path) canonicalizes paths via canonicalize() (resolving symlinks and .. components) and verifies the resolved path starts with the workspace root. If the target does not exist, the nearest existing ancestor is canonicalized and the suffix reattached. Paths that escape the sandbox return an error. Six unit tests cover traversal rejection for readfile, list_files, and search.
#7.3 Agent-scoped execution
ToolExecutor::withagentid() clones the executor with a different agent_id, reusing shared Arc<MailboxClient> and Arc<BoardClient>. The turn handler scopes each turn to the session's actual agent (extracted from the session key), so mailbox reads and board posts are attributed correctly.
#8. Heartbeat
heartbeat.rs. HeartbeatTask runs as a detached tokio::spawn task with a configurable interval (default 300s). On each tick:
- Read
SessionManager::standing_agents()to enumerate persistent sessions. - For each agent, read
MailboxClient::unreadfor(agentid). - If unread messages exist and the session status is
Idle, trigger a turn with a prompt: "You have N unread mailbox message(s). Check your mailbox and act on any pending messages." - The triggered turn runs in a spawned task (fire-and-forget).
RunningorClosed/Cancelledsessions are skipped. - Failed turns log at
errorlevel but do not crash the loop.
The heartbeat never marks messages as read -- the agent's own turn handles that via the constitutional mailbox injection.
#9. Security Model
Constitutional hash as integrity anchor. The BLAKE3 hash of patterns/policies/01-constitution.md is computed once at startup and embedded in every PolicyGateResult and system prompt footer. Any change to the constitution text changes all subsequent policy verdicts' constitution_hash, creating a visible boundary in the ledger.
Policy gate before API call. Blocked tools are removed from TurnInput.tools before the inner runtime sees them. The model never receives tool definitions it is not permitted to use. This is enforced at Layer 3 (ConstitutionalRuntime), making bypass impossible from Layers 4-5.
Fail-closed evaluation. If no policy rule matches a tool, the engine returns Blocked. Unknown agents receive a restrictive default: read, search, and send_message only.
Ledger as audit trail. Every session open, tool gate, mailbox injection, and completed turn writes a LedgerEntry with a content-addressed cid. Turn entries are hash-chained via prev_cid, producing a verifiable sequence within each session.
Trust escalation. Unknown agents can request registration by posting to the board. A human (the PI) reviews and adds them to agent-roster.jsonl. This is a manual gate, not an automated protocol.
Crypto roadmap:
| Phase | Mechanism | Status |
|---|---|---|
| v0 (current) | BLAKE3 CIDs, proof: null, envelope: null | Deployed |
| v1.0 (Aug 2026) | Proof::Attestation with agent Ed25519 signature; prev_cid chain verification | Fields typed, tests passing |
| v1.5 | Envelope with KDF audience keys; rotate on agent departure | Field typed |
| v2.0 | Proof::ZkStark generation and verification | Variant typed, roundtrip tested |
#10. Testing
#10.1 Unit tests
114 unit tests across 18 source files, covering:
- Ledger: CID determinism (same fields = same CID), field mutation = different CID, JCS canonical key sorting, insertion-order independence, BLAKE3 hex length,
ProofandEnveloperoundtrip serialization,LedgerWriterappend/get/idempotency/parents_of. - Runtime types:
ContentBlockroundtrip (text, tooluse, toolresult, thinking),RuntimeEventfield verification,StopReason/SessionModesnake_case serialization,Messagecontent deserialization (string shorthand and array-of-blocks). - Policy: Real
constitution.yamlloaded; unknown trust blocks bash/writefile, allows readfile/search/sendmessage; registered/standing trust allows arbitrary tools; first-match-wins ordering; fail-closed for unmatched tools;gateall returns one result per tool; constitution hash is 64-char hex. - Constitutional runtime: Blocked tools not passed to inner runtime;
PolicyGateevents emitted beforeTextDelta; system prompt prepended; mailbox injection events before text. - Manifest: Unknown agents get default mandate; standing agents recognized; dead agents become unknown; system prompt contains constitution hash, mandate, board excerpt, trust level, available tools list.
- Session: Ensuresession creates and caches; runturn yields events and Done; cached runtime reused across turns; close removes from cache; hash chain integrity (prev_cid populated).
- KeyedQueue: Same-session turns serial; different-session turns concurrent; enqueue yields expected events.
- Eviction: Idle domain past TTL evicted; recent domain survives; persistent never evicted; running domain marked for deferred eviction.
- Store: Upsert/get roundtrip; missing returns None; upsert idempotent; append/complete turn; hash chain prevcid; listsessions.
- Tools: readfile returns contents, not-found is error, path traversal rejected; listfiles with pattern; search finds content, no matches returns message; sendmessage/readmailbox roundtrip; postboard/readboard; spawn_subagent requires prompt; unknown tool returns error.
- Server: session.init returns session_key; turn.run streams events ending with done + result; session.status returns idle after init.
#10.2 Integration tests
Seven integration tests in tests/test_integration.py, requiring a running server with a real Anthropic API key:
- Happy path -- session.init + turn.run, verify textdelta events, done with endturn, monotonic seq values, result frame with status=complete, DB session and turn rows.
- Constitutional bind -- Unknown agent with bash tool: policygate blocked event emitted, no bash toolcall in stream, textdelta events still arrive, ledger contains policyverdict entry.
- Mandate injection -- Reed (standing agent): response contains role-specific keywords, not the generic stub mandate.
- Ledger hash chain -- Two turns on same persistent session: turn 0 prevcid is NULL, turn 1 prevcid equals turn 0 id.
- Tool execution -- readfile tool: toolcall event with name=read_file, response text mentions constitution content.
- OpenClaw compatibility -- Session key format
agent:telegram:@zach: init result echoes key exactly, text_delta and done events received, DB row matches. - Cancellation -- Start long-running turn, cancel after 3 textdeltas via separate WebSocket connection, verify stream stops (<30 textdeltas total), session.status returns cancelled/idle/closed.
#11. Deployment
#11.1 Build and run
cd infra/daemons/agent-runner
cargo build --release
./target/release/haak-agent-runner \
--port 18789 --db data/agent-runner.db \
--policy infra/daemons/agent-runner/constitution.yaml \
--constitution patterns/policies/01-constitution.md \
--preload-standing
Requires ANTHROPICAPIKEY in environment (sourced from ~/.secrets in production).
#11.2 Launchd
com.haak.agent-runner plist. KeepAlive + RunAtLoad. make install patches the API key from ~/.secrets into the launchd plist. Logs at ~/Library/Logs/haak-agent-runner.{log,err.log}.
#11.3 CLI flags
| Flag | Default | Description |
|---|---|---|
--port | 18789 | Gateway port |
--bind | 127.0.0.1 | Bind address |
--db | data/agent-runner.db | SQLite database path |
--policy | constitution.yaml | Policy rules YAML |
--constitution | patterns/policies/01-constitution.md | Constitution markdown |
--ttl | 30 | Domain session TTL (minutes) |
--preload-standing | false | Preload standing agents from roster |
--heartbeat-interval | 300 | Heartbeat interval (seconds) |
--no-heartbeat | false | Disable heartbeat |
#11.4 Console integration
The agent runner listens on port 18789 (production) or 18799 (test suite). The HAAK web console connects via WebSocket to this port for agent interaction.
#11.5 Crate layout
infra/daemons/agent-runner/
Cargo.toml
constitution.yaml
src/
main.rs startup, CLI args, runtime factory, ErrorBackend
lib.rs module declarations, re-exports
runtime/
mod.rs AgentRuntime trait, RuntimeEvent, Handle, types
anthropic.rs AnthropicBackend (reqwest + SSE)
claude_code.rs ClaudeCodeBackend (subprocess)
constitution/
mod.rs ConstitutionalRuntime<R>
manifest.rs AgentManifest, TrustLevel, system prompt assembly
policy.rs PolicyEngine, YAML loading, rule evaluation
mailbox.rs MailboxClient (JSONL read/write)
board.rs BoardClient (board.md read/append)
ledger/
mod.rs Cid, EntryQuality, LedgerEntry, LedgerEntryBuilder
hash.rs canonical_json (JCS), blake3_hex, entry_cid
proof.rs Proof enum, Envelope struct
writer.rs LedgerWriter (SQLite)
session/
mod.rs SessionManager, post_turn_stream
queue.rs KeyedQueue, worker_task, drive_turn
store.rs SessionStore (SQLite WAL), DDL, row types
eviction.rs EvictionTask (TTL loop)
server/
mod.rs GatewayServer, Axum router, health + ws handlers
routes.rs RPC dispatch, handle_turn_run (tool loop)
tools.rs ToolExecutor, 8 tools, safe_resolve
tests/
test_integration.py 7 integration tests (Python, websockets)
Architecture 40 — HAAK Agent Runner: Technical Specification — 2026 — Zachary F. Mainen / HAAK