<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Takashi Matsuyama</title>
    <description>The latest articles on DEV Community by Takashi Matsuyama (@takashimatsuyama).</description>
    <link>https://dev.to/takashimatsuyama</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3996973%2F51c4515b-a3df-433f-9276-13e86fe74ba0.jpg</url>
      <title>DEV Community: Takashi Matsuyama</title>
      <link>https://dev.to/takashimatsuyama</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/takashimatsuyama"/>
    <language>en</language>
    <item>
      <title>It Owns the Merge, Not the Transport — basou's local-first, zero-network design</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Mon, 31 Aug 2026 04:10:51 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/it-owns-the-merge-not-the-transport-basous-local-first-zero-network-design-500f</link>
      <guid>https://dev.to/takashimatsuyama/it-owns-the-merge-not-the-transport-basous-local-first-zero-network-design-500f</guid>
      <description>&lt;p&gt;Open &lt;code&gt;~/.claude/projects/&lt;/code&gt; on your own machine.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;An agent's transcripts are &lt;strong&gt;the most sensitive collection of files in a developer's environment&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://basou.dev" rel="noopener noreferrer"&gt;basou&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;What should a tool like that do with that data?&lt;/p&gt;

&lt;p&gt;This post is the record of that design decision. Everything below is against &lt;strong&gt;basou v0.37.0 (released 2026-08-04)&lt;/strong&gt;. (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.)&lt;/p&gt;

&lt;h2&gt;
  
  
  I already made the claim. Today is what sits underneath it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://blog.tak3.jp/en/blog/introducing-basou/" rel="noopener noreferrer"&gt;introduction post&lt;/a&gt; put it in a single bullet:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Local-first / zero-network.&lt;/strong&gt; It reads the agent logs already on your machine and writes only to the &lt;code&gt;.basou/&lt;/code&gt; directory next to your code. Nothing leaves the machine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As a claim, that is enough. But a bullet can only state the outcome. What follows is &lt;strong&gt;what happened after that was decided&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One more thing: part of the bullet above is worded too strongly. I will fix that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not send it — not taking custody of other people's data
&lt;/h2&gt;

&lt;p&gt;First, let me grant the case for sending it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The problem is that the moment you own one upload path, &lt;strong&gt;you have taken custody of other people's data&lt;/strong&gt;. Custody means making these promises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How long do you keep it? If someone asks you to delete it, can you reach the copies inside your backups?&lt;/li&gt;
&lt;li&gt;Transcripts can contain credentials. When one arrives with a credential inside, how do you handle it?&lt;/li&gt;
&lt;li&gt;If there is a breach, whom do you notify? Not only the user — their &lt;strong&gt;employer's repository&lt;/strong&gt; is in those transcripts too.&lt;/li&gt;
&lt;li&gt;Which country holds the server? Are logs from work under contract or NDA allowed to sit there?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Parts of each of those have technical answers (credential scrubbing, for one), but the substance is &lt;strong&gt;an operational promise that comes with custody&lt;/strong&gt;. And promises should only be made by people with the capacity to keep them.&lt;/p&gt;

&lt;p&gt;There are middle options — with client-side encryption where only the user holds the key, you can offer sync from a server that &lt;strong&gt;only ever holds data it cannot read&lt;/strong&gt;, 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."&lt;/p&gt;

&lt;p&gt;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. &lt;strong&gt;The amount of custody that could be honored was zero, so the structure was built to require none.&lt;/strong&gt; That is all.&lt;/p&gt;

&lt;h3&gt;
  
  
  The responsibility did not vanish. It moved
&lt;/h3&gt;

&lt;p&gt;I want to be precise here. &lt;strong&gt;Not taking custody does not make the responsibility disappear.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What this design did was not erasure but &lt;strong&gt;a re-drawing of the boundary&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;not expected to change across major versions&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The main case — it owns the merge, not the transport
&lt;/h2&gt;

&lt;p&gt;The first requirement arrived quickly.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;on the remote side&lt;/strong&gt;, which means the transcripts only ever exist on the remote side.&lt;/p&gt;

&lt;p&gt;So what happens? You run &lt;code&gt;basou orient&lt;/code&gt; — the command that tells you where you currently are — on the laptop, and &lt;strong&gt;you see half of what exists&lt;/strong&gt;. Decisions made on the dev box are simply not there. For a trail tool, that is a problem.&lt;/p&gt;

&lt;p&gt;The naive fix is to stand up a sync server and push from both ends. That builds precisely the path the previous section refused.&lt;/p&gt;

&lt;p&gt;So give up? No. What happened instead was &lt;strong&gt;a re-drawing of the interface&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Define "another host's trail" as a local path
&lt;/h3&gt;

&lt;p&gt;basou has a registry at &lt;code&gt;~/.basou/hosts.yaml&lt;/code&gt;. This is the whole of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ~/.basou/hosts.yaml&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;devbox&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/mirrors/devbox/service-api&lt;/span&gt;   &lt;span class="c1"&gt;# repo root holding another host's .basou&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What goes in &lt;code&gt;path&lt;/code&gt; is &lt;strong&gt;a local path this machine can see normally&lt;/strong&gt;. 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 &lt;code&gt;~&lt;/code&gt; is expanded. Give one entry a relative path and it is not just that entry that is dropped — the whole &lt;code&gt;hosts.yaml&lt;/code&gt; is discarded and the run continues local-only, printing &lt;code&gt;basou: ignoring ~/.basou/hosts.yaml (Host paths must be absolute (or start with '~').); showing local sessions only.&lt;/code&gt; to stderr, with exit code 0.)&lt;/p&gt;

&lt;p&gt;The implementation comment (&lt;code&gt;packages/cli/src/lib/hosts-config.ts&lt;/code&gt;) says it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;basou performs NO network I/O: the operator's own tooling (over the SSH they
already use) keeps these paths in sync.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CHANGELOG puts it more briefly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;it owns the merge, not the transport&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence holds the core of the design.&lt;/p&gt;

&lt;p&gt;Why was that sufficient? Break the feature down and it needs two things: &lt;strong&gt;transport&lt;/strong&gt; and &lt;strong&gt;merge&lt;/strong&gt;. And &lt;strong&gt;the transport usually already exists&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The only thing that had to be newly owned was the merge.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What it actually looks like
&lt;/h3&gt;

&lt;p&gt;I built two stores locally to check. One decision and one note on the local side; one decision and one note on &lt;code&gt;devbox&lt;/code&gt;. Both excerpts below are trimmed, with omissions marked &lt;code&gt;…&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;hosts.yaml&lt;/code&gt;, you get what you would expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; 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.
…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the registry and it changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; Generated at 2026-08-31T02:02:46.204Z · sessions 4 · newest 8s ago · pending 0 · suspect 0
&amp;gt; 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).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sessions 2&lt;/code&gt; became &lt;code&gt;sessions 4&lt;/code&gt;, and a &lt;code&gt;&amp;gt; hosts: local, devbox&lt;/code&gt; line appeared in the header.&lt;/p&gt;

&lt;p&gt;The part worth looking at is &lt;code&gt;@devbox&lt;/code&gt;. Merged entries &lt;strong&gt;keep the host they came from&lt;/strong&gt;. Merging normally destroys provenance; here it does not. Which machine a decision was made on is not information a trail is allowed to drop.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same screen states its own limit
&lt;/h3&gt;

&lt;p&gt;There is one more change in that output.&lt;/p&gt;

&lt;p&gt;The last line went from &lt;code&gt;The capture is current&lt;/code&gt; to &lt;code&gt;The capture on this host (local) is current&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;That is the direct cost of not owning the transport. Whether another host's mirror is current &lt;strong&gt;cannot be known without running the import on that host&lt;/strong&gt;. If the mirror's contents are three days old, there is no way to tell from here.&lt;/p&gt;

&lt;p&gt;What matters is that it does not paper over it. Quietly printing "probably current" was technically available. Instead, &lt;strong&gt;the shrinking of the verdict's scope was written on the screen&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The feature's reach is also not uniform yet. The merge applies on the &lt;code&gt;basou orient&lt;/code&gt; path, and the &lt;code&gt;orientation.md&lt;/code&gt; that path writes carries the host attribution too. Handoff, decisions, &lt;code&gt;basou view&lt;/code&gt;, and the &lt;code&gt;orientation.md&lt;/code&gt; that &lt;code&gt;basou refresh&lt;/code&gt; regenerates are local-only as of v0.37.0.&lt;/p&gt;

&lt;p&gt;One disclosure: &lt;strong&gt;this feature appears in neither the README nor &lt;code&gt;docs/spec/&lt;/code&gt;&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rule that came out of it
&lt;/h3&gt;

&lt;p&gt;Generalized:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;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.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same judgment, in two other places
&lt;/h2&gt;

&lt;p&gt;Two other decisions touched the network. Neither was settled the way the first one was. One was &lt;strong&gt;deferred&lt;/strong&gt;; the other was &lt;strong&gt;accepted with conditions&lt;/strong&gt;. Calling all three "solved" would be a lie, so let me mark the difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deferral — fix the schema, don't build the endpoint
&lt;/h3&gt;

