HAAK Agent Runner: Technical Specification

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…

#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

VariantWritten when
TurnA turn completes (Done event). Payload carries the skill-trace format: inputshash, outputshash, timestamp, actor.
ToolCallThe model requests a tool invocation.
ToolResultA tool returns its result.
PolicyVerdictThe policy engine gates a tool (allowed or blocked). Payload includes constitution_hash.
MailboxInjectAn unread mailbox message is injected into the turn history.
SessionLifecycleA 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:

  1. Tool gating -- policy.gate_all(&tools, &trust) evaluates every tool. Blocked tools are removed from input.tools. A PolicyGate ledger entry and RuntimeEvent::PolicyGate event are emitted for each tool (allowed or blocked).
  1. 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.
  1. Mailbox injection -- MailboxClient::unreadfor(agentid) reads unread messages. Each is appended as a MailboxInject ledger entry and emitted as a RuntimeEvent::MailboxInjected event. The messages are formatted as ContentBlock::Text and inserted at position 0 of input.messages as a User role message.
  1. 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

LevelDeterminationTool access
UnknownNot in agent-roster.jsonl, or state == "dead"Read, search, send_message only. Shell, write, network blocked.
RegisteredIn roster with state != "dead"All tools allowed by default.
StandingIn 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), sessionid FK, 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, sessionid FK, turnid FK, seq, role, content (JCS JSON), createdat. Index on (sessionid, seq).
  • ledger: Same schema as LedgerWriter DDL.

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

MethodParamsResultStreaming
session.initagentid (required), sessionkey (optional), model (optional), mode (optional, default "domain"){ sessionkey, sessionid }No
turn.runsession_key (required), message or messages (required), tools (optional){ status: "complete" }Yes
session.cancelsession_key{ ok: true }No
session.closesession_key, reason (optional){ ok: true }No
session.statussession_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:

ToolParamsNotes
read_filepath (required)50KB truncation. Path-safe.
list_filespath, patternGlob-based, 200 entry limit.
searchquery (required), path, globGrep with -rn --include, 100 match limit.
send_messageto, subject, body (all required)Appends to agent-mailbox.jsonl.
read_mailbox(none)Returns unread messages for the session's agent.
read_boardlines (default 20)Tail of board.md.
post_boardcontent (required)Appends [agent_id] content to board.md.
spawn_subagentprompt (required), working_dirclaude --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:

  1. Read SessionManager::standing_agents() to enumerate persistent sessions.
  2. For each agent, read MailboxClient::unreadfor(agentid).
  3. 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."
  4. The triggered turn runs in a spawned task (fire-and-forget). Running or Closed/Cancelled sessions are skipped.
  5. Failed turns log at error level 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:

PhaseMechanismStatus
v0 (current)BLAKE3 CIDs, proof: null, envelope: nullDeployed
v1.0 (Aug 2026)Proof::Attestation with agent Ed25519 signature; prev_cid chain verificationFields typed, tests passing
v1.5Envelope with KDF audience keys; rotate on agent departureField typed
v2.0Proof::ZkStark generation and verificationVariant 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, Proof and Envelope roundtrip serialization, LedgerWriter append/get/idempotency/parents_of.
  • Runtime types: ContentBlock roundtrip (text, tooluse, toolresult, thinking), RuntimeEvent field verification, StopReason/SessionMode snake_case serialization, Message content deserialization (string shorthand and array-of-blocks).
  • Policy: Real constitution.yaml loaded; 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; PolicyGate events emitted before TextDelta; 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:

  1. 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.
  2. Constitutional bind -- Unknown agent with bash tool: policygate blocked event emitted, no bash toolcall in stream, textdelta events still arrive, ledger contains policyverdict entry.
  3. Mandate injection -- Reed (standing agent): response contains role-specific keywords, not the generic stub mandate.
  4. Ledger hash chain -- Two turns on same persistent session: turn 0 prevcid is NULL, turn 1 prevcid equals turn 0 id.
  5. Tool execution -- readfile tool: toolcall event with name=read_file, response text mentions constitution content.
  6. OpenClaw compatibility -- Session key format agent:telegram:@zach: init result echoes key exactly, text_delta and done events received, DB row matches.
  7. 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

FlagDefaultDescription
--port18789Gateway port
--bind127.0.0.1Bind address
--dbdata/agent-runner.dbSQLite database path
--policyconstitution.yamlPolicy rules YAML
--constitutionpatterns/policies/01-constitution.mdConstitution markdown
--ttl30Domain session TTL (minutes)
--preload-standingfalsePreload standing agents from roster
--heartbeat-interval300Heartbeat interval (seconds)
--no-heartbeatfalseDisable 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