Open ~/.claude/projects/ on your own machine.
What has accumulated there is the full text of every exchange you have had with an AI coding agent for months. Half-finished designs. Pasted stack traces. The directory layout of an internal repository. A requirements discussion with a customer's name in it. The query you re-pasted again and again because it kept failing. And somewhere in there, possibly, a credential you thought you had deleted.
An agent's transcripts are the most sensitive collection of files in a developer's environment. More sensitive than the source code, arguably. Source code records conclusions; a transcript records everything on the way there — the hesitation, the rejected option, the internal politics, the proper noun you said out loud.
basou, which I build, takes exactly those files as its raw material. It reads the agent's logs and turns them into a session trail and a record of decisions. Which means basou had to be designed as a tool that routinely reads the most sensitive files in a developer's environment.
What should a tool like that do with that data?
This post is the record of that design decision. Everything below is against basou v0.37.0 (released 2026-08-04). (v0.38.0 shipped on 2026-08-28 after this was written; the change is to the import event format and does not touch anything discussed here.)
I already made the claim. Today is what sits underneath it
The introduction post put it in a single bullet:
Local-first / zero-network. It reads the agent logs already on your machine and writes only to the
.basou/directory next to your code. Nothing leaves the machine.
As a claim, that is enough. But a bullet can only state the outcome. What follows is what happened after that was decided.
A constraint like this is easiest at the moment you declare it, and hard afterwards. The center of this post is what happened when a requirement arrived that seemed to need the network — spanning more than one machine — and how it was settled. There were two other decisions that touched the network, and those landed differently. I will come to them briefly, later.
One more thing: part of the bullet above is worded too strongly. I will fix that too.
Why not send it — not taking custody of other people's data
First, let me grant the case for sending it.
It is a trail tool. Of course it would be more useful on a server. Share it with the team. Search across machines. Build a dashboard. View it in a browser. None of that is hard to design. It is, in fact, the normal thing to do.
The problem is that the moment you own one upload path, you have taken custody of other people's data. Custody means making these promises:
- How long do you keep it? If someone asks you to delete it, can you reach the copies inside your backups?
- Transcripts can contain credentials. When one arrives with a credential inside, how do you handle it?
- If there is a breach, whom do you notify? Not only the user — their employer's repository is in those transcripts too.
- Which country holds the server? Are logs from work under contract or NDA allowed to sit there?
Parts of each of those have technical answers (credential scrubbing, for one), but the substance is an operational promise that comes with custody. And promises should only be made by people with the capacity to keep them.
There are middle options — with client-side encryption where only the user holds the key, you can offer sync from a server that only ever holds data it cannot read, which structurally removes the credential-exposure problem and greatly lightens breach notification (retention, deletion, and jurisdiction survive it). Even so: basou is a single-author OSS project, still pre-1.0. Saying it would take on those four would, I thought, have been dishonest. It is not the kind of thing you settle with "we'll do our best."
So it takes custody of nothing. That single point is where zero-network comes from. There was no prior ideological commitment to privacy, and this is not a "local is faster" argument. The amount of custody that could be honored was zero, so the structure was built to require none. That is all.
The responsibility did not vanish. It moved
I want to be precise here. Not taking custody does not make the responsibility disappear.
As you will see below, moving data between machines becomes the user's job. Not exposing the local UI's port is an operational condition on the user. If you use the feature that launches an arbitrary command, what that command does is on you.
What this design did was not erasure but a re-drawing of the boundary. It narrowed what the basou project directly carries, down to what it can carry. The remainder did not evaporate; it sits with the user and with the tools the user already chose. I will name where each piece went, later in this post.
This constraint is also not the kind that bends if the maintainer's capacity changes. basou's specification lists zero-network as a property of the design rather than of any release, and states that it is not expected to change across major versions.
The main case — it owns the merge, not the transport
The first requirement arrived quickly.
You write on a laptop, but the actual builds and tests run on a dev box you reach over Remote-SSH. That setup is common. In it, Claude Code runs on the remote side, which means the transcripts only ever exist on the remote side.
So what happens? You run basou orient — the command that tells you where you currently are — on the laptop, and you see half of what exists. Decisions made on the dev box are simply not there. For a trail tool, that is a problem.
The naive fix is to stand up a sync server and push from both ends. That builds precisely the path the previous section refused.
So give up? No. What happened instead was a re-drawing of the interface.
Define "another host's trail" as a local path
basou has a registry at ~/.basou/hosts.yaml. This is the whole of it:
# ~/.basou/hosts.yaml
version: 1
hosts:
- label: devbox
path: ~/mirrors/devbox/service-api # repo root holding another host's .basou
What goes in path is a local path this machine can see normally. Whether that is an SSHFS mount, an rsync mirror, or a Syncthing target, basou does not care. If it reads as a directory, that is enough. (It must be absolute; a leading ~ is expanded. Give one entry a relative path and it is not just that entry that is dropped — the whole hosts.yaml is discarded and the run continues local-only, printing basou: ignoring ~/.basou/hosts.yaml (Host paths must be absolute (or start with '~').); showing local sessions only. to stderr, with exit code 0.)
The implementation comment (packages/cli/src/lib/hosts-config.ts) says it directly:
basou performs NO network I/O: the operator's own tooling (over the SSH they
already use) keeps these paths in sync.
The CHANGELOG puts it more briefly:
it owns the merge, not the transport
That sentence holds the core of the design.
Why was that sufficient? Break the feature down and it needs two things: transport and merge. And the transport usually already exists. If you SSH into the dev box, the route and the keys are already in your hands. Bringing basou's own transport alongside that means one more key to manage and one more path to review. Where the existing route has already been through an organization's approval, the difference is larger still.
The only thing that had to be newly owned was the merge.
What it actually looks like
I built two stores locally to check. One decision and one note on the local side; one decision and one note on devbox. Both excerpts below are trimmed, with omissions marked ….
Without hosts.yaml, you get what you would expect:
> Generated at 2026-08-31T02:02:46.019Z · sessions 2 · newest 8s ago · pending 0 · suspect 0
…
- Latest decision: Stream the CSV export instead of buffering it [decision_01M1ARVSEC] (just now)
…
✅ The capture is current. Last work: just now (manual note). No uncaptured native sessions.
…
Add the registry and it changes:
> Generated at 2026-08-31T02:02:46.204Z · sessions 4 · newest 8s ago · pending 0 · suspect 0
> hosts: local, devbox
## Where you are now
- Last session: Ad-hoc note: Run the migration against staging first (completed) [ses_01M1ARVT1H] @devbox
- Latest decision: Use a partial index rather than a full one [decision_01M1ARVSX2] (just now) @devbox
…
## Recent direction (last 5 sessions)
- Ad-hoc note: Run the migration against staging first (just now) @devbox
- Ad-hoc capture: 1 decision (just now) @devbox
- Decisions: Use a partial index rather than a full one
- Ad-hoc note: Add an integration test for the export path (just now)
…
## Is this current
✅ The capture on this host (local) is current. Last work: just now (manual note). …
…
Note: the freshness verdict covers only this machine's local store. Missed work on other hosts cannot be assessed here (run `basou refresh` on each host to sync).
sessions 2 became sessions 4, and a > hosts: local, devbox line appeared in the header.
The part worth looking at is @devbox. Merged entries keep the host they came from. Merging normally destroys provenance; here it does not. Which machine a decision was made on is not information a trail is allowed to drop.
The same screen states its own limit
There is one more change in that output.
The last line went from The capture is current to The capture on this host (local) is current, and a note was added at the end: the freshness verdict covers only this machine's local store, and missed work on other hosts cannot be assessed here.
That is the direct cost of not owning the transport. Whether another host's mirror is current cannot be known without running the import on that host. If the mirror's contents are three days old, there is no way to tell from here.
What matters is that it does not paper over it. Quietly printing "probably current" was technically available. Instead, the shrinking of the verdict's scope was written on the screen.
The feature's reach is also not uniform yet. The merge applies on the basou orient path, and the orientation.md that path writes carries the host attribution too. Handoff, decisions, basou view, and the orientation.md that basou refresh regenerates are local-only as of v0.37.0.
One disclosure: this feature appears in neither the README nor docs/spec/. The only descriptions are CHANGELOG 0.16.0 (2026-06-23) and the implementation, which is what this post is written from. There is currently nowhere to turn if you set it up and get stuck.
The rule that came out of it
Generalized:
When a requirement seems to need the network, the options are not only "add the network" and "give up." The third is to re-draw the interface and hand the transport to something the user already has. But whatever you hand off, you can no longer vouch for its freshness — which brings an obligation to print the degradation on the screen.
Read "multi-host support" as "a sync feature" and you are down to two options. Read it as "a merge feature" and the third appears.
The same judgment, in two other places
Two other decisions touched the network. Neither was settled the way the first one was. One was deferred; the other was accepted with conditions. Calling all three "solved" would be a lie, so let me mark the difference.
Deferral — fix the schema, don't build the endpoint
Put a human approval step in front of an agent's actions and the obvious follow-up is wanting to approve while away from your desk. The specification (docs/spec/overview.md) says:
Remote approval is a core capability: the schema is fixed and a local CLI provides the minimal implementation. Remote endpoints are out of scope.
So the original requirement is not met. There is no way to approve from your phone. What exists is that the approval events have a fixed schema. The events that record an approval or a rejection have a dedicated field for what resolved them, and the only value that goes in it today is the local CLI (local-cli). The requesting event uses the generic "where did this come from" field shared by all events, which carries the agent-side adapter name (claude-code-adapter).
Those fields are plain strings; nothing is reserved for a remote future. Still, having the shape settled first means that when a remote resolver does appear, it writes into the same record. Less that a vessel was built than that the migration pressure was lowered. That is the single thing that separates deferral from procrastination: settling the data's shape needs no network.
Bounded acceptance — one socket
This one starts with an admission.
basou opens a socket. basou view is a local web UI, and it stands up an HTTP server. This is not an instance of "no network was added." It is a choice to add a loopback listener and still own no outbound path.
So the definition has to be exact. Zero-network here does not mean "opens no socket"; it means "owns no path out." That is not the same as saying nothing can leak from the receiving side — reach it and get a response back, and it has leaked. So the one socket that is open is defended on that assumption (packages/cli/src/lib/view-server.ts):
// :108 — default bind
const { port, host = "127.0.0.1", deps } = opts;
// :664-667 — Host header check, on every request
function hostAllowed(req: IncomingMessage, port: number): boolean {
const host = req.headers.host;
return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
}
// :669-673 — Origin check, on POST only; an absent Origin is allowed
function originAllowed(req: IncomingMessage, port: number): boolean {
const origin = req.headers.origin;
if (origin === undefined) return true; // non-browser client (curl, tests)
return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
}
The reason it does not stop at the bind is that binding 127.0.0.1 still leaves the route through the user's own browser. Get them to open a page, rebind DNS so the attacker's domain resolves to 127.0.0.1, and as far as the browser is concerned it is same-origin with the server on your machine (DNS rebinding). The Host header carries the attacker's domain at that point, so checking it rejects the request.
The specification (docs/spec/cli-commands.md) is blunt about the limit: the server stays localhost-only and unauthenticated — do not expose the port. Put it behind a reverse proxy and the checks above will not save you: they exist to stop browser-mediated access, and a client calling directly can forge Host and simply omit Origin.
Holding one honest exception, together with its conditions, makes the whole claim easier to check. "It opens no sockets at all" would be a cleaner line, and a false one. Mix one falsehood into a claim and the moment a reader tries to verify it, all of it becomes suspect.
Where it actually writes
Now the correction I promised.
In the introduction post I wrote that basou "writes only to the .basou/ directory next to your code." That was too strong. The same bullet list two lines down says "Everything stays inside .basou/," which overstates it in the same way.
As the storage location of the trail itself, .basou/ is right. But the set of files the CLI as a whole may write is wider.
-
.basou/— the trail itself. -
The workspace's
.gitignore—basou initappends the lines that keep raw logs out of history. -
The agent's own config files, by default — this one needs care. A plain
basou refreshupdates the orientation block in~/.codex/AGENTS.mdevery time, andbasou run codexdoes the same immediately before spawning. That is default behavior, not an option (best-effort, though: if~/.codex/does not exist it does not create the directory and skips —refreshsays so withcodex channel skipped: …, while the pre-spawn path stays silent).basou protocol syncwrites a marker block into~/.claude/CLAUDE.md;basou hook installregisters a Stop hook in~/.claude/settings.json. Each keeps a one-time<target>.basou-bak. -
Wiring in declared repositories — with
--apply,basou projectgenerators write a.gitignoreand theAGENTS.md/CLAUDE.md/.github/copilot-instructions.mdsymlinks into the target repository (these generators are dry-run by default). The aggregated workspace view is created outside the repository by default — as a sibling directory.--prunedeletes view symlinks independently of--apply. -
Any path the user names —
basou report generate --out <path>, for instance.
Conversely, basou never writes the configuration under ~/.basou/ (hosts.yaml, portfolio.yaml, protocols.yaml). It only reads it.
basou's own specification is accurate about where the trail lives:
the workspace trail lives under
.basou/next to your code; optional integrations may also write user-level files (~/.claude/,~/.codex/). Everything stays on-machine — nothing is sent off-machine.
That is an invariant about the trail and optional integrations, not an inventory of everything the CLI emits. The introduction post read that sentence as the whole picture and then added "only" on top of it. "Nothing leaves the machine" was right — in the sense that basou itself sends nothing (a command you hand to basou exec going out is a separate matter, covered below). What was inaccurate was the scope of where it writes.
How to check any of this
So far this has been design. What is actually worth something to a reader, I think, is whether you can check it yourself. Every number below is as of v0.37.0.
Start with the dependencies. Do it somewhere empty, so you do not dirty a working directory:
mkdir /tmp/basou-audit && cd /tmp/basou-audit && npm init -y
npm install --omit=dev @basou/cli@0.37.0
The production transitive closure is 13 packages (npm counts the root and prints audited 14); two of those are basou's own, so 11 are third-party. ulid, yaml, zod, and commander have no dependencies; simple-git brings five directly, and its debug brings ms. There is no HTTP client in there.
Run it with outbound traffic denied
There is a faster check than reading dependencies: run it in an environment where outbound traffic is forbidden. On macOS that is one profile handed to sandbox-exec.
; deny-out.sb
(version 1)
(allow default)
(deny network-outbound)
Denying only the outbound direction is the point. A profile that forbids network operations wholesale ((deny network*)) also stops inbound, bind, and Unix domain sockets, which makes it impossible to tell what caused a failure. Direction is the thing under test here, so the block is drawn by direction.
Save the profile as deny-out.sb in that same /tmp/basou-audit, and run the following from there. Confirm first that the block is real — skip this and nothing that follows proves anything. Use a peer you stand up yourself; point it at a third party and a reader cannot tell a block from a dead host.
# a listener, outside the sandbox
node -e "require('net').createServer().listen(48080,'127.0.0.1',()=>console.log('listening'))" &
sleep 1
# connect from the plain environment — this is the baseline
node -e "require('net').connect(48080,'127.0.0.1')
.on('connect', function () { console.log('connected'); this.end() })
.on('error', e => console.log('blocked:', e.code))"
# connect to the same peer, from inside the sandbox
sandbox-exec -f "$PWD/deny-out.sb" \
node -e "require('net').connect(48080,'127.0.0.1')
.on('connect', function () { console.log('connected'); this.end() })
.on('error', e => console.log('blocked:', e.code))"
kill %1
listening
connected
blocked: EPERM
The same peer: reachable from outside, EPERM from inside. That is outbound being blocked. Child processes inherit it, so a route like basou exec curl … cannot get out from inside either.
Now run basou against a repository that has a .basou/. Pass -f as an absolute path (the profile stays in the audit directory), and call the binary by path — npm install was a local install, so basou is not on your PATH.
AUDIT=/tmp/basou-audit
cd /path/to/your/repo
sandbox-exec -f $AUDIT/deny-out.sb $AUDIT/node_modules/.bin/basou orient
sandbox-exec -f $AUDIT/deny-out.sb $AUDIT/node_modules/.bin/basou refresh
sandbox-exec -f $AUDIT/deny-out.sb $AUDIT/node_modules/.bin/basou view
All three go through. orient and refresh exit 0; view starts (it is a server, so it does not exit — Ctrl-C when you have seen it).
basou view running at http://127.0.0.1:4319
Localhost only, no authentication. Do not expose this port beyond your machine. Press Ctrl+C to stop.
Note the second line. The operational condition quoted from the specification earlier — localhost only, unauthenticated, do not expose the port — is printed by the tool itself on every start.
Blocking outbound breaks nothing, and the loopback listener comes up normally. Both halves of the shape described earlier — no path out, one socket open for the local UI — show up at once.
What this experiment does not show
This needs stating precisely. What those three lines let you claim is narrower than it looks.
The import did not run here. Look at what refresh printed:
claude-code: skipped (no source logs for this project)
codex: skipped (no source logs for this project)
handoff: regenerated (sessions: 2, decisions: 1)
decisions: regenerated (1)
orientation: regenerated (in-flight: 0, pending approvals: 0, suspect: 0)
…
In a repository with no agent logs, the importers pass straight through. What actually ran was Markdown regeneration. The merge is the same story: without ~/.basou/hosts.yaml, the federation path is never entered. Checking import and merge under the same conditions needs a repository with real logs and a registry pointing at a second store.
It also only ran three commands. basou --help lists 25 commands (one of them is help itself, so 24 real ones). If a branch that only executes under some other command reaches out, this check does not see it.
And it only observes success. An implementation that sends, swallows the failure, and carries on would sail through an outbound-blocked environment. This check shows that basou does not depend on outbound traffic succeeding — not that it sends nothing.
Back it with a static scan
So look at the code as well. The question here is whether the tools for connecting outward are in the bundle at all.
grep -rlE "(from|require\()[[:space:]]*['\"](node:)?(http|https|http2|net|tls|dgram|dns)['\"]|\bfetch\(|WebSocket|XMLHttpRequest" \
node_modules --include='*.js' --include='*.cjs' --include='*.mjs'
It catches both node:-prefixed and bare specifiers, and both static import and require. Bundler output is not consistent, so an expression that only looks for one form misses easily. I got this wrong three times while writing this post — an expression that ignored bare specifiers, one that only looked at require, and one that missed the from "http" form. Offered here as material for re-deriving what follows.
Two files match, both from basou's own bundle. Swap -l for -n and you see what matched:
node_modules/@basou/cli/dist/index.js:10902:import { createServer } from "http";
node_modules/@basou/cli/dist/index.js:11076: return fetch(path, opts).then(function (res) {
node_modules/@basou/cli/dist/program.js:10902:import { createServer } from "http";
node_modules/@basou/cli/dist/program.js:11076: return fetch(path, opts).then(function (res) {
Four lines, two distinct things (index.js and program.js are separate entry points into the same bundle, so each line appears twice). The only network builtin this expression found is http, and what it takes from it is { createServer }. The fetch( is inside a browser-side JS string. Within the reach of this scan, the tool for building a server is present and the tool for connecting out as a client is not.
Worth checking what a static expression cannot reach, too: the bundle contains one dynamic import("…"), and it resolves readline/promises.
The expression above does not look at child_process, though. Spawn a process and you can get out without importing a network module at all. Add it and the result changes:
grep -rlE "(from|require\()[[:space:]]*['\"](node:)?(http|https|http2|net|tls|dgram|dns|child_process)['\"]|\bfetch\(|WebSocket|XMLHttpRequest" \
node_modules --include='*.js' --include='*.cjs' --include='*.mjs'
Now it is six files, and commander and simple-git appear. That difference is the more honest result, so let me take those one at a time.
-
simple-git— spawns git as a child process. What turns up are reads likerev-parse,status, anddiff; in the shipped bundle, no call to the writing side (push/fetch/pull/clone) turns up. The library defines those operations —pushTaskand friends are insimple-git's bundle — and basou's own test suite pushes once, to a throwaway local repository. -
commander— carries the ability to launch executable-file subcommands, which basou does not use. -
Processes basou spawns directly — what turns up is
which(locating a command),open(opening a local URL in a browser), andgit ls-files(called directly, not through simple-git, bybasou view's pre-flight check). -
basou run— its targets are the fixed subcommandsclaude-codeandcodex, not an arbitrary command. The agent it launches, of course, uses the network itself. -
basou exec— this one can launch an arbitrary command. Typebasou exec curl …and curl goes out, naturally.
So what can actually be said
The two checks reach different places. There are also holes they share.
| Check | Reaches | Does not reach |
|---|---|---|
| Running with outbound denied | That the executed paths do not depend on outbound traffic succeeding | The 21 subcommands not executed / an implementation that sends and swallows the failure |
| Static scan | That, in the forms this expression catches, connection APIs are not in the bundle | Paths that assemble a name at runtime / paths that write to a file and let another process carry it |
The bottom two cells overlap. An implementation inside an unexecuted branch that assembles a module name and loads it is invisible to the run (it never executes) and invisible to the scan (the shape is different). Stacking the two does not fill that in.
With that said: within what was checked, nothing turns up to suggest basou's own process uses an API for connecting outward. It does use createServer on the receiving side. What goes out goes out because the user handed over a command, or because an agent they asked for was launched — not because basou sent it.
And there is no CI guard forbidding the network. "It's covered by tests" is not something I can say. What is doing the work is that it can be inspected, which is why the steps are here.
One caveat on all of it: sandbox-exec is a command Apple marks deprecated, and the dependency versions are ^ ranges, so there is no guarantee this procedure returns these numbers indefinitely. Every figure above was taken on 2026-08-31, on macOS, against @basou/cli@0.37.0.
What was given up
If you have read this far and think it holds together, look next at what was handed over in exchange. For anyone weighing adoption, this is the part that gives you something to decide on.
1. There is no third-party notarization.
basou detects tampering in its trail, but with no server and no notary, what that yields is a check that the files on hand are internally consistent with each other. The specification is honest about the threat model: someone who rewrites the related files together, consistently, is not detected — the spec puts it as raising the bar from "edit one line" to "recompute and rewrite two coordinated files," which it calls the right primitive for catching accidental and casual mutation. It is not a cryptographic proof from a third party.
2. There is no centralized freshness.
As above. If work was missed on another host, you cannot tell from here. Running the import on each host is the only way.
3. It does not detect a mirror that has gone away.
This one I found by trying it. If the SSHFS mount drops, or the path has a typo, orient does not say "devbox is missing." It quietly returns the local-only result. The only hint is the > hosts: banner disappearing. A warning does appear when the directory exists but cannot be read, and again when the config file's syntax is broken — but when the path simply points at nothing, it says nothing.
Without defending it: this is the underside of a best-effort, non-fatal design. orient is the default command, and if a malformed config file could take it down you would see nothing at all. So it is tuned not to fall over. In exchange, it degrades silently.
4. There is no authenticated shared UI.
The viewer is localhost-only and unauthenticated. Sending a colleague a URL to look at is not a usage this supports.
5. There is no aggregation across a team or an organization.
There is a mode that shows several workspaces side by side, but it is for a single owner to survey their own work, not for watching other people. The specification states that the cards placed side by side carry no work-stats and no per-agent productivity or utilization metrics (there is a separate command that measures output volume; the line is that it stays off the side-by-side survey surface). That one is not something given up — it is an intended non-goal.
If any of those five is unacceptable for your requirements, basou does not fit them. If you want a team's activity aggregated, pick a product built for that. Saying up front where it does not fit seems to me the right way to treat someone weighing adoption.
In closing
Deciding to own no network did not set a ceiling on features. It decided where the boundaries go.
One case is at the center. Against a requirement to span machines, no sync server was written: the transport went to a route the user already had, and only the merge was kept. The other two are not solutions. Remote approval settled the shape and deferred the rest; the local UI accepted a loopback listener with conditions and wrote those conditions down.
And with outbound traffic blocked, basou runs; scan the code and the tools for connecting outward do not turn up. What goes out goes out because the user handed over a command, or because an agent they asked for was launched. That is not a statement of policy — it is something you can check by hand, though only as far as each check reaches, and the reach of each is written above. There is no CI enforcing it either.
The starting point was not an ideology. It was a question about capacity: can a single-author, pre-1.0 OSS project take on the promise of holding other people's data? If it cannot, build the structure so that no custody is required. And that does not make the responsibility disappear — the transport, not exposing the port, and whatever the commands you launch actually do all stay with the user. Writing that part down too is what makes it an explanation of the design.
If you want to try it, basou.dev and the quickstart are the places to start.
Top comments (0)