Updated in 1.0

One Graph, Every Project

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.

Why a server, not a file per repo

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.

Two writable stores, if you're not careful. Once a project has a remote, the CLI (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.

Setup, once per machine

Store the token once — outside every repository, so it can never be committed by a stray git add.

deciduous remote login
$ deciduous remote login --url https://example.com/deciduous-mcp
Token (read from stdin, not saved to shell history):
Token: stored in /Users/you/.config/deciduous/credentials
mode 0600, outside every repository

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.

Point a project at the server

Inside a repository:

deciduous remote init
$ deciduous remote init https://example.com/deciduous-mcp
Remote: https://example.com/deciduous-mcp
workspace: blog
server holds 719 nodes, 845 edges, 0 documents
Written to .deciduous/config.toml. The token stays in DECIDUOUS_MCP_TOKEN.

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:

.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:

custom workspace name
$ deciduous remote init https://example.com/deciduous-mcp --workspace my-project

Configure many projects at once

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:

deciduous remote adopt
$ deciduous remote adopt ~/code --url https://example.com/deciduous-mcp --dry-run
Would configure: 91 projects under /Users/you/code
blog already configured
scratch (some-experiment) would configure
my-app would configure
...
90 would be configured, 1 left alone
$ deciduous remote adopt ~/code --url https://example.com/deciduous-mcp
90 configured, 1 left alone

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.

Checking for drift

status compares local and remote counts and names which side is ahead — the advice differs depending on the direction:

deciduous remote status
$ deciduous remote status
Remote: https://example.com/deciduous-mcp
Workspace: blog
local remote
nodes 719 719
edges 845 845
OK: counts match.

Equal counts print counts match, not in sync — two graphs can hold the same number of nodes and different nodes. When they differ:

drift, in either direction
local remote
nodes 2 3
Drift: the server holds more than this machine. `deciduous remote pull` to refresh.
local remote
nodes 2 0
Drift: local holds more than the server. `deciduous remote push` to send it up.

Pull and push

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.

a node written via MCP, pulled into local SQLite
$ deciduous remote status
nodes 2 3
Drift: the server holds more than this machine. `deciduous remote pull` to refresh.
$ deciduous remote pull
Pulled: roundtrip <- https://example.com/deciduous-mcp
fetched 3 nodes, 1 edges
imported 1 nodes, 0 edges into the local database
$ deciduous nodes
3 68701826 observation pending Written remotely, never seen 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:

seeding a workspace
$ deciduous remote push
Pushed: roundtrip -> https://example.com/deciduous-mcp
nodes 2 of 2
edges 1 of 1

Asking across every project

Read tools accept workspace: "*", over MCP, for the cross-project view:

CallResult
query_nodes workspace="blog"just that repository
query_nodes workspace="*"every repository, each node tagged with its own
list_workspacesthe index — what exists, how big, when it last changed

Writes refuse the global token, because a node has to land somewhere specific:

add_node workspace="*"
{"code": -32000, "message": "workspace \"*\" is read-only: a node must be written to one project. Pass the repo name."}

Pinning a repository

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.

.mcp.json

{
  "mcpServers": {
    "deciduous": {
      "type": "http",
      "url": "https://example.com/deciduous-mcp/mcp",
      "headers": {
        "Authorization": "Bearer ${DECIDUOUS_MCP_TOKEN}",
        "X-Deciduous-Workspace": "my-project"
      }
    }
  }
}
Use ${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.

Working alongside other agents

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.

Locks

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.

add_node from a second session on a branch someone else holds
workspace "blog" (branch "feat/auth") is locked by claude-code (2.1.278), session GNeD3Qwg. Releases in 8s if that session goes idle, or finishes sooner. Retry shortly, or write to a different branch.

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.

Fixed in 1.0: 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.

check_activity

The read side. Call it before a burst of writes, or after reading a peer's decision and before acting on it.

FieldWhat it holds
sessionsevery unexpired lock: branch, client, version, whether it is you, seconds left on the lease
branchesthe 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.

Events

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:

one frame

{"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.

deciduous remote watch

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.

deciduous remote watch
Watching: tetris-arena
04:45:14 agent-9 observation "Took 4 hidden rows from agent-3: SRS kicks can lift a piece 2 rows at the ceiling"
04:46:13 agent-10 observation "Took 4 hidden rows (agent-9, from agent-3) and lock-out (agent-2, from agent-1); dropped my y<0 exemption"
04:47:15 agent-7 observation "Took the step-reset for lock delay from agent-8's decision, and spawn-then-step-down and lock-out from agent-8 and agent-3"
FlagEffect
--types outcome,observationonly these node types
--branch agent-3only this branch
--edgesinclude edge frames, printed as agent-3 edge chosen a1b2c3d4 -> e5f6a7b8
--jsonthe raw frame, one per line
--urlprint the socket URL and exit, for another client
--claude-codewith --url: also print a Monitor(...) call to paste into Claude Code
Why quote rather than count. During the arena a session watching the raw stream kept a per-branch tally and reported that a "second goal" convention was spreading through the agents. The graph had ten goals, one per agent; the tally had counted updates as inserts. A line that quotes the title and the operation cannot invent that.

took_from

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

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.

Running the server

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.

What this doesn't do

  • Themes don't transfer. The graph export used to move data to the server doesn't carry theme assignments yet, so they stay local to each repository's SQLite.
  • push isn't the normal write path. It exists to seed a workspace and to carry history that predates the server.
  • There's no offline queue. Without the network you have a local cache and a CLI that can still write to it; those writes reach the server on the next push, and until then the two differ.
  • Self-loop edges are rejected by the server's schema, and reported per project rather than silently dropped.
  • A session that outlives the server is refused, not resumed. Sessions expire after 24 idle hours, and a restart drops all of them. The next call on an old 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.
  • The event stream is advisory. Postgres does not queue a 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.
  • Locks are advisory too. A client that ignores the refusal and writes anyway still can. The lock is a refusal with a name attached, not a constraint on the table.
  • A document can outlive its bytes. If a file referenced by a document row is missing at import time, the row still imports as history and a fetch answers 410 Gone rather than 404 — the attachment was real; the bytes are gone.

Prefer plain text? Read this page as markdown.