One Postgres server holds every repository's decision graph, one workspace each — queryable alone or all at once. The server is the truth; your local database becomes a cache of it.
The default Deciduous setup keeps one SQLite database per project, synced between
machines through a graph.json file in git. That works well for a team on
one repository. It has no answer for a question that spans several: “what did I
decide about auth, across every project I've touched?”
A shared server answers that. Every repository still gets its own workspace — nothing about per-project isolation changes — but the data lives in one place, so a single query can also cross every workspace at once.
deciduous add) and any MCP client (add_node)
can both write. Nothing links them automatically — that's what
deciduous remote status is for. Treat the server as the source of truth
and the local database as a cache you refresh with pull, not a second copy
you edit independently.
Store the token once — outside every repository, so it can never be committed by
a stray git add.
login verifies the server answers and that the token works before
storing anything. Read from stdin, so the token stays out of shell history and the
process list. Every subcommand afterward reads it from
~/.config/deciduous/credentials automatically — no environment
variable needed, though DECIDUOUS_MCP_TOKEN is checked first for CI and
one-off overrides.
Inside a repository:
init verifies the server answers before writing anything — a saved
URL that doesn't answer is worse than no URL, because the failure only surfaces later,
on some unrelated command. It writes one table to .deciduous/config.toml:
[remote]
url = "https://example.com/deciduous-mcp"
A URL and nothing else. This file is meant to be committed — it's how every clone of the repository finds the same workspace — and it can be, safely, because the token never goes in it.
The workspace name is the git repository root's directory name, lowercased, not the current directory — so running the CLI from a subdirectory can't split one project across two workspaces. Override it if the directory name isn't what the graph should be called:
adopt walks a directory for projects that already have a
.deciduous, and points each at the server. It never repoints a project
already aimed somewhere else — that moves where its decisions land, and is not a
bulk operation's call. Dry-run first if you have a lot of repositories:
Several directories can share one workspace name: anything outside a git repository
pools into scratch rather than minting a workspace per temporary
directory, so adopt prints the directory alongside the workspace whenever
they'd otherwise be indistinguishable.
status compares local and remote counts and names which side is ahead
— the advice differs depending on the direction:
Equal counts print counts match, not in sync — two graphs can hold the same number of nodes and different nodes. When they differ:
pull is the normal way to refresh — it fetches the server's graph
and merges it into the local cache through the same reconcile logic
deciduous sync already uses between machines: newer updated_at
wins, deletes are tombstones, an edge waits until both endpoints exist locally.
push is not the normal write path — routine writes should go through
MCP or the CLI's own add, which write locally and are then pulled or pushed
as part of the normal loop. push exists to seed a new workspace, or to
carry history that predates the server:
Read tools accept workspace: "*", over MCP, for the cross-project view:
| Call | Result |
|---|---|
query_nodes workspace="blog" | just that repository |
query_nodes workspace="*" | every repository, each node tagged with its own |
list_workspaces | the index — what exists, how big, when it last changed |
Writes refuse the global token, because a node has to land somewhere specific:
A repository that wants its workspace unambiguous — regardless of what any tool
call says — adds a .mcp.json with a header. The header beats the
workspace argument, on reads and writes alike, and a pinned repository
ignores workspace: "*" on reads too: a project that opted into isolation
can't be made to read its neighbours.
{
"mcpServers": {
"deciduous": {
"type": "http",
"url": "https://example.com/deciduous-mcp/mcp",
"headers": {
"Authorization": "Bearer ${DECIDUOUS_MCP_TOKEN}",
"X-Deciduous-Workspace": "my-project"
}
}
}
}
${DECIDUOUS_MCP_TOKEN}, never a literal token.
.mcp.json is a file people commit — that's how a team shares server
config — and Claude Code expands ${VAR} from the environment when it
connects. With the variable unset it refuses and says so:
Missing environment variables: DECIDUOUS_MCP_TOKEN. A literal token here is
a credential committed to your repository.
Two sessions writing to one workspace need three things: not to interleave, to know the other exists, and to find out when it writes. The server does all three, and the branch is the unit for each of them.
Every write claims an advisory lock keyed on (workspace, branch) with a ten
second lease. The same session writing again renews it, so one lease covers an arbitrary
run of writes; a different session on the same branch is refused and told who holds it and
for how long. Sessions on different branches never contend. This is why the arena ran ten
agents in ten worktrees rather than one directory: the branch name is the lock key.
Pass branch on every write. Without it a session locks the empty-branch bucket,
and contends with everyone else who also forgot. A workspace that wants branches to contend
sets lock_scope: "workspace" in its settings.
update_node, delete_node and
delete_edge did not claim the lock at all. They resolve the node's workspace
and claim it now. The 0.19 commit that said it closed this gap had only added the
branch argument to their schemas; the tools never called the lock.
The read side. Call it before a burst of writes, or after reading a peer's decision and before acting on it.
| Field | What it holds |
|---|---|
sessions | every unexpired lock: branch, client, version, whether it is you, seconds left on the lease |
branches | the twenty most recently written branches, each with its last node (type, title, change_id, created_at) and who holds its lock, if anyone. Pass branches: N for more; branches_total says how many the workspace has in all |
branches is new in 1.0. In the arena the agents polled query_nodes
for decisions because the lock list was the only thing check_activity returned;
the last node on each branch is what they were actually after.
Postgres triggers call pg_notify on every insert or update to
decision_nodes and every insert to decision_edges. One listener
bridges that to a WebSocket at GET /events, scoped to a workspace or to
"*" for all of them. A frame is a pointer with enough text to quote, not the
row:
{"table":"decision_nodes","op":"INSERT","workspace":"tetris-arena",
"id":"…","change_id":"8d2f482a-…","node_type":"observation","status":"pending",
"branch":"agent-7",
"title":"Took the step-reset for lock delay from agent-8's decision, …"}
Edge frames carry edge_type, both endpoints' change_id, and the
branch of the source node. Edges have no branch of their own; before 1.0 a watcher could not
tell whose edge it was looking at.
The handshake cannot carry an Authorization header from a browser
WebSocket or anything built on one, so ?token= is accepted as a
fallback. It is a bearer token in a URL: do not paste it anywhere that logs.
Connects to that socket for the current repository's workspace and prints one line per write. It quotes; it does not count. It reconnects with backoff when the socket closes.
| Flag | Effect |
|---|---|
--types outcome,observation | only these node types |
--branch agent-3 | only this branch |
--edges | include edge frames, printed as agent-3 edge chosen a1b2c3d4 -> e5f6a7b8 |
--json | the raw frame, one per line |
--url | print the socket URL and exit, for another client |
--claude-code | with --url: also print a Monitor(...) call to paste into Claude Code |
An edge type for a borrow. from is the node you took from, to is
your node that used it, and it crosses branches on purpose. log_observation
takes took_from (a node UUID or a full change_id) and why (the edge's rationale) and writes the observation and the edge
in one call; add_edge and deciduous link -t took_from take it
directly.
log_observation({
workspace: "tetris-arena", branch: "agent-7",
title: "Took the step-reset for lock delay from agent-8's decision",
took_from: "767cce30-9d0b-4e56-a4d8-2f0b1c8e7a11",
why: "Resetting the lock timer on every step is what makes fast play at the floor feel fair.",
description: "agent-8's game.js did not have it coded yet when I read it, so this implements the idea from the graph rather than from code."
})
In the arena zero of 471 edges crossed a branch. The agents cited each other in 89 of 91
observation titles, four of them by change_id, and never linked to the node, because the
rules said to link every node to its parent and did not say the node you borrowed from
counts. The borrow figures in the write-up come from a regular expression over titles. That
is the wrong place for that information to live; took_from is the right one.
The server is an Elixir app (deciduous_mcp/ in the repository) backed by
Postgres, with an MCP endpoint over Streamable HTTP. Documents are stored in Postgres
too, keyed by content hash, so a file attached to more than one project's graph is
stored once. It's meant to sit behind a reverse proxy on infrastructure you already
run — the reference deployment joins an existing Docker Compose stack the same
way any other service in it does.
See deciduous_mcp/DEPLOY.md in the repository for the full runbook:
creating the database, generating a token, the compose service, and the reverse-proxy
route.
push isn't the normal write path. It exists to seed a
workspace and to carry history that predates the server.push, and until then the two differ.mcp-session-id gets 404 with JSON-RPC error -32001 Session not
found under the request's own id, and the client starts a new session. Before 1.0 the
server answered such a call with a 200 under a made-up id, and Claude Code waited
300 seconds for a reply it could never match, on every call, until the process was
restarted.NOTIFY that fires while the listener's connection is down, and a reconnect
races new LISTENs against notifications issued at the same moment. A gap in
the stream is not proof that nothing happened; a subscriber that needs certainty calls
check_activity or query_nodes to catch up.Connecting Claude Code or Claude Desktop to a running server.
Prefer plain text? Read this page as markdown.