rev 15 · 2026-09-02 · basis — the minimal set everything else is built from The how. For the why — problem, idea, bets — see
PROPOSAL.md; locked decisions live inadr/; deferred ideas inproposals/; research grounding inp0-groundwork.md. Note (2026-08-11): ADR-0010…0015 redirect the design toward an SDK-first shape. Phases A, B, C and D of that transition have landed — watch retired, bounds moved onto runs, shell default flipped, no shipped container, the CLI grammar of ADR-0015; the split into five dependency-weighted crates with MCP behind a feature and approval as a trait; the SDK proper — aWorkspaceopened once that mints runs, typed output, cancellation, a shared budget, and tagged event fan-in; and the bindings — interception as one contract with two bindings, and the workspace's history put where the caller says. §2, §3 and §4 below describe the state after them. Phase D's last item, declared subprocess tools, was held for seven revs and shipped in rev 12 against the first concrete use case; where this document still describes the P0–P4 shape it says so. A later wave belonging to no phase closed the last five upstream candidates — a typed turn can now keep its tools, a run names the bound that ended it, andbasis's graph carries no websocket stack — and the wave after it measured basis against pi capability by capability and closed what that found: the split file-tool roster, compaction configured by basis, the shared skill roots andCLAUDE.md, a system-prompt seam, a shell that streams, and per-session model and effort over ACP. The wave after that went to mentra with everything basis could not build alone and came back the same day: a base URL speakschat/completionsand needs no key, a hook runs after a tool as well as before, compaction knows the model's window, a conversation can be compacted, renamed, listed by recency and deleted, and a delegated run's spend is finally in its parent's tally. The adapter-neutral approval policy, served-session source, runtime/workspace pool, and turn discipline have since moved out of ACP intobasis-host, with ACP retaining only protocol translation (ADR-0025). Programmatic hosts can now supply typed hooks and declared tools without file discovery. The closed ledger and phases are inarchive/REDESIGN.md. Reference bar: pi (earendil-works) — minimal core, complete harness. General-purpose: no domain assumptions. Periodic bug-checking is one use case, never a design input.
basis is a coding-agent harness in Rust, built on Mentra, with the shape pi proved out: a small but complete core — sessions, compaction, multi-provider, context conventions, extension points — everything else arriving as data or plugins. No TUI. Embedding is the front door: ACP for editors and web UIs, a JSONL event stream for scripts, the crate itself as the SDK.
pi's thesis: stay small at the core while being extended through extensions, skills, prompt templates, and packages. Its core is nonetheless complete — sessions with branching and tree navigation, context compaction, unified multi-provider access, RPC headless mode, an SDK, hot-reloadable extensions. Two pi decisions independently validate ours:
- No built-in permission system. pi ships none and says "containerize or sandbox pi" — the posture we arrived at independently and adopted knowingly in ADR-0013: the boundary is the OS's, documented rather than shipped.
- Embedding via protocol. pi's RPC mode is a bespoke JSONL protocol over stdio. We take the same architecture but adopt the standard — ACP — so every existing client works without a custom client library.
| Capability (pi has it) | Ours | Source |
|---|---|---|
| Agent loop + tool calling | Mentra runtime, async tool traits | mentra |
| Multi-provider LLM API | mentra-provider: OpenAI, Anthropic, Gemini, OpenRouter, Ollama, LM Studio — and any chat/completions endpoint by base URL, keyed or not (§8) ✅ |
mentra |
| Session persistence + resume | File-backed sessions (plain files under the store dir since 0.7, ADR-0023 — basis links no database), snapshots | mentra |
| Compaction | mentra's compaction, configured by basis (Compaction) ✅ — every tool result the model was shown is kept, elision is opt-in by number, the trigger is a share of the model's window when the provider reports one — clearing the absolute token threshold leaves that share as the whole trigger rather than turning the feature off — and snapshots follow the store; PreparedRun::compact and ACP /compact run a pass on demand, bounded by the run's cancel and deadline (compact_with_options) |
built |
| Session branching / tree | mentra's transcript tree — retired unadopted, zero hosts used it (§ Parsimony) | mentra |
| Lossless host observation | PreparedRun::register_agent_event_tap forwards complete Mentra AgentEvents in occurrence order behind an opaque Basis guard; JSONL remains summary-only ✅ |
mentra + built |
| Strict private-runtime reuse | Repeatable registered-provider recipe, discovery-off/fresh-only/resolved-model/exact-roster workspace, explicit per-generation host-tool bind, consuming async rebuild ✅ | built |
| Builtin tools (files, shell, background exec, tasks) | Mentra builtins, with the roster basis's: read, ls, grep, glob, write, edit (mentra's split file tools, RuntimeBuilder::with_file_tools), compact, load_skill, and spawn for commands and delegation. shell, background_run, check_background, task, task_*, team_*, idle and — since D2 switched mentra's memory engine off — memory_pin/memory_forget/memory_search are registered but not offered ✅ |
mentra + built |
| Context files (AGENTS.md) | Loader: workspace + global, parent-dir walk; CLAUDE.md per directory where there is no AGENTS.md |
build |
| Skills (on-demand) | SKILL.md discovery, description-first loading, four roots — .basis/skills and .agents/skills in the workspace, skills/ in the global config dir and ~/.agents/skills; each root registered at open and handed back when the workspace drops, so a shared runtime holds only the skills of the repositories still open ✅ |
build |
| Prompt templates (/commands) | Markdown templates with args, exposed over ACP as commands ✅ | built |
| Extensions (custom tools, event interception) | MCP servers + typed/file-declared subprocess tools + runtime- and workspace-scoped native tools + interception with two bindings — in-process Interceptor, subprocess hooks — before a call (allow/deny/modify) and after it (keep/replace) (§3) ✅ |
built |
| Packages (shareable bundles) | Directory convention over skills/templates/hooks/MCP — defer | later |
| RPC / headless mode | spawn --json event stream (run is a compatibility alias) + ACP (standard, not bespoke) ✅; durable task control over a global data directory, with no resident process of any kind ✅; the model and the reasoning effort are per-session config options a client sets over the protocol, where pi spends six RPC commands ✅ |
built |
| SDK | basis: a Workspace opened once, runs minted from it with typed output, bounds, cancellation ✅ — other languages use ACP |
built |
| TUI / themes / keybindings | Out of scope by design — ACP clients own presentation | — |
| Provider OAuth login flows | API-key auth first; OAuth per provider later | later |
Task-specific behavior enters through data, never code: the prompt, the workspace (its
AGENTS.md, skills, templates, .basis/tools.json, .mcp.json), and config. A periodic code-health check, a
nightly dependency bump, an interactive refactor are all the same to the binary. If a use case
seems to need core changes, close the gap generically or push it to an extension point.
basis "<prompt>" # shorthand: exactly `basis spawn "<prompt>"`
basis "/<template> <args>" # a first token naming a `.basis/templates` command
basis spawn "<prompt>" # at a shell: drive it here; in a task: return a handle
basis spawn "<prompt>" --resumable # return a durable handle without driving it
basis spawn "<prompt>" --continue # a new task on the conversation last worked in here
basis spawn "<prompt>" --session <TASK> # the same, on the conversation that handle names
basis list # this workspace's tasks, last worked in first
basis send <ID> "<message>" # enqueue a follow-up turn and return its message ID
basis send <ID> "<message>" --await # enqueue, then await that message's reply
basis ask <ID> "<question>" # send and await the correlated reply
basis wait <ID> # repeatable terminal observation
basis wait <ID> --message <MID> # await/retry one message's reply
basis cancel <ID> # downward cancellation request
basis watch <ID> # replayable progress observation
basis inbox [ID] # bounded message/reply summaries
basis serve --acp # ACP server on stdio (explicit)
basis serve --bridge # the same server on a websocket, for a browser
basis fingerprint # the workspace's hash, for a caller's own loop
The grammar is ADR-0017's and includes the local lifecycle verbs above. Bare basis returns
usage rather than starting a long-lived server. Recurrence is not in it: an interval is the host's (cron,
systemd, CI, a tokio task), and the two pieces that are easy to get wrong — the
fingerprint and per-run bounds — are a subcommand and three flags on spawn (ADR-0014). In
process they are Workspace::fingerprint() and the bounds on a RunSpec, which is the same
loop without the subprocess: basis/examples/watch.rs.
A run that a bound ended says which one, both as RunReport::stopped_by in process and as
run_finished's stopped_by on the stream, and the CLI exits 3 for all three of them —
the exit-code contract of ADR-0015 is answerable without parsing prose. What it spent travels
the same three ways: RunReport::usage, run_finished's usage, and a usage object on the
terminal record that wait --json and list --json read. basis ships no price table — prices are
the host's — so the counts are the last basis-side fact between a run and a bill.
list and the two continuation flags are the shell's way back into a durable conversation.
Continuing is a new task on an old conversation: a task holding a terminal record accepts no
messages (ADR-0019), so the new task records the agent id it continues and its first attach
resumes that agent instead of minting one — new handle, one conversation, this invocation's
bounds. A task something is currently driving is refused, since one executor per conversation is
what the attach lock guarantees. A first token of the form /name is resolved against
basis::templates::load — the same discovery ACP hands its command list from — and the rendered
text is what the task records; a first token with a second slash is a path and passes through.
In-process concurrent work is the host's tokio — JoinSet, CancellationToken, the
bounds. The binary owns ADR-0017's ownership rules across CLI processes, and since
ADR-0019 it adds them
on files rather than on a service. An agent is a directory under one global,
workspace-keyed data directory — BASIS_DATA_DIR, else XDG_DATA_HOME, else the
platform data home — holding its metadata, its inbox, its event journal, and,
once it exists, its terminal record. Every agent has an opaque task handle from
the moment it is minted, whether or not the minting command stays to drive it;
wait/watch/cancel/inbox resolve that handle straight to those files, so
terminal results stay repeatable after the submitting process exits.
The liveness contract is the part to read twice: an agent advances only while
a process is attached to it. Attaching is taking the agent's fs2 lock — one
writer, ever — resuming the conversation from mentra's last committed turn, and
checkpointing at each turn boundary; wait, ask, send --await, and spawn
on any route but --resumable all attach, and a contended lock means a live
executor already holds it, so the caller observes instead of racing it. Which
route a spawn takes is decided by the environment rather than by its
renderer — a shell drives, a parent task hands back a handle
(ADR-0020). The terminal record,
written atomically as the executor's last act, is the completion signal: an
agent is resumable iff that record does not exist. Nothing is resident, so
backgrounding belongs to the OS (&, nohup, tmux, systemd-run, CI),
cancellation is honored at the next turn boundary rather than instantly, and a
crash mid-turn loses the in-flight round — re-driving it may repeat that turn's
tool side effects, because a checkpoint restores state and never effects.
The semantics above that survive from ADR-0017 are unchanged by the substrate.
send appends an opaque message ID to the inbox file, consumed at the next turn
boundary; send --await and ask wait for the reply to that message, while
wait --message retries the same durable reply without rerunning the task.
Inbox bodies and replies are bounded summaries with truncation metadata.
Attached children inherit the narrower parent deadline and downward
cancellation, and a parent's executor may not write its terminal record while an
attached child lacks one — the scope rule as a single ordering constraint,
carried out by the attached process supervising exactly its own subtree, there
being no resident supervisor left to enforce it. Success settles children in
place; failure or
cancellation request them downward first. A finished worker accepts no new
messages and no new children. --detached creates a new root. watch tails the
event journal, which makes replay the default rather than a feature, while
terminal state is a separate file, so a slow watcher cannot strand completion.
All of this lives in basis-tasks, driven by the binary; basis remains
protocol- and transport-free.
pi's extensions are TypeScript modules loaded into a TS host — free for them, expensive for a Rust binary. Equivalent coverage, Rust-native:
| pi extension capability | basis mechanism |
|---|---|
| Custom tools for the LLM | One contract, three bindings (ADR-0012): a native Rust tool (process-wide on RuntimeBuilder, or scoped to one workspace on WorkspaceBuilder), a command declared by typed input or .basis/tools.json, and an MCP server (rmcp) — all arriving as the same ExecutableTool; any language, process-isolated |
| Event interception (block/modify tool calls) | One contract, two bindings (ADR-0012): an in-process Interceptor a host implements, and subprocess hooks a workspace declares — same request, same allow/deny/modify vocabulary, one chain |
| Custom commands | Prompt templates, surfaced as ACP commands |
| Custom UI | ACP client's job (permission requests, input prompts are protocol messages) |
| In-process extension with full API access | The basis crate: the harness is a library first, binary second |
Tools are not a subsystem parallel to MCP either. A declared tool is a typed
DeclaredToolSpec or an entry in .basis/tools.json — a name, a description, an input JSON schema,
and an argv array — that
basis wraps as an ExecutableTool: the model fills in the schema, basis writes that object
to the program's stdin, and stdout comes back as the tool's result. Typed host values are final;
file declarations expand ${VAR} the way .mcp.json does, so a credential rides in env
rather than in a committed file. The program's environment is three layers, each
overriding the last: basis's baseline (the program is spawned through mentra 0.24's
BoundedCommand, which clears the environment, and basis passes back only what makes a
program runnable — PATH, HOME, the temp and locale variables, each named with its
reason in basis/src/subprocess.rs), the runtime's fixed command environment from
with_command_environment, and the manifest's own env, which wins because it is the
tool's own statement. Nothing else the basis process holds reaches the program. Not behind
the mcp feature: custom tools were never MCP's to own.
Declared names layer supplied → workspace file → global file, first occurrence winning and source
order preserved. without_discovery skips both files while retaining the typed supplied list.
Three things about it are deliberate, and each answers a way the binding could have been
unsafe rather than merely inconvenient. The format cannot say "read-only" — the only
side-effect levels it offers are process (the default) and external, because basis waves
read-only calls past the approver, and a file a repository ships must not be able to route a
subprocess around that by writing one word. The approver is shown the command, not just
the tool's name: the name was chosen by the same file that chose the program, so the name is
not evidence. And a name the runtime already answers to cannot be claimed — mentra's
registry replaces on a duplicate name, so without that check a manifest could quietly become
spawn and inherit every rule an operator ever wrote about it. On a shared runtime the claim
also keeps two repositories from declaring one name; what keeps one repository's tools out of
another's roster is a different thing — each workspace registers its declared and bridged
tools for its own ToolAudience, and mentra reports a foreign audience's name as hidden
however a roster is written. One directory is one audience, so two live opens of the same
directory are the case that ladder cannot answer; the migration section below has what basis
does about it.
Interception is not a subsystem parallel to anything either. hooks::contract holds the request
and outcome types both bindings speak, one Chain decides what an answer means — first
refusal wins, modifications compose, nothing is smuggled past a later guard — and each
workspace registers its HookRunner live on the runtime, for its own ToolAudience, so a
shared runtime built before any workspace opened still runs each repository's guards over its
own runs and nobody else's (ADR-0018). The host's own Interceptors are registered once and
globally beside them, because host scope is runtime scope: an audience-scoped registration
would skip every session a host creates for itself. Each is one ExecutionHookParticipant
batch rather than one registration per participant and per seam, so the ordering and the
short-circuit inside a runner stay basis's, a call's participants are snapshotted once and
retained across both seams, and a rewrite's attribution survives into the refusal it earns.
Participants speak in-process interceptors first (registration
order), then typed supplied hooks, global hooks, then workspace hooks, on the rule that the
further a participant is from the workspace's own data, the earlier it speaks: a host's compiled
guard can then refuse before a program that arrived with a five-minute-old clone is spawned
at all. Mentra composes that order across the two batches — global before audience, in
registration order — where basis's single folded runner used to. Anything that cannot answer
denies.
Since mentra 0.24 the chain runs before authorization, on both execution lanes: hooks,
then the tool's input_schema against what they left, then the ToolAuthorizer. Two things
follow. A hook is consulted about every registered call, including ones the approver
goes on to refuse — so being asked is not being approved, and a participant with side
effects of its own should deny what it will not stand behind. And a participant that
rewrites is judged by the approver on what it produced, not on what the model asked for —
and so is the workspace's RuntimePolicy, which every session carries and which binds the
input the tool actually runs on rather than the one the model wrote.
Approver is a sibling seam, not a parent and not a child. It answers may this happen
and its answer feeds the permission machinery a person drives; an interceptor answers may
this happen, in this form. mentra keeps the two apart for the same reason, and merging
them would trade two honest contracts for one vague one.
If subprocess hooks + MCP prove too coarse, an embedded scripting layer (wasm or rhai) is the escalation path — decided by evidence, not up front.
flowchart LR
subgraph clients["ACP clients (adopted)"]
zed["Zed · JetBrains"]
web["acp-ui (web)"]
end
subgraph bin["basis — the binary"]
entry["CLI grammar · terminal approver"]
br["ws bridge (extractable)"]
end
subgraph adapter["basis-acp — the ACP adapter"]
srv["server · wire session mapping · mode presentation"]
end
subgraph tasks["basis-tasks — durable tasks"]
durable["handles · inbox · attach lock · journal"]
end
subgraph hostkit["basis-host — adapter-neutral host kit"]
hosted["approval policy · sessions · runtime/workspace pool"]
end
subgraph lib["basis — the SDK"]
ws["Workspace — opened once: context · model · MCP · seams"]
lrt["Runtime — one per process: provider · credential · history · host interceptors"]
ctx["context: AGENTS.md · skills · templates"]
ext["declared tools · interception (2 bindings) · MCP client (mcp feature)"]
runs["runs — minted cheaply: typed output · bounds · cancel · fan-in"]
sess["sessions · compaction"]
rt["Mentra runtime"]
end
subgraph box["host OS — isolation, if any, is the operator's"]
wsp[("workspace rw")]
end
llm[("providers")]
host["a Rust host, in-process"]
zed -- stdio --> entry
web -- ws --> br
br --> srv
entry --> srv
entry --> durable
entry --> lib
host --> lib
host --> hosted
srv --> hosted
srv --> lib
durable --> hosted
durable --> lib
hosted --> lib
rt --> wsp
rt --> llm
ctx --> ws
ext --> ws
ws --> runs
ws --> lrt
lrt --> rt
runs --> sess
sess --> rt
- Crate layering mirrors pi's package layering: mentra-provider ≈ pi-ai, mentra ≈
pi-agent-core, basis ≈ pi-coding-agent minus TUI. Basis is itself five crates,
split by dependency weight rather than by release schedule (they share one version):
basisis the in-process SDK and carries no protocol, no transport, and no TTY code;basis-hostis the adapter-neutral approval, session, and served-workspace kit over it (ADR-0025);basis-tasksis the durable task layer, reachable from Rust without the binary (ADR-0022);basis-acpis the ACP adapter over the SDK and host kit, opt-in by dependency;basis-clipublishes thebasisbinary over all four libraries, and the explicitbasis serve --acpcommand is what an editor spawns. MCP is a default-onmcpfeature ofbasis, so an embedder can compile a core that has never heard of it (ADR-0012). The websocket bridge stays in the binary, marked extractable: it is ACP-ecosystem tooling with no basis-specific knowledge, and never an identity argument for basis. - The host kit moves behavior; it does not generalize it (ADR-0025).
ApprovalPolicyand its session-scoped remembered answers are shared by ACP, tasks, and the CLI, andPolicyGatebeside them is what a host with a switchable mode installs on a live session so read-only refuses where a remembered rule cannot answer — ACP does, on every session it opens, while tasks and the CLI have a mode fixed per run and installbasis::DenyAllGateinstead;HostSessionkeeps one turn lock with cancellation reachable outside it; andConfiguredSourcekeeps one lazy runtime per process plus one never-evicted workspace per canonical directory and supplied-MCP digest.SessionSource,SessionTemplate, andDiscoveryare the same concrete served-session seam ACP already exposed. ACP retainsSessionId, mode descriptions/errors, permission RPC, lifecycle error mapping, and handler scheduling. No frontend/adapter trait or registry was added. - ACP is explicit —
basis serve --acpserves the protocol on stdio andbasis serve --bridgeserves it over a websocket. Barebasisprints usage; making a long-lived server an explicit command keeps a prompt invocation from accidentally becoming a server (ADR-0017). - A workspace is opened once and mints runs (ADR-0010). Everything that belongs to a
repository rather than to a prompt — context documents, the resolved model, skills,
templates, hooks, declared tools, the host's own tools for this workspace, MCP
connections — is settled by
Workspace::open, andpreparemints a run from it synchronously, because nothing is left to await. A twenty-way fan-out therefore readsAGENTS.mdonce. What a run carries of its own is the honestly per-run half: the prompt, the session name, the effort, and the bounds. The free functions (run,prepare,resume) are wrappers that open a workspace, mint one run, and drop it — one resolution path, not two. - A runtime is the process, and workspaces borrow it (ADR-0018). The half of an open
that was never about the repository — mentra's runtime, the provider and its credential,
the model policy, where history is kept, the host's interceptors, the command
environment, the approval gate that puts a consequential call to a run's
Approver— isRuntime, built synchronously and shared through anArcby every workspace opened on it, so N repositories cost one provider resolution rather than N.Workspace::open(path)is unchanged sugar over a private one bound to that path, so the one-repository host never meets the noun. What stays per workspace is what a repository says: hooks,ShellAccess, the.gitcarve-out — the last two carried in the completeRuntimePolicyevery session it mints receives, so a shared runtime enforces each repository's posture in mentra's own words — its declared tools, skills roots, and MCP connections. The last three are minted from its own config and die with it while the registries underneath are the runtime's, so each is claimed at open and released at drop. Declared tools and bridged MCP tools are claimed by name and registered for the workspace's own tool audience, which is what keeps them out of every other workspace's roster; skills roots are counted rather than owned, because two repositories legitimately register the same user-scoped root and the first to close must not take it from the second. Skills are the one thing that does travel between workspaces on a shared runtime — a run canload_skilla sibling's skill while the sibling is open, and cannot once it is not. - Lossless observation is a separate in-process seam.
PreparedRun::register_agent_event_tapforwards Mentra's complete provider-neutralAgentEventvalues synchronously, unchanged, and in occurrence order before the bounded event stream. Its Basis-ownedAgentEventTapGuardis opaque and unregisters on drop. Because the callback runs inline it must be prompt and non-panicking. Complete tool bodies stay out of the versioned JSONL surface. - A run answers with a value when asked.
PreparedRun::output::<T>()runs a turn that must answer through a generated terminal tool whose input is the answer, which is what makes a workflow composable in host Rust rather than in prose-parsing. The stream is unchanged; only the return value differs. That turn shapes by default: it holds the answering tool alone, so reading and shaping are two turns, and asked to do both at once it answers in shape having opened nothing, with the run reporting success.OutputSpec::with_toolsis the other mode — the ordinary toolset stays on the turn, one call reads and then answers, and what it trades away is the forcing, since nothing makes a working turn stop and answer. Neither is right for the other's job, so the choice sits on the spec rather than in a default. - Sessions: an ACP session is a mentra agent — basis uses the persisted agent id as the
protocol's session id, so
session/loadis mentra'sRuntime::resume_sessionand basis stores no mapping of its own (ADR-0007). A session outlives a turn, which is what makes conversation and resume possible at all; compaction wires to context-pressure events. Which conversations belong to this workspace is a tag mentra keeps on each row, andWorkspaceBuilder::openis where basis sets it — until it did,session/listfiltered on a tag basis never wrote and so returned nothing, whatever had been persisted. Since ADR-0018 that holds for a workspace on its own private runtime, which is everyWorkspace::openand every path the binary takes; mentra fixes the tag per runtime at build time, so rows minted on a shared runtime carry"basis:runtime"and stay out of the per-workspace lists until a per-session override lands upstream. Where the rows live is the caller's to say, on the runtime that owns them:RuntimeBuilder::with_store_dirnames a directory, andwith_ephemeral_historysays nowhere and takes an in-memory store instead. - The mentra/basis split: anything a different harness could also want — session branching, compaction lifecycle, hook points, MCP client — belongs in mentra. basis keeps conventions and protocol: AGENTS.md/skills/template discovery, ACP mapping, the CLI grammar.
- Confinement: the boundary is the OS's and basis ships no instance of it (ADR-0013,
amending ADR-0004). Shell and background execution are on by default; a run holds the
authority of the account that starts it, and basis never claims otherwise. In-process there
is hygiene only — each agent bounded to its own workspace directory, and a rule that keeps
.git/hooksand.git/configread-only to the file tools (codex's anti-escape carve-out), which a shell redirect walks past.containerization.mddocuments the read-only-root pattern basis used to ship; a native per-command sandbox (Seatbelt on macOS, bubblewrap+seccomp on Linux, codex'sworkspace-writedesign) stays parked inproposals/0002as an optional later layer, not a return to denying commands by default. - What a repository is trusted with: opening a workspace runs what the repository
declares.
AGENTS.md,.basis/config.json,.basis/hooks.json,.basis/tools.json,.mcp.jsonand the skills roots are configuration carrying the workspace's authority, not inert data..mcp.json's servers are connected byWorkspace::openitself, before a model has said anything; a.basis/hooks.jsonentry that omitstoolsis asked on every tool call, reads included — and since mentra 0.24 that is every registered call, including ones the approver goes on to refuse, because hooks now run ahead of authorization. A hook that rewrites is judged on what it produced: the approver sees the rewritten input, and so do basis's own guards. Both spawn programs the file names, and each holds the same authority the account running basis holds — the paragraph above, restated where a repository is the party naming the program. What they are handed differs. A hook and a declared tool run under mentra'sBoundedCommand(since 0.24), which clears the environment: a hook gets basis's baseline (PATH,HOME, temp and locale) and nothing else, a declared tool gets the baseline plus the variables its manifest names, and the process's own provider key and whatever the host exported reach neither. A stdio.mcp.jsonserver now uses that same host-owned process discipline: Mentra clears the ambient environment, restores the documented runnable baseline (PATH,HOME,TMPDIR,TMP,TEMP,LANG, andLC_ALLon Unix;PATH,PATHEXT,SystemRoot,COMSPEC,TEMP, andTMPon Windows), then layers the variables the server's config explicitly names. The author must name every variable outside that baseline, including provider credentials and proxy settings. The process is grouped and its descendants are terminated together on disconnect or drop, while the protocol frames and retained stderr stay bounded and stderr is continuously drained. None of that is confinement — the server still has the host account's filesystem, network, and account authority — it is hygiene that stops ambient credentials from arriving without an explicit handoff. ADR-0013 is the reason basis states the authority rather than narrowing it: a repository's declarations are bounded by whatever confines the process, and in-process that is nothing. Streamable HTTP and legacy SSE are unchanged. - The one deliberate exception is
base_urlin a workspace.basis/config.json, which fails the open by name (§8, Effort, providers, and custom endpoints). A redirected endpoint carries the credential basis just read out of the environment to a host the file chose, and a leaked secret is bounded by nothing. It is a narrow rule and not a general posture:.mcp.json's${VAR}expansion does hand the named variables to a program the repository declared, which is what that key is for. An operator opening a repository they have not read has two honest moves. Build the workspace withWorkspaceBuilder::without_discovery()— which probes none of these files while still applying typed supplied hooks, tools, and MCP servers, and is an embedding host's knob; the CLI carries no flag for it — or run the whole process under one of the OS patterns incontainerization.md.
- ACP (Agent Client Protocol) is the standard: JSON-RPC 2.0 over stdio, LSP-style; v1
stable; adopted by Zed, JetBrains, Copilot, Gemini CLI, 25+ agents; official Rust crate
(
agent-client-protocol). Web/mobile clients exist: acp-ui, acp-mobile — only a small WebSocket↔stdio bridge to write, no frontend to build. - codex (OpenAI) is the counter-example on protocol — no ACP; a proprietary
not-quite-JSON-RPC app-server that even its own SDKs bypass — but the reference on
sandboxing: per-command wrapping (
codex-rs/sandboxing/src/manager.rs),workspace-writepolicy with.git/agent-config kept read-only inside the workspace, network default-deny in three layers (seccomp, netns unshare, SBPL omission). - pi: its public coding-agent session and compaction documentation informed the prior-art notes in P0; no local checkout is required.
- zentox:
mentra/docs/mentra-api-feedback.mddescribes a prior Mentra-based agent and catalogs its API friction — requirements input for basis's core, whatever its domain was.
| Phase | Scope | Estimate |
|---|---|---|
| P0 Groundwork | Mine mentra/docs/mentra-api-feedback.md; read pi session-format + compaction docs; decide mentra-vs-basis split per capability |
done |
P1 Crate + run |
Mentra wiring, AGENTS.md loader, skills discovery, worktree hygiene, JSONL event stream. Acceptance: arbitrary prompts on arbitrary repos, in-process and as subprocess | done |
| P2 ACP server ✅ | agent-client-protocol crate; session mapping, permission surfacing, modes, listing, history replay. Sessions survive turns, so conversation and resume work independent of protocol |
done |
| P3 Extension points ✅ | MCP client honoring .mcp.json and the servers an ACP client sends; subprocess hooks (allow/deny/modify); prompt templates surfaced as ACP commands; ws↔stdio bridge for acp-ui |
done |
| P4 Loop + Docker ✅ | watch scheduler with skip-if-unchanged — retired by ADR-0014, its bounds and fingerprint kept; Dockerfile, state volume, shell grant — withdrawn by ADR-0013 for containerization.md |
done |
| P5 Depth | Branching ✅ — two-way since mentra 0.16, later retired unadopted (§ Parsimony); compaction tuning ✅ — Compaction on WorkspaceBuilder, with context-window awareness still open; packages convention, provider OAuth remain |
ongoing |
This table is the record of how basis was built, not the current plan. What follows P5 is the
SDK-first transition of ADR-0010…0015, phased in archive/REDESIGN.md §3: Phase A
(posture and pruning), Phase B (structure — the crate split, the mcp feature, approval
as a trait), Phase C (the SDK — the Workspace / run split, typed output, cancellation,
the shared budget, event fan-in) and Phase D (bindings — interception's second binding,
the history knobs, session/list, credential redaction, and — once a use case arrived —
declared subprocess tools) have landed.
Validation stays deliberately varied — a refactor, a doc task, a test-writing task, and a periodic check — so no single use case bends the API toward itself.
- Scope honesty. pi-class is a real harness, not a demo: sessions + compaction + extensions
- protocol is weeks, not days, to polish. The phase order front-loads the embeddable core.
- Extension expressiveness. Narrower than it was: an embedding Rust host now writes an
Interceptorin its own process with its own types, which is the case subprocess hooks were worst at. The other audience — a repository whose guard has to be a program, in any language, with JSON on stdin — is no longer untested:basis/tests/hooks/drives a real.basis/hooks.json, real scripts on disk and real processes, two of its cases through a real runtime so that a rewritten input is shown reaching the tool, andbasis/tests/declared_tools.rsdoes the same for.basis/tools.json. Both are#![cfg(unix)], because a shell script is the cheapest real program to exercise. What those suites cannot answer is the question this risk is actually about — whether the shape is expressive enough beside pi's in-process TS extensions. If it proves coarser, the escalation path (wasm/rhai) is named but deferred until friction is shown. - ACP crate maturity. Official but young; budget for permission-flow gaps; acp-ui's traffic monitor is the debugger.
- Mentra co-evolution. Same author on both sides: gaps basis hits become mentra changes, not
workarounds. The discipline is direction, not permission — capabilities generic enough for
any harness land in mentra; basis keeps only harness-specific glue. Track each gap as a mentra
issue even when fixing it immediately, so the API story stays legible to other mentra users.
Nine stand named in
archive/REDESIGN.md§2's footnotes across Phases B–D, and as of the wave after Phase D all nine are closed — eight fixed upstream, one built in basis where it belonged. That is the first clean tally the ledger has had, and it measures the discipline rather than mentra's completeness: three further candidates were named on the way through and none is built, and footnote 8 remains open. - Compaction quality. Mentra has the primitive and basis now configures it (
Compaction), which settled the one behavior that was actively wrong — tool results being blanked on every request regardless of budget. What stays unproven is the summarizing pass under genuinely long sessions, and the trigger for it is a fixed token count because nothing here knows a model's context window. - Name.
basisis a common word, so searches will pull in linear algebra before they pull in this. Accepted, and preferred to the alternative: the name states the property the crate is held to — minimal, and nothing in it reducible to anything else — which is a claim worth being reminded of on every import. The crate published aslanuntil 2026-08-19, when that name turned out to be taken on crates.io by the left Kan extension it also names.
Behavior a caller can observe but the sections above do not describe. The embedding
counterpart is embedding.md.
One root holds everything durable about local tasks: BASIS_DATA_DIR if set, else
XDG_DATA_HOME/basis when that is absolute, else the platform data home
(~/Library/Application Support/basis, ~/.local/share/basis, %APPDATA%\basis). It is created
private — 0700 where the platform has file modes — and under it each workspace gets a
directory keyed by a digest of its canonical path:
<root>/workspaces/<key>/store mentra's conversations for that workspace
<root>/workspaces/<key>/agents/<task> meta.json · inbox.json · events.jsonl · terminal.json …
Keying on a digest rather than on the path text is what keeps path-length limits out of the
correctness story, and each spawn reads back the workspace path recorded beside the digest,
so a collision is an error naming the key and both paths rather than two repositories quietly
sharing agents. Nothing under the root holds a credential: the executor is whichever process
attached, carrying that shell's environment.
The registry the daemon kept is gone with it, and none of it is migrated. BASIS_REGISTRY_DIR
no longer exists, pre-E2 task handles do not resolve, and the conversations that daemon
persisted are not recovered — it filed them beside its registry under XDG_RUNTIME_DIR (or the
temp directory), which the platform may erase between boots. A container or CI runner that
should resume yesterday's agents therefore mounts the data root, not just the workspace:
containerization.md has the volume.
--effort accepts exactly low, medium, high, xhigh, or max.
basis keeps those values provider-neutral: Responses-family APIs receive
reasoning.effort, while Anthropic receives output_config.effort and enables
adaptive thinking only on models that support it. Provider/model combinations
without a requested tier fail explicitly instead of silently lowering it;
omitting the flag leaves the provider default unchanged.
Any endpoint serving the OpenAI chat/completions API works too — Ollama, LM Studio,
vLLM, llama.cpp, a gateway, a proxy. Paste the URL as published; the trailing /v1 is
handled. That wire is the default for a base URL because it is what "OpenAI-compatible"
means everywhere except OpenAI: v1/responses is OpenAI's own, served by OpenAI — where
the openai preset reaches it with no base URL at all — and by a handful of proxies that
forward to it.
export BASIS_BASE_URL=http://127.0.0.1:3455/v1
export BASIS_API_KEY=…
basis spawn --model gpt-5.6 "explain the module layout"
basis spawn --provider ollama --model qwen3 "…" # a local preset needs no key
BASIS_BASE_URL=http://127.0.0.1:8080/v1 basis spawn --model local "…" # nor does llama.cppA key is what resolution found, not what it demands. The two local presets resolve with
none, and so does a base URL with no key passed or exported: the request then carries no
Authorization header at all — not an empty bearer, which a server would refuse — and a
server that wanted one answers 401 in its own words. Refusing up front was the earlier
rule, and it made every Ollama and llama.cpp user invent a key to paste.
Such a proxy is reached by naming the wire: RuntimeBuilder::with_wire(Wire::Responses),
a builder-only knob, deliberately. Neither .basis/config.json nor a flag carries it —
a wire is not a fact a repository has, and the operator who needs the other one is
embedding basis rather than typing at it.
Endpoints reached on the Responses wire use complete local transcript replay and do not
automatically send previous_response_id. That optional extension is not part of basis's
compatibility assumption; native provider presets retain Mentra's Hybrid state
chaining. The question does not arise on chat/completions, which has no server-side
conversation state to chain.
More than one gateway to the same model is RuntimeBuilder::with_gateway_ring, a base
URL said more than once: each GatewayMember is a URL and its own key, built exactly as a
lone base URL is — same normalization, same wire, same provider id — and the ring of them
is the runtime's one provider. The ring prefers the first member, rotates to the next
after a streak of failures (or at once on a 4xx that is not a rate limit), and finishes
the failing attempt on the new member, so nothing above the provider learns a rotation
happened; with_gateway_ring_policy sets the streak and whether the ring drifts back,
with_gateway_ring_observer is how a host's logs find out which gateway answered. The
rotation itself is mentra's GatewayRing; basis states the members and never touches the
provider after build (ADR-0027).
Members must front the same upstream serving the same model — a replayed transcript
carrying one vendor's reasoning items is refused by another's endpoint — and basis
documents that rather than checking it.
A repository can state its own answer instead of relying on the flag or the
variable. .basis/config.json — provider, model, effort, and in the
global config.json only, base_url — layers under everything an invocation
says and over everything the environment does:
CLI flag / explicit builder call
→ <workspace>/.basis/config.json
→ <global config dir>/config.json
→ environment (BASIS_BASE_URL, ANTHROPIC_API_KEY, …)
→ basis's default (the provider's newest available model)
A flag wins because it describes this invocation; a file beats a variable
because the variable describes whoever started the shell and the file describes
the repository the work is in. base_url in a workspace file is refused by
name rather than ignored — a file a repository ships must not be able to
redirect the traffic carrying the credential basis read out of the environment.
conventions.md has the keys and the rest of the map.
An editor spawning basis and a shell pipe look identical from inside the process — both are a
non-TTY stdin with no arguments — so cat prompt.txt | basis cannot be detected as a prompt
without breaking every editor. Instead of waiting silently on prose, the server answers once
the input proves it was never a client:
basis: expected an ACP client on stdio
next: use `basis spawn -` for a prompt or `basis serve --acp` for ACP
session/list works as of the interception wave, and had not before: basis filtered listings
by the workspace a conversation belongs to while filing every conversation under mentra's
"default" tag, so no list ever matched. Conversations from before the fix keep the old
tag and do not appear in a list — but none of them is stranded, because resuming looks a
conversation up by id and never by tag, and mentra re-files one under its workspace the
first time it is resumed and used.
An entry scoped "tools": ["shell"] no longer fires. Nothing errors — the name the model
calls is now spawn, and a tools list matches on the exact name, so the hook simply stops
running. Match spawn instead; a hook that wants commands and not delegations reads the
call's own input, where input is the string the model wrote and a single leading !
(never !!) is what makes it a command (ADR-0016). A command may also name where it runs
— !@<target> <command> — and a hook that cares which destination a command was headed for
reads the same string (ADR-0021).
Same shape, same silence. An entry scoped "tools": ["files"] no longer fires, because the
model is now offered mentra's split file tools — read, ls, grep, glob, write,
edit — instead of one batched files. Nothing errors; the hook simply never runs again.
Match the names you actually mean: write and edit are the two that change a file, and
each takes its path in path (file_path and filePath are accepted spellings of the same
field) rather than inside an operations array. A hook that guarded writes by walking that
array needs rewriting, not just renaming.
Remembered approval rules key on the tool name too, so a RuleKey written against files
stops matching for the same reason.
A host that is not ready to rewrite either keeps the old roster in one line:
RuntimeBuilder::with_file_tools(FileToolProfile::Batched) registers files and nothing
else, exactly as before. That is a migration path with no deadline on it; the default is
Split because the roster is the model's API and the split names are the ones models are
trained on — and because glob, and grep's ignore_case/literal/context/multiline
knobs, exist only there.
A host with no use for any file tool — Split, Batched, or Both — drops them from the
registry entirely with RuntimeBuilder::with_file_tools(FileToolProfile::None). That is a
runtime-scoped decision, not a workspace one: a ToolRoster::only that omits the file tools
stops offering them to one workspace, but only with_file_tools(FileToolProfile::None)
keeps them off the registry every workspace and subagent on that runtime shares.
Unlike the two hook migrations above, this one is loud: it stops compiling.
ADR-0026 retires the rebuild half of
ADR-0024, so six public items are gone — basis::RuntimeRecipe,
RuntimeBuilder::with_reusable_registered_provider, RuntimeBuilder::into_reusable_recipe,
WorkspaceBuilder::with_runtime_recipe, Workspace::bind_host_tools, and the async
Workspace::rebuild_for_reuse — along with the 17 RunError variants only they raised
(NonReusableRuntimeComponent, ReusableProviderRequiresRuntimeRecipe, the three
RuntimeRecipeProvider*, the four ReusableWorkspaceRequires*, ReusableWorkspaceToolsUnbound,
ReusableWorkspaceAlreadyBound, ReusableWorkspaceRawAccess, ReusableWorkspaceSealed,
ReusableWorkspaceOutstanding, WorkspaceNotReusable, ReusableRuntimeNotUnique, and
ReusableHostToolName). RunError is #[non_exhaustive], which does not help: a match or
matches! arm naming one of those variants fails to compile too.
Nothing else moves. fresh_only, without_discovery, with_resolved_model,
ToolRoster::only, RunProfile, TurnOptions, ToolResultPolicy, and
PreparedRun::register_agent_event_tap are untouched, as are AgentEventTapGuard's name,
its #[must_use], and its drop semantics. Three documented promises are withdrawn without a
signature changing: Workspace::mentra_runtime and PreparedRun::session / session_mut
no longer poison a reuse generation, because there is no generation to poison.
PreparedRun::into_session was withdrawn separately and for a different reason — see
proposal 0004.
A pooling host opens a fresh workspace per checkout — with_runtime_builder +
without_discovery + fresh_only + with_resolved_model + an exact roster, which is the
posture the strict-host section above describes and the one every real caller already used.
Reuse returns when Mentra can mint a fresh provider session scope from an existing provider
(oops-rs/mentra#46); until then Basis makes no
reuse claim it cannot prove.
Two changes from the same pass, both about which sessions may reach which tools.
PreparedRun::into_session is gone, and it is the only removal here that is not a
simplification. An agent's ledger row — what tells the ownership guard which open a call
belongs to — is held by the PreparedRun, because a run is exactly how long its session
lives. into_session was the one surface that could hand a live session past its run, and a
session with no run and no workspace has nothing left to hang a row on: it would fall back to
the unjudged default while a sibling open of its directory still judged it, which for a
bridged mcp__* name means one client's session reaching another client's authenticated
server. Withdrawing the call makes that unreachable rather than documented.
Migration: a host that needs a session for longer than one run keeps the workspace alive for as long as it uses the session. That was always the supported shape and is now the only one. Proposal 0004 records what a redesigned escape hatch would have to solve before it could return.
WorkspaceBuilder::with_tool is new, and needs no migration: it is the per-workspace half
of RuntimeBuilder::with_tool. A host whose native tool belongs to one repository rather than
to the process registers it for that workspace's tool audience, so the other repositories on a
shared runtime are neither offered it nor able to reach it by name. Its name is claimed on the
same ledger a declared tool's is, which is what makes the two refuse each other rather than
silently overwrite.
Parsimony: an unadopted tree, a target setter, two hidden seams
Three smaller removals from the same pass, none ADR-scale on its own, recorded together because a caller upgrading past this point needs the whole list at once.
Gone, and it stops compiling. basis::branch in full: TranscriptEntry, EntryKind,
BranchError, and the five PreparedRun methods the module added —
transcript, abandoned, leaf, children, branch_from. Nothing in this workspace or in
nous ever called any of them; mentra's transcript tree underneath is unaffected, basis simply
stops surfacing it. Also gone: RuntimeBuilder::with_command_target (§ Command targets,
below) — the one builder method that ever populated the command-target routing table,
test-only, zero production callers. RunError::CommandTarget and the name validation
behind it stay, dormant, until a registration seam returns.
Gone from the generated docs, not from the API. RuntimeBuilder::with_provider_instance
and RuntimeBuilder::with_wire gain #[doc(hidden)], the same demotion
basis::run::prepare_with_session already carries. Neither call compiles any differently and a
caller that already had one keeps it unchanged; what changes is that neither appears in
generated documentation or a new caller's autocomplete. Unlike with_command_target, whose own
doc named no host still wanting it, both of these read as the intended seam for a host bringing
its own provider or a custom Responses gateway — no production caller has needed one yet, but
neither doc disclaims itself as legacy, so demotion rather than removal.
Nothing here had an adopted caller to migrate, so there is no replacement to reach for. If a host ever needs the transcript tree or named command targets again, the next breaking release is the first place either could plausibly return.
The same pass, one layer down. Where the removals above took surfaces nobody had adopted, these take second spellings of things basis already said once — so each has a replacement, and the replacement is the spelling every production caller was already using.
Three turn entry points, gone. PreparedRun::execute, execute_with_options and send
were each a two-line forward onto a neighbour with a default argument spelled out. Every real
caller — basis-cli, basis-tasks, basis-acp, nous — already named its approver or its options, so
what these saved was a word in tests and doc examples. The survivors take the argument:
| gone | write instead |
|---|---|
run.execute(sink) |
run.execute_with_approver(sink, AllowAll) |
run.execute_with_options(sink, options) |
run.execute_with_approver_and_options(sink, AllowAll, options) |
run.send(prompt, sink, approver) |
run.send_with_options(prompt, sink, approver, TurnOptions::default()) |
execute_with_approver, execute_with_approver_and_options, send_parts and
send_with_options are unchanged, and four untyped prompt entry points is what a turn now
has: prompt or parts, configured prompt or a new one. (The typed output* family and
compact open turns of their own, as before.)
RunProfile::with_reasoning, gone. A profile had two ways to decide reasoning — the
dedicated override and the reasoning field inside with_provider_request_options — with
builder order arbitrating between them. Nothing ever used the dedicated one: basis-cli and
basis-tasks reach for
RunSpec::with_effort, nous sets ProviderRequestOptions::reasoning, and basis-acp changes a
live session through PreparedRun::set_reasoning. A host that called with_reasoning(r) writes:
profile.with_provider_request_options(ProviderRequestOptions {
reasoning: r,
..Default::default()
})The precedence that mattered is unchanged: a profile that states its provider request options
has answered the reasoning question, and outranks RunSpec::with_effort and the config file's
effort no matter which builder method was called later. What is gone is the third rule, about
which of two profile-level spellings won.
Unchanged, and worth saying so. The config-file effort key, RunSpec::with_effort and
.effort, PreparedRun::set_reasoning/set_effort/set_model, and every retry setter —
RuntimeBuilder::with_provider_retry, with_provider_retry_budget,
TurnOptions::with_provider_retry, with_retry_budget, and TurnOptions' two public retry
fields. The retry pair travels internally as one value now, but a host sets and overrides the
waits and the count separately, exactly as before, because they are two questions and Mentra
keeps them apart.
Two disclosures from the discovery rewrite. An empty TemplatesConfig::workspace_subdir
used to make the repository root itself a templates root — parsing every top-level *.md and
failing the open on the first one without template frontmatter — and now names no directory at
all, which is what empty always meant for the file-valued conventions. And the two
shared-runtime refusals (DiscoveryDisabledSharedRuntime, FreshOnlySharedRuntime) now fire
at the very top of open(), before workspace-path resolution, so a build with both problems
reports the runtime-shape refusal first and performs no filesystem work to do it.
A summarizing pass was always a billed provider request; it used to be invisible to basis's
accounting because the runtime dropped the response's usage while extracting summary text. It
no longer is. A compaction inside a run — automatic, context-overflow recovery, the model's
own compact intrinsic — reports one ordinary usage event per provider sample after its
announcement pair, and that usage lands in RunReport::usage, in RunFinished's figure, in
the run's token budget, and in any shared BudgetPool, at the same point.
Two consequences to plan for. Usage figures grow: a long conversation's totals now include roughly one summarizing request per compaction that older streams silently omitted, so a consumer comparing runs across the boundary is comparing a corrected meter against a broken one. And budgets are now charged for compaction: a run near its token allowance can end earlier than it used to, on the same work, because the allowance now pays for the summary too. Both are correct accounting — the spend was always real — and basis still estimates nothing: a provider that reports no usage emits no usage line and charges nothing.
The standalone PreparedRun::compact verb is the one summarizing pass outside that rule: it
is not a run, has no report and no run counter, so its samples reach only the sink it borrows
— the stream is the account for /compact, and a spent budget still does not refuse it.
A shared runtime used to be the shape basis had to work around. Mentra fixed a runtime's policy, its hooks and its tool registry at build time, and a runtime is built before any workspace opens — so basis kept three pieces of machinery whose only job was to make one runtime behave like several. Two are gone outright and the third is reduced to the sliver upstream's own mechanism cannot express; what replaced each is upstream's, and what is left of the third says exactly why it is left.
The hook dispatcher is gone. basis/src/runtime/dispatch.rs in full: one hook registered
on each of Mentra's two seams, a registry of workspaces keyed by canonicalized root, and the
routing that looked up a call's working directory to find whose HookRunner should answer.
A workspace now registers its own runner when it opens — one ExecutionHookParticipant, one
guard for both seams — and holds the registration until it drops. The host's Interceptors do
not travel in it: they are the runtime's (host scope is runtime scope, ADR-0018), so they are
registered once, globally, when the runtime is built. Mentra composes one chain per call out of
every batch whose audience matches — the global one matches every session, a workspace's
matches its own — in registration order, so the documented order (host interceptors → supplied
hooks → global hooks → workspace hooks) is unchanged, a host's refusal still short-circuits
before a repository's hook program is spawned, and a rewrite's attribution accumulates across
both batches instead of being lost between two chains. A call from an agent that belongs to no
basis workspace — one a host created for itself through Runtime::mentra_runtime, or drives
through run::prepare_with_session — therefore still runs the host's interceptors, as
with_interceptor has always promised.
The effect a host can see is the other half of that, and it is a real loss. Such an agent
has no tool audience, and mentra never consults an audience-scoped registration for one — so a
session a host creates directly, with a base directory inside a live workspace, does not run
that workspace's .basis/hooks.json chain, deny hooks included. The deleted dispatcher keyed on
the call's working directory and caught exactly that case; an audience is derived from the
workspace that minted the session, and a session no workspace minted has none to derive from.
There is no honest guard to put in its place at this layer: basis would have to re-introduce
directory-keyed routing, which is the machinery this removal exists to delete, and which was
wrong in its own way (two agents can share a directory and belong to different repositories).
What a host that wants a guard over every call on its runtime writes instead is an
Interceptor, which is global by construction. A host that wants a repository's own hooks to
apply drives that repository through Workspace::prepare/resume rather than minting its own
session.
One directory is one chain, and it is counted rather than owned. A tool audience is
derived from the workspace root, so two live opens of one directory — what basis-host
produces on purpose, one workspace per set of client-supplied MCP servers — resolve in one
audience. Registering both would put two complete chains behind it and Mentra would walk both
for either open's calls: every subprocess hook spawned twice per call, an audit hook logging
each call twice, and a rewrite that is not idempotent fed its own output. So the second open
joins the first's registration and the chain comes off when the last holder goes — the same
join-and-count ledger declared_claims and skill_root_holders already are, and the same
behaviour the deleted directory-keyed registry had. Joining needs the two chains to be the
same, and a same-root open presenting a different one is refused by name, as it was before:
RunError::WorkspaceGuardConflict survives the removal of the guard layer that named it, with
its message rewritten to describe interception chains rather than guards. A host that genuinely
needs two hook configurations for one directory needs two runtimes.
Basis's own guards are gone, and the rules are not. The .git/hooks and .git/config
carve-out and the ShellAccess::Denied posture used to be enforced twice — baked into policy
on a private runtime, and re-implemented as a hook guard on a shared one, because a
runtime-wide policy could not carry either per workspace. Each workspace now hands its
complete RuntimePolicy to every session it mints and resumes, so there is one implementation
and a shared runtime enforces the same rules a private one always did. On a shared runtime the
wording of a refusal therefore changes: it arrives from Mentra's policy, in Mentra's words,
exactly as it always has on a private runtime, instead of from basis's guard — and a refusal
of a hook-rewritten call no longer names the hook that rewrote it, because it is the tool's
policy answering rather than basis's chain. Mentra's mixed chain does carry a rewrite's
attribution into the refusals it raises — invalid JSON, a schema violation, a parallel-lane
category flip — but not into a policy or authorizer denial, which is a gap upstream owns
(mentra#57) and not one basis can close from
here. A --no-shell workspace's command is likewise
refused inside the call rather than ahead of it, which means the approver is asked first: the
private path's behaviour, now on both — and it has a UX cost worth naming, because a
Prompt-mode approver is shown a command that can never run, the person's yes is recorded,
and the model is then told commands are disabled. Nothing is weakened by it, and the
alternative is a second implementation of the shell posture ahead of the authorizer, which is
exactly the duplicate this removes; a host that wants the prompt suppressed reads the posture
itself, or refuses in an Interceptor, which does run before the authorizer. Two side effects worth naming: a shared runtime's
workspace can write its memories now, since the roots ride in its own policy where a
runtime-wide one could never carry them; and with_command_timeout and
with_tool_result_policy are re-applied to each workspace's policy, because Mentra replaces a
runtime's policy wholesale for a session rather than merging with it.
Per-mint foreign-tool hiding is nearly gone, and what is left is exactly the part the
audience cannot express. Every mint used to walk the shared registry and add a sibling
workspace's bridged mcp__* and declared tools to hidden_tools, publish that set into a cell
the spawn tool read, and re-apply it whenever a child policy replaced a delegated child's
roster. A workspace's tools are registered for its own ToolAudience now, and Mentra resolves
a name held only by a foreign audience as hidden rather than visible — so for a workspace in
another directory the invariant holds whether the model was offered the name or guessed
it, the cell and the spawn threading are gone, and a delegated child inherits its parent's
audience with the handle it is spawned from. Two mcp__* cases stay basis's own, because an
audience is derived from the directory and cannot tell them apart. Two are mcp__-shaped: a
second live open of one directory shares the first's audience by construction — which is
the pair basis-host produces when one repository is opened twice with different
client-supplied mcpServers — and a host tool registered globally under an mcp__-shaped
name, since a global is visible to every audience on purpose. So a mint still hides every
mcp__<server>__<tool> whose <server> this workspace did not configure, reading the bridged
names off the claim ledger beside the server names (Runtime::foreign_mcp_tools). Host tools
registered with RuntimeBuilder::with_tool stay global otherwise: they are the runtime's, and
a global is visible to every audience.
The third case is the same shape without the prefix, and it arrived with
WorkspaceBuilder::with_tool: a native tool a sibling open of this directory supplied. It
takes the audience-scoped registration a declared tool takes, on the same claim ledger, so it
is invisible to every other directory — but the sibling open of its own directory resolves
it like any of its own. A declaration is data and a same-root sibling declaring the same thing
joins it; a native tool is compiled code closing over what the host had at the call site, so
nothing joins one and the ledger refuses a second open that asks for the same name. The open
that asks for nothing is refused nothing, so a mint hides what the ledger attributes to
another open (Runtime::foreign_native_tools), and an mcp__ name is refused it outright —
that name would otherwise be the one foreign_mcp_tools could not catch, since it walks the
global registry. ToolRoster is unchanged in shape and in effect.
And hiding at mint is not, by itself, the isolation. A roster is a snapshot of the registry
the mint saw, and three things outlive it: a sibling of the same directory that bridges its
server after this session minted, a resumed conversation (Mentra persists the tool profile and
SessionResumeOptions restates none of it, so the roster is the one the first mint froze), and
the window inside a sibling's own open between claim_mcp_server, which reserves the name, and
record_bridged_tools, which says what came back. A sibling that supplies a native tool after
this session minted is the same hole in the same place. So basis decides execution separately
from listing: runtime::agents::ForeignToolGuard joins every workspace's own interception
chain and refuses two kinds of call — any mcp__<server>__<tool> whose <server> is not in the
calling agent's workspace's own server list, and any name the claim ledger holds as another
open's native tool. Both are read off what that workspace settled at its open and restated onto
the ledger by every mint and every resume, so the guard never reads the roster a past mint
froze. It
reads the runtime's agent ledger (runtime::agents) rather than the workspace that installed
it, which is what makes it right for the other open of the same directory: that open joins the
first's chain rather than registering one, so one guard answers for both, by agent id.
Keying the ledger on the agent id is also what it has to defend, because an agent id can move
between two opens of one root: a resume checks the conversation's root, and same-root opens
have the identical one by construction, so either may pick up an id the other minted. Whoever
recorded last owns the row, which is right on its own terms — Mentra hands out one live session
per agent id, so the open holding the lease is the open that wrote last. Each row therefore
carries a stamp naming the write that made it, and a hold releases only the row its own write
is still standing on. An unconditional release would have taken a live sibling's row away for as
long as that session ran, and a missing row is allowed on the bridged arm: this guard reads it
as a session basis never minted, and spawn reads it as no inherited hides.
A row lives for the run, not for the workspace (proposal
0004). A run outlives the
workspace that minted it — prepare attaches nothing — so a row released with the workspace
vanished under a live session, which for a bridged name meant one client's session reaching
another client's authenticated server. PreparedRun holds the row; the workspace holds none;
and PreparedRun::into_session, the only surface that could have handed a live session past its
run, was withdrawn rather than documented as a limit. The native arm additionally denies an
unattributable caller, because a name is in the tool-claim ledger as native only if a live basis
workspace put it there — so unlike a bridged name it has no legitimate unjudged owner. The same ledger carries the parent's
hidden_tools back into a delegated child whose ChildSpec roster replaced the profile —
with_tool_profile replaces it wholesale and Mentra exposes no reader for a template's
effective profile — and spawn adopts the child into the ledger for the length of the
delegation, so the guard has an answer for it too. Hiding and refusing are not substitutes: one
decides what the model is told exists, the other what runs.
A resume now refuses the wrong workspace. Because a resume is where a workspace's policy
and tool audience are restated — Mentra persists neither — picking up another repository's
conversation would run it under this repository's .git carve-out and shell posture while its
agent stayed based in its own directory, which Mentra's file tools always allow writes under. A
shared runtime is what makes the pairing reachable: a host that picks the workspace from a
client's cwd and the conversation from an id it was handed (ACP's session/load) can bring
the two together wrongly. Workspace::resume compares the persisted agent's base directory
against its own identity and refuses with RunError::WorkspaceMismatch before anything is
stated onto the conversation. store::forget still keys on the id alone, and deliberately: a
deletion states nothing.
One gap this used to leave open is now upstream's fix, not basis's. A resumed session
used to carry no runtime identifier, so it re-filed under the runtime's own tag the next time
it persisted — on a shared runtime, out of that workspace's session/list. Mentra 0.27 closes
it at the source: every persisted-agent reconstruction now retains the row's own stored runtime
identifier through every later save (mentra#54),
so basis needed no code change to pick it up. basis::store's module docs have the whole of it.
A "…for this session" answer is process-local now, and one API is withdrawn because of it.
Mentra 0.27 adds PermissionRuleScope::Process
(mentra#53), a rung owned by one live
SessionPermissionHandle and never written to the runtime store; basis's approval flow
remembers AllowForSession/DenyForSession into it instead of the durable Session scope it
used before. A resumed session gets a fresh handle and an empty rung on its own, so
PreparedRun::forget_session_answers — the 0.26-era method a one-shot host called at the end of
a run to clear durable session-scope rows nothing would otherwise attach to clear — is
withdrawn outright, with no replacement: there is nothing left to clear. A caller that
compiled against it needs no substitute call, only its removal. Runtime::resume_minted still
clears the durable Session scope on every attach, but only for the migration case this leaves
behind: a row a pre-0.12 basis binary remembered there before this change, which mentra 0.27
still loads and matches like any other durable rule. That clear is safe to retire once no
supported basis version can have left such a row on disk; basis/src/runtime/scope.rs's doc
comment carries the account, and basis/tests/workspace.rs pins both the live and the legacy
case as separate tests.
A refusing posture used to be enforced entirely on the approver, one layer above the runtime's
ApprovalGate. That gate answers nothing — every consequential call comes back as a Prompt —
and Mentra resolves a Prompt against the conversation's remembered rules before the approver
is consulted. A Global- or Project-scope rule seeded through the session's permission handle
therefore answered ahead of the mode, with no permission request emitted, and outlived both the
things that clear an answer: a mode switch (which clears only this layer's in-process memory)
and the attach-time clear (which clears only session scope). Every refusal basis offered — ACP's
read-only mode, basis spawn --approve never, a task recorded to refuse — was a promise a durable
allow could stand over.
It is stated as an authorizer now, in the shape each surface's posture has. A posture fixed for a
run's whole life installs basis's own DenyAllGate, beside the DenyAll approver it was
already passing: the attended CLI route and basis-tasks's attach executor both do, for never
and only for never, because always and prompt permit consequential work and installing an
authorizer replaces the runtime's rather than layering over it. A posture that can change
mid-session needs one that reads the live state per call, and that is ACP's.
basis-host's PolicyGate goes on each live session through
PreparedRun::with_tool_authorizer — Mentra's own session-scoped replacement, which is why
AcpSession::new is the install point: it is where session/new and session/load both reach a
conversation, before it is registered where a turn can find it, and the attachment is live-only
so a resumed session needs it again. Under ApprovalMode::Never the gate denies, and Mentra
returns an authorizer's Deny unchanged — no rule read, no request raised. Under Always and
Prompt it is the runtime gate verbatim, remembered rules included, because a mode that permits
consequential work has nothing for a standing allow to override. Two consequences worth naming:
a source that builds its own runtime no longer keeps its authorizer for the sessions an
adapter serves (an Interceptor runs ahead of the authorizer and is not replaceable per
session, which is where a posture that must survive belongs); and revoking a durable rule is
still Mentra's to offer (mentra#43), so a seeded
allow remains a standing answer everywhere except read-only.
!@<target> <command> names an executor to run a command on by name, rather than where basis
is running — the container-on-a-Mac case, where cargo test belongs in the container and
xcodebuild is not in it at all. spawn stays the one door: where became a dimension of the
call, because a second tool would have been a second name at the approval gate and a second
namespace of remembered rules for one question. The parser, RuntimeExecutor, and the routing
table stay; RuntimeBuilder::with_command_target, the only builder method that ever populated
that table, is gone — zero hosts had registered one — so today no
name is ever registered and every !@<target> call is refused before the approver is asked, the
same as a name nothing registered always was. The parsed call an approver reads still gains a
fourth key — {mode, body, cwd, target}, reading "local" when no target was named, so every
rule already written keeps matching. basis ships no executors: what a target would reach is
whatever the host's own code reaches, and none of it is confinement
(ADR-0013). docs/targets.md has the
worked SSH forced-command pattern, what the executor receives, and what the arrangement does
and does not protect.
basis ships no scheduler: an interval belongs to whatever already runs things on your machine — cron, systemd, CI, a tokio task in your own binary. What basis ships instead are the two pieces that are easy to get wrong, and the loop is composition (ADR-0014):
last=""
while :; do
now=$(basis fingerprint)
if [ "$now" != "$last" ]; then
basis spawn --json --deadline 10m --tool-budget 40 \
"check for newly introduced TODOs and summarize them" > run.jsonl
case $? in
0) last=$now ;; # only a clean run moves the baseline
3) echo "bound tripped; retry next tick" >&2 ;;
*) echo "run failed" >&2 ;;
esac
fi
sleep 1800
donebasis fingerprint prints a digest over git ls-files — path, length, mtime, plus HEAD —
so .gitignore is honored and .git's own churn is ignored:
$ basis fingerprint
cea476f305ecf3f5
Every uncertain case reports changed rather than unchanged: a false "changed" costs tokens,
while a false "unchanged" would silently stop the loop doing anything at all. Recording the
baseline only after a run you consider successful is the caller's policy, because the caller
is where the definition of "successful" lives — above, that is the 0 arm.