Code constraints: `proto` became `bxcore` in M5b task 00 and holds the code roles share as well as the shared types. Recorded in docs/decisions.md with the M5b spec. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
19 KiB
Boxmaker design brief
Boxmaker is a sovereign personal agent harness. The name is from Count Zero: the remnant AI in the Straylight cores that patiently assembles small boxes from fragments.
This brief is binding. Changes to it land as their own commit and are recorded in
docs/decisions.md. Milestones and session prompts are in docs/milestones.md.
Crate names and llama.cpp server parameters below are from memory or secondhand research. Verify each one against primary docs before use.
Goal
A personal agent harness that maximizes sovereignty: local inference by default, no ambient authority, state in plain files the owner can read, a codebase small enough to audit.
Target environment
- Inference host:
straylight, AMD Strix Halo, 128 GB unified memory, of which the GPU may address 104 GiB. The harness runs on the same host. - Serving: llama.cpp
llama-serverin router mode. One endpoint, one child server per model, at most two models loaded. The owner's coding agents and Open WebUI use the same router and the same Ornith instance. Details and measurements are indocs/inference-contract.md. - Model: Ornith-1.5-35B-A3B (Qwen3.5-family MoE, hybrid linear/full attention, Qwen XML tool calls with thinking blocks). As deployed: Q4_K_M weights, q8_0 KV cache, two slots of 131,072 tokens. Weights and cache are unloaded after six idle hours, or when a third model is loaded.
- Memory is not abundant: Laguna S 2.1 (69 GB) and Ornith (22 GB) are normally both loaded. Generation is fast. Prompt processing is slow and cache invalidation is the dominant cost. Hybrid-attention models cannot partially rewind their cache; M0 confirmed it.
- Measured in M0 with the GPU otherwise idle: prompt processing 1,170 tokens/s at depth 0 and 669 at 32k depth; generation 69 tokens/s falling to 60. A second active session on Ornith roughly halves generation speed. An uncached 32k-token prompt took 36 s, so a 64k re-read costs well over a minute.
- Deployment: rootless containers on a host that is already a tailnet node. Containers do not get their own tailnet identities, so tailnet ACLs cannot tell one role from another. Isolation between roles is done with container networking on the host (see "Network isolation"). Tailnet ACLs restrict what the host as a whole may reach.
- First channel: the owner's existing self-hosted Mattermost server, reached over the tailnet.
Non-goals (v0)
Plugin registry, skill marketplace, web dashboard, multi-user, voice, more than one chat channel, cloud-led orchestration as the default, in-process third-party code of any kind.
Roles (separate binaries, narrow interfaces)
| Role | Purpose | Holds | Can reach | Never holds |
|---|---|---|---|---|
gatewayd |
Carries messages between the owner's Mattermost and the harness. | Mattermost bot token, allowlist, message queue | Mattermost server (outbound only), loopd socket |
Tool secrets, grants |
loopd |
Runs the conversation: builds each prompt, calls the model, and asks brokerd for every tool call. |
Session logs, prompt assembly | Four Unix sockets: gatewayd, brokerd, inferproxy, memoryd |
Any credential, any network interface |
inferproxy |
Lets loopd reach the model without having a network. |
The llama-server address |
llama-server only |
Anything else; it forwards bytes and logs nothing |
brokerd |
Decides whether each tool call may run, runs it in a fresh container, and records the decision. | Grants (read-only), secrets, audit log | Container runtime, gatewayd socket (for approvals) |
Conversation history |
memoryd |
Keeps the memory files and their index, syncs finished turns to Honcho, and runs every memory tool call brokerd has decided. |
memory/, index/, Honcho's address and workspace list (and its key, if any) |
Honcho only; sockets from loopd (finished turns in, the memory files out once per epoch) and from brokerd (memory tool calls); sessions/ read-only, for reindexing |
Grants, tool secrets, the Mattermost token |
| tool containers | Do one approved tool call and then cease to exist. | Only what one call was granted | Only what one call was granted | Anything persistent |
v0 runs all roles on one host. The interfaces must not assume co-location with llama-server.
Network isolation
The host's tailnet identity is shared by every container that has a network, so a container with ordinary outbound networking can reach the whole tailnet and the internet as the host.
loopdruns with no network namespace interfaces except loopback (--network=none). Its only I/O is four Unix sockets on a shared volume.inferproxyis a forwarder of about 100 lines: one Unix socket in, one fixed upstream out. The upstream is the shared router's TCP listener on the host. It is required:loopdhas no network, and the router stays on TCP for its other clients.gatewaydhas outbound network only. It opens no listening TCP port. Approvals are commands (!approve,!deny) replied in the approval's thread, which arrive over the Mattermost WebSocket, so no callback URL is needed.memorydreaches Honcho and nothing else. It does not index sessions above a configured data class.- Tool containers get no network unless the grant names hosts. Granted network access goes through
an allowlisting egress proxy that
brokerdconfigures per call. - Tailnet ACLs (kept in
deploy/) limit the host's node tollama-server, Mattermost and Honcho, plus whatever tool grants need. This is defence in depth, not the role boundary.
Channel: Mattermost
- Bot account with a token held in the
SecretStore. REST API for posting, WebSocket for events. Implement directly over HTTP and WebSocket; do not adopt a community SDK without checking its maintenance. Fetch the Mattermost API reference before writing any request. - Identity is the Mattermost user ID. The allowlist is a config file of user IDs. Direct messages only by default; channels must be allowlisted by ID. Authentication is delegated to the server.
- Long tasks: post a placeholder, edit or reply when finished. Threads map to sessions.
- Mattermost stores messages unencrypted in its database. That is acceptable because the server is
the owner's, and it is recorded in
docs/egress.md.
Inference contract (the part existing harnesses get wrong)
- Baseline budget. System prompt, tool schemas and the three memory files at session start:
3,000 tokens or less, measured with the server's tokenizer (
/tokenize), enforced by a test. That test needs straylight, so it runs inmake verify-device, not in the offlinemake gate. - Append-only. The request for turn N+1 is a strict extension of the request for turn N.
Nothing volatile (time, heartbeat notes, memory refreshes) goes anywhere but the newest message.
Enforced by a property test on the serialized message array. The session log stores each
assistant message exactly as the server returned it (
content,reasoning_content,tool_calls) and replays it unchanged. Ornith's template keeps every thinking block, so dropping or editing one changes the prefix. - No in-place pruning. Tool results are size-capped when first appended, never trimmed later.
- Compaction is an epoch change. It happens only when the session is idle, writes a summary, and starts a new epoch whose prefix is the baseline plus the summary. The old log is kept.
- Pinned slots, not reserved. Every request carries
id_slot, and a session always uses the same slot: moving it costs a full re-read. Slot assignment is configuration. With the two slots deployed today, main sessions (one per Mattermost thread) share one slot, and subagents and scheduled runs share the other. The server is shared, so another client can take a harness slot and the router can unload Ornith. An evicted session is normally restored from the server's host-RAM prompt cache when it returns on its slot; an unloaded model is not. Cache loss is therefore an expected event:loopddetects it (cache_nfar below the previous request's total), records it in the session log and carries on. It is never an error. - Progressive disclosure. The
toolsarray is fixed for the whole epoch. The template renders it at the top of the prompt, so any change re-reads everything. Only a small core tool set has schemas in it. Everything else is found throughfind_tool, which returns schemas as an ordinary tool result, and is called through one fixedcall_tool(name, arguments)meta-tool.brokerdapplies grants to the target tool, not tocall_tool. Never prompt the model to call a tool that is not in thetoolsarray: the server's grammar forces such a call into a declared tool. - Liveness, not deadlines. Streaming always, with
return_progress: trueso that prompt processing produces events. The timeout is "no bytes for N seconds", never a total-request deadline. Progress events count as bytes. Before the first byte, a request may be queued behind another client on its slot or waiting for the router to load the model. A separate, longer limit covers that wait, and the liveness timer starts at the first byte. M2 measures what the stream sends while a request is queued. - Runaway control. Per-turn thinking-token cap, per-turn tool-iteration cap, detection of
repeated identical tool calls.
loopdenforces the thinking cap: it counts streamed reasoning tokens and ends the block with the server'sreasoning_controlmechanism (POST /v1/chat/completions/control, actionreasoning_end). M2 exercises this on straylight before relying on it. - Startup self-test. On boot
loopdchecks: tool-call round trip parses, turn-2 prompt processing count shows a cache hit, configured context matches what the server reports. It refuses to start if any check fails. - Serving settings are recorded, not owned. The launch flags live in the owner's NixOS
configuration (
~/src/nixoson straylight). This repo records the expected values: chat template hash, per-slot context, slot count, q8_0 KV cache, no speculative decoding. The startup self-test compares them with/propsand/slots. Sampling settings (temp 0.6, top-p 0.95, top-k 20) are sent with every request, because the server's default temperature is 1.0.
Settled by M0 (docs/inference-contract.md): loopd uses the server's chat-completions endpoint
with server-side tool parsing. It does not render the template in-process. Cache reuse held across
a multi-turn tool conversation and tool parsing had no failures in 20 trials.
Authority contract
loopdhas no authority. Every tool call goes tobrokerdas a request.- A grant is a file the owner writes: tool, argument constraints (paths, hosts, patterns),
allowed data classes, expiry, mode (
auto,ask,deny).brokerdreads grants and cannot write them.loopdcannot see the directory. - No matching grant means deny.
askroutes an approval request to the owner throughgatewaydand the turn suspends until answered or expired. - Each approved call runs in a fresh rootless container: no network unless granted, only granted paths mounted, only the granted secret injected. Tool containers run from one image built from source by Nix and named by digest; nothing is pulled at call time.
- Audit log: append-only JSONL, hash-chained, one record per decision including denials.
- Data classes: every tool result is labelled
public,private, orsecret. A session's taint is the union of labels it has seen. The cloud-consult tool is refused for tainted sessions unless a grant allows that class out. Every outbound cloud payload is logged in full. - Scheduled jobs are re-validated against current grants on every run.
Cloud consult (M6)
- One tool,
consult, behindbrokerdlike any other. The endpoint is any OpenAI-compatible URL, set in config. Nothing in the code is specific to one vendor. - Candidate as of 2026-09-16: GLM-5.3-Flash (open weights, MIT, 320B total / 18B active). It does not run usefully on the inference host, so it is cloud-only here. Hosting choices: Z.ai's international API (Singapore entity, Singapore law, states it does not store API content), or a third-party host chosen by jurisdiction with zero-data-retention routing. Z.ai's subscription coding plan is restricted to its officially supported tools; use pay-as-you-go.
- Call it at temperature 0.6 or lower. Community tests show malformed tool calls at the default
of 1.0. Prefer giving
consultno tools at all: it answers a self-contained question in text. - Hosted GLM always thinks. Treat
consultas slow and asynchronous. - Payloads are built from an explicit allowlist of fields, never from raw session history.
Memory (M5)
Memory is what carries continuity across epoch compaction, so it is required. It must not break the inference contract, and it must not become a way for injected text to persist.
- Identity and experience. Memory is two things. Identity is who the agent is and what
it holds true about its work and its owner: three files read once per epoch into the baseline,
in this order after the system prompt:
memory/soul.md, the persona, written only by the owner (at most 4,000 characters);memory/memory.md, the agent's core notes on its work (at most 2,200 characters); andmemory/user.md, its core notes on the owner (at most 1,375 characters), each shown with how full it is. Experience is what it has seen or been told:memory/notes.md(append-only) and the session logs, never in the prompt, found withrecall, each item labelled with its source and trust. Not everything remembered is believed. The agent writes memory with amemorytool, without asking:noterecords something seen, in any session;add,replace,removeandconsolidate(one edit marking every entry of a file kept, rewritten, moved or dropped) edit identity, and only a session that has not read untrusted content may use them. In a session that has, an identityaddbecomes anoteand the other edits are refused. What leaves an identity file goes tonotes.mdas moved or dropped; nothing is deleted. A write takes effect at the next epoch, never mid-session. A write over a file's limit is refused with a pointer toconsolidate. - Everything else is retrieved, not injected. A
recall(query, k)tool returns size-capped snippets, each with a source tag (identityfor entries moved out of identity,memoryfor trusted experience,memory-taintedfor experience from sessions or workspaces that are not trusted), where it came from, and its class. Anask_memorytool asks Honcho's dialectic. Results are appended like any tool result, so the cache prefix is untouched. - Local index. SQLite FTS5 over the session logs and
memory/notes.md, kept bymemorydand rebuildable withbxctl reindex. It needs no network and no model, so it still answers when Honcho is down.memorydcountsrecallhits per note and when each was last found. Retrieval sits behind aRetrievertrait. - Honcho. The fleet's memory service; it and the models it calls run on the owner's hosts. Each
Boxmaker writes finished turns to its own workspace, and only turns from sessions that saw no
untrusted content and are within a configured data class;
memorydenforces this itself. Entries moved byconsolidateare written there too. Honcho's own extraction, summaries and consolidation replace a flush job of ours. What Honcho holds is derived from the session logs andmemory/notes.md, and can be rebuilt from them. - Workspaces and trust.
memorydcan read other workspaces. The owner marks each one trusted or untrusted; untrusted is the default. Results from an untrusted workspace are marked untrusted and taint the session. Honcho's conclusions carry no per-entry provenance, so the filter is applied when writing and the label is per workspace. - Provenance on recall. Recalled text is presented as data with its source and data class,
never placed in the system prompt, and taints the session with that class; a
memory-taintedhit marks the session untrusted. - The tools go through
brokerdand run inmemoryd. Their policy is built in, and no grant file can change it:recall,ask_memoryandmemoryare allauto. What protects the prompt is which sessions may edit identity (item 1), plus a check on every write (no control or invisible characters, no chat-template markup, a short list of instruction phrases), run again when the prompt is built. Every write is journalled inmemory/edits/, listed bybxctl memory log, and the latest edit of a file can be undone. An owner setting stages identity writes for review instead (off by default). - No memory text leaves the owner's hosts. Honcho and the models it calls run on the owner's
hosts, and each is listed in
docs/egress.md.
State
Files are the source of truth. SQLite is allowed only for rebuildable indexes and queues.
sessions/<id>/<epoch>.jsonl— append-only session logmemory/soul.md,memory/memory.md,memory/user.md,memory/notes.md,memory/edits/— owner-readable memoryindex/— FTS5, kept bymemoryd; disposable and rebuildable frommemory/andsessions/grants/*.toml— owner-writtenaudit/*.jsonl— hash-chainedbroker/sessions/<id>.json— each session's taint and untrusted flag, written only bybrokerd; it can be rebuilt from the audit log'sresultrecords- Secrets: behind a
SecretStoretrait with three backends, chosen per secret: a systemd credential (encrypted at rest; the key is the host's TPM and host key, not stored beside it), an environment variable, or a file that only the owner can read. The credential backend is the default; the other two put the secret in plaintext, in the process environment or on disk, and are for portability; a file secret prints a warning at startup. None in config, none in the repo.
Code constraints
- Rust stable, Cargo workspace. One crate per role in the table above (
loopd,brokerd,gatewayd,inferproxy,memoryd), plus three that are not roles:Crate Purpose bxcoreDefines the data every role shares and the frame format they exchange it in, and holds the code roles share: the secret store and the sanitizer. Called protountil M5b.toolkitHolds the programs that run inside tool containers. bxctlIs the owner's command line: chat, local approvals, checks and maintenance. - No source file over 500 lines. No crate depends on another role's crate, only on
bxcore. - Authority is encoded in types: a tool cannot execute without a
Decisionvalue that onlybrokerd's policy module can construct.Decisionis defined inbrokerd, has a private field and does not implementDeserialize, so no other crate or wire message can produce one.bxcorecarries a plainDecisionRecordfor the audit log and the wire. - Dependencies are few and justified in
docs/dependencies.md.cargo-denyruns in the gate. make gateruns offline. Checks that need straylight run inmake verify-device.- No telemetry, no update checks, no outbound call not listed in
docs/egress.md.