Status: experimental — validation-first MVP, past its first external trial. Pre-releases (v0.4.0-alpha.1) are run from source; no binaries are distributed.
A desktop app that renders the semantic model kozou compiles from your PostgreSQL schema — table/column COMMENTs with @ai/@policy tags, views and their lineage, foreign-key relationships and their documented meaning — as a human-facing visual map, across multiple databases. It is read-only by default: row browsing and row editing are per-profile opt-ins, and the introspection and MCP surfaces stay read-only whether or not you enable them. (A database's row-access level is shown wherever the database is described — the bar above the map when it is selected, and its card in the All databases view — and each opt-in is offered separately, browsing first, editing as its own approval; a Data tab then appears on the detail pane.) Comments can be edited into COMMENT ON statements the app hands back to you — it applies none of them, so that path needs no grant and reaches no database.
AI agents already see this model through kozou's MCP describe surface. Generic DB clients show raw tables and none of the semantics. This app is the missing human-facing side: see what your AI sees.
- Is: a local visual surface for compiled schema semantics — read-only unless you opt a profile in — with named per-database profiles:
- Semantic map — tables, views, FK relationships (with their documented meaning), view lineage, and
@ai/@policy/RLS badges, laid out as a graph. - Detail pane — the full compiled semantics of a relation, including join suggestions and example queries.
- AI view — the payload an AI agent receives from the MCP describe tools of a default-configured kozou server for that relation: same functions, same serialization. Server-side opt-ins (RPC exposure config, privilege-aware annotations) are not reproduced yet.
- Cross-database overview — the All databases view at the top of the rail: a card per database with relation counts and annotation coverage, side by side.
- Cross-database search — find a table/view by name, comment, or
@ainote across every open database and jump to it. - Row browsing (opt-in, off by default) — once a profile is opted in through a native approval dialog, the detail pane gains a Data tab: rows of the selected table or view, sorted by column, walked with keyset cursors. Reads run inside a
READ ONLYtransaction; a relation without a primary key has no total order, so it shows a single page and says so. (Sorting covers every column whose name the REST layer's comma-separated sort grammar can express — a name containing a comma is left out of the control rather than offered and rejected.) The pager counts the steps of the walk rather than naming an absolute page: there is no row count behind it, so nothing there licenses arithmetic about how many rows lie before the ones on screen. This pane is a preview, not a viewer — large values are cut before the rows leave the database connection (1024 characters or bytes; a larger json value is dropped) and every cut is marked in the cell rather than passed off as the value. The same limit applies to the page cursors, which carry the sort values of the row they stop at: sorting by a column whose values are too large to fit a cursor ends the walk, and the pane says so instead of offering a hop it cannot make. A database's current row-access level is shown whether or not anything is granted — on the bar above the map while it is selected, and on its card in the All databases view — and says in words what the level means: while off, that no row query runs at that level and that editing carries an approval of its own; once granted, that the Data tab is where the grant is used and that your database decides what that connection may see or change. A profile edit that repoints it at a different database, different schemas, or a different credential state drops the grant back to off, so the next browse asks for approval again. - Row editing (a second opt-in, also off by default) — a browsing grant does not carry it: editing is its own level, approved through its own native dialog, and the Data tab gains write controls only once it is in force. A row is inserted, edited or deleted one statement at a time, each in its own transaction. Every field answers one of three ways — leave it to the database, send a value, or send null — so a generated key can be generated and a nullable column can be cleared, and an edit sends only the fields you changed. Editing starts by re-reading the row in full, so a value the browse pane had to shorten is never written back shortened. What can be edited is deliberately narrow: a view is not offered write controls (kozou refuses a write to one), and neither is a table without a primary key or a row whose key this app cannot put in a request — including a key too large to have travelled in full, where what is on screen is only its beginning and could belong to another row. A field whose stored value has no text form this app can write back (a byte array, an array, a geometric type) is shown as read-only rather than as a rendering you could accidentally save. Refusals come back as fixed sentences (permission denied, no matching row, conflicts with existing data), which is also why they never quote the values involved; the database has the final say on all of it, and a value's type is checked by kozou before any statement runs. There is no optimistic concurrency: if someone else changes a field between your opening the editor and saving, your value wins.
- Comment drafting (no opt-in, because it touches no database) — the Semantics tab can edit the COMMENT of a relation or a column and hand you back the
COMMENT ONstatement. It never runs one: the statements collect in a panel you copy or save as a.sqlfile, and you apply them the way you apply any other schema change. The text box is seeded with the comment exactly as it stands in the database, not with the rendered description above it — kozou lifts@widget:/@example:blocks out of that field, so editing what you see there would silently delete them (there is a test that performs that mistake against a real database and watches the tag disappear). A materialized view is named as one, becauseCOMMENT ON VIEWis an error against it; if the app cannot read which kind a view is, it offers no statement for the view rather than guessing. Drafts live for the session and belong to the profile they were written against.
- Semantic map — tables, views, FK relationships (with their documented meaning), view lineage, and
- Is not: a chat client (bring your own — Claude Desktop, Cursor, etc. connect to kozou over MCP), a migration tool, or a general DB client. It is not a schema editor either: schema and COMMENTs stay in SQL/Git, which is why the comment editor hands you a statement instead of applying one.
Not sure whether you want this or a Kozou server? This app is the one you look at — several databases' semantics in one interactive view. A server is what other things reach: a database your machine cannot open a socket to, a client that only accepts a URL it can reach over the network, a second person, or an agent that should execute rather than only read. The desktop app, or a server? is the decision in full.
No installer yet — run it from source (Node 22+ and pnpm required):
git clone https://github.com/kozou-dev/kozou-desktop.git
cd kozou-desktop
pnpm install
pnpm startpnpm start builds and launches the packaged app, so you run it under the
same strict Content-Security-Policy the shipped build enforces (see Security
posture below). pnpm dev also works but uses a looser, localhost-only dev
CSP for hot reload — prefer pnpm start for a trial.
Then click + Add database, paste a read-only PostgreSQL connection URL
(postgresql://user:password@host:5432/db), list the schemas to include, and
press Save profile. Add a second database the same way.
Databases are listed in the rail down the left, one line each, and clicking one opens it: a bar describing that database — its connection, its row-access level, its MCP row — above the map and the detail pane. All databases at the top of the rail switches to a card per database side by side, which is where annotation coverage is comparable across them. The search box above spans every database that has been introspected, whichever of the two you are looking at.
The map and the detail pane share the workspace, except when they cannot usefully: opening the Data tab collapses the map and gives the pane the whole width, because a row grid in a side column is mostly horizontal scrolling. full width / show map in the tab row switches that by hand on any tab — so getting back to the map after browsing is one click, and reading semantics wide is available without going through Data.
The icon in the left margin, level with All databases, stows the database list. Between switches it is a list nobody is reading, and the database you are in is named in the bar above the workspace anyway; the width it gives up goes to the map, which is the pane that wanted it (measured at a 1280px window: the map goes from 529px to 751px, and the detail column does not move). The icon costs none of that width, because it sits in the margin the shell already had. It stays there whether the list is showing or not — a control that hides something cannot live inside what it hides — and it stays on screen when the page scrolls, because a way back you have to scroll back up to find is one you will not find.
Enum types, Functions and Drafts share one row along the bottom of
the workspace. Shut, that row is the whole of what they cost; opening one gives
it the workspace and collapses the map and the pane, the same switch the Data tab
makes, and the row stays below it to shut again. Drafting a COMMENT ON
statement opens the drafts panel, since a statement you have to go looking for is
one you have no reason to think exists.
Connect with a least-privilege role. On Supabase, do not use
service_role/postgres (they bypass row-level security). A read-only role is
enough for everything this build does: it introspects inside a READ ONLY
transaction, and row data is reached only for a profile you explicitly opt in
(a native dialog has to approve it). Row browsing runs inside a READ ONLY
transaction too, so a read-only role covers it as well. Row editing is a further
opt-in with its own dialog: grant write privileges only if you mean to use it,
and remember that the database, not this app, has the final say on what your role
may change.
If you are trying this at our request, see TRIAL.md for what feedback is most useful.
The app can also serve the same compiled semantics to AI clients on your machine, so you and an agent read one model instead of two. It is off by default, and read-only regardless of anything else you have opted into.
- In Settings, tick Allow profiles to serve MCP on loopback. Ticking it starts nothing — it lets a profile be started.
- On the database's own row — the bar above the map when it is selected, or its
card in the All databases view — click start; the badge becomes
MCP on :<port>. - Click AI client config and copy the form your client wants — the panel offers three. The URL it holds is one database's, so moving to a different database closes it; it stays while you are in All databases, where every database is on screen.
mcpServers JSON, for Cursor:
{
"mcpServers": {
"kozou-local-<profile>": {
"type": "http",
"url": "http://127.0.0.1:3335/mcp-<random>"
}
}
}a one-liner, for Claude Code:
claude mcp add --transport http kozou-local-<profile> http://127.0.0.1:3335/mcp-<random>and the bare URL, for any client that takes one directly:
http://127.0.0.1:3335/mcp-<random>
Claude Desktop does not connect to a URL. Its custom connectors are opened
from Anthropic's cloud rather than from your machine and require a publicly
reachable https address, so 127.0.0.1 is not something they can resolve to
your Mac — adding TLS would not help, and exposing the hub publicly is the
opposite of what it is for. Its own configuration file launches local commands
(stdio) instead. Reaching the hub from there needs a local stdio-to-HTTP bridge
process, and this build both contains one (EGRESS.md item 16 describes what
it may and may not do) and offers it — the same panel hands you a fourth
shape, an entry that starts it:
{
"mcpServers": {
"kozou-local-<profile>": {
"command": "/Applications/Kozou.app/Contents/MacOS/Kozou",
"args": [
"/Applications/Kozou.app/Contents/Resources/app.asar/out/main/stdioBridge.js",
"--id",
"<locator id>"
],
"env": { "ELECTRON_RUN_AS_NODE": "1" }
}
}
}Three things to know about it. It names a locator, not a URL, so unlike the
three shapes above it leaves the capability path out of another application's
configuration file — the bridge reads the port and the path from a file this app
owns and keeps to itself. Claude Desktop keeps every server it launches in one
file, so add this inside the mcpServers object already there rather than
replacing the file, and restart it afterwards (it reads that file at startup).
And its paths are absolute, naming this app where it is now. Two failure
modes follow, and only one of them this app can speak to: move the app and your
client's launch fails with nothing to explain it — that happens in the client,
before any code here runs. What the bridge does report is the server: with no
server published for that profile it says so and starts nothing (EGRESS.md
item 16, property (d)). Rebuilding the app in place changes neither path.
What has actually been run, by hand: on 2026-08-09, Claude Desktop 1.26832.0 was
given the entry this app produced, spawned the bridge from it, completed the MCP
handshake, and then answered from its own chat over that entry — the client's log
records two tools/call requests, each answered, and the chat's replies carried the
database's compiled semantics. Stopping the app and calling again over the same entry
produced the explicit failure it is supposed to: a JSON-RPC error naming a refused
connection, with nothing started on its own.
An earlier run (2026-08-07) reached the same point with an entry written by hand;
the relay has changed since then. One machine, one client version, one run;
EGRESS.md keeps the full scope of what that does and does not establish.
One server per profile, so several databases can be served at once — each under its own name, port and path. What is in one place is the managing of them: you start and stop them here, and this app hands you the client config for each. Your client still lists them one by one. A single endpoint that routes to several databases is not what this builds.
What it serves is the describe surface only: list_tables,
describe_table, list_views, describe_view, list_concepts,
get_concept_context, describe_functions, search_schema. There is no
execution tool — it is neither advertised nor dispatchable, and a forced call
is refused (pinned by an integration test against a real database). Row data
never reaches this surface, whatever a profile's row-access grant says.
Worth knowing before you paste:
- The app bounds the listener's lifetime. Quitting closes the port. A
profile you started comes back on the next launch until you press stop —
with three exceptions. Repointing a profile at another database drops that
intent along with the allocation it lives in, so a repointed profile does not
auto-start against a database you have not started it for. Adding a schema
drops the intent too while keeping the allocation: what you pasted still names
this database, but it would reach the added schemas on the next launch without
you doing anything, so starting it again is how you say yes to that. Narrowing
the schema list, rotating or removing the stored password, and changing the role
are none of those: they stop the running server — it was connected with the old
ones — and the profile comes back on the next launch as before. (The row-access
grant is a different matter and drops on all of them — see above.) And a profile
you started over the duplicate-declaration warning is left at
MCP blocked (duplicate)at launch instead, because restoring one would mean re-asking, and the app raises no dialogs while starting up. Any edit that changes what the connection reaches — the database, the schema list, the stored password — stops the server rather than serving the old one under the same name, and the badge says so. - The port is sticky while the profile names the same database — assigned
once from 3335 upward (3334 is skipped: it is the kozou CLI's own default) and
never silently renumbered, because the configs you pasted name it. A bind
conflict is reported as
MCP port busywith a move port control instead of being resolved behind your back, and while that lasts the database's MCP row stops offering to open the config panel (a panel you already had open keeps showing the snippet, above a note that another process is on this port, so that config would point your client at something else). The other thing that replaces it is an edit that points the profile at another database, and there the invalidation is the point: a config you pasted for one database stops resolving rather than quietly serving the new one under the old name. Only the database does this, and "the same database" means the same host, port and database name — the app's own definition, the one behind the duplicate warning, so:5432and the default port are not two databases. Changing the schema list, the stored password or the role stops the running server — it was connected with the old ones — but the port, the path and the locator id survive, so what you pasted still names the same database and resolves again as soon as the server is back. The bridge entry fails immediately, on a locator id nothing answers for; a pasted URL stops resolving once the old server is actually down, which is normally at once and takes up to three seconds if that worker ignores the request to stop. - The path is a capability, not authentication. Any process on your machine that can read your AI client's config files can read schema metadata through it — never row data. Move port therefore rotates the path along with the port: a URL you pasted names the old port, which this app no longer serves — and which, in the case that made you move, another process still holds — so that line is re-copied either way, and rotating means what the other process may have learned is a dead path rather than one that finds the new port. A bridge entry names only the locator id and is unaffected — it reads both port and path from this app when it connects. See EGRESS.md item 10 for the exact local exposure.
- Claude Code, project scope: a server pasted into a project
.mcp.jsonsits atPending approvaluntil you approve it once insideclaude. The copied command adds no--scope, so it lands in whatever scope your CLI defaults to — local at the time of writing, which connects immediately. - A database already served from elsewhere can be marked as such on the
profile: a remote MCP server already serves this database. That declaration
does two things, and neither one depends on the permission above. That
database's own row carries
served remotely (declared)whether or not this app is allowed to serve — what you recorded is about another server, so our own setting does not change it. And starting a local server for a database a declared remote one appears to cover stops to warn you first: two servers could then answer the same question differently, since this app does not reproduce a server's own opt-ins. You can start past that warning, because the declaration is something you told us and not something we checked: the app never contacts that server, a declaration is valid with no URL at all, and a remote server that has since stopped would otherwise leave you unable to start a local one without first deleting the record saying it exists. The word declared is in the badge for the same reason — it reports what you entered, not a reachability check. - What the duplicate check actually compares is host, port and database name
as the connection URL spells them (default port filled in, database name
percent-decoded). It is deliberately one-sided — a miss is accepted, a match
is meant to be reliable — so it resolves no DNS aliases and looks through no
pooler, and it skips the comparison altogether for a URL carrying
host,hostaddr,portordbnamein its query string rather than guess at what such a URL resolves to. In one direction it is looser than "reliable" suggests, which is worth knowing before you trust a match:localhost,127.0.0.1and[::1]are treated as one host, so two different servers bound separately to IPv4 and IPv6 on the same port, with the same database name, count as the same database.
To run it as a real .app instead of from a terminal, build one locally.
The build is unsigned (no Apple Developer ID, no notarization) and
Apple-silicon only (arm64):
pnpm install
pnpm distThis produces dist/mac-arm64/Kozou.app. Drag it into /Applications
(or run it in place). Since you built it locally it launches directly — a
self-built app carries no Gatekeeper quarantine — and appears as Kozou
in the Dock and Spotlight.
If you instead copy Kozou.app to another Mac, macOS quarantines it and,
because it is unsigned, reports it as damaged: the dialog offers Move to
Trash or Cancel, and nothing that opens it anyway. Clearing the flag with
xattr -dr com.apple.quarantine /Applications/Kozou.app is the only way
past it, which is why nothing is published as a binary.
This is a convenience build for local validation. Nothing is distributed: no signing, notarization, auto-update, or published binaries are in scope.
- Read-only by default; write is a separate, opt-in surface: introspection always runs inside a
READ ONLYtransaction, and so does every row read. Row browsing and row editing are off for every profile until you turn them on, per profile, through a dialog drawn by the app's main process (never by the UI, which can only ask). The introspection and MCP surfaces stay read-only regardless of that grant. What enforces the split is structural, and CI checks it (scripts/check-treeshake.mjs): the write path may only be reachable from the row-data worker's bundle — the introspection worker, the MCP server worker and the main process are each bundled and scanned for it; no source anywhere may name@kozou/api's HTTP-server entry points; and no source outside the worker directory may import a database driver, so a hand-written statement is confined to the same process as the generated ones. Reachability, not absence: the packaged app shipsnode_moduleswhole, so the code is present in the artifact whether or not anything can call it.READ ONLYis the database's own enforcement of what PostgreSQL classifies as a write — a view or function that calls out to something external is beyond what any transaction mode can undo. The optional local MCP hub serves the describe tools only — the execution tool is neither advertised nor dispatchable, pinned by an integration test against a real database. - What a row-editing grant really costs: for a profile you opted in to editing, a compromised renderer can write to that database with your stored credentials. That is the one genuinely new risk here, and the mitigations stop at "off by default", "per profile", and "the grant needs a native approval" — see
EGRESS.mdfor why nothing stronger is available inside Electron's trust model. - Zero egress, loopback-only serving: outbound, the only network peer is your own database. No telemetry, no crash upload, no update checks, spellchecker disabled (CI-checked:
scripts/check-egress-static.mjs). By default the app opens no server and no port; the opt-in local MCP hub (which needs the app-wide permission, off by default, and then a per-profile start) listens on127.0.0.1only, behind a per-profile secret path and a DNS-rebinding guard — seeEGRESS.mdfor the exact local exposure. - Secrets: database passwords are stored via Electron
safeStorage(OS keychain-backed), passed to workers via environment only — never argv, logs, or config files. On Linux this additionally requires a real keyring backend: thebasic_textfallback (a hardcoded key) is rejected rather than silently accepted. - Least privilege: connect with a minimal read role. On Supabase, do not use
service_role/postgres(they bypass RLS).
pnpm install
pnpm dev # launch the app (electron-vite)
pnpm typecheck
pnpm test:unitIntegration checks need a PostgreSQL (any database works; CI uses fixtures/contract.sql):
export KOZOU_TEST_DATABASE_URL=postgresql://user:pass@localhost:5432/db
pnpm test:contract # worker output ≡ `kozou inspect` output
pnpm build && pnpm test:e2eApache-2.0. Part of the kozou project (https://github.com/kozou-dev/kozou).