&lt;p&gt;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 (&lt;code&gt;docs/spec/overview.md&lt;/code&gt;) says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Remote approval is a core capability: the schema is fixed and a local CLI provides the minimal implementation. Remote endpoints are out of scope.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So &lt;strong&gt;the original requirement is not met&lt;/strong&gt;. 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 (&lt;code&gt;local-cli&lt;/code&gt;). The requesting event uses the generic "where did this come from" field shared by all events, which carries the agent-side adapter name (&lt;code&gt;claude-code-adapter&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;the migration pressure was lowered&lt;/strong&gt;. That is the single thing that separates deferral from procrastination: settling the data's shape needs no network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bounded acceptance — one socket
&lt;/h3&gt;

&lt;p&gt;This one starts with an admission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;basou opens a socket.&lt;/strong&gt; &lt;code&gt;basou view&lt;/code&gt; 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 &lt;strong&gt;add a loopback listener and still own no outbound path&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the definition has to be exact. Zero-network here does not mean "opens no socket"; it means &lt;strong&gt;"owns no path out."&lt;/strong&gt; 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 (&lt;code&gt;packages/cli/src/lib/view-server.ts&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// :108 — default bind&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// :664-667 — Host header check, on every request&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hostAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IncomingMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;`127.0.0.1:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;`localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// :669-673 — Origin check, on POST only; an absent Origin is allowed&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;originAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;IncomingMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// non-browser client (curl, tests)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;`http://127.0.0.1:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;`http://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason it does not stop at the bind is that binding &lt;code&gt;127.0.0.1&lt;/code&gt; still leaves the route &lt;strong&gt;through the user's own browser&lt;/strong&gt;. Get them to open a page, rebind DNS so the attacker's domain resolves to &lt;code&gt;127.0.0.1&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The specification (&lt;code&gt;docs/spec/cli-commands.md&lt;/code&gt;) is blunt about the limit: the server stays &lt;strong&gt;localhost-only and unauthenticated&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually writes
&lt;/h2&gt;

&lt;p&gt;Now the correction I promised.&lt;/p&gt;

&lt;p&gt;In the introduction post I wrote that basou "writes &lt;strong&gt;only&lt;/strong&gt; to the &lt;code&gt;.basou/&lt;/code&gt; directory next to your code." &lt;strong&gt;That was too strong.&lt;/strong&gt; The same bullet list two lines down says "Everything stays inside &lt;code&gt;.basou/&lt;/code&gt;," which overstates it in the same way.&lt;/p&gt;

&lt;p&gt;As the storage location of the trail itself, &lt;code&gt;.basou/&lt;/code&gt; is right. But the set of files the CLI as a whole may write is wider.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.basou/&lt;/code&gt;&lt;/strong&gt; — the trail itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The workspace's &lt;code&gt;.gitignore&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;basou init&lt;/code&gt; appends the lines that keep raw logs out of history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent's own config files, by default&lt;/strong&gt; — this one needs care. A plain &lt;code&gt;basou refresh&lt;/code&gt; updates the orientation block in &lt;code&gt;~/.codex/AGENTS.md&lt;/code&gt; every time, and &lt;code&gt;basou run codex&lt;/code&gt; does the same immediately before spawning. That is &lt;strong&gt;default behavior, not an option&lt;/strong&gt; (best-effort, though: if &lt;code&gt;~/.codex/&lt;/code&gt; does not exist it does not create the directory and skips — &lt;code&gt;refresh&lt;/code&gt; says so with &lt;code&gt;codex channel skipped: …&lt;/code&gt;, while the pre-spawn path stays silent). &lt;code&gt;basou protocol sync&lt;/code&gt; writes a marker block into &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt;; &lt;code&gt;basou hook install&lt;/code&gt; registers a Stop hook in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;. Each keeps a one-time &lt;code&gt;&amp;lt;target&amp;gt;.basou-bak&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wiring in declared repositories&lt;/strong&gt; — with &lt;code&gt;--apply&lt;/code&gt;, &lt;code&gt;basou project&lt;/code&gt; generators write a &lt;code&gt;.gitignore&lt;/code&gt; and the &lt;code&gt;AGENTS.md&lt;/code&gt; / &lt;code&gt;CLAUDE.md&lt;/code&gt; / &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; symlinks into the target repository (these generators are dry-run by default). &lt;strong&gt;The aggregated workspace view is created outside the repository by default — as a sibling directory.&lt;/strong&gt; &lt;code&gt;--prune&lt;/code&gt; deletes view symlinks independently of &lt;code&gt;--apply&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any path the user names&lt;/strong&gt; — &lt;code&gt;basou report generate --out &amp;lt;path&amp;gt;&lt;/code&gt;, for instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conversely, basou never writes the configuration under &lt;code&gt;~/.basou/&lt;/code&gt; (&lt;code&gt;hosts.yaml&lt;/code&gt;, &lt;code&gt;portfolio.yaml&lt;/code&gt;, &lt;code&gt;protocols.yaml&lt;/code&gt;). It only reads it.&lt;/p&gt;

&lt;p&gt;basou's own specification is accurate about where the trail lives:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the workspace trail lives under &lt;code&gt;.basou/&lt;/code&gt; next to your code; optional integrations may also write user-level files (&lt;code&gt;~/.claude/&lt;/code&gt;, &lt;code&gt;~/.codex/&lt;/code&gt;). Everything stays on-machine — nothing is sent off-machine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is &lt;strong&gt;an invariant about the trail and optional integrations&lt;/strong&gt;, 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 &lt;strong&gt;basou itself sends nothing&lt;/strong&gt; (a command you hand to &lt;code&gt;basou exec&lt;/code&gt; going out is a separate matter, covered below). What was inaccurate was the scope of where it writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check any of this
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Start with the dependencies. Do it somewhere empty, so you do not dirty a working directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; /tmp/basou-audit &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/basou-audit &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev @basou/cli@0.37.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The production transitive closure is &lt;strong&gt;13 packages&lt;/strong&gt; (npm counts the root and prints &lt;code&gt;audited 14&lt;/code&gt;); two of those are basou's own, so &lt;strong&gt;11 are third-party&lt;/strong&gt;. &lt;code&gt;ulid&lt;/code&gt;, &lt;code&gt;yaml&lt;/code&gt;, &lt;code&gt;zod&lt;/code&gt;, and &lt;code&gt;commander&lt;/code&gt; have no dependencies; &lt;code&gt;simple-git&lt;/code&gt; brings five directly, and its &lt;code&gt;debug&lt;/code&gt; brings &lt;code&gt;ms&lt;/code&gt;. There is no HTTP client in there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run it with outbound traffic denied
&lt;/h3&gt;

&lt;p&gt;There is a faster check than reading dependencies: &lt;strong&gt;run it in an environment where outbound traffic is forbidden&lt;/strong&gt;. On macOS that is one profile handed to &lt;code&gt;sandbox-exec&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;; deny-out.sb
(version 1)
(allow default)
(deny network-outbound)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Denying only the outbound direction is the point. A profile that forbids network operations wholesale (&lt;code&gt;(deny network*)&lt;/code&gt;) also stops inbound, bind, and Unix domain sockets, which makes it &lt;strong&gt;impossible to tell what caused a failure&lt;/strong&gt;. Direction is the thing under test here, so the block is drawn by direction.&lt;/p&gt;

&lt;p&gt;Save the profile as &lt;code&gt;deny-out.sb&lt;/code&gt; in that same &lt;code&gt;/tmp/basou-audit&lt;/code&gt;, 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a listener, outside the sandbox&lt;/span&gt;
node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"require('net').createServer().listen(48080,'127.0.0.1',()=&amp;gt;console.log('listening'))"&lt;/span&gt; &amp;amp;
&lt;span class="nb"&gt;sleep &lt;/span&gt;1

&lt;span class="c"&gt;# connect from the plain environment — this is the baseline&lt;/span&gt;
node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"require('net').connect(48080,'127.0.0.1')
  .on('connect', function () { console.log('connected'); this.end() })
  .on('error', e =&amp;gt; console.log('blocked:', e.code))"&lt;/span&gt;

&lt;span class="c"&gt;# connect to the same peer, from inside the sandbox&lt;/span&gt;
sandbox-exec &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/deny-out.sb"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"require('net').connect(48080,'127.0.0.1')
    .on('connect', function () { console.log('connected'); this.end() })
    .on('error', e =&amp;gt; console.log('blocked:', e.code))"&lt;/span&gt;

&lt;span class="nb"&gt;kill&lt;/span&gt; %1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;listening
connected
blocked: EPERM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same peer: reachable from outside, &lt;code&gt;EPERM&lt;/code&gt; from inside. That is outbound being blocked. Child processes inherit it, so a route like &lt;code&gt;basou exec curl …&lt;/code&gt; cannot get out from inside either.&lt;/p&gt;

&lt;p&gt;Now run basou against a repository that has a &lt;code&gt;.basou/&lt;/code&gt;. Pass &lt;code&gt;-f&lt;/code&gt; as an absolute path (the profile stays in the audit directory), and call the binary by path — &lt;code&gt;npm install&lt;/code&gt; was a local install, so &lt;code&gt;basou&lt;/code&gt; is not on your &lt;code&gt;PATH&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;AUDIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/basou-audit
&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/your/repo

sandbox-exec &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$AUDIT&lt;/span&gt;/deny-out.sb &lt;span class="nv"&gt;$AUDIT&lt;/span&gt;/node_modules/.bin/basou orient
sandbox-exec &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$AUDIT&lt;/span&gt;/deny-out.sb &lt;span class="nv"&gt;$AUDIT&lt;/span&gt;/node_modules/.bin/basou refresh
sandbox-exec &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$AUDIT&lt;/span&gt;/deny-out.sb &lt;span class="nv"&gt;$AUDIT&lt;/span&gt;/node_modules/.bin/basou view
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;All three go through.&lt;/strong&gt; &lt;code&gt;orient&lt;/code&gt; and &lt;code&gt;refresh&lt;/code&gt; exit 0; &lt;code&gt;view&lt;/code&gt; starts (it is a server, so it does not exit — Ctrl-C when you have seen it).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the second line. The operational condition quoted from the specification earlier — localhost only, unauthenticated, do not expose the port — is &lt;strong&gt;printed by the tool itself on every start&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this experiment does not show
&lt;/h3&gt;

&lt;p&gt;This needs stating precisely. What those three lines let you claim is narrower than it looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The import did not run here.&lt;/strong&gt; Look at what &lt;code&gt;refresh&lt;/code&gt; printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a repository with no agent logs, the importers pass straight through. What actually ran was Markdown regeneration. &lt;strong&gt;The merge is the same story&lt;/strong&gt;: without &lt;code&gt;~/.basou/hosts.yaml&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It also only ran three commands.&lt;/strong&gt; &lt;code&gt;basou --help&lt;/code&gt; lists 25 commands (one of them is &lt;code&gt;help&lt;/code&gt; itself, so 24 real ones). If a branch that only executes under some other command reaches out, this check does not see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it only observes success.&lt;/strong&gt; An implementation that sends, swallows the failure, and carries on would sail through an outbound-blocked environment. This check shows that basou &lt;strong&gt;does not depend on&lt;/strong&gt; outbound traffic succeeding — not that it sends nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Back it with a static scan
&lt;/h3&gt;

&lt;p&gt;So look at the code as well. The question here is whether the tools for connecting outward are in the bundle at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rlE&lt;/span&gt; &lt;span class="s2"&gt;"(from|require&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;)[[:space:]]*['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;](node:)?(http|https|http2|net|tls|dgram|dns)['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|WebSocket|XMLHttpRequest"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  node_modules &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.js'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.cjs'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.mjs'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It catches both &lt;code&gt;node:&lt;/code&gt;-prefixed and bare specifiers, and both static &lt;code&gt;import&lt;/code&gt; and &lt;code&gt;require&lt;/code&gt;. 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 &lt;code&gt;require&lt;/code&gt;, and one that missed the &lt;code&gt;from "http"&lt;/code&gt; form. Offered here as material for re-deriving what follows.&lt;/p&gt;

&lt;p&gt;Two files match, both from basou's own bundle. Swap &lt;code&gt;-l&lt;/code&gt; for &lt;code&gt;-n&lt;/code&gt; and you see what matched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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) {
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines, two distinct things (&lt;code&gt;index.js&lt;/code&gt; and &lt;code&gt;program.js&lt;/code&gt; are separate entry points into the same bundle, so each line appears twice). The only network builtin this expression found is &lt;code&gt;http&lt;/code&gt;, and what it takes from it is &lt;code&gt;{ createServer }&lt;/code&gt;. The &lt;code&gt;fetch(&lt;/code&gt; is inside a browser-side JS string. &lt;strong&gt;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.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Worth checking what a static expression cannot reach, too: the bundle contains one dynamic &lt;code&gt;import("…")&lt;/code&gt;, and it resolves &lt;code&gt;readline/promises&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The expression above does not look at &lt;code&gt;child_process&lt;/code&gt;, though. Spawn a process and you can get out without importing a network module at all. Add it and the result changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rlE&lt;/span&gt; &lt;span class="s2"&gt;"(from|require&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;)[[:space:]]*['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;](node:)?(http|https|http2|net|tls|dgram|dns|child_process)['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|WebSocket|XMLHttpRequest"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  node_modules &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.js'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.cjs'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.mjs'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it is &lt;strong&gt;six files&lt;/strong&gt;, and &lt;code&gt;commander&lt;/code&gt; and &lt;code&gt;simple-git&lt;/code&gt; appear. That difference is the more honest result, so let me take those one at a time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;simple-git&lt;/code&gt;&lt;/strong&gt; — spawns git as a child process. What turns up are reads like &lt;code&gt;rev-parse&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and &lt;code&gt;diff&lt;/code&gt;; &lt;strong&gt;in the shipped bundle, no call to the writing side (&lt;code&gt;push&lt;/code&gt; / &lt;code&gt;fetch&lt;/code&gt; / &lt;code&gt;pull&lt;/code&gt; / &lt;code&gt;clone&lt;/code&gt;) turns up&lt;/strong&gt;. The library defines those operations — &lt;code&gt;pushTask&lt;/code&gt; and friends are in &lt;code&gt;simple-git&lt;/code&gt;'s bundle — and basou's own test suite pushes once, to a throwaway local repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;commander&lt;/code&gt;&lt;/strong&gt; — carries the ability to launch executable-file subcommands, which basou does not use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processes basou spawns directly&lt;/strong&gt; — what turns up is &lt;code&gt;which&lt;/code&gt; (locating a command), &lt;code&gt;open&lt;/code&gt; (opening a local URL in a browser), and &lt;code&gt;git ls-files&lt;/code&gt; (called directly, not through simple-git, by &lt;code&gt;basou view&lt;/code&gt;'s pre-flight check).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;basou run&lt;/code&gt;&lt;/strong&gt; — its targets are the fixed subcommands &lt;code&gt;claude-code&lt;/code&gt; and &lt;code&gt;codex&lt;/code&gt;, not an arbitrary command. The agent it launches, of course, uses the network itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;basou exec&lt;/code&gt;&lt;/strong&gt; — this one &lt;strong&gt;can launch an arbitrary command&lt;/strong&gt;. Type &lt;code&gt;basou exec curl …&lt;/code&gt; and curl goes out, naturally.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  So what can actually be said
&lt;/h3&gt;

&lt;p&gt;The two checks reach different places. &lt;strong&gt;There are also holes they share.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Reaches&lt;/th&gt;
&lt;th&gt;Does not reach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Running with outbound denied&lt;/td&gt;
&lt;td&gt;That the executed paths do not depend on outbound traffic succeeding&lt;/td&gt;
&lt;td&gt;The 21 subcommands not executed / an implementation that sends and swallows the failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static scan&lt;/td&gt;
&lt;td&gt;That, in the forms this expression catches, connection APIs are not in the bundle&lt;/td&gt;
&lt;td&gt;Paths that assemble a name at runtime / paths that write to a file and let another process carry it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The bottom two cells overlap.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;With that said: &lt;strong&gt;within what was checked, nothing turns up to suggest basou's own process uses an API for connecting outward.&lt;/strong&gt; It does use &lt;code&gt;createServer&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;there is no CI guard forbidding the network&lt;/strong&gt;. "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.&lt;/p&gt;

&lt;p&gt;One caveat on all of it: &lt;code&gt;sandbox-exec&lt;/code&gt; is a command Apple marks deprecated, and the dependency versions are &lt;code&gt;^&lt;/code&gt; ranges, so there is no guarantee this procedure returns these numbers indefinitely. Every figure above was taken on 2026-08-31, on macOS, against &lt;code&gt;@basou/cli@0.37.0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was given up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. There is no third-party notarization.&lt;/strong&gt;&lt;br&gt;
basou detects tampering in its trail, but with no server and no notary, what that yields is &lt;strong&gt;a check that the files on hand are internally consistent with each other&lt;/strong&gt;. The specification is honest about the threat model: someone who rewrites the related files together, consistently, is &lt;strong&gt;not detected&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. There is no centralized freshness.&lt;/strong&gt;&lt;br&gt;
As above. If work was missed on another host, you cannot tell from here. Running the import on each host is the only way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It does not detect a mirror that has gone away.&lt;/strong&gt;&lt;br&gt;
This one I found by trying it. If the SSHFS mount drops, or the path has a typo, &lt;code&gt;orient&lt;/code&gt; does not say "devbox is missing." &lt;strong&gt;It quietly returns the local-only result.&lt;/strong&gt; The only hint is the &lt;code&gt;&amp;gt; hosts:&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Without defending it: this is the underside of a best-effort, non-fatal design. &lt;code&gt;orient&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. There is no authenticated shared UI.&lt;/strong&gt;&lt;br&gt;
The viewer is localhost-only and unauthenticated. Sending a colleague a URL to look at is not a usage this supports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. There is no aggregation across a team or an organization.&lt;/strong&gt;&lt;br&gt;
There is a mode that shows several workspaces side by side, but it is &lt;strong&gt;for a single owner to survey their own work&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  In closing
&lt;/h2&gt;

&lt;p&gt;Deciding to own no network did not set a ceiling on features. &lt;strong&gt;It decided where the boundaries go.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One case is at the center. Against a requirement to span machines, no sync server was written: &lt;strong&gt;the transport went to a route the user already had, and only the merge was kept.&lt;/strong&gt; The other two are not solutions. Remote approval settled the shape and &lt;strong&gt;deferred&lt;/strong&gt; the rest; the local UI &lt;strong&gt;accepted a loopback listener with conditions&lt;/strong&gt; and wrote those conditions down.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to try it, &lt;a href="https://basou.dev" rel="noopener noreferrer"&gt;basou.dev&lt;/a&gt; and the &lt;a href="https://basou.dev/quickstart/" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; are the places to start.&lt;/p&gt;

</description>
      <category>basou</category>
      <category>security</category>
      <category>privacy</category>
      <category>ai</category>
    </item>
    <item>
      <title>Enforce, Don't Issue: Remote MCP with OAuth Without Becoming an Authorization Server</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Wed, 19 Aug 2026 10:56:02 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/enforce-dont-issue-remote-mcp-with-oauth-without-becoming-an-authorization-server-31gh</link>
      <guid>https://dev.to/takashimatsuyama/enforce-dont-issue-remote-mcp-with-oauth-without-becoming-an-authorization-server-31gh</guid>
      <description>&lt;p&gt;&lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;Kozou&lt;/a&gt; is an open-source tool that exposes structured context from your PostgreSQL database to AI agents — over MCP and a REST API. This post is the record of a design decision: what happened when that MCP surface moved to a remote URL and needed OAuth. The capability shipped in v1.13.0, but the release is not the subject — &lt;strong&gt;why Kozou never grew its own authorization server&lt;/strong&gt; is. Everything below is as of &lt;strong&gt;v1.19.0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The moment you put an MCP server on a remote URL, authentication stops being a footnote and becomes the whole question. Kozou's own "before" state was honest about it: with no auth block configured, a remote call had exactly one shape available — every caller shared a single fixed &lt;code&gt;execution.role&lt;/code&gt;. That's fine for a local, single-user setup and useless for anything multi-tenant, because there's no such thing as &lt;em&gt;who&lt;/em&gt; is calling. Kozou v1.13.0 is the release where per-caller identity becomes possible at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What landed was a choice of mode
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/kozou-dev/kozou/releases/tag/v1.13.0" rel="noopener noreferrer"&gt;Kozou v1.13.0&lt;/a&gt; (released 2026-07-13) brought &lt;strong&gt;remote MCP with OAuth (resource-server mode)&lt;/strong&gt;. The MCP transport can now authenticate callers with OAuth, so &lt;strong&gt;the execution tool that touches data (&lt;code&gt;call&lt;/code&gt;) runs as the verified token's role&lt;/strong&gt; rather than one shared role. The tools that describe the schema are gated on the scopes the token carries, and read a shared schema context.&lt;/p&gt;

&lt;p&gt;The mode matters, and it is the whole point of this post. Kozou did &lt;strong&gt;not&lt;/strong&gt; grow its own authorization server. It became an OAuth &lt;em&gt;resource server&lt;/em&gt;: it validates the tokens someone else issued and enforces what they're allowed to do. The setup guide lives at &lt;a href="https://kozou.org/guides/mcp-oauth/" rel="noopener noreferrer"&gt;kozou.org/guides/mcp-oauth/&lt;/a&gt;, and I'm deliberately not going to reproduce it here — this post is about &lt;em&gt;why&lt;/em&gt; the design looks the way it does, not how to configure it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The posture came first
&lt;/h2&gt;

&lt;p&gt;The tempting story is "the MCP spec says split the roles, so Kozou split the roles." That's backwards. The posture was published first.&lt;/p&gt;

&lt;p&gt;On 2026-06-08 — more than a month before v1.13.0 — Kozou's &lt;a href="https://kozou.org/concepts/auth-posture/" rel="noopener noreferrer"&gt;auth posture&lt;/a&gt; was already public and settled: &lt;strong&gt;Kozou is an enforcement layer; it does not issue identity.&lt;/strong&gt; That wasn't an aspiration. &lt;strong&gt;A week earlier the REST surface had already shipped it&lt;/strong&gt;: JWT auth and Postgres RLS enforcement landed in &lt;a href="https://github.com/kozou-dev/kozou/pull/54" rel="noopener noreferrer"&gt;#54&lt;/a&gt; on 2026-06-01 and went out in &lt;strong&gt;v0.2.0&lt;/strong&gt; — validate a JWT against a JWKS endpoint, &lt;code&gt;SET LOCAL ROLE&lt;/code&gt; to the identity that token carries, and let Postgres row-level security do the actual enforcement.&lt;/p&gt;

&lt;p&gt;So when remote MCP needed authentication, there was no design decision to agonize over. The resource-server shape wasn't chosen to conform to a spec — the shape was &lt;strong&gt;already there&lt;/strong&gt;, and the &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" rel="noopener noreferrer"&gt;MCP authorization spec, revision 2025-11-25&lt;/a&gt; happens to describe exactly that split: a resource server that enforces, an authorization server that issues, and a clean boundary between them. v1.13.0 is the JWT → &lt;code&gt;SET LOCAL ROLE&lt;/code&gt; → RLS pipeline, already load-bearing on REST, extended onto the MCP transport. Same posture, one more surface.&lt;/p&gt;

&lt;p&gt;The order is the argument. The implementation on 2026-06-01, the posture written down on 06-08, the extension onto MCP on 07-13 — &lt;strong&gt;code first, words second, the new surface last.&lt;/strong&gt; "Not chosen to conform to a spec" means exactly that sequence: something already running turned out to match what a later spec described.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;em&gt;not&lt;/em&gt; running an authorization server buys you
&lt;/h2&gt;

&lt;p&gt;Declining to be an authorization server is not a gap in the feature set. It's what makes the rest coherent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kozou holds no credentials.&lt;/strong&gt; There are no user accounts and no identity credentials to store — so there's no token store to breach. It never sees a password. What it checks is a signature and the conditions that come with it — algorithm, expiry, issuer, audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't have to change identity providers.&lt;/strong&gt; Because Kozou validates whatever your authorization server issues, enterprise SSO isn't a special integration — it's the same JWKS validation, pointed at your identity provider. That is not the same as &lt;em&gt;nothing to do&lt;/em&gt;: your IdP has to be configured to put the right audience, the &lt;code&gt;mcp:*&lt;/code&gt; scopes, and a role claim into the token. Getting that wrong is the first thing people trip on — it is the 403 below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For anything that touches data, the final authorization decision is path-independent.&lt;/strong&gt; The last word belongs to Postgres RLS, so a query gets the same policy whether it arrived over REST or over MCP's &lt;code&gt;call&lt;/code&gt;. The transport changes; the rules don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the quiet payoff. When identity is enforced at the database rather than re-implemented per transport, adding a surface doesn't mean re-deriving your access model — it inherits it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP surface is deliberately stricter
&lt;/h2&gt;

&lt;p&gt;Here's the part that's easy to miss: the MCP surface is not a copy of the REST surface with a token check bolted on. It is deliberately narrower about what it will accept.&lt;/p&gt;

&lt;p&gt;The reason is not "the caller is remote and probably not a human." Put it there and the argument collapses — the person driving claude.ai is a human, and not one of these rules relaxes for them. Two other things drive it, and both have the same shape: &lt;strong&gt;one more thing you do not control.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;you hand your tokens and your advertised metadata to a client you don't run.&lt;/strong&gt; A remote MCP caller is a hosted client — claude.ai, ChatGPT, Claude Code — the protected-resource metadata Kozou publishes travels to it, and bearer tokens travel to the URLs that metadata names.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;you are not the one granting roles.&lt;/strong&gt; Identity arrives from a federated directory: put Google Workspace behind Keycloak or Auth0, and every first-time user is a principal who authenticated fine and whom nobody assigned a role to.&lt;/p&gt;

&lt;p&gt;On REST, Kozou will honor an anonymous role and a default role if you configure them. On MCP it deliberately won't — this is where the second reason bites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No anonymous access, no default role.&lt;/strong&gt; A default role here would silently grant authority to a principal your IdP admin never assigned one to — in a federated directory, that is every first-time user. With no role claim there is no clear authority to execute under, so the server fails closed rather than guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enabling &lt;code&gt;execute&lt;/code&gt; requires a non-empty &lt;code&gt;allowedRoles&lt;/code&gt;.&lt;/strong&gt; You have to declare which roles a tool applies to; arbitrary role execution isn't granted by omission. A missing config can't quietly become a privilege escalation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The remaining rules have no counterpart on REST at all. They come from the first reason — the tokens and metadata you hand to somebody else's client:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The URLs it advertises can't be plaintext &lt;code&gt;http&lt;/code&gt; outside loopback.&lt;/strong&gt; &lt;code&gt;auth.resource&lt;/code&gt; and &lt;code&gt;auth.authorizationServers&lt;/code&gt; are handed to third-party clients in the protected-resource metadata, and bearer tokens travel to them, so a non-loopback plaintext &lt;code&gt;http&lt;/code&gt; URL isn't a warning — it's a startup error. (There's an explicit opt-in for an isolated test network, &lt;code&gt;allowInsecureHttp&lt;/code&gt;; using it logs a startup warning.) &lt;strong&gt;What's checked is the advertised value, not the listener's TLS&lt;/strong&gt; — binding Kozou to loopback and terminating https at a reverse proxy or tunnel in front of it is the normal shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The resource URI is never derived from the &lt;code&gt;Host&lt;/code&gt; header.&lt;/strong&gt; Headers can be spoofed, so Kozou won't trust the &lt;code&gt;Host&lt;/code&gt; header for the kind of decision a DNS-rebinding attack would try to bend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mcp:admin&lt;/code&gt; is a default scope, and is never advertised.&lt;/strong&gt; Some clients echo the advertised list straight into their own registration request, so listing it would invite them to ask for an admin grant they never need. It stays out of &lt;code&gt;scopes_supported&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The strictness isn't only about refusing things, though. When a token is missing a scope, Kozou (as a resource server) answers with an &lt;code&gt;insufficient_scope&lt;/code&gt; challenge that names what's missing — a &lt;code&gt;scope&lt;/code&gt; and a &lt;code&gt;resource_metadata&lt;/code&gt; pointer — so a client capable of scope upgrade can then go re-authorize. The scopes it advertises are &lt;code&gt;mcp:describe&lt;/code&gt; and &lt;code&gt;mcp:execute&lt;/code&gt;. In practice this challenge shows up most often when &lt;strong&gt;a token carrying no recognized scope at all is refused at the door&lt;/strong&gt; — usually a setup mistake: the IdP's mapper isn't emitting the scope claim, the audience is wrong, or the token was minted for a different client. With &lt;code&gt;auth.resource&lt;/code&gt; set to &lt;code&gt;https://mcp.example.com/mcp&lt;/code&gt;, that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt; &lt;span class="ne"&gt;Forbidden&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer error="insufficient_scope", scope="mcp:describe", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"This operation requires the &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;mcp:describe&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; scope."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same challenge exists per tool, but it is far rarer. &lt;code&gt;tools/list&lt;/code&gt; is filtered by scope — a tool whose scope the token lacks is never listed — so a per-tool 403 only happens if a client calls a name it was never shown, or cached &lt;code&gt;tools/list&lt;/code&gt; and then had its scopes narrowed.&lt;/p&gt;

&lt;p&gt;And the matching case with no credentials at all — per RFC 6750, a 401 with no &lt;code&gt;error&lt;/code&gt; attribute, carrying a pointer to where the rules are advertised:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="ne"&gt;Unauthorized&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Missing or malformed Authorization header."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both answers carry the same shape of JSON body. But when it is the token itself that failed, &lt;strong&gt;the body never says which check it failed&lt;/strong&gt; — signature, expiry, audience, issuer all collapse into one generic message. A missing scope, which the client can resolve by re-authorizing, is named; a failed verification is not. Only what the client can act on goes in the &lt;code&gt;WWW-Authenticate&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;That's the shape worth internalizing: refusal and &lt;em&gt;discoverability&lt;/em&gt; are two halves of the same behavior. A strict server that only ever said "no" would be hostile; one that says "no, and here's precisely the scope you'd need and where to find the metadata" is strict &lt;em&gt;and&lt;/em&gt; usable. On the discovery side, Kozou implements both the &lt;code&gt;WWW-Authenticate&lt;/code&gt; and the well-known metadata discovery mechanisms, so a client can find the rules either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came after — what used to be implicit became the operator's to declare
&lt;/h2&gt;

&lt;p&gt;One of the things resource-server mode settled is that &lt;strong&gt;the resource URI is declared in configuration, never derived&lt;/strong&gt; — because, as above, the &lt;code&gt;Host&lt;/code&gt; header can't be trusted. That refusal to leave things implicit later spread to the deployments that use no OAuth at all.&lt;/p&gt;

&lt;p&gt;It started with a mundane confusion. The port you bind is where &lt;em&gt;you&lt;/em&gt; are listening. It is not where the client arrives. Those two agree only while nothing sits between them — put a proxy in front, run it through a tunnel, remap the published port, move it inside a devcontainer, and the agreement breaks. Kozou's connection page — the screen that hands a non-engineer a working config — was building the second address out of the first, with no way to correct it once they came apart. It would hand out, confidently, an address that was not the endpoint.&lt;/p&gt;

&lt;p&gt;The OAuth side already had the answer. &lt;code&gt;auth.resource&lt;/code&gt; is a declared value, never derived from a &lt;code&gt;Host&lt;/code&gt; header. v1.18.0's &lt;code&gt;server.mcp.http.advertisedUrl&lt;/code&gt; extends that to the deployments that don't configure &lt;code&gt;auth&lt;/code&gt; (writing both is a config error). A line drawn against spoofing turned out to be right where nobody was attacking anything — where there was simply one proxy in the way. An unplanned dividend on a rule drawn for strictness.&lt;/p&gt;

&lt;p&gt;There is a second one that looks like the same story. v1.17.0's &lt;code&gt;server.mcp.http.enabled&lt;/code&gt; (default true) lets an operator declare that the MCP HTTP endpoint &lt;strong&gt;should not run&lt;/strong&gt;. Until then the only lever was the bind address: the endpoint stayed up and its posture depended entirely on network topology. If you are never going to point an agent at it, the choice should be not to run it — not to look for somewhere to hide it.&lt;/p&gt;

&lt;p&gt;That one, though, did not descend from OAuth. Its motivation at the time was that the posture had become a byproduct of network topology, which has nothing to do with a spoofable header. &lt;strong&gt;Not a descendant of the same rule — a different rule standing next to it.&lt;/strong&gt; Put side by side they do face the same way, turning something implicit into something declared. I'll leave it at that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs, honestly
&lt;/h2&gt;

&lt;p&gt;None of this is free, and a post that pretended otherwise wouldn't be worth reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need an external authorization server.&lt;/strong&gt; Resource-server mode means bringing your own issuer. The guide walks through two concrete recipes — &lt;a href="https://kozou.org/guides/mcp-oauth-keycloak/" rel="noopener noreferrer"&gt;Keycloak&lt;/a&gt; and &lt;a href="https://kozou.org/guides/mcp-oauth-auth0/" rel="noopener noreferrer"&gt;Auth0&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audience handling differs by IdP.&lt;/strong&gt; Auth0 supports resource indicators (RFC 8707) natively; Keycloak needs a mapper to get the audience into the token. Same destination, different setup step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted authorization servers vary in their dynamic-registration behavior.&lt;/strong&gt; This is squarely a client-to-AS concern rather than something the resource server decides, so I'll leave the specifics to the recipes above rather than half-explain them here.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The minimal setup, the per-IdP recipes, and the troubleshooting are all in one place: &lt;a href="https://kozou.org/guides/mcp-oauth/" rel="noopener noreferrer"&gt;kozou.org/guides/mcp-oauth/&lt;/a&gt;. There's no point in my retyping the steps — the guide is the source of truth for &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  In summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kozou does remote MCP with OAuth as a &lt;strong&gt;resource server, not an authorization server&lt;/strong&gt; — it validates tokens and enforces access, it doesn't issue identity.&lt;/li&gt;
&lt;li&gt;That wasn't spec-chasing. The posture ("enforce, don't issue") was public and load-bearing on the REST surface first; the MCP authorization spec's resource-server / authorization-server split simply matched a shape that already existed.&lt;/li&gt;
&lt;li&gt;Because the model for touching data is consistent end to end — JWT → &lt;code&gt;SET LOCAL ROLE&lt;/code&gt; → Postgres RLS — the MCP surface can afford to be &lt;strong&gt;deliberately stricter&lt;/strong&gt; than the REST one: no anonymous access, no default role, fail-closed by default, and refusals that tell the client exactly what scope is missing.&lt;/li&gt;
&lt;li&gt;And "declare it, don't derive it" outgrew resource-server mode: &lt;code&gt;advertisedUrl&lt;/code&gt; (v1.18.0, for the deployments that don't set &lt;code&gt;auth&lt;/code&gt; — the config refuses the two together) handed the other postures an answer the OAuth side already had. A line drawn for strictness turned out to be the correct answer elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It was written with the help of &lt;a href="https://basou.dev" rel="noopener noreferrer"&gt;basou&lt;/a&gt;, a harness I'm building for steering AI coding agents.&lt;/p&gt;

</description>
      <category>kozou</category>
      <category>mcp</category>
      <category>oauth</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Hide It from People, Tell It to the Agent — What May This Role Touch?</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Fri, 07 Aug 2026 07:40:20 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/hide-it-from-people-tell-it-to-the-agent-what-may-this-role-touch-4el8</link>
      <guid>https://dev.to/takashimatsuyama/hide-it-from-people-tell-it-to-the-agent-what-may-this-role-touch-4el8</guid>
      <description>&lt;p&gt;Point an AI agent at a database through a least-privilege role and here is what happens. The agent writes a confident query against a table it cannot read. What comes back is &lt;code&gt;permission denied&lt;/code&gt;. Or — if all it ever got was a catalog already filtered by privilege — it reads the absence as nonexistence, substitutes a similarly named column, and hands you a number with complete confidence.&lt;/p&gt;

&lt;p&gt;The privileges are working. What isn't working is the &lt;strong&gt;telling&lt;/strong&gt;. PostgreSQL is stopping it with &lt;code&gt;GRANT&lt;/code&gt; and row-level security (RLS). The only party never told why, or how far, is the one being limited.&lt;/p&gt;

&lt;p&gt;So: how should an agent be told what a role may touch? And — does telling it weaken the privileges?&lt;/p&gt;

&lt;p&gt;This post is the record of how &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;Kozou&lt;/a&gt; — a compiler that turns a PostgreSQL schema into AI context (handed to the agent over &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;, the standard protocol for connecting AI agents to external tools), an Admin UI, and a REST API — answered that. The short version: don't hide it from the agent, tell it. &lt;strong&gt;Hide it in the human Admin UI, annotate it in the AI's context&lt;/strong&gt;: the same information, pointed in opposite directions.&lt;/p&gt;

&lt;p&gt;Everything below is &lt;strong&gt;as of v1.17.0&lt;/strong&gt;, using the demo schema bundled with Kozou's &lt;a href="https://github.com/kozou-dev/kozou/tree/main/examples/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; — a small online store (&lt;code&gt;customers&lt;/code&gt; / &lt;code&gt;products&lt;/code&gt; / &lt;code&gt;orders&lt;/code&gt; / &lt;code&gt;order_items&lt;/code&gt; plus three reporting views). The &lt;code&gt;support_agent&lt;/code&gt; role, its grants, and the RLS are the overlay I added for this post. Version-wise: the privilege annotations require v1.8.0 or later and the RLS signal v1.11.0 or later, so an earlier version omits whichever of the two it predates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line already drawn
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://blog.tak3.jp/en/blog/introducing-kozou/" rel="noopener noreferrer"&gt;Kozou introduction&lt;/a&gt; drew a line: Kozou hands meaning over, it doesn't enforce it — the actual access control stays on the PostgreSQL side, in privileges and RLS. The &lt;a href="https://blog.tak3.jp/en/blog/writing-db-meaning-for-ai/" rel="noopener noreferrer"&gt;follow-up on writing schema meaning&lt;/a&gt; put it from the author's side: if you truly must hide something, stop it with a privilege, not a comment.&lt;/p&gt;

&lt;p&gt;This post is about the &lt;strong&gt;outside&lt;/strong&gt; of that line. You stopped it with a privilege. How does the agent find out?&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1 — hide, or annotate?
&lt;/h2&gt;

&lt;p&gt;Kozou's privilege support is opt-in. Turn it on with a role and two surfaces start reflecting what that role can do — and they do it in opposite directions. (The REST API and its OpenAPI document are not among them; they stay schema-wide. The API enforces each request with the caller's role and RLS, so it has no need for an advisory annotation.)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Admin UI (for people)&lt;/strong&gt;: a table the role cannot &lt;code&gt;SELECT&lt;/code&gt; &lt;strong&gt;disappears from the navigation&lt;/strong&gt;. A column it cannot write doesn't disappear from the form, but it renders read-only per form mode — no &lt;code&gt;INSERT&lt;/code&gt; privilege makes it read-only on create, no &lt;code&gt;UPDATE&lt;/code&gt; privilege makes it read-only on edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The MCP &lt;code&gt;describe_table&lt;/code&gt; / &lt;code&gt;describe_view&lt;/code&gt; tools and &lt;code&gt;kozou docs&lt;/code&gt; (for the agent)&lt;/strong&gt;: &lt;strong&gt;nothing is hidden&lt;/strong&gt;. Every relation stays, and each one is &lt;strong&gt;annotated&lt;/strong&gt; with what this role may and may not do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's easier to just look at it. Here is &lt;code&gt;describe_table("public.customers")&lt;/code&gt; evaluated for &lt;code&gt;support_agent&lt;/code&gt; — a support-desk role that can read orders but was never granted &lt;code&gt;SELECT&lt;/code&gt; on the customer table (it holds personal data):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"qualifiedName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public.customers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"privileges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support_agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"select"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"insert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"update"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"delete"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"columns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"insertable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"updatable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"full_name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"insertable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"updatable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"insertable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"updatable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="c1"&gt;// …&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;select&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; — this role has no privilege to read this table. What you see here is the &lt;code&gt;GRANT&lt;/code&gt; situation only, evaluated independently of the RLS we'll get to later (Kozou asks &lt;code&gt;has_table_privilege&lt;/code&gt;). &lt;strong&gt;And the table still comes back whole.&lt;/strong&gt; Columns, the &lt;code&gt;@ai&lt;/code&gt; notes and the &lt;code&gt;@policy&lt;/code&gt; business rules written into the table's &lt;code&gt;COMMENT ON&lt;/code&gt; — all of it, still attached. &lt;code&gt;kozou docs&lt;/code&gt;, the Markdown schema document, does the same: the section for that table doesn't vanish, its Security row just goes all &lt;code&gt;no&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**Security**&lt;/span&gt; — effective privileges for role &lt;span class="sb"&gt;`support_agent`&lt;/span&gt; (advisory; PostgreSQL enforces access):

| SELECT | INSERT | UPDATE | DELETE |
| --- | --- | --- | --- |
| no | no | no | no |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now open the Admin UI with that same configuration. The header reads &lt;strong&gt;3 tables / 0 views&lt;/strong&gt;. &lt;code&gt;customers&lt;/code&gt; is gone from the list, and so are the three views the role was never granted &lt;code&gt;SELECT&lt;/code&gt; on. At the same moment, MCP and &lt;code&gt;kozou docs&lt;/code&gt; return &lt;strong&gt;all seven objects — four tables and three views&lt;/strong&gt; — each annotated with what the role can and cannot do.&lt;/p&gt;

&lt;p&gt;Same privilege information, same configuration, opposite output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why point them in opposite directions
&lt;/h3&gt;

&lt;p&gt;People don't look for what isn't there. A button you can't see doesn't get pressed; a menu item that's missing doesn't get demanded. For a human UI, not showing what can't be done is the kinder choice: it keeps controls that cannot succeed out of the way.&lt;/p&gt;

&lt;p&gt;An agent, I assumed, is the opposite: it &lt;strong&gt;fills in absences by guessing&lt;/strong&gt;. If the table isn't visible, it concludes no such table exists and reaches for a similarly named column instead. That is the same failure shape as the one in the &lt;a href="https://blog.tak3.jp/en/blog/writing-db-meaning-for-ai/" rel="noopener noreferrer"&gt;previous post&lt;/a&gt; — an AI that sees only raw DDL, doesn't know which column is a trap, and is plausibly wrong — except now it happens on the &lt;strong&gt;privilege&lt;/strong&gt; side. To be clear, this is not a measured result; it's the direction the design bet on.&lt;/p&gt;

&lt;p&gt;So the agent is better told: it's here, and you cannot read it. &lt;code&gt;"select": false&lt;/code&gt; isn't a refusal, it's &lt;strong&gt;information&lt;/strong&gt;. The agent learns its limits before it tries.&lt;/p&gt;

&lt;p&gt;The split is in the code's own vocabulary. In the comment beside Kozou's config schema, the Admin UI "hides tables the role cannot SELECT", while MCP and docs "&lt;strong&gt;do NOT hide&lt;/strong&gt; — they keep every relation and &lt;em&gt;annotate&lt;/em&gt; it". And the docs generator is handed a &lt;code&gt;privilegeDisplay: 'annotate'&lt;/code&gt; when privilege mode is on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The option not taken&lt;/strong&gt;: hide on the AI side too and ship a narrowed schema. It looks like giving away less, but the agent learns the object doesn't exist and starts guessing to fill the hole where the overall picture used to be. Less information out, more mistakes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2 — read only the booleans
&lt;/h2&gt;

&lt;p&gt;Beyond table-level and column-level &lt;code&gt;GRANT&lt;/code&gt;s there's row-level security. What the agent gets about RLS is &lt;strong&gt;three booleans and one line of advice&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;orders&lt;/code&gt; — RLS on, one policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"rowSecurity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"forced"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hasPolicies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Row-level security is enabled: the rows you can read and the rows you can write are filtered by policy for the connecting role, so do not assume a result is complete or that a write will be accepted."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;customers&lt;/code&gt; — RLS on, and &lt;strong&gt;not a single policy defined&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"rowSecurity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"forced"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hasPolicies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Row-level security is enabled but no policy is defined, so non-owner roles can read and write no rows (default-deny). RLS also applies to the table owner (roles with BYPASSRLS still bypass it)."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That quirk of PostgreSQL — where &lt;em&gt;writing no policy&lt;/em&gt; is the strictest setting you can pick, because RLS with no policy is default-deny — travels intact to the agent. &lt;code&gt;forced&lt;/code&gt; means RLS applies to the owner too, and that gets a line as well.&lt;/p&gt;

&lt;p&gt;What's &lt;strong&gt;absent&lt;/strong&gt; here matters. The &lt;code&gt;USING&lt;/code&gt; and &lt;code&gt;WITH CHECK&lt;/code&gt; expressions — the policy bodies — are &lt;strong&gt;never handed over&lt;/strong&gt;. Only the booleans are read; the expressions aren't even fetched.&lt;/p&gt;

&lt;p&gt;Three reasons.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It would put authorization logic in two places.&lt;/strong&gt; A copy of the rules living in the context will go stale. That's the same problem as written meaning having a shelf life, from the previous post, now applied to authorization — and a stale explanation of &lt;em&gt;who may see what&lt;/em&gt; is worse than a stale column comment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knowing doesn't help you get around it.&lt;/strong&gt; Reading the expression gives the agent no way past RLS; the database enforces it regardless. There's little to gain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The expressions themselves can be sensitive.&lt;/strong&gt; How you distinguish between users is often exactly what you don't want disclosed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What that costs&lt;/strong&gt; is clear too. The agent can't explain &lt;em&gt;why&lt;/em&gt; it was refused. A rejected write at least surfaces as an error; a &lt;code&gt;SELECT&lt;/code&gt; is quieter — RLS drops the non-matching rows silently and returns a perfectly normal result. So what gets handed over instead is the warning that a result may not be complete. Explaining the reason is not a job this takes on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The option not taken&lt;/strong&gt;: summarize the policy expressions and pass the summary. The moment that summary goes stale, the agent starts lying with confidence — "you should be able to see this row."&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3 — what gets to be opt-in
&lt;/h2&gt;

&lt;p&gt;Two kinds of information have shown up: the role's privileges, and the RLS signal. &lt;strong&gt;Their defaults are opposites.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;describe_table&lt;/code&gt; twice against the same database, changing only the configuration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;&lt;code&gt;respectPrivileges: true&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;privileges&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;per-column &lt;code&gt;insertable&lt;/code&gt; / &lt;code&gt;updatable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rowSecurity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;present&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule fits in one line: &lt;strong&gt;role-dependent facts are opt-in; structural facts are included by default.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;privileges&lt;/code&gt; is a lie unless you've settled &lt;strong&gt;whose&lt;/strong&gt; privileges these are — which is why the output says whose: &lt;code&gt;"role": "support_agent"&lt;/code&gt;. Hand out a privilege picture without deciding the role and you've published misinformation, not information. Whether RLS is enabled, forced, or policy-less is a structural property of the table and doesn't depend on any role, so it can go out unasked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The options not taken&lt;/strong&gt;: enable both by default (with no role configured, you'd be handing out a privilege picture belonging to nobody), or make both opt-in (an agent walks into a default-deny table, gets an empty result it can't account for, and reasons from it).&lt;/p&gt;

&lt;p&gt;One detail worth noting: evaluating the privileges &lt;strong&gt;doesn't require connecting as that role&lt;/strong&gt;. It's &lt;code&gt;has_table_privilege&lt;/code&gt; / &lt;code&gt;has_column_privilege&lt;/code&gt;, so nothing borrows the role's authority just to describe it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 4 — don't let describe and act disagree
&lt;/h2&gt;

&lt;p&gt;So far, everything has been about description. Kozou can also &lt;strong&gt;execute&lt;/strong&gt; exposed functions over MCP (opt-in as well), and once execution is in play, a description is only accurate if it matches the role that acts.&lt;/p&gt;

&lt;p&gt;Turn execution on and &lt;strong&gt;the annotated role is bound to the executing role&lt;/strong&gt;. The agent cannot pick a role — self-elevation isn't forbidden so much as structurally unavailable.&lt;/p&gt;

&lt;p&gt;Running the remote MCP endpoint as an OAuth resource server changes the shape: execution happens as each verified token's PostgreSQL role, and every assumable role must appear in an explicit allowlist. But &lt;strong&gt;the privilege annotation can only be combined with that when the allowlist contains exactly one role&lt;/strong&gt; — any other combination refuses to start. If the acting role varies per caller while the annotation claims a single role, the annotation is a lie. Per-caller annotation doesn't exist yet.&lt;/p&gt;

&lt;p&gt;The reason is simple: if the role whose privileges were described isn't the role that acts, the agent is working from an accurate description of the wrong role. A description is only as true as its agreement with execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The option not taken&lt;/strong&gt;: let the annotated role and the execution role be configured separately. More flexible — and it would let you run in a state where the two disagree. So it isn't configurable; the disagreeing combinations fail at startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary — this is not permission
&lt;/h2&gt;

&lt;p&gt;Finally, what this deliberately doesn't do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An annotation is not a permission.&lt;/strong&gt; &lt;code&gt;"select": true&lt;/code&gt; is advice that reading should work, not a grant. Granting is what &lt;code&gt;GRANT&lt;/code&gt; and RLS do, and there is nothing Kozou can add to that (on the execution side it can &lt;strong&gt;narrow&lt;/strong&gt; things — the functions it exposes go through an allowlist). The same idea shows up in how functions are published: whether an agent may run a function exposed with &lt;code&gt;@expose: rpc&lt;/code&gt; is decided by the &lt;code&gt;EXECUTE&lt;/code&gt; privilege. &lt;strong&gt;Exposure is not permission.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Executing as a single role is not multi-tenant per-user authorization.&lt;/strong&gt; There's no per-caller identity in it. That's the job of the REST surface, or of the OAuth path where the role comes from the token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is a cost, though.&lt;/strong&gt; Not hiding means the context ends up carrying the names of tables the role cannot read, their columns, and the business notes written on them. Not one bit of data access changes, but &lt;strong&gt;the disclosure surface of the schema as metadata grows&lt;/strong&gt;. If the audience for that surface is wider than the database role — say you expose the MCP endpoint beyond your machine — that needs designing separately.&lt;/p&gt;

&lt;p&gt;With that said, back to the opening question. &lt;strong&gt;Telling doesn't weaken the privileges.&lt;/strong&gt; Access to data stays exactly where PostgreSQL put it. The riskier party, I'd argue, is the agent that doesn't know its limits: it writes speculative workarounds, reads an empty result as "there is no data," and carries that into its conclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;To see this on your own schema, there are three steps.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create one least-privilege role.&lt;/strong&gt; The trick is to &lt;strong&gt;deliberately leave one table without &lt;code&gt;SELECT&lt;/code&gt;&lt;/strong&gt; — that's where the interesting part of this design becomes visible.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;support_agent&lt;/span&gt; &lt;span class="n"&gt;NOLOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;support_agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;support_agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- customers withheld&lt;/span&gt;
   &lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;support_agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                          &lt;span class="c1"&gt;-- the INSERT grant only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add two lines of configuration.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;   &lt;span class="na"&gt;introspection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
     &lt;span class="na"&gt;respectPrivileges&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
     &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;support_agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Call &lt;code&gt;describe_table&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;kozou docs&lt;/code&gt; grows a Security section too — though the per-column &lt;code&gt;insertable&lt;/code&gt; / &lt;code&gt;updatable&lt;/code&gt; only exist in the MCP payload (docs stops at the four verbs per table). Views carry relation-level privileges only: PostgreSQL itself can grant on a view's columns, but Kozou only collects column-level privileges from tables.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With the grants above, &lt;code&gt;orders&lt;/code&gt; comes back like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"privileges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support_agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"select"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"insert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"update"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"delete"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"columns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"insertable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"updatable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"insertable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"updatable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="c1"&gt;// …&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then, writing this example, my own post tripped me up. This demo's &lt;code&gt;orders&lt;/code&gt; has RLS enabled, and the only policy I wrote is for &lt;code&gt;SELECT&lt;/code&gt;. In PostgreSQL, inserting into a table with RLS enabled requires an INSERT policy. So actually trying it gives you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  new row violates row-level security policy for table "orders"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;GRANT INSERT&lt;/code&gt; is there. The payload says &lt;code&gt;"insert": true&lt;/code&gt;. PostgreSQL refuses anyway. &lt;strong&gt;The annotation is saying "the privilege exists," not "this will go through."&lt;/strong&gt; That is the most concrete possible form of what this whole post has been about — and it's why the same payload carries &lt;code&gt;rowSecurity&lt;/code&gt; right beside it, warning that a write may be rejected. Neither half alone is enough to hand to an agent.&lt;/p&gt;

&lt;p&gt;Privilege mode announces itself in the log, too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[kozou mcp] privilege-aware context ON: describe tools annotate what role "support_agent" may touch (advisory; enforcement stays in PostgreSQL)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;advisory; enforcement stays in PostgreSQL&lt;/em&gt; — this post is, in the end, about what that one line means as a design.&lt;/p&gt;

&lt;p&gt;Kozou lives at &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;kozou.org&lt;/a&gt; and on &lt;a href="https://github.com/kozou-dev/kozou" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; (Apache-2.0). The demo schema above ships in the &lt;a href="https://github.com/kozou-dev/kozou/tree/main/examples/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The same privilege information is &lt;strong&gt;hidden from people and annotated for the AI&lt;/strong&gt; — on the bet that an agent kept in the dark fills the absence by guessing.&lt;/li&gt;
&lt;li&gt;RLS travels as &lt;strong&gt;booleans only&lt;/strong&gt;. The policy expressions stay unread, so authorization logic never leaves the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role-dependent facts are opt-in; structural facts are included by default.&lt;/strong&gt; And the role you describe must be the role that acts.&lt;/li&gt;
&lt;li&gt;An annotation never promises the operation will go through. &lt;code&gt;insert: true&lt;/code&gt; and an RLS refusal coexist happily.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Enforcement was PostgreSQL's all along. What changes by telling an agent its limits is whether it can do useful work inside them.&lt;/p&gt;

&lt;p&gt;The Japanese version of this post — its "paired" article — is already live.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>mcp</category>
      <category>kozou</category>
      <category>security</category>
    </item>
    <item>
      <title>Write Database Meaning Your AI Agent Can Actually Use — a Practical Guide to COMMENT ON</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:45:54 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/write-database-meaning-your-ai-agent-can-actually-use-a-practical-guide-to-comment-on-2cn6</link>
      <guid>https://dev.to/takashimatsuyama/write-database-meaning-your-ai-agent-can-actually-use-a-practical-guide-to-comment-on-2cn6</guid>
      <description>&lt;p&gt;In the &lt;a href="https://blog.tak3.jp/en/blog/introducing-kozou/" rel="noopener noreferrer"&gt;Kozou introduction&lt;/a&gt; — Kozou being an open-source tool that hands your PostgreSQL database's meaning to an AI agent over MCP — I made a claim: the place to write that meaning already exists in PostgreSQL, in &lt;code&gt;COMMENT ON&lt;/code&gt; and view definitions, so &lt;strong&gt;write the meaning into the DB first&lt;/strong&gt;. An AI agent that only sees the raw DDL is good at reading column names and types, but it doesn't know which column is a trap or which view is authoritative. So it's plausibly wrong.&lt;/p&gt;

&lt;p&gt;This post is the how. How do you write that meaning so an agent can actually use it? The answer isn't in column names or types; it's in &lt;code&gt;COMMENT ON&lt;/code&gt; and view definitions, written to a small set of conventions. The examples come from the demo schema (a small online store) bundled with &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;Kozou&lt;/a&gt;'s quickstart — the real thing, verbatim. Everything shown here you can do on &lt;strong&gt;plain PostgreSQL, before adopting Kozou at all&lt;/strong&gt;; how the written meaning then reaches the agent is the last section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write meaning at the start of a line
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;COMMENT ON&lt;/code&gt;, you write convention tags at the &lt;strong&gt;start of a line&lt;/strong&gt;. Three carry the meaning: &lt;code&gt;@ai&lt;/code&gt; / &lt;code&gt;@policy&lt;/code&gt; / &lt;code&gt;@example&lt;/code&gt;. There's also &lt;code&gt;@widget&lt;/code&gt; (for the Admin UI) and &lt;code&gt;@expose&lt;/code&gt; (for functions), but neither is about handing meaning to an agent, so this post leaves them aside.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Where it goes&lt;/th&gt;
&lt;th&gt;Covered here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@ai&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Instructions/background for the AI agent (free text)&lt;/td&gt;
&lt;td&gt;table / column / view / foreign key / function&lt;/td&gt;
&lt;td&gt;◎ central&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@policy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A business rule, recorded (advisory — not enforced)&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;◎ central&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@example&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An executable example query attached to a view&lt;/td&gt;
&lt;td&gt;view&lt;/td&gt;
&lt;td&gt;○&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@widget&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Admin UI input control for a column&lt;/td&gt;
&lt;td&gt;column&lt;/td&gt;
&lt;td&gt;mentioned only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@expose&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expose a function as an RPC action&lt;/td&gt;
&lt;td&gt;function&lt;/td&gt;
&lt;td&gt;mentioned only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's a bit of craft to it. Dull, but skip it and the tag won't fire.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write it at the start of a line.&lt;/strong&gt; A known tag (&lt;code&gt;@ai&lt;/code&gt;, &lt;code&gt;@policy&lt;/code&gt;, …) placed mid-line is not parsed as a tag; it stays as part of the surrounding prose (Kozou warns about it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One tag per line for &lt;code&gt;@ai&lt;/code&gt; / &lt;code&gt;@policy&lt;/code&gt;.&lt;/strong&gt; If you have several points, stack several &lt;code&gt;@ai:&lt;/code&gt; lines. The only note allowed to span multiple lines is an &lt;code&gt;@example&lt;/code&gt; block.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more part of the convention, worth knowing on a multilingual team: &lt;strong&gt;the note body can be in any language.&lt;/strong&gt; The tag name (&lt;code&gt;@ai&lt;/code&gt;, …) and the colon after it are ASCII, but the text after the colon is kept as you wrote it — trimmed of surrounding space, but never summarized or paraphrased — so your notes can be in English, Japanese, German, whatever your team reads and the agent understands. One caveat if you write in a language with fullwidth punctuation (Japanese, for instance): the tag's colon must stay ASCII &lt;code&gt;:&lt;/code&gt;, not a fullwidth &lt;code&gt;：&lt;/code&gt;. Get it wrong and the line &lt;strong&gt;silently&lt;/strong&gt; falls back to plain prose — no warning, unlike the mid-line case above.&lt;/p&gt;

&lt;p&gt;Writing also feeds discoverability. Kozou's &lt;code&gt;search_schema&lt;/code&gt; searches not just object names but &lt;code&gt;COMMENT&lt;/code&gt; bodies and &lt;code&gt;@ai&lt;/code&gt; / &lt;code&gt;@policy&lt;/code&gt; notes too. The words you put in a note become the handle an agent uses to find the meaning.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The code fragments below are the real thing from Kozou's quickstart demo schema. Inside a COMMENT string, &lt;code&gt;''&lt;/code&gt; (two single quotes) is an escaped single quote; the &lt;code&gt;@ai:&lt;/code&gt; / &lt;code&gt;@policy:&lt;/code&gt; notes are kept one per line (per the rule), while the &lt;code&gt;CREATE VIEW&lt;/code&gt; SELECT further down is reflowed for readability.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;@ai:&lt;/code&gt; — name the trap, point to the source of truth
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@ai&lt;/code&gt; is free text for the agent. The trick to making it land is to go past "what this column is" and write &lt;strong&gt;what the agent should and shouldn't do&lt;/strong&gt; — usually in the imperative. Three from the demo schema.&lt;/p&gt;

&lt;p&gt;First, the clearest trap: a column you must not use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_total&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'DEPRECATED denormalized order total.
@ai: Do NOT use this for reporting — it is a stale cache the application stopped maintaining, can disagree with the line items, and includes test orders. Compute revenue from vw_recognized_revenue instead.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;amount_total&lt;/code&gt; has a plausible name ("total") and a &lt;code&gt;numeric(12,2)&lt;/code&gt; type. Asked for revenue, an agent naturally reaches for &lt;code&gt;SUM&lt;/code&gt;. But it's a cache the application stopped maintaining, out of step with the line items, with test orders mixed in. The pattern here: &lt;strong&gt;don't stop at "don't use it" — always add the way out, "use &lt;code&gt;vw_recognized_revenue&lt;/code&gt; instead."&lt;/strong&gt; Naming a trap without an exit leaves the agent with nowhere to go next.&lt;/p&gt;

&lt;p&gt;Next, an exclusion that always applies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_test&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'Internal QA / load-test order flag.
@ai: ALWAYS exclude is_test = true from revenue, order counts, and dashboards — these are not real customer orders.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern: &lt;strong&gt;use strong words (&lt;code&gt;ALWAYS&lt;/code&gt; / &lt;code&gt;NEVER&lt;/code&gt;) and spell out the scope.&lt;/strong&gt; Saying "exclude it from revenue, from counts, and from dashboards" keeps the agent from misjudging which contexts the rule covers.&lt;/p&gt;

&lt;p&gt;Third, the nastiest kind — the one that fails silently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;list_price&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'The CURRENT catalog price.
@ai: This price changes over time. NEVER use it to value past orders — each order captured its own price in order_items.unit_price; joining list_price onto historical orders silently misprices revenue.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Value a past order with &lt;code&gt;list_price&lt;/code&gt; (the current catalog price) and the JOIN succeeds, the query throws no error, and only the number is quietly wrong. The price to use is the one captured at order time, &lt;code&gt;order_items.unit_price&lt;/code&gt;. For traps that &lt;strong&gt;produce no error, just a wrong result&lt;/strong&gt;, write down &lt;em&gt;why&lt;/em&gt; it's wrong — otherwise no one has a thread to pull to catch it.&lt;/p&gt;

&lt;p&gt;So how does an &lt;code&gt;@ai&lt;/code&gt; you wrote reach the agent? When Kozou reads the schema, an &lt;code&gt;@ai&lt;/code&gt; line comes back over &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; (the standard protocol for connecting AI agents to external tools) as &lt;code&gt;aiDescription&lt;/code&gt;, sitting right next to the column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"amount_total"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dataType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"numeric(12,2)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aiDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Do NOT use this for reporting — it is a stale cache the application stopped maintaining, can disagree with the line items, and includes test orders. Compute revenue from vw_recognized_revenue instead."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The note you wrote arrives beside the column, in the words you wrote it in. Stack several &lt;code&gt;@ai:&lt;/code&gt; lines on one column and they're joined with newlines into a single &lt;code&gt;aiDescription&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;@policy:&lt;/code&gt; — advisory, not enforcement
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;@ai&lt;/code&gt; is "here's how I'd like you to behave," &lt;code&gt;@policy&lt;/code&gt; is "record this as a rule that must not be broken." Two from the demo schema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'Customer orders.
@ai: An order is recognized revenue only when status = &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; AND is_test = false AND deleted_at IS NULL and its customer is not soft-deleted; value each line at order_items.unit_price (the captured price), not products.list_price.
@ai: The vw_recognized_revenue view already applies every one of these rules — start there for any revenue question.
@policy: &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;refunded&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; and &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;chargeback&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; reverse a sale; never count them as revenue.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'People who place orders.
@ai: Rows with deleted_at IS NOT NULL are soft-deleted (kept for audit / legal retention); exclude them from customer-facing queries, counts, and metrics.
@policy: Treat email as personal data; do not expose it in aggregate or public reports.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a line here, the same one the introduction drew. &lt;code&gt;@policy&lt;/code&gt; is &lt;strong&gt;advisory&lt;/strong&gt; for the agent, not enforcement. Writing "don't expose email in aggregate or public reports" in a &lt;code&gt;@policy&lt;/code&gt; does not, by itself, stop access. The actual enforcement stays on the PostgreSQL side — privileges (&lt;code&gt;GRANT&lt;/code&gt;) and row-level security (RLS). &lt;strong&gt;Keep supplying meaning separate from enforcing permissions&lt;/strong&gt; — it's Kozou's design stance, and a line worth holding as an author too. If you truly must hide something, stop it with a privilege, not a comment.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@policy&lt;/code&gt; arrives separately from &lt;code&gt;@ai&lt;/code&gt; (a single newline-joined string) — as an array of rules. "How I'd like you to think" (&lt;code&gt;@ai&lt;/code&gt;) and "a rule you must not break" (&lt;code&gt;@policy&lt;/code&gt;) reach the agent as different things. A rule of thumb: "I want the agent to think this way" → &lt;code&gt;@ai&lt;/code&gt;; "I want this recorded as a rule" → &lt;code&gt;@policy&lt;/code&gt;. But never forget that what enforces a rule is a DB privilege, not a comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make a view a named concept
&lt;/h2&gt;

&lt;p&gt;So far we've been adding notes to columns and tables. There's a level above that: &lt;strong&gt;define the right way of doing something as a view.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If "the correct way to compute recognized revenue" lives in someone's head, turning it into a view makes it a named, executable concept. The demo's &lt;code&gt;vw_recognized_revenue&lt;/code&gt; is exactly that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;vw_recognized_revenue&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;placed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unit_price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;net_revenue&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;placed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Excluding test orders, excluding soft-deleted orders and customers, counting only &lt;code&gt;'paid'&lt;/code&gt;, valuing each line at the captured unit price — all baked into the definition. Rules that were scattered collapse into one executable definition.&lt;/p&gt;

&lt;p&gt;Give that view a &lt;code&gt;COMMENT ON&lt;/code&gt; too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;vw_recognized_revenue&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'Authoritative recognized revenue, one row per paid order.
@ai: This is the source of truth for revenue — it already excludes test orders, soft-deleted orders and customers, and non-paid statuses, and values each line at the captured unit_price minus discount. Start here for any revenue / sales question; do not re-derive from orders.amount_total or products.list_price.
@example: Revenue by quarter.
  SELECT date_trunc(&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;quarter&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;, placed_at) AS quarter,
         sum(net_revenue) AS revenue
  FROM vw_recognized_revenue
  GROUP BY 1
  ORDER BY 1;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern: in the view's &lt;code&gt;@ai&lt;/code&gt;, &lt;strong&gt;declare it the source of truth — start here, don't re-derive from the raw tables&lt;/strong&gt; — and attach a representative use in &lt;code&gt;@example&lt;/code&gt;. An &lt;code&gt;@example&lt;/code&gt; is a description line followed by indented SQL; it's the one note allowed to span multiple lines. That's why, above, &lt;code&gt;@ai&lt;/code&gt; is one line and &lt;code&gt;@example&lt;/code&gt; is several.&lt;/p&gt;

&lt;p&gt;Concepts stack. Another view can build on this one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;vw_customer_lifetime_value&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'Total recognized revenue per active customer.
@ai: Lifetime value reuses vw_recognized_revenue, so the same recognition rules apply automatically; soft-deleted customers are excluded.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vw_customer_lifetime_value&lt;/code&gt; selects from &lt;code&gt;vw_recognized_revenue&lt;/code&gt;, so its &lt;strong&gt;rules&lt;/strong&gt; — excluding test orders and soft deletes — apply automatically; there's no filter to rewrite. What's inherited is the executable definition, not the notes: each view still carries its own &lt;code&gt;@ai&lt;/code&gt;, and this one's says, in as many words, that it reuses &lt;code&gt;vw_recognized_revenue&lt;/code&gt;. Stack a concept on a concept and the rules you wrote once keep working from underneath — but you still write a note on each concept.&lt;/p&gt;

&lt;p&gt;Kozou treats such views as domain concepts. Call &lt;code&gt;get_concept_context&lt;/code&gt; on one and the agent gets back the view's &lt;code&gt;@ai&lt;/code&gt; notes, a recommended query source, related tables, and the &lt;code&gt;@example&lt;/code&gt; queries — together. Instead of reassembling the business rules from raw tables, the agent heads for the view that already encapsulates them.&lt;/p&gt;

&lt;p&gt;And when you cross tables: the &lt;code&gt;ON&lt;/code&gt; condition itself, a machine can derive from the foreign key. But &lt;strong&gt;why this JOIN exists&lt;/strong&gt; is the meaning a human writes on the FK constraint. Put a &lt;code&gt;COMMENT&lt;/code&gt; on the FK constraint and Kozou hands its text over as the JOIN's &lt;code&gt;purpose&lt;/code&gt;, alongside the &lt;code&gt;ON&lt;/code&gt;. The demo schema doesn't comment its foreign keys, so here's an illustrative example of how to write one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Illustrative (not in the demo schema)&lt;/span&gt;
&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_customer_id_fkey&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'Each order belongs to exactly one customer.
@ai: Join through this FK to attribute revenue to a customer; do not match on email or name.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write "what this relationship is for" plus "how to join it (avoid loose matches)," and you can point an agent — one that might otherwise join on email or name despite the foreign key — at the right way to connect the tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes go stale — keep them maintained
&lt;/h2&gt;

&lt;p&gt;Written meaning has a shelf life. &lt;code&gt;@ai&lt;/code&gt; and &lt;code&gt;@policy&lt;/code&gt; are primary assets, but &lt;strong&gt;change the schema without updating the notes and the agent will confidently believe a claim that's gone stale.&lt;/strong&gt; A column you marked "don't use" that has quietly come back into service is a mismatch harder to catch than a raw-DDL one — because the note states the wrong thing with full confidence.&lt;/p&gt;

&lt;p&gt;So when a migration changes a column or a rule, fix the matching &lt;code&gt;@ai&lt;/code&gt; / &lt;code&gt;@policy&lt;/code&gt; in the same change. There's generally no test guarding COMMENT freshness, so the last line of defense is review by eye. When you read a schema diff, get in the habit of reading the comment diff alongside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The meaning you wrote reaches the agent as-is
&lt;/h2&gt;

&lt;p&gt;Writing the meaning is itself something you do on plain PostgreSQL. &lt;code&gt;COMMENT ON&lt;/code&gt; and view definitions live in the same place as the schema and migrate alongside it — primary assets. The habit pays off even without Kozou: onboarding a new hire, aligning understanding in review, carrying context across a future tool switch. Meaning bundled into the schema means anyone reading it hits the same primary source.&lt;/p&gt;

&lt;p&gt;Delivering that written meaning to the agent — &lt;strong&gt;without summarizing or paraphrasing&lt;/strong&gt; — is what Kozou does, over MCP; the &lt;a href="https://blog.tak3.jp/en/blog/introducing-kozou/" rel="noopener noreferrer"&gt;introduction&lt;/a&gt; covers that side. The one thing worth restating here, because it matters when you're the author: &lt;strong&gt;Kozou's MCP is describe-only by default&lt;/strong&gt; — there's no tool for generating, executing, or writing SQL out of the box. Supplying meaning and operating the database stay clearly apart, so writing rich notes doesn't widen what an agent can &lt;em&gt;do&lt;/em&gt; to your data.&lt;/p&gt;

&lt;p&gt;Setup, per-client configuration, and the real (English) demo schema are all at &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;kozou.org&lt;/a&gt; and on &lt;a href="https://github.com/kozou-dev/kozou" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. I won't restate the steps here — &lt;em&gt;how&lt;/em&gt; to do it is what the docs are the source of truth for. (Deriving an Admin UI, REST, docs, and types from the same schema-plus-comments — "one definition, many faithful forms" — is a post of its own.)&lt;/p&gt;

&lt;h2&gt;
  
  
  In summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Meaning goes not in column names or types but in &lt;code&gt;COMMENT ON&lt;/code&gt; and view definitions. The craft: &lt;strong&gt;start-of-line tags, one &lt;code&gt;@ai&lt;/code&gt;/&lt;code&gt;@policy&lt;/code&gt; per line, stack lines for more points&lt;/strong&gt; — and the body can be in any language (only the tag name and its &lt;code&gt;:&lt;/code&gt; are ASCII).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@ai&lt;/code&gt; names the trap and points to the source of truth (&lt;strong&gt;"don't use it" + "use the authoritative view instead"&lt;/strong&gt;). &lt;code&gt;@policy&lt;/code&gt; records a rule that must not be broken — but it's advisory; &lt;strong&gt;enforcement stays with DB privileges and RLS.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A view can be a named concept. Give the authoritative view an &lt;code&gt;@ai&lt;/code&gt; (declaring it the source of truth) and an &lt;code&gt;@example&lt;/code&gt; (a representative query); build one view on another and the rules are inherited — though each view still needs its own note. An FK's comment travels as the JOIN's purpose.&lt;/li&gt;
&lt;li&gt;Notes aren't write-and-forget. Change the schema and &lt;strong&gt;update the notes in the same change&lt;/strong&gt; — a stale note misleads the agent with full confidence.&lt;/li&gt;
&lt;li&gt;Then Kozou delivers what you wrote to the agent, as-is. &lt;strong&gt;Write the meaning into the DB first.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository is &lt;a href="https://github.com/kozou-dev/kozou" rel="noopener noreferrer"&gt;github.com/kozou-dev/kozou&lt;/a&gt; (Apache-2.0); the docs are at &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;kozou.org&lt;/a&gt;. The demo schema ships with the quickstart. Try adding one &lt;code&gt;@ai:&lt;/code&gt; line to your own schema and start there.&lt;/p&gt;

&lt;p&gt;The Japanese version of this post is already live.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>mcp</category>
      <category>aiagents</category>
      <category>kozou</category>
    </item>
    <item>
      <title>Your AI Agent Can Read Your Database, But Not What It Means — Introducing Kozou</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:30:02 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/your-ai-agent-can-read-your-database-but-not-what-it-means-introducing-kozou-540b</link>
      <guid>https://dev.to/takashimatsuyama/your-ai-agent-can-read-your-database-but-not-what-it-means-introducing-kozou-540b</guid>
      <description>&lt;p&gt;I closed the &lt;a href="https://basou.dev" rel="noopener noreferrer"&gt;basou&lt;/a&gt; introduction with a promise: that there's a companion project, &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;Kozou&lt;/a&gt;, for the side that tells an agent what a database &lt;em&gt;means&lt;/em&gt;, and that I'd introduce it separately. This is that post.&lt;/p&gt;

&lt;p&gt;In one line, Kozou is &lt;strong&gt;an open-source tool that gives an AI agent the &lt;em&gt;meaning&lt;/em&gt; of your PostgreSQL database&lt;/strong&gt;. The README opens with it: "Give your AI agent the &lt;em&gt;meaning&lt;/em&gt; of your PostgreSQL database, not just its columns." It reads the meaning sitting in your schema — &lt;code&gt;COMMENT ON&lt;/code&gt; text, view definitions, type information — and hands it to the agent over &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; (the standard protocol for connecting AI agents to external tools).&lt;/p&gt;

&lt;h2&gt;
  
  
  Columns it can read, meaning it can't
&lt;/h2&gt;

&lt;p&gt;Let an AI agent loose on a database and you notice something fast. It's good at reading schema — it lists tables, lines up columns and types, assembles a plausible-looking JOIN. And it still gets the answer wrong. &lt;strong&gt;Plausibly wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The demo schema bundled with Kozou's quickstart (a small online store) reproduces this failure well. The &lt;code&gt;orders&lt;/code&gt; table has an &lt;code&gt;amount_total numeric(12,2)&lt;/code&gt; column. A total. Asked for revenue, an agent reaches for &lt;code&gt;SUM(amount_total)&lt;/code&gt; — the natural move. But that column is a &lt;strong&gt;stale denormalized cache the application stopped maintaining&lt;/strong&gt;; it can disagree with the line items, and it mixes in test orders. The &lt;code&gt;status&lt;/code&gt; column holds &lt;code&gt;'paid'&lt;/code&gt;, &lt;code&gt;'refunded'&lt;/code&gt;, and &lt;code&gt;'chargeback'&lt;/code&gt; among others, but only &lt;code&gt;'paid'&lt;/code&gt; counts as a sale — the other two &lt;em&gt;reverse&lt;/em&gt; one.&lt;/p&gt;

&lt;p&gt;None of that knowledge appears in the column names, the types, or the constraints — in the structure itself. It normally lives in a data dictionary, a team wiki, or someone's head. The agent writes a plausible query from the structure in front of it. What fills the gap between plausible and correct is this "meaning."&lt;/p&gt;

&lt;h2&gt;
  
  
  The meaning already has a home: the database
&lt;/h2&gt;

&lt;p&gt;Before reaching for anything new, one thing is worth establishing. PostgreSQL has had a native place to write meaning all along.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;COMMENT ON&lt;/code&gt;. Kozou adds a small set of conventions on top of it. The three that carry meaning are &lt;code&gt;@ai&lt;/code&gt; / &lt;code&gt;@policy&lt;/code&gt; / &lt;code&gt;@example&lt;/code&gt; — there's also &lt;code&gt;@widget&lt;/code&gt; for the Admin UI, among others. Here's the real thing from the demo schema (reflowed for readability):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="s1"&gt;'Customer orders.
@ai: An order is recognized revenue only when status = &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;
  AND is_test = false AND deleted_at IS NULL and its customer is not
  soft-deleted; value each line at order_items.unit_price
  (the captured price), not products.list_price.
@ai: The vw_recognized_revenue view already applies every one of these
  rules — start there for any revenue question.
@policy: &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;refunded&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; and &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;chargeback&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; reverse a sale;
  never count them as revenue.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then views. If the knowledge of "how to compute revenue correctly" lives in someone's head, defining it as a view turns it into a &lt;strong&gt;named, executable concept&lt;/strong&gt;. The demo's &lt;code&gt;vw_recognized_revenue&lt;/code&gt; is the source of truth for recognized revenue: excluding test orders, accounting for soft deletes, valuing each line at the captured unit price — all baked into the definition.&lt;/p&gt;

&lt;p&gt;Writing meaning into the database itself is a habit that predates Kozou and pays off on its own. The moment you write it, the meaning lives in the same place as the schema and migrates alongside it — a primary source in its own right.&lt;/p&gt;

&lt;p&gt;But writing it isn't the same as delivering it to the agent. Over a raw connection an agent &lt;em&gt;can&lt;/em&gt; read &lt;code&gt;COMMENT&lt;/code&gt; if it digs through the catalog — but whether it digs is up to the agent. What tells it where things are written, which view is authoritative, which rules are advisory and which are enforced — a bare connection has none of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handing that meaning over — this is Kozou
&lt;/h2&gt;

&lt;p&gt;First, what Kozou &lt;em&gt;doesn't&lt;/em&gt; do to your database. It doesn't stand up a new metadata store. It only &lt;strong&gt;reads&lt;/strong&gt; the &lt;code&gt;COMMENT&lt;/code&gt; and view definitions already in your DB — it creates no tables of its own and runs no DDL (Kozou is migration-tool-agnostic; it compiles the schema your migrations produce). Remove it, and you're back to plain PostgreSQL.&lt;/p&gt;

&lt;p&gt;With that settled: Kozou reads the PostgreSQL schema and hands the &lt;code&gt;COMMENT ON&lt;/code&gt; text, view definitions, and type information to the agent over MCP — &lt;strong&gt;without summarizing or paraphrasing&lt;/strong&gt; (the README's word is &lt;em&gt;verbatim&lt;/em&gt;), structured by tag. Here's what the agent sees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list_tables&lt;/code&gt; / &lt;code&gt;describe_table&lt;/code&gt; — the table list, and the full schema + COMMENT for one table&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_views&lt;/code&gt; / &lt;code&gt;describe_view&lt;/code&gt; — a view's columns, purpose, underlying tables, and the &lt;strong&gt;view definition SQL itself&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_concepts&lt;/code&gt; / &lt;code&gt;get_concept_context&lt;/code&gt; — treats a view as a domain concept, returning its related tables and recommended query path&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;describe_functions&lt;/code&gt; — lists the exposed RPC actions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search_schema&lt;/code&gt; — metadata search across names, labels, COMMENT bodies, &lt;code&gt;@ai&lt;/code&gt;/&lt;code&gt;@policy&lt;/code&gt; notes, and enum members&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, sending &lt;code&gt;search_schema&lt;/code&gt; the word "revenue" brings back both the view named &lt;code&gt;vw_recognized_revenue&lt;/code&gt; and the "do not use this for reporting" COMMENT on &lt;code&gt;amount_total&lt;/code&gt; — one hit on a name, one on a body. You find where the meaning relevant to that word lives without enumerating and &lt;code&gt;describe&lt;/code&gt;-ing the whole schema.&lt;/p&gt;

&lt;p&gt;The default is describe-only — reading. The execution tool, &lt;code&gt;call&lt;/code&gt;, appears only when you explicitly enable it in the config file (&lt;code&gt;kozou.config.yaml&lt;/code&gt;, under &lt;code&gt;server.mcp.execution&lt;/code&gt;) and name the role it runs as. It isn't a CLI flag — a plain &lt;code&gt;kozou mcp&lt;/code&gt; is always describe-only; being able to execute is a state the operator opts into through configuration.&lt;/p&gt;

&lt;p&gt;Here's a fragment of what &lt;code&gt;describe_table("public.orders")&lt;/code&gt; actually returns (real excerpt from the demo schema, abridged to the relevant fields and reflowed for readability):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"amount_total"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dataType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"numeric(12,2)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aiDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Do NOT use this for reporting — it is a stale cache
    the application stopped maintaining, can disagree with the line items,
    and includes test orders. Compute revenue from vw_recognized_revenue
    instead."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enumValues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"cart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refunded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chargeback"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aiDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Only 'paid' is a captured sale; 'cart' and 'pending'
    are not sales yet; 'refunded' and 'chargeback' reverse a prior sale."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap from earlier arrives right next to the column, as a note to the agent. An &lt;code&gt;@ai:&lt;/code&gt; line from the &lt;code&gt;COMMENT ON&lt;/code&gt; becomes &lt;code&gt;aiDescription&lt;/code&gt;, a &lt;code&gt;@policy:&lt;/code&gt; line becomes &lt;code&gt;policy&lt;/code&gt;. The meaning you wrote comes back in the words you wrote it in, structured. An agent holding that note can head for the view declared authoritative — &lt;code&gt;vw_recognized_revenue&lt;/code&gt; — instead of guessing its way through &lt;code&gt;amount_total&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Relations work the same way. &lt;code&gt;get_concept_context&lt;/code&gt; derives JOIN candidates from the &lt;strong&gt;real foreign keys&lt;/strong&gt; among a view's underlying tables, and attaches the COMMENT on the FK constraint as the JOIN's purpose. If you've written a COMMENT on the FK constraint, you get a suggestion like this (this fragment is illustrative — the demo schema doesn't comment its FK constraints, so calling it as-is returns an empty &lt;code&gt;purpose&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="nl"&gt;"joinSuggestions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public.customers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders.customer_id = customers.id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"purpose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"the customer who placed the order"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A machine can derive the &lt;code&gt;ON&lt;/code&gt; condition. But "what is this JOIN for" is the meaning a human wrote in the FK constraint's COMMENT. Kozou hands over both, as one suggestion.&lt;/p&gt;

&lt;p&gt;There's a deliberate line here. &lt;strong&gt;Kozou hands meaning over; it doesn't enforce it.&lt;/strong&gt; &lt;code&gt;@policy&lt;/code&gt; is advisory for the agent; the actual access control stays on the PostgreSQL side — privileges and row-level security (RLS). Kozou tells the agent whether RLS is on, but never surfaces the policy expression itself. Supplying meaning and enforcing permissions are kept separate.&lt;/p&gt;

&lt;h2&gt;
  
  
  One definition, many faithful forms
&lt;/h2&gt;

&lt;p&gt;MCP is the biggest differentiator, but it's one outlet among several. From the same read of the schema, Kozou also generates an Admin UI, REST + OpenAPI, Markdown docs, and TypeScript types. From &lt;strong&gt;one source&lt;/strong&gt; — schema plus comments — it derives every form a human or an AI needs; no duplicate definitions, no drift. That "one source, many uses" stance is a post of its own, so I'll save it for another time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
  S[PostgreSQL schema&amp;lt;br&amp;gt;COMMENT ON / view definitions] --&amp;gt; K[Kozou]
  K --&amp;gt; MCP[MCP context&amp;lt;br&amp;gt;AI agent]
  K --&amp;gt; UI[Admin UI]
  K --&amp;gt; API[REST + OpenAPI]
  K --&amp;gt; DOC[Markdown docs]
  K --&amp;gt; TS[TypeScript types]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Start in ten minutes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kozou-dev/kozou
&lt;span class="nb"&gt;cd &lt;/span&gt;kozou/examples/quickstart
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That brings up PostgreSQL seeded with the trap-laden demo schema, plus &lt;code&gt;kozou dev&lt;/code&gt; — the Admin UI on &lt;code&gt;http://localhost:3333&lt;/code&gt; and an MCP server on &lt;code&gt;http://localhost:3334/mcp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To try it over stdio against your own DB, without the Docker stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://user:pass@localhost:5432/mydb npx &lt;span class="nt"&gt;-y&lt;/span&gt; kozou mcp &lt;span class="nt"&gt;--stdio&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or to start it as your own project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-p&lt;/span&gt; kozou create-kozou my-project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-client MCP setup, the remote-MCP (OAuth) guide, and the reference are all at &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;kozou.org&lt;/a&gt;. I won't restate the steps — &lt;em&gt;how&lt;/em&gt; to do it is what the docs are the source of truth for.&lt;/p&gt;

&lt;h2&gt;
  
  
  In summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An AI agent can read columns and types, but the meaning — which column is a trap, which view is authoritative — isn't in the names and types. That's where plausible, wrong queries come from.&lt;/li&gt;
&lt;li&gt;The meaning has a home in PostgreSQL already: &lt;code&gt;COMMENT ON&lt;/code&gt; and view definitions. &lt;strong&gt;Write the meaning into the DB first&lt;/strong&gt; — a habit that predates Kozou and works on its own.&lt;/li&gt;
&lt;li&gt;Kozou hands that meaning to the agent over MCP without summarizing or paraphrasing — a note beside each column, a purpose on each JOIN, a recommended query path for each concept. And it hands meaning over while leaving enforcement to PostgreSQL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository is &lt;a href="https://github.com/kozou-dev/kozou" rel="noopener noreferrer"&gt;github.com/kozou-dev/kozou&lt;/a&gt; (Apache-2.0); the docs are at &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;kozou.org&lt;/a&gt;. The latest release at the time of writing is v1.15.1. If something trips you up, I'd be grateful for an issue.&lt;/p&gt;

&lt;p&gt;The Japanese version of this post — its "paired" article — is already live.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>mcp</category>
      <category>aiagents</category>
      <category>kozou</category>
    </item>
    <item>
      <title>Add a Verifiable, Replayable Trail to Your Claude Code Sessions in 5 Minutes</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Tue, 14 Jul 2026 02:15:39 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/add-a-verifiable-replayable-trail-to-your-claude-code-sessions-in-5-minutes-3043</link>
      <guid>https://dev.to/takashimatsuyama/add-a-verifiable-replayable-trail-to-your-claude-code-sessions-in-5-minutes-3043</guid>
      <description>&lt;p&gt;Claude Code is already keeping a diary in your repos. It just keeps it as a pile of vendor logs you will never read back — not readable, not verifiable, and nowhere near your code.&lt;/p&gt;

&lt;p&gt;Five minutes from now, that changes. You won't change anything about your workflow. And your past sessions are included — retroactively.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll have in five minutes
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.basou/&lt;/code&gt; directory next to your code, holding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;handoff.md&lt;/code&gt;, for humans — where things stand, what happened, where to pick up&lt;/li&gt;
&lt;li&gt;per-session &lt;code&gt;events.jsonl&lt;/code&gt; files under &lt;code&gt;.basou/sessions/&lt;/code&gt;, the source of truth — append-only, hash-chained event logs&lt;/li&gt;
&lt;li&gt;and &lt;code&gt;basou verify&lt;/code&gt;, a machine answer to the question "has this record been tampered with?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full picture of basou is in the &lt;a href="https://blog.tak3.jp/en/blog/introducing-basou/" rel="noopener noreferrer"&gt;introduction post&lt;/a&gt;. In one line: it's a local-first harness — a full set of tack — for AI coding agents: a saddle (a declarative workspace), reins (orientation that carries intent), and, underneath both, a replayable record of what the agent actually did. Today is hands-on with that bottom layer only: the &lt;strong&gt;trail&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites and Step 1: install and init (30 seconds)
&lt;/h2&gt;

&lt;p&gt;Three prerequisites: Node.js 20.10.0 or later, Claude Code, and a real repository you've been using Claude Code in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @basou/cli
&lt;span class="nb"&gt;cd &lt;/span&gt;path/to/your-repo
basou init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This article is written against basou &lt;strong&gt;v0.34.0&lt;/strong&gt; (as of 2026-07-14).&lt;/p&gt;

&lt;p&gt;One thing to know up front: what ends up in git. By default, basou keeps the noisy parts out of git — the raw event log, internal logs, and the live orientation view — and leaves the durable summaries trackable: the manifest, &lt;code&gt;handoff.md&lt;/code&gt;, &lt;code&gt;decisions.md&lt;/code&gt;, and each session's &lt;code&gt;session.yaml&lt;/code&gt;, so you can review them in a PR. Those summaries carry real work content — session labels, decision titles, the handoff text itself — so if you'd rather commit nothing at all, run &lt;code&gt;basou init --local-only&lt;/code&gt; and the whole &lt;code&gt;.basou/&lt;/code&gt; directory stays gitignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: import what's already there — &lt;code&gt;basou import&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the moment the title promised.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;basou import claude-code &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Imported sessions: 20 path(s) sanitized
Imported 4 session(s) (101 events)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command, and four Claude Code sessions I had run in this repository — 101 events' worth — became a trail, retroactively. &lt;code&gt;--all&lt;/code&gt; just means "everything you can find for this repo"; there is no path to point at and no config to write — basou auto-detects the native logs on disk. If you'd rather look before you leap, add &lt;code&gt;--dry-run&lt;/code&gt; first: it writes nothing and returns a preview — &lt;code&gt;Dry run: would import …&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The finer points and the usual gotchas are in the &lt;a href="https://basou.dev/quickstart/" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt;. The one thing to take away here: &lt;strong&gt;you changed nothing about your workflow&lt;/strong&gt;, and your history became a trail anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: generate the handoff, read it back — and what "replayable" means
&lt;/h2&gt;

&lt;p&gt;What the import wrote is the &lt;strong&gt;record&lt;/strong&gt;: append-only, hash-chained JSONL under &lt;code&gt;.basou/sessions/&lt;/code&gt;. The human view of it is something you ask for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;basou handoff generate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Handoff&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; Generated at 2026-07-13T16:31:18.106Z from ses_01KXE534X7..ses_01KXE534XZ&lt;/span&gt;

&lt;span class="gu"&gt;## Current state&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Last session: claude-code 2026-06-14: 23 commands, 3 files (imported) [ses_01KXE534X7]

&lt;span class="gu"&gt;## Latest decision&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; ████████ の AGENTS.md 文書そのもの（散文本文）の言語をどう改定しますか？ … -&amp;gt; 本文は日本語のまま維持 [decision_01KXE534X0]

(3 decisions total — see decisions.md)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The Japanese line under &lt;em&gt;Latest decision&lt;/em&gt; is my data — a decision title — and basou keeps user data verbatim; this is a real workspace of mine, not a staged demo. The view chrome itself defaults to English; declare &lt;code&gt;language: ja&lt;/code&gt; on the anchor repo in the manifest and the headings render in Japanese.)&lt;/p&gt;

&lt;p&gt;This is an excerpt of &lt;code&gt;.basou/handoff.md&lt;/code&gt; — where things stand, what happened last, where to pick up. Point a teammate at it. Point tomorrow's you at it. Point the next agent session at it.&lt;/p&gt;

&lt;p&gt;Now, the part that earns the word &lt;em&gt;replayable&lt;/em&gt; in the title. The Markdown you just read is not the record — it's a &lt;strong&gt;view&lt;/strong&gt;, derived from the record. Which means it's disposable. Let's prove it. I could &lt;code&gt;rm&lt;/code&gt; it, but I'll move it aside so we can diff afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; .basou/handoff.md /tmp/before.md
basou handoff generate
diff /tmp/before.md .basou/handoff.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;4c4
&lt;/span&gt;&lt;span class="gd"&gt;&amp;lt; &amp;gt; Generated at 2026-07-13T16:31:18.106Z from ses_01KXE534X7..ses_01KXE534XZ
&lt;/span&gt;&lt;span class="p"&gt;---
&lt;/span&gt;&lt;span class="gi"&gt;&amp;gt; &amp;gt; Generated at 2026-07-13T16:32:02.596Z from ses_01KXE534X7..ses_01KXE534XZ
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line moved: the generation timestamp. &lt;strong&gt;Everything else came back byte-identical&lt;/strong&gt; — re-derived from &lt;code&gt;events.jsonl&lt;/code&gt;, deterministically, offline, with no LLM involved. Same command, same view, resurrected from the source of truth.&lt;/p&gt;

&lt;p&gt;That's what replayable means here. There is no step-through player — there is a &lt;strong&gt;source log you can re-consume at any time&lt;/strong&gt;, and every human-readable view can be rebuilt from it. The handoff, the decision log, the orientation — each is a cheap projection of one verifiable log. Hand-edit the views if you like; when you want the log's version back, regenerate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: verify — and what "verifiable" means
&lt;/h2&gt;

&lt;p&gt;Peek into a session's &lt;code&gt;events.jsonl&lt;/code&gt; and you'll see each event holding the hash of the one before it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"schema_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"evt_01KXE534X7…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ses_01KXE534X7…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"occurred_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-14T06:10:20.317Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"claude-code-import"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"session_started"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"prev_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"12d50f19…2f70014"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"schema_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"evt_01KXE534X7…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ses_01KXE534X7…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"occurred_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-14T06:10:25.021Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"claude-code-import"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"command_executed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"prev_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"793df9dd…330ac00"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is why a machine can answer the question: "has this record been tampered with since it was written?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;basou verify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ses_01KXE534X7…  verified (34 events)
ses_01KXE534XJ…  verified (19 events)
ses_01KXE534XV…  verified (45 events)
ses_01KXE534XZ…  verified (3 events)
Sessions: 4 total — 4 verified, 0 unchained, 0 empty, 0 incomplete, 0 in_progress, 0 tampered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape you want is that summary line ending in &lt;code&gt;0 tampered&lt;/code&gt;. If even one session had been altered, it would show up as &lt;code&gt;TAMPERED (...)&lt;/code&gt; and the exit code would be 1 — which means you can put this in a script. And note that the chain has been intact since the moment the import wrote it. There is no blessing step afterwards.&lt;/p&gt;

&lt;p&gt;The trail format itself is vendor-neutral — an adapter reads each tool's native logs, and from there everything lands in the same shape under a common schema. And as you just saw, verification is a local operation against the &lt;code&gt;.basou/&lt;/code&gt; on your disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sessions ahead
&lt;/h2&gt;

&lt;p&gt;Everything so far was about the past. The future is even easier.&lt;/p&gt;

&lt;p&gt;Start the morning with &lt;code&gt;basou orient&lt;/code&gt; — "where was I?" answered from the record. Day to day, &lt;code&gt;basou refresh&lt;/code&gt; is the one-shot that ingests new sessions and refreshes every view. When a design decision lands, &lt;code&gt;basou decision capture&lt;/code&gt;; before you step away, &lt;code&gt;basou note "pick up here next"&lt;/code&gt;. And if you want a session recorded live from the start, &lt;code&gt;basou run claude-code&lt;/code&gt; wraps the whole process.&lt;/p&gt;

&lt;p&gt;If you use Codex: &lt;code&gt;basou import codex --all&lt;/code&gt; — same shape, same kind of chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;Not a dashboard SaaS. Nothing leaves your machine. No LLM calls at runtime. The trail itself lives entirely inside &lt;code&gt;.basou/&lt;/code&gt;, and walking away is as easy as deleting that one directory. If you want to browse locally, there's &lt;code&gt;basou view&lt;/code&gt; (bound to 127.0.0.1 only) — that too is optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;That was the five minutes: install and &lt;code&gt;init&lt;/code&gt;, one &lt;code&gt;import&lt;/code&gt;, &lt;code&gt;handoff generate&lt;/code&gt;, &lt;code&gt;verify&lt;/code&gt;. From here the trail keeps growing next to your code, views can be deleted and resurrected from the source of truth, and tampering gets caught by a machine.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://basou.dev/quickstart/" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; and the CLI reference have the rest. If something snags on 0.x, &lt;a href="https://github.com/basou-dev/basou" rel="noopener noreferrer"&gt;issues&lt;/a&gt; are welcome.&lt;/p&gt;

</description>
      <category>basou</category>
      <category>claudecode</category>
      <category>vibecoding</category>
      <category>agents</category>
    </item>
    <item>
      <title>Ending the CLAUDE.md / AGENTS.md / copilot-instructions.md Sprawl — Declare One Source, Verify the Wiring</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Sat, 11 Jul 2026 14:25:57 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/ending-the-claudemd-agentsmd-copilot-instructionsmd-sprawl-declare-one-source-verify-the-1gbb</link>
      <guid>https://dev.to/takashimatsuyama/ending-the-claudemd-agentsmd-copilot-instructionsmd-sprawl-declare-one-source-verify-the-1gbb</guid>
      <description>&lt;p&gt;Open a repository these days and you'll find several nearly identical agent-instruction files sitting side by side — &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt;. This post is about ending that sprawl not by hand-syncing it forever, but with declaration and verification. Last time I introduced &lt;a href="https://blog.tak3.jp/en/blog/introducing-basou/" rel="noopener noreferrer"&gt;the harness itself, basou&lt;/a&gt;. This one is about a smaller, duller problem that sits just upstream of it — and that far more people are quietly stepping on.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Yet another instruction file"
&lt;/h2&gt;

&lt;p&gt;Over the past year or two, every AI coding tool has brought its own "agent-instruction file." The project's rules, coding conventions, the things not to do. A file meant to be read by the agent, separate from the human-facing README.&lt;/p&gt;

&lt;p&gt;The trouble is that each tool uses a different filename. Claude Code reads &lt;code&gt;CLAUDE.md&lt;/code&gt;, Codex reads &lt;code&gt;AGENTS.md&lt;/code&gt;, GitHub Copilot reads &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt;. Use more than one of these tools on the same team and you end up with &lt;strong&gt;three copies of the same content&lt;/strong&gt; in the repo.&lt;/p&gt;

&lt;p&gt;And they rot in silence. You fix one and leave the other a version behind. You think you've told the agent "here are the rules," while a different tool is reading the previous generation's rules. Worse, you can't tell by eye which copy is current and which has gone stale.&lt;/p&gt;

&lt;p&gt;The scope of this post is a single repository, for an individual or a small team. Not company-wide policy distribution — just the everyday problem of "instruction files keep multiplying in my repo," dealt with in a realistic number of moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't just merge them into one
&lt;/h2&gt;

&lt;p&gt;The naive thought is "just standardize on one AGENTS.md." And in fact, AGENTS.md is becoming the de facto common format. Designed as a "README for agents," it's an open spec that, as of mid-2026, is stewarded by the &lt;a href="https://agents.md/" rel="noopener noreferrer"&gt;Agentic AI Foundation&lt;/a&gt; (under the Linux Foundation), read by more than twenty tools, and adopted in over 60,000 open-source repositories. Codex CLI, Cursor, Windsurf, Aider, Zed — most of the field has converged on AGENTS.md, and even GitHub Copilot's coding agent now reads it. Google's newer entrants (Jules and friends) do too.&lt;/p&gt;

&lt;p&gt;The one that didn't converge is Claude Code. What Claude Code reads is &lt;code&gt;CLAUDE.md&lt;/code&gt;, not &lt;code&gt;AGENTS.md&lt;/code&gt;. It doesn't even read AGENTS.md as a fallback (the claim that "it reads AGENTS.md if CLAUDE.md is absent" is wrong). Start Claude Code in a repo that has only AGENTS.md and you'll get no error — it just runs, cheerfully, with zero project instructions loaded. That's the quietest place for a "unified" team to break. The request to "please support AGENTS.md" has gathered thousands of reactions on &lt;a href="https://github.com/anthropics/claude-code/issues/6235" rel="noopener noreferrer"&gt;anthropics/claude-code issue #6235&lt;/a&gt;, but as of July 2026 the official docs show no sign of it on the roadmap.&lt;/p&gt;

&lt;p&gt;Laid out, the matrix looks like this (as of July 2026).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Instruction file it reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; (does not read &lt;code&gt;AGENTS.md&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex CLI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; (the coding agent also reads &lt;code&gt;AGENTS.md&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; (&lt;code&gt;.cursor/rules&lt;/code&gt; is a separate, current mechanism; the old &lt;code&gt;.cursorrules&lt;/code&gt; is deprecated)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;As long as you use Claude Code, you can't delete &lt;code&gt;CLAUDE.md&lt;/code&gt;. Standardize on AGENTS.md and you're still left maintaining a second file just for Claude Code. That one asymmetry is what keeps "just merge it into one file" from ever quite working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naive solutions, and where they run out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Copy-paste into all of them
&lt;/h3&gt;

&lt;p&gt;The most naive move is to copy the same body into each file. It works the first time. It falls apart from the second time onward: every one-line rule change now obliges you to edit N files, and a human will always forget one of them. And, as above, you can't see which copy has rotted. The cost of syncing, and the inability to detect rot — those two are the intrinsic limits of copy-paste.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consolidate into one file and reference it via import
&lt;/h3&gt;

&lt;p&gt;Claude Code has an &lt;code&gt;@path&lt;/code&gt; import syntax. Write &lt;code&gt;@AGENTS.md&lt;/code&gt; as the first line of &lt;code&gt;CLAUDE.md&lt;/code&gt; and, at startup, the contents of AGENTS.md are expanded inline and read as-is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- CLAUDE.md --&amp;gt;&lt;/span&gt;
@AGENTS.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a clean instinct. The real body lives in a single place — AGENTS.md — and CLAUDE.md is just a one-line stub. Import also lets you &lt;strong&gt;add tool-specific instructions&lt;/strong&gt; on top of the shared body — if there's a rule you only want Claude Code to follow, put it from line two of the stub onward. It has limits, though. The import syntax isn't common across tools (the same trick doesn't work in Copilot's &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt;). And since both files can still carry a body, the moment the "keep the stub to one line" discipline slips, the drift is back.&lt;/p&gt;

&lt;p&gt;For what it's worth, running Claude Code's &lt;code&gt;/init&lt;/code&gt; in a repo that has an AGENTS.md will read the existing AGENTS.md and incorporate the relevant parts into the generated CLAUDE.md — but that's a &lt;strong&gt;copy&lt;/strong&gt;, not a reference. If consolidation is the goal, writing the one import line by hand is the cleaner move.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bundle into one real file with a symlink
&lt;/h3&gt;

&lt;p&gt;The more direct option is to make "there is only one real file" true at the filesystem level. Make &lt;code&gt;CLAUDE.md&lt;/code&gt; a symlink to &lt;code&gt;AGENTS.md&lt;/code&gt;, and when Claude Code opens &lt;code&gt;CLAUDE.md&lt;/code&gt;, the OS transparently returns the contents of AGENTS.md. The tool never has to know about the symlink. The worry that "it won't follow the symlink" is, at least for &lt;strong&gt;reads&lt;/strong&gt;, unfounded: the tool does the opening, but the OS does the following. In fact, Claude Code's own official documentation lists &lt;code&gt;ln -s AGENTS.md CLAUDE.md&lt;/code&gt; as an option.&lt;/p&gt;

&lt;p&gt;For a single repository, this is the sturdiest choice. Here's the actual procedure.&lt;/p&gt;

&lt;h2&gt;
  
  
  In practice — bundling to one canonical with symlinks
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Topology
&lt;/h3&gt;

&lt;p&gt;Make &lt;code&gt;AGENTS.md&lt;/code&gt; the canonical (the hub) and let the other files be spokes pointing at it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;myrepo/AGENTS.md                          # canonical (the real file — edit only this)
myrepo/CLAUDE.md                          -&amp;gt; AGENTS.md
myrepo/.github/copilot-instructions.md    -&amp;gt; ../AGENTS.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason to make AGENTS.md the canonical is that it's the format the most tools read out of the box. If Claude Code ever supports AGENTS.md, you just drop one spoke.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commands
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# If the body currently lives in CLAUDE.md, move the real file to AGENTS.md first&lt;/span&gt;
git &lt;span class="nb"&gt;mv &lt;/span&gt;CLAUDE.md AGENTS.md

&lt;span class="c"&gt;# Make CLAUDE.md a symlink to AGENTS.md&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; AGENTS.md CLAUDE.md

&lt;span class="c"&gt;# For Copilot (mind the relative path as seen from inside .github/)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; .github
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; ../AGENTS.md .github/copilot-instructions.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Making the paths relative is the trick. Clone the repo anywhere and the links resolve entirely within the repo, so they don't break.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Git handles it
&lt;/h3&gt;

&lt;p&gt;Git records a symlink plainly, as a "blob of mode &lt;code&gt;120000&lt;/code&gt; whose content is the link target path." So symlinks commit as-is and are restored on clone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git ls-files &lt;span class="nt"&gt;-s&lt;/span&gt; AGENTS.md CLAUDE.md .github/copilot-instructions.md
120000 …  .github/copilot-instructions.md  &lt;span class="c"&gt;# symlink&lt;/span&gt;
100644 …  AGENTS.md                        &lt;span class="c"&gt;# real file&lt;/span&gt;
120000 …  CLAUDE.md                        &lt;span class="c"&gt;# symlink&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caveat is Windows. Checking symlinks out correctly requires &lt;code&gt;core.symlinks=true&lt;/code&gt; plus developer mode or the right privileges. Without that, a symlink is expanded as a plain text file whose content is the link target path. A team with Windows in the mix should just confirm this point up front. (As a general note — I run macOS myself.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this approach runs out
&lt;/h3&gt;

&lt;p&gt;Symlinks are enough within a single repo, but three problems surface as the scope widens.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Public repos and private contents.&lt;/strong&gt; If the instruction file contains non-public planning information, putting the real file in a public repo's history exposes that content directly. A symlink keeps the &lt;em&gt;contents&lt;/em&gt; out, but the link target path — the directory layout on the private side — still lands in the history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wanting one canonical across several repos.&lt;/strong&gt; The more repos you have, the more hand-linking breaks down as a practice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wiring rots in silence.&lt;/strong&gt; A link goes missing, breaks, or points somewhere else — and you can't track that by eye. You've reintroduced instruction-file rot, this time at the symlink layer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In short, symlinks solve "bundle to one real file," but not "declare" and "verify." You're still linking by hand and checking by hand, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further — declare the canonical, verify the wiring
&lt;/h2&gt;

&lt;p&gt;Everything so far stayed inside one repository. From here we step just past the scope I drew in the first section — to when repos multiply and public and private start to mix. The idea is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Declare one canonical, generate the wiring to each tool, and verify that wiring.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That three-beat rhythm needs no dedicated tool. Write the canonical's location once (declare), lay the links with a few lines of script (generate), and drop a few lines in CI that diff the expected output of &lt;code&gt;git ls-files -s&lt;/code&gt; (verify) — and the shape holds. What separates it from linking by hand is that "the wiring as it should be" exists as code rather than prose, and a machine tells you when it breaks.&lt;/p&gt;

&lt;p&gt;What I actually use is this same idea realized in &lt;a href="https://basou.dev" rel="noopener noreferrer"&gt;basou&lt;/a&gt;. basou is a harness for steering AI coding agents, and one of its features treats the wiring of agent-instruction files declaratively. What follows describes v0.32.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Declare
&lt;/h3&gt;

&lt;p&gt;You declare each repository and its attributes (visibility — public or private, language, and so on) in a manifest. The canonical body lives in exactly one place, on a private "anchor" — the repo that aggregates the canonicals (&lt;code&gt;agents/&amp;lt;repo&amp;gt;/AGENTS.md&lt;/code&gt;). Each repo's instruction file is treated as wiring pointing there.&lt;/p&gt;

&lt;p&gt;The topology is the same hub-and-spoke as before, except the hub points at the anchor's canonical.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;repo&amp;gt;/AGENTS.md                          -&amp;gt; &amp;lt;anchor&amp;gt;/agents/&amp;lt;repo&amp;gt;/AGENTS.md   # hub -&amp;gt; canonical
&amp;lt;repo&amp;gt;/CLAUDE.md                          -&amp;gt; AGENTS.md                          # spoke -&amp;gt; hub
&amp;lt;repo&amp;gt;/.github/copilot-instructions.md    -&amp;gt; ../AGENTS.md                       # spoke -&amp;gt; hub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The canonical is the single file on the anchor side. Each repo's AGENTS.md becomes a &lt;strong&gt;gitignored symlink&lt;/strong&gt; pointing to it. So the canonical (which may contain non-public planning information) is edited exactly once, every tool reads it through the symlink, and it never enters a public repo's history.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generate
&lt;/h3&gt;

&lt;p&gt;Don't lay the wiring by hand — generate it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;basou project derive    &lt;span class="c"&gt;# generate the full wiring from the declaration (dry-run by default; --apply writes)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;derive&lt;/code&gt; builds, in dependency order, each repo's symlinks, the canonical's boilerplate block, the &lt;code&gt;.gitignore&lt;/code&gt; that keeps a public repo clean, and the workspace view that bundles several repos (there are also &lt;code&gt;symlinks&lt;/code&gt; / &lt;code&gt;preset&lt;/code&gt; / &lt;code&gt;gitignore&lt;/code&gt; / &lt;code&gt;workspace&lt;/code&gt; to run individually). All of it is &lt;strong&gt;non-destructive&lt;/strong&gt;. It creates only what's missing (&lt;code&gt;preset&lt;/code&gt; updates only its own generated block and never touches your hand-written body), it won't overwrite an existing file or repoint a symlink that points elsewhere, and it reports conflicts as conflicts and leaves them to a human. There's also a &lt;code&gt;retrofit&lt;/code&gt; for pulling an existing hand-written AGENTS.md into this topology.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify
&lt;/h3&gt;

&lt;p&gt;And then, verify the wiring. This is the piece that was missing from the hand-run symlink approach.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;basou project check     &lt;span class="c"&gt;# surface drift between the declaration and the actual wiring (read-only)&lt;/span&gt;
✅ Every present repo&lt;span class="s1"&gt;'s and the view'&lt;/span&gt;s instruction files &lt;span class="o"&gt;(&lt;/span&gt;AGENTS.md + spokes&lt;span class="o"&gt;)&lt;/span&gt; are wired as declared.

&lt;span class="nv"&gt;$ &lt;/span&gt;basou project wiring    &lt;span class="c"&gt;# inspect the wiring and privacy risk (read-only)&lt;/span&gt;
✅ No instruction file is tracked by git &lt;span class="k"&gt;in &lt;/span&gt;a public-facing repo &lt;span class="o"&gt;(&lt;/span&gt;no privacy risk&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check&lt;/code&gt; reports drift like "the canonical is missing," "a spoke is absent," or "there's a conflict." &lt;code&gt;wiring&lt;/code&gt; goes further and surfaces &lt;strong&gt;privacy risk&lt;/strong&gt; — a state where a public repo tracks an instruction file in git and could expose the contents of a private canonical in public history. What the eye couldn't track comes out as a verdict from a single command.&lt;/p&gt;

&lt;p&gt;The output above isn't a staged sample. It's the real thing (excerpted), run just now against the repository of the blog you're reading. And this blog's symlinks were laid &lt;strong&gt;by hand&lt;/strong&gt;, following the procedure in the "In practice" section — not through basou's wiring feature. Add the manifest declaration after the fact and run &lt;code&gt;check&lt;/code&gt;, and it finds nothing to fix — the hand-laid wiring passes the reconciliation against the declaration as-is. The "In practice" and "Going further" sections aren't an either/or; they converge on the same shape. Start by hand, and nothing is wasted.&lt;/p&gt;

&lt;h3&gt;
  
  
  The exception — a self-contained private repo
&lt;/h3&gt;

&lt;p&gt;You don't always converge on the anchor with hub-and-spoke. The very blog repo I've been using as the example is private, and it &lt;strong&gt;keeps &lt;code&gt;AGENTS.md&lt;/code&gt; as a real file and commits it, symlinks and all&lt;/strong&gt; — the &lt;code&gt;git ls-files&lt;/code&gt; output shown in the "In practice" section is, in fact, from this very repository. Its manifest declaration is, in reality, just these three lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;visibility&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;private&lt;/span&gt;
    &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ja&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Being private, it has no leakage risk, so rather than aggregating the canonical onto an outside anchor, it's more natural to keep the instruction docs' editing and diffs entirely inside the repo — the single-repo shape, where the repo is its own anchor. In basou you can declare a repo that "commits its instruction files by design" as &lt;code&gt;instructions: self&lt;/code&gt;, and a declared repo isn't flagged as a privacy risk even though it tracks instruction files.&lt;/p&gt;

&lt;p&gt;Where &lt;code&gt;self&lt;/code&gt; really earns its keep is on &lt;strong&gt;public&lt;/strong&gt; repos — an OSS project that deliberately commits AGENTS.md for contributors to read. A private repo doesn't need it: it passes on the strength of its visibility alone (that's what the &lt;code&gt;wiring&lt;/code&gt; output above shows). So a public repo hides its canonical and wires to it, while a repo that's fine being self-contained commits the canonical outright — being able to declare even that distinction is the difference from hand-running.&lt;/p&gt;

&lt;h3&gt;
  
  
  An honest positioning
&lt;/h3&gt;

&lt;p&gt;Let me put the exit up front. If you're on a single repository and don't need per-tool differences, &lt;strong&gt;stopping at the symlink from the "In practice" section is fine&lt;/strong&gt;. This blog itself is still in exactly that shape. Conversely, once you want to add agent-specific instructions, that's the domain of the import from the earlier section — full symlink unification (and basou's wiring too) has no answer for it.&lt;/p&gt;

&lt;p&gt;And, to be clear: basou is my own harness, still at a single-author dogfooding stage. What I want to recommend here isn't a product but the &lt;strong&gt;shift in framing&lt;/strong&gt; — from "hand-sync N files" to "declare one canonical, and have the wiring generated and verified." Laying symlinks by hand already gets you half of it today, and verification can start from a few lines in CI. basou is just one example of putting that three-beat rhythm onto a single manifest. What basou itself is, I'll leave to &lt;a href="https://blog.tak3.jp/en/blog/introducing-basou/" rel="noopener noreferrer"&gt;the previous post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  In summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Each AI coding tool uses a different instruction filename (&lt;code&gt;CLAUDE.md&lt;/code&gt; / &lt;code&gt;AGENTS.md&lt;/code&gt; / &lt;code&gt;copilot-instructions.md&lt;/code&gt;). AGENTS.md is converging toward a de facto standard, but because &lt;strong&gt;Claude Code reads only the &lt;code&gt;CLAUDE.md&lt;/code&gt; family, never &lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/strong&gt;, you can't collapse everything into one file.&lt;/li&gt;
&lt;li&gt;The first hands-on move is the &lt;strong&gt;symlink&lt;/strong&gt;. Make &lt;code&gt;AGENTS.md&lt;/code&gt; the canonical and turn &lt;code&gt;CLAUDE.md&lt;/code&gt; and friends into symlinks to it: the real file becomes one, and tools read it transparently. Git carries it plainly as a &lt;code&gt;120000&lt;/code&gt; blob (only Windows needs care).&lt;/li&gt;
&lt;li&gt;Symlinks solve "bundle to one real file," but not the mix of public and private, multiple repos, or wiring rot. From there, move onto the three-beat rhythm of &lt;strong&gt;declare (one canonical), generate (the wiring), and verify (drift and privacy risk)&lt;/strong&gt; — and you can start from a few lines of CI, no dedicated tool required.&lt;/li&gt;
&lt;li&gt;Take the management of instruction files themselves from hand-syncing to human declaration plus tool generation and verification. The wiring of instruction files is one more rein a human should hold. The scarce resource, again, is control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://blog.tak3.jp/ja/blog/declarative-agent-instructions/" rel="noopener noreferrer"&gt;Japanese version of this post&lt;/a&gt; — its "paired" article — is already live.&lt;/p&gt;

</description>
      <category>agentsmd</category>
      <category>claudemd</category>
      <category>claudecode</category>
      <category>basou</category>
    </item>
    <item>
      <title>When AI Agents Write the Code, What's Missing Are the Reins — Introducing basou</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Thu, 09 Jul 2026 02:00:39 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/when-ai-agents-write-the-code-whats-missing-are-the-reins-introducing-basou-1m62</link>
      <guid>https://dev.to/takashimatsuyama/when-ai-agents-write-the-code-whats-missing-are-the-reins-introducing-basou-1m62</guid>
      <description>&lt;p&gt;I closed the previous post with a promise: that the development style behind this blog, and the OSS I've been shipping — a harness for steering AI coding agents — deserved their own write-up. This is that write-up.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://basou.dev" rel="noopener noreferrer"&gt;basou&lt;/a&gt;. In one line: it's &lt;strong&gt;a harness — a full set of tack — for steering AI coding agents&lt;/strong&gt;. A "saddle" you declare once per repository to say how the project should be driven; "reins" that carry intent from one session to the next; and, underneath both, a replayable record of what the agent actually did. All of it as plain files, in a &lt;code&gt;.basou/&lt;/code&gt; directory right next to your code. Local-first: nothing leaves your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code only captures the &lt;em&gt;what&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Once agents write most of the code, the scarce resource isn't typing — it's &lt;strong&gt;control&lt;/strong&gt;. The more I leaned on agents, the more the pain narrowed to three things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: the "why" evaporates between sessions.&lt;/strong&gt; The answer to "what does this code do" lives in the code; even a read-only assistant can reconstruct it. What can't be reconstructed is the living context around a decision — what you intended, which path you rejected, how far you got. That has to be captured in the moment, and what you capture is worthless unless it survives to the next session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: the cost of re-explaining.&lt;/strong&gt; Because it wasn't captured, every session starts with a human re-narrating the context by mouth. Run three projects in parallel and you pay that tax three times over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: you can't audit it.&lt;/strong&gt; What an agent did is buried inside vendor-specific log formats, or nowhere at all. "That change last week — why did we do it that way?" has no answer you can verify after the fact.&lt;/p&gt;

&lt;p&gt;basou is my answer to those three. Not a dashboard you glance at, not an audit log you file away — tack you keep in your hands the whole time you're working. The name comes from &lt;em&gt;basou&lt;/em&gt; (馬装), the Japanese word for tacking up a horse: fitting the saddle and the reins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two foundations — the saddle and the reins
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🐎 The saddle: a declarative workspace
&lt;/h3&gt;

&lt;p&gt;You &lt;strong&gt;declare each repository's nature once&lt;/strong&gt; — public or private, what language it's in, where the canonical copy of the agent-instruction file (&lt;code&gt;AGENTS.md&lt;/code&gt;) lives — in a manifest. Then &lt;code&gt;basou project&lt;/code&gt; derives the rest: which roots to record, the wiring of &lt;code&gt;AGENTS.md&lt;/code&gt; / &lt;code&gt;CLAUDE.md&lt;/code&gt; / Copilot instruction files, the &lt;code&gt;.gitignore&lt;/code&gt; that keeps a private canonical file out of a public repo's history, and a workspace view that bundles multiple repositories. Humans edit the declaration; basou maintains the plumbing.&lt;/p&gt;

&lt;p&gt;Every generating command is &lt;strong&gt;dry-run by default&lt;/strong&gt; (&lt;code&gt;--apply&lt;/code&gt; is what actually writes), and additive and non-destructive. How to consolidate the ever-multiplying pile of instruction files is a whole post on its own, so I'll save it for another time.&lt;/p&gt;

&lt;h3&gt;
  
  
  🪢 The reins: orientation that carries intent
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;basou orient&lt;/code&gt; answers "where is the work right now, and what's next" from the &lt;strong&gt;record&lt;/strong&gt;, not from memory. Recent decisions, open tracks, the recorded next step, and a note on whether the record itself has gone stale.&lt;/p&gt;

&lt;p&gt;The decision record has a trick to it. A decision made mid-conversation with an agent — the reasoning, the alternatives, and why you rejected them — normally dies with the chat log. &lt;code&gt;basou decision capture&lt;/code&gt; makes the &lt;strong&gt;agent itself&lt;/strong&gt;, while it still holds the conversation in context, emit the decision as structured data, and basou writes it down deterministically. basou never runs an LLM of its own. So the record is yours, verifiable, and works offline.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;basou note&lt;/code&gt; leaves a one-line "pick up here next." &lt;code&gt;basou handoff generate&lt;/code&gt; regenerates a handoff document for the next session — or the next teammate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Underneath: a replayable provenance trail
&lt;/h2&gt;

&lt;p&gt;The two foundations sit on a simple, inspectable base.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The source of truth is JSONL.&lt;/strong&gt; Observed events — commands run, files changed, decisions, approvals — are appended to &lt;code&gt;.basou/sessions/&amp;lt;session_id&amp;gt;/events.jsonl&lt;/code&gt; and &lt;strong&gt;linked in a hash chain&lt;/strong&gt;. Tampering is detectable with &lt;code&gt;basou verify&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Humans read Markdown.&lt;/strong&gt; &lt;code&gt;.basou/handoff.md&lt;/code&gt; and &lt;code&gt;.basou/decisions.md&lt;/code&gt; are regenerated from the event log (you can hand-edit them too). Review them in an editor, or right in a PR diff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingestion doesn't depend on any vendor's internal format.&lt;/strong&gt; &lt;code&gt;basou run claude-code&lt;/code&gt; wraps a process from the outside and records it; &lt;code&gt;basou import claude-code&lt;/code&gt; / &lt;code&gt;basou import codex&lt;/code&gt; derive sessions after the fact from the logs each tool leaves behind. &lt;code&gt;basou refresh&lt;/code&gt; does ingestion and Markdown regeneration in one pass, and &lt;code&gt;basou view&lt;/code&gt; shows the whole thing in a local web UI bound to 127.0.0.1 only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep exactly one source of truth and regenerate the human-facing copies from it — that stance is the "one source, many uses" discipline from twenty years of designing business systems, carried straight into the agent era.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real thing — this blog's own records
&lt;/h2&gt;

&lt;p&gt;I use basou every day, across all of my projects. The repository behind the blog you're reading is one of them. What follows isn't a staged sample — it's a real excerpt from that repository's own &lt;code&gt;.basou/&lt;/code&gt; (some paths and identifiers are masked; the Japanese working notes are translated here for readability).&lt;/p&gt;

&lt;p&gt;First, the current position &lt;code&gt;basou orient&lt;/code&gt; returned when I resumed work. Behind it, &lt;code&gt;handoff.md&lt;/code&gt; holds the same content.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Handoff&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; Generated at 2026-07-08T13:20:50Z from ses_01KVZAFJT0..ses_01KX0Y6TPT&lt;/span&gt;

&lt;span class="gu"&gt;## Current state&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Last session: claude-code 2026-07-07: 110 commands, 11 files (imported) [ses_01KX0Y6TPR]
&lt;span class="p"&gt;-&lt;/span&gt; Last task: (no tasks recorded yet)

&lt;span class="gu"&gt;## Recently changed files&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; src/components/MermaidRenderer.astro
&lt;span class="p"&gt;-&lt;/span&gt; src/layouts/BaseLayout.astro
&lt;span class="p"&gt;-&lt;/span&gt; src/pages/ja/index.astro
&lt;span class="p"&gt;-&lt;/span&gt; …

&lt;span class="gu"&gt;## Recent decisions&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Use the existing static image for the shared header avatar [decision_01KWYHFVDE]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, one decision made during that work, recorded like this — with the reasoning and the road not taken.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Render Mermaid diagrams as SVG on the client&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; date: 2026-07-07
&lt;span class="p"&gt;-&lt;/span&gt; rationale: Build-time rendering (rehype-mermaid and friends) depends on headless
  Chromium, which risks breaking Cloudflare's build pipeline. Instead, load mermaid.js
  in the browser and swap the Shiki-highlighted pre[data-language=mermaid] for SVG.
&lt;span class="p"&gt;-&lt;/span&gt; alternatives: Pre-generate the SVG at build time (SSG) with a headless browser
&lt;span class="p"&gt;-&lt;/span&gt; rejected_reason: Adding a headless-Chromium dependency to the build makes builds
  fragile; that operational risk outweighs the "no JS required" benefit.
&lt;span class="p"&gt;-&lt;/span&gt; linked_files: src/components/MermaidRenderer.astro, src/styles/global.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And beneath all of it, the event log and its verification.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"schema_version":"0.1.0","session_id":"ses_01KW0J3F…","type":"session_started","prev_hash":"1e4d0428…e34dc37"}
{"schema_version":"0.1.0","session_id":"ses_01KW0J3F…","type":"session_status_changed","from":"initialized","to":"running","prev_hash":"e3108afa…efb2bc9"}
{"schema_version":"0.1.0","session_id":"ses_01KW0J3F…","type":"session_ended","exit_code":0,"prev_hash":"9d11ac73…b4b29f0e"}

$ basou verify
ses_01KW0J3F…  verified (5 events)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Markdown is easy to read, but it's only a copy. The source of truth is the hash-chained JSONL — which is why a machine can answer "has this record been tampered with?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Design promises
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local-first / zero-network.&lt;/strong&gt; It reads the agent logs already on your machine and writes only to the &lt;code&gt;.basou/&lt;/code&gt; directory next to your code. Nothing leaves the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic.&lt;/strong&gt; No runtime LLM. It just writes down the structure the agent handed it, so the same input produces the same record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reversible.&lt;/strong&gt; Everything stays inside &lt;code&gt;.basou/&lt;/code&gt;, and generating commands are dry-run by default and non-destructive. Adopting it — or backing out — is low-risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI surface is frozen even at 0.x.&lt;/strong&gt; The on-disk format is versioned and ships with a JSON Schema. The format and semver guarantees land at 1.0, which is what I'm building toward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apache-2.0.&lt;/strong&gt; A single author dogfooding it daily, aiming for a 1.0 that outside maintainers and teams can rely on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start in five minutes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Node.js &amp;gt;= 20.10&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @basou/cli

&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/your/project
basou init

&lt;span class="c"&gt;# Try a minimal record by hand&lt;/span&gt;
basou task new &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Refactor login form"&lt;/span&gt;
basou note &lt;span class="s2"&gt;"Starting from a look at auth.ts"&lt;/span&gt;
basou handoff generate &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; .basou/handoff.md

&lt;span class="c"&gt;# Or ingest in bulk from the agent logs already on disk&lt;/span&gt;
basou refresh
basou view   &lt;span class="c"&gt;# local UI, 127.0.0.1 only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A walkthrough with sample output and the usual gotchas is at &lt;a href="https://basou.dev/quickstart/" rel="noopener noreferrer"&gt;basou.dev/quickstart/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One more thing: if basou is the side that records what an agent &lt;em&gt;did&lt;/em&gt;, there's a companion project, &lt;a href="https://kozou.org" rel="noopener noreferrer"&gt;Kozou&lt;/a&gt;, for the side that tells an agent what a database &lt;em&gt;means&lt;/em&gt;. I'll introduce that one separately too.&lt;/p&gt;

&lt;h2&gt;
  
  
  In summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In the age of coding agents, the scarce resource is control — staying in the saddle, driving every project the same way, and not losing the "why" between sessions.&lt;/li&gt;
&lt;li&gt;basou is the harness for that: a saddle (a declarative workspace) and reins (orientation that carries intent), with a hash-chained, replayable trail underneath.&lt;/li&gt;
&lt;li&gt;All of it as plain files next to your code. Local-first, zero-network, no runtime LLM. Adoption is low-risk, and you can always back out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository is &lt;a href="https://github.com/basou-dev/basou" rel="noopener noreferrer"&gt;github.com/basou-dev/basou&lt;/a&gt;; the docs are at &lt;a href="https://basou.dev" rel="noopener noreferrer"&gt;basou.dev&lt;/a&gt;. If something trips you up on 0.x, I'd be grateful for an issue.&lt;/p&gt;

&lt;p&gt;The Japanese version of this post — its "paired" article — is already live.&lt;/p&gt;

</description>
      <category>basou</category>
      <category>aiagents</category>
      <category>harnessengineering</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Building a Bilingual Blog on Cloudflare Workers Static Assets — and Why I Skipped Pages</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Fri, 03 Jul 2026 08:00:19 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/building-a-bilingual-blog-on-cloudflare-workers-static-assets-and-why-i-skipped-pages-419o</link>
      <guid>https://dev.to/takashimatsuyama/building-a-bilingual-blog-on-cloudflare-workers-static-assets-and-why-i-skipped-pages-419o</guid>
      <description>&lt;p&gt;🔗 This article is republished from &lt;a href="https://blog.tak3.jp/en/blog/building-this-blog/" rel="noopener noreferrer"&gt;https://blog.tak3.jp/en/blog/building-this-blog/&lt;/a&gt; — the original source.&lt;/p&gt;

&lt;p&gt;This blog (blog.tak3.jp) is a static Astro site served from Cloudflare Workers static assets, edited through Sveltia CMS, and published in two languages, Japanese and English. When a post goes live, it is automatically syndicated to DEV.to and, by design, to Qiita (a major Japanese developer platform) — either as a summary with a pointer back or in full, chosen per post. If you're reading this on DEV.to, this article reached you through the exact pipeline it describes. (Qiita is the honest exception right now: its API is blocking this account, so that side runs manually for the moment — more on that below.)&lt;/p&gt;

&lt;p&gt;One more meta detail before we start: a Japanese edition of this post exists, and it is &lt;em&gt;not&lt;/em&gt; a translation of this one. It's a pair — same subject, independently written article. That distinction is a deliberate design decision, and it's half of what this post is about. The other half is why I didn't use Cloudflare Pages.&lt;/p&gt;

&lt;p&gt;This is a design write-up, not a tutorial. I'll focus on the decisions: skipping Pages, pairing languages with &lt;code&gt;translationKey&lt;/code&gt;, draft safety in a setup where publishing has side effects, and running a Git-based CMS with no auth infrastructure at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements and stack
&lt;/h2&gt;

&lt;p&gt;What I wanted wasn't "a blog" but a publishing base with my own site as the single source of origin. Five requirements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Publish in both Japanese and English&lt;/li&gt;
&lt;li&gt;Keep all content in Git (history, no lock-in, editable from both an editor and a browser)&lt;/li&gt;
&lt;li&gt;Push operating costs as close to zero as possible (serverless, free tiers)&lt;/li&gt;
&lt;li&gt;Auto-syndicate published posts to external platforms (DEV.to / Qiita)&lt;/li&gt;
&lt;li&gt;Be implementable by AI coding agents, with a spec document as the single source of truth&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The resulting stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSG&lt;/td&gt;
&lt;td&gt;Astro + TypeScript (Content Layer API)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content&lt;/td&gt;
&lt;td&gt;Markdown / MDX + YAML frontmatter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CMS&lt;/td&gt;
&lt;td&gt;Sveltia CMS (Git-based, admin at &lt;code&gt;/admin/&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;Cloudflare Workers static assets + a thin main Worker for language routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Syndication&lt;/td&gt;
&lt;td&gt;A separate Worker (Cron + D1 + KV)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS / sitemap&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@astrojs/rss&lt;/code&gt; / &lt;code&gt;@astrojs/sitemap&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The whole system in one picture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
  subgraph edit[Writing &amp;amp; editing]
    A[Editor / AI agents] --&amp;gt; G
    C[Sveltia CMS /admin/] --&amp;gt; G
  end
  G[(GitHub repo)] --&amp;gt;|push to main| B[Build: astro build → dist/]
  B --&amp;gt;|wrangler deploy| W[Cloudflare Workers&amp;lt;br&amp;gt;static assets + main Worker]
  W --&amp;gt; V[Readers&amp;lt;br&amp;gt;/ja/ and /en/]
  W --&amp;gt;|/feed.json| S[Syndication Worker&amp;lt;br&amp;gt;Cron + D1 + KV]
  S --&amp;gt; Q[Qiita]
  S --&amp;gt; D[DEV.to]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the CMS I chose Sveltia over Decap (formerly Netlify CMS): it keeps Decap-compatible config while being a newer, lighter, actively developed implementation. I treat a Git-based CMS not as a WordPress replacement but as a UI for editing structured content that lives in Git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is Cloudflare Pages deprecated?
&lt;/h2&gt;

&lt;p&gt;Facts first, because this question generates a lot of noise on Reddit and Hacker News. &lt;strong&gt;As of July 2026, there is no official end-of-life announcement for Pages.&lt;/strong&gt; Existing projects are supported, and you can still create new ones.&lt;/p&gt;

&lt;p&gt;The practical reality, though, is "not recommended for new projects." Cloudflare's docs ship an official Pages → Workers migration guide and state plainly that development focus is on Workers going forward, recommending Workers for anything new. Feature-wise, Workers now covers nearly everything Pages does, and adds things Pages never had — Durable Objects, Cron Triggers, richer observability. The remaining gaps in the official compatibility matrix are small (branch-build configurability, for one).&lt;/p&gt;

&lt;p&gt;One clarification, because it fuels the confusion: the thing that &lt;em&gt;is&lt;/em&gt; officially deprecated is Workers &lt;strong&gt;Sites&lt;/strong&gt; — an older, entirely different feature that predates Pages — with a documented migration path to Workers static assets. A good share of the "Pages is deprecated" claims are actually about that.&lt;/p&gt;

&lt;p&gt;So no, I didn't skip Pages "because it's deprecated." I skipped it for one defensive reason and two positive ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The defensive reason:&lt;/strong&gt; adopting Pages for a brand-new project means signing up for a future migration on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Positive reason #1: routing as code.&lt;/strong&gt; This site has &lt;code&gt;/ja/&lt;/code&gt; and &lt;code&gt;/en/&lt;/code&gt; side by side, and no real page at the root &lt;code&gt;/&lt;/code&gt;. Static hosting can't read a visitor's language — but a thin main Worker sitting in front of the static assets can handle exactly one path, &lt;code&gt;/&lt;/code&gt;, and answer with a 302.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrangler.jsonc (excerpt)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"takashimatsuyama-blog"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"worker/index.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"directory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ASSETS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"run_worker_first"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// only the root runs the Worker; everything else is served as-is&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Language negotiation is a three-step cascade: ① an explicit &lt;code&gt;?hl=ja|en&lt;/code&gt; query from the language switcher (recorded in a cookie), ② the cookie from a previous explicit choice (which outranks browser settings), ③ on first visit, &lt;code&gt;Accept-Language&lt;/code&gt; — top preference &lt;code&gt;en*&lt;/code&gt; goes to &lt;code&gt;/en/&lt;/code&gt;, everything else defaults to &lt;code&gt;/ja/&lt;/code&gt;, because this is a Japanese-primary site. The 302 carries &lt;code&gt;Vary: Accept-Language&lt;/code&gt; and &lt;code&gt;Cache-Control: private, no-store&lt;/code&gt; so the language decision never ends up in a shared cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// worker/index.ts (excerpt, simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isRootDocument&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HEAD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// language pages, assets, 404s: straight to static assets&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isRootDocument&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASSETS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 1. explicit switch → 2. cookie → 3. Accept-Language (default: ja)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hl&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ja&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;hl&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirectToLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LANG_COOKIE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ja&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirectToLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirectToLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;preferredLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accept-Language&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Positive reason #2: one deploy story.&lt;/strong&gt; This repo also contains the syndication Worker (Cron / D1 / KV). Instead of maintaining a Pages project and a Workers project side by side, everything is &lt;code&gt;wrangler deploy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trade-off, stated honestly: the build-and-preview experience Pages gave you for free becomes your problem. Here, a push to &lt;code&gt;main&lt;/code&gt; triggers &lt;code&gt;wrangler deploy&lt;/code&gt;, and previews are the URLs issued by &lt;code&gt;wrangler versions upload&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bilingual design: pairs, not translations
&lt;/h2&gt;

&lt;p&gt;URLs use path prefixes, &lt;code&gt;/ja/...&lt;/code&gt; and &lt;code&gt;/en/...&lt;/code&gt;, and the hreflang &lt;code&gt;x-default&lt;/code&gt; points to &lt;code&gt;/ja/&lt;/code&gt;. Most i18n write-ups assume an English-primary site; this one is Japanese-primary, and I wanted zero ambiguity about what a visitor with an unknown language preference sees.&lt;/p&gt;

&lt;p&gt;The decision that actually needed thought was how to hold the content collections:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;A: one collection per language&lt;/th&gt;
&lt;th&gt;B: single collection + &lt;code&gt;lang&lt;/code&gt; filter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Queries&lt;/td&gt;
&lt;td&gt;call &lt;code&gt;getCollection('blog_ja')&lt;/code&gt; etc. per language&lt;/td&gt;
&lt;td&gt;one uniform query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CMS mapping&lt;/td&gt;
&lt;td&gt;1:1 with CMS collections&lt;/td&gt;
&lt;td&gt;language is pickable (and mis-pickable) in the UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;td&gt;a forgotten &lt;code&gt;lang&lt;/code&gt; filter fails silently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I went with A (&lt;code&gt;blog_ja&lt;/code&gt; / &lt;code&gt;blog_en&lt;/code&gt; / &lt;code&gt;notes_ja&lt;/code&gt; / &lt;code&gt;notes_en&lt;/code&gt;). The deciding factor: neither a human editor nor an AI agent should ever have to think about which language they're in. Each Sveltia collection maps 1:1 to an Astro collection, &lt;code&gt;lang&lt;/code&gt; is a hidden fixed field in the CMS, and the entire class of "silently leaking the wrong language" bugs disappears.&lt;/p&gt;

&lt;p&gt;The two languages are linked through a &lt;code&gt;translationKey&lt;/code&gt; in the frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// content.config.ts (excerpt, simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blogSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ja&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;translationKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
  &lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;syndication&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blog_en&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src/content/blog_en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blogSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part: &lt;strong&gt;the slug is independent of the translationKey.&lt;/strong&gt; The two editions of a post may have different slugs, different titles, different structure. What you build is not a translation but a pair — two articles on the same subject, each written for its audience. This very post demonstrates it: the Japanese edition opens differently, orders its arguments differently, and spends more words on the platforms Japanese readers syndicate to.&lt;/p&gt;

&lt;p&gt;Since &lt;code&gt;translationKey&lt;/code&gt; is typed by hand, a typo fails silently — the language-switch link just doesn't appear. That's fixed with a two-tier build check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A translationKey that exists in only one language → &lt;strong&gt;warning&lt;/strong&gt; ("not translated yet" is a normal state, so it must be allowed)&lt;/li&gt;
&lt;li&gt;A duplicate translationKey within one language → &lt;strong&gt;error&lt;/strong&gt; (that's always a typo)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You cannot mechanically distinguish "no pair yet" from "typo'd key." So route the possibly-legitimate case to a warning and the impossible case to an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draft safety across every output path
&lt;/h2&gt;

&lt;p&gt;Posts with &lt;code&gt;draft: true&lt;/code&gt; are excluded from all of this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Output path&lt;/th&gt;
&lt;th&gt;Drafts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Home / lists / detail pages / tags&lt;/td&gt;
&lt;td&gt;excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS / JSON Feed&lt;/td&gt;
&lt;td&gt;excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sitemap&lt;/td&gt;
&lt;td&gt;excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language-switch links, related posts&lt;/td&gt;
&lt;td&gt;excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External syndication (Worker)&lt;/td&gt;
&lt;td&gt;excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a plain static blog, a mis-published draft is fixed with a redeploy. Here, publishing has side effects: it triggers syndication to DEV.to and Qiita. Deleting the post from your own site doesn't recall what already left. So the CMS defaults new posts to &lt;code&gt;draft: true&lt;/code&gt;, and the exclusion is enforced not just in HTML but in feeds, the sitemap, and the syndication Worker. Automated syndication and draft discipline only work as a set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sveltia CMS with a token — no OAuth proxy
&lt;/h2&gt;

&lt;p&gt;The standard way to run a Git-based CMS against GitHub is to deploy an OAuth proxy for authentication. This site doesn't have one. There's a single editor (me) and a single target repo, and Sveltia offers "Sign in with Token": paste a GitHub fine-grained PAT and you're in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# public/admin/config.yml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github&lt;/span&gt;
  &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-account/your-blog-repo&lt;/span&gt; &lt;span class="c1"&gt;# point at your repo&lt;/span&gt;
  &lt;span class="na"&gt;branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;span class="na"&gt;publish_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="na"&gt;media_folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/images/uploads&lt;/span&gt;
&lt;span class="na"&gt;public_folder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/images/uploads&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is scoped down hard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A fine-grained PAT restricted to this one repository&lt;/li&gt;
&lt;li&gt;Contents: Read and write (plus the mandatory Metadata: Read) — nothing else&lt;/li&gt;
&lt;li&gt;Expiring. With &lt;code&gt;publish_mode: simple&lt;/code&gt; (direct commits to main), no PR permissions are needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest risk assessment: the token lives in the browser's localStorage and goes straight to the GitHub API. You keep zero secrets on your own infrastructure, in exchange for operational rules — don't use shared machines, rotate on expiry. For a single-operator personal blog, removing an entire moving part (the OAuth proxy) is worth that trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The syndication pipeline, briefly
&lt;/h2&gt;

&lt;p&gt;The reveal for the opening line. The site emits &lt;code&gt;/feed.json&lt;/code&gt; (JSON Feed), where each item carries a &lt;code&gt;_meta&lt;/code&gt; block for syndication: the GitHub sourcePath / gitRef / sha, the canonical URL, and the post's &lt;code&gt;syndication&lt;/code&gt; settings. A separate Worker polls it on a Cron schedule, detects new posts, and publishes to whichever platforms are set to &lt;code&gt;enabled&lt;/code&gt; — with an allowlist: only DEV.to and Qiita are ever posted to automatically; everything else stays manual. Each post chooses whether its syndicated copy is a summary with a pointer back or the full text (via the &lt;code&gt;syndicationBodyMode&lt;/code&gt; frontmatter field); either way, the canonical URL stays on this blog. This post is syndicated in full.&lt;/p&gt;

&lt;p&gt;One honest caveat: at publish time, Qiita's API returns 403 Forbidden for this account — not a scope or rate-limit problem, but an account-level block on creating items through the API (the same account posted fine a few days earlier). So Qiita is temporarily manual while DEV.to keeps running automatically. A post about automating syndication, tripped up by a real syndication limit — which felt worth stating plainly.&lt;/p&gt;

&lt;p&gt;Duplicate protection is single-flight via a unique constraint in D1, and every adapter ran in dry-run mode (no real HTTP, no storage writes) before going live. Retries, rate limits, and partial-success handling deserve their own post, so that's where they'll go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;compatibility_date&lt;/code&gt; cannot be in the future.&lt;/strong&gt; The moment the main Worker was added, deploys started failing runtime validation — because the wrangler config carried a compatibility date Cloudflare hadn't released yet. An assets-only config had accepted it silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single collection for static pages collides.&lt;/strong&gt; The glob loader uses the frontmatter &lt;code&gt;slug&lt;/code&gt; as the entry id, so putting &lt;code&gt;/ja/about&lt;/code&gt; and &lt;code&gt;/en/about&lt;/code&gt; in one &lt;code&gt;pages&lt;/code&gt; collection collides on the same slug. Pages got split per language too (&lt;code&gt;pages_ja&lt;/code&gt; / &lt;code&gt;pages_en&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin the CMS CDN version.&lt;/strong&gt; Loading Sveltia from a &lt;code&gt;latest&lt;/code&gt; CDN URL means the admin UI can change behavior whenever upstream ships. The script tag in &lt;code&gt;/admin/index.html&lt;/code&gt; is version-pinned; upgrades happen on my schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The value of Workers static assets is "static hosting where routing is code." You don't need "Pages is deprecated" as a reason — a thin Worker in front and a single deploy story are positive ones&lt;/li&gt;
&lt;li&gt;Design bilingual content as pairs, not translations. Separating &lt;code&gt;slug&lt;/code&gt; from &lt;code&gt;translationKey&lt;/code&gt;, plus a warning/error split in validation, is the whole implementation&lt;/li&gt;
&lt;li&gt;If publishing triggers syndication, draft discipline has to cover every output path, not just HTML. The automation and the discipline only exist together&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This repo was implemented primarily by AI coding agents, working from a spec document as the single source of truth. That workflow — and the open-source harness for steering coding agents that I'm building — is a story for another post.&lt;/p&gt;

&lt;p&gt;The Japanese pair of this article is already live on the &lt;code&gt;/ja/&lt;/code&gt; side of this blog.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>sveltiacms</category>
      <category>cloudflareworkers</category>
      <category>i18n</category>
    </item>
    <item>
      <title>About this blog</title>
      <dc:creator>Takashi Matsuyama</dc:creator>
      <pubDate>Wed, 24 Jun 2026 23:00:34 +0000</pubDate>
      <link>https://dev.to/takashimatsuyama/about-this-blog-28ij</link>
      <guid>https://dev.to/takashimatsuyama/about-this-blog-28ij</guid>
      <description>&lt;p&gt;Starting a personal media that records and shares all my work.&lt;/p&gt;

&lt;p&gt;Read the full article 👉 &lt;a href="https://blog.tak3.jp/en/blog/hello-blog/" rel="noopener noreferrer"&gt;https://blog.tak3.jp/en/blog/hello-blog/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>cloudflare</category>
    </item>
  </channel>
</rss>
