Skip to main content
Glama

Schematic Planner

Plan in the browser. Own the output.

Turn a software idea into a visual plan graph — features, tasks and decisions on a canvas — then take it with you as a plain Markdown tree and an Obsidian Canvas file.

License: AGPL v3 Status: pre-alpha


Table of contents


Related MCP server: tatwo-ultrawork-beta

What this is

AI coding agents are good at writing code and good at writing plans. They are bad at holding a plan still. Ask one to build a feature and it will happily produce a plausible task list, lose half of it three messages later, and reinvent the architecture on the next run.

Schematic Planner is the step before the code. It gives a plan a shape — a graph of features, tasks and decisions laid out on a coordinate plane — so that both the human and the agent are looking at the same artifact. When the shape is agreed, the plan leaves as files you can commit next to your source.

Two properties define the product:

  1. The output is yours. Every plan exports to a directory tree of Markdown files plus an Obsidian Canvas, in a single zip. Nothing about the format needs this service to be readable. If we disappear, your plans still open.

  2. The whole server is yours if you want it. The stack is AGPL-3.0 and runs from one Docker Compose file. No proprietary auth service, no managed-only dependency.

Who it is for

Audience

What they get

Solo developers doing AI-assisted ("vibe") coding

A durable plan an agent can read on every run instead of re-deriving it

Small product teams

A shared canvas for scoping, with real-time co-editing and share links

Obsidian / plain-text users

Plans that land in the vault as Markdown and Canvas, not in someone's database

Companies with source-code policies

A self-hostable instance behind their own network boundary

How it works

The web app and AI agents are peer clients of the same document. An agent adding a task through MCP and a human dragging a node in the browser are writing to the same place, at the same time, and both see the result immediately.

flowchart LR
  W["apps/web<br/>React + Vite + React Flow"]
  A["AI agent<br/>Cursor · Claude · any MCP client"]
  API["apps/api — NestJS"]
  Y["Y.Doc (CRDT)<br/>write model"]
  S["PlanDoc snapshot (jsonb)<br/>read model"]
  Z["plan-export.zip<br/>Markdown tree + .canvas"]

  W <-->|"WebSocket · Hocuspocus"| API
  A -->|"Remote MCP · URL + Bearer key"| API
  API --> Y
  Y -->|"debounced projection"| S
  S --> Z

apps/www (Next.js) sits beside all of this and serves the landing page, docs, guides and legal pages — everything that needs to be indexed by a search engine, and nothing that needs to be interactive.

The data model

This is the single most important thing to understand about the codebase.

Real-time collaboration and agent-driven writes cannot share one representation, so there are two, and the direction between them never reverses:

Y.Doc (Yjs CRDT, stored as bytea)   ← the WRITE model.
                                      Human drags and MCP calls both land here.
        │  debounced projection
        ▼
PlanDoc snapshot (jsonb)            ← the READ model.
                                      Lists, search, share pages and export read this.

Rules that follow from this, and must not be broken:

  • packages/ydoc is shared by apps/web and apps/api. Both sides bind the same document shape from the same code. If either side reimplements the shape, they will drift and corrupt documents.

  • Nodes and edges live in Y.Map keyed by id, never in Y.Array. React Flow reorders its arrays freely; putting that on an array CRDT produces duplicates and lost nodes under concurrent editing.

  • The snapshot is derived. Nothing writes to it directly. If you find yourself patching jsonb, you are in the wrong layer.

  • Sync is served by Hocuspocus embedded in the NestJS process — same auth, same permissions, one container. Splitting it out is a scaling decision to make when load actually shows it, not before.

Where things live

Workspace              people, roles, invitations, and the API keys agents connect with
  └─ Project           one thing being built, and the vocabulary its plans are drawn with
       └─ Folder       optional: a drawer inside the project
            └─ Folder  …which may hold drawers of its own
                 └─ Plan  one graph

Folders nest: Folder.parentId is null at the project's top level and names the drawer it sits in otherwise, always in the same project. A name is unique among its siblings rather than across the project, so Specs/Billing and Archive/Billing can both exist. A folder cannot be moved into itself or anything below it, and moving one moves its whole subtree. A plan does not have to be in a folder at all — folderId is null for every plan at a project's own top level, which is where they all were before folders existed.

Every signed-in screen is one screen: an explorer on the left and the content on the right. The explorer holds the workspace switcher, Recent, and the tree — every project, its folders as deep as they go, and their plans — with the open plan highlighted and its ancestors expanded. Its right edge is a grip, and double-clicking it puts the width back. A plan opens on the right; so do members, workspace and project settings, the trash, your account and your agent keys, with the tree still beside them. New plans, folders and projects are made from icons on the rows and at the foot of the tree and named in place, and a row's menu (or a right-click) holds the rest: rename, settings, move, share, export, move to the trash.

A workspace and a project are addressed by a readable slug; a plan is not, and sits at the top level:

/recent                            what you have worked on lately, across every workspace
/workspace/acme/members  /settings  /trash
/plan/:planId                      the canvas
/share/:token                      read only, no session
/settings  /settings/agents        your account, and the keys your agents hold

The application opens on /recent rather than on a workspace: people come back to a plan, and rarely remember which workspace it was filed under. The addresses of the list screens the explorer replaced — a workspace, a project, a folder — still answer, and land on Recent with that project or folder opened in the tree, so a link somebody saved goes somewhere sensible.

A plan link is the thing people paste to each other, so it stays flat: renaming a workspace or a project must not break a link somebody saved. The cost is that the canvas cannot tell where it sits from its own address, so it asks. GET /workspaces/:id/navigation returns a whole workspace's tree in one request — projects, their folders with parentId, and their plans, names only — which is what the explorer draws; GET /plans/:id/navigation returns the same shape for the workspace around one plan, plus the project it is in.

Deleting, and the trash

Deleting a plan or a project sets deletedAt and nothing more. Every listing filters on deletedAt IS NULL — AccessService treats a trashed row as missing unless the caller passes includeTrashed, which only the trash itself does — so one flag is enough and no query has to remember a second table.

A trashed project takes its plans with it without marking them: their own deletedAt stays clear, so restoring the project brings back exactly what was under it and not the plans somebody had already thrown away. A folder behaves the same way, and so does everything below it: trashing a folder marks that folder only, and the folders and plans under it are hidden by ancestry, so restoring it brings the whole subtree back as it was. Every listing therefore has to ask about the folders above a plan as well as the plan — folder-tree.ts answers that from the project's rows in memory, since a project holds a handful of folders. Nothing leaves the trash on its own; removing a row for good is a separate call, and emptying the trash is the only thing that deletes in bulk. Destroying a folder for good is the one case that destroys nothing else: the folders below it go with it, but their plans fall back to the project's top level.

An invitation to a workspace can be listed and withdrawn — GET and DELETE on workspaces/:id/invites, both ADMIN. Only the ones that would still let somebody in are listed, following the instance's own invitation screen: a list of links that no longer work is a list nobody can act on. Withdrawing removes the row, because a single-use link that was taken back before it was used has nothing left to say. An invitation may not carry a role above the one its issuer holds.

A share link outlives the trash, because the trash is reversible and a fresh token would not be the link people have already pasted around. That makes the trash the only screen the link can be turned off from — every other route to "stop sharing" runs through a plan page that reports a trashed plan as missing — so the trash marks which plans are still answering one, and offers the action there.

Plan vocabulary

packages/schema is the plan domain: the zod schemas every app imports, the graph primitives built on them (containment tree, topological order, cycle detection), the repair pass that turns arbitrary CRDT state back into a valid document, and the pure reference implementation of the write path. A PlanDoc is:

  • nodes — each has a kind and a status from its project's vocabulary (below), a human-readable slug, a title, a body, tags, and an optional pinned position. The slug is derived from the title when the node is made and can be changed after, through rename_node — which moves every edge, note anchor and containment pointing at it in the same transaction, since edge identity is derived from the endpoints. It matters because the slug is both the address an agent calls a node by and the name of the file it exports to, so a node whose title had moved on used to carry a name from its first minute in both places. A rename is recognised in the history rather than read as a deletion and an unrelated arrival.

  • edges — contains (nesting; becomes directory structure on export), depends_on (ordering; becomes file numbering on export), and relates_to (association with no structural meaning).

  • comments — notes left on the drawing rather than in it. A node says what the system is; a comment says what somebody thinks about it. They are keyed by a readable id the author chooses, carry a position of their own, and may be anchored to a node or float free. Deleting the node they were about unanchors them rather than deleting them: an objection should not disappear along with the box it was raised against.

Comments are in the document rather than in a table, which is what makes them worth having. They appear on every open canvas the moment they are written, they come back through get_plan, and an agent asked to carry on with a plan reads the open objections before it starts. The cost is that author is self-reported like any other field — real attribution comes from the history, which is derived on the server from the difference between two versions.

Edge identity is derived from its endpoints rather than generated, so submitting the same relationship twice collapses to one edge instead of duplicating it. That is what makes an agent's retry safe.

Kinds, statuses and tags belong to the project

What a node can be and what state it can be in are not fixed lists. Each project has a vocabulary (packages/schema/src/vocabulary.ts), stored as a JSON column on Project beside a version number, read and written through GET/PUT /projects/:id/vocabulary. Every plan in the project shares it. A save names the version it was read at and is refused with 409 if somebody else saved in between, so two people editing it cannot overwrite each other.

  • A status is { id, name, color, category }. The category — todo, active, blocked, done or cancelled — is what the status does: next_task offers todo and active work, the progress line counts active and blocked, a blocked node turns the flows leaving it red, and done and cancelled are settled. Nothing reads a status id to decide behaviour any more.

  • A kind is { id, name, look }, where the look is the card's outline — solid, strong, dashed or clipped — plus a work flag that says whether it counts as something to do. group is built in, because a box around other boxes is structure, and cannot be removed.

  • A tag is a name and a colour. Tags typed on a node are added to the project's list, which is what the tag field suggests from.

  • Colours are one of ten named ones (PALETTE) rather than hex, so the browser draws each from a --palette-* variable and the Obsidian Canvas export maps each to a colour Obsidian knows.

A project nobody has edited has null in that column and reads as DEFAULT_VOCABULARY, which is the old fixed lists exactly — statuses idea, planned, in_progress, blocked, done, dropped; kinds feature, task, decision, note, group — so every document, export and agent prompt written before the vocabulary existed reads the same. A default name is translated in the interface until somebody renames it.

Nodes store the id of their kind and status, and the name of each tag, as plain strings, which is what lets a vocabulary change without a single document being rewritten. Renaming changes a name and keeps the id. Removing a status or kind archives it: it leaves the pickers, and a node still carrying it goes on being drawn with its last name and colour until someone changes it. A save may archive anything but may not drop an id it was given. A value the vocabulary has never heard of — written by an older client or imported, say — is kept and drawn neutral as "Unknown: …" rather than discarded. A new status or kind gets a readable id made from its first name (in-review), which stays when it is renamed; export writes these ids to front matter, so an unchanged project exports byte for byte as it did before.

A node's body is a document several people write at once

A node's body is a Y.XmlFragment of blocks, bound to a block editor (ProseMirror, through Tiptap and y-prosemirror), so two people and an agent typing into one body merge character by character. Headings, lists, to-dos, quotes, code, dividers, tables, toggles and callouts are blocks; images and raw HTML are not, and Markdown the schema cannot hold is kept verbatim as a raw block rather than dropped.

Everything outside the editor reads a body as Markdown: export, the MCP tools, next_task, search, the history's diff, the card's measured height. The schema and both conversions live in packages/body: the browser's editor is built on it, and the API and the MCP server reach it through packages/ydoc, so there is one answer to what a body can hold. The snapshot, and so the exporter, only ever see Markdown. Their Markdown forms are the ones other tools read: a table is a GFM table, a toggle is <details><summary>…</summary>…</details>, and a callout is an Obsidian callout, > [!note] Title, of type note, tip, warning or danger. An agent's body is parsed and applied to the fragment as the smallest change that gets there, not a replacement, so a person typing in the same body keeps their place.

Two details keep that honest. The serialiser writes one spelling of each construct (- bullets, **bold**), so packages/ydoc keeps the Markdown a body was last written as beside the fragment, under bodySource, and reads that back for as long as the fragment still says the same thing — otherwise a body an agent wrote with * bullets would come back respelled, every export would change, and the history would report an edit nobody made. And bodies written before the editor were Y.Text: the server converts them once, when it first loads a document and before any client syncs (migrateBodies), so nothing is converted twice.

The MCP surface

Agents are a first-class client, so the tool surface is designed around what an LLM is actually good at. Three principles drive it:

1. The agent never computes coordinates. It declares structure; the server runs ELK.js layout. A node without a position gets placed. A node a human has dragged is pinned and auto-layout leaves it alone forever after.

2. The agent speaks in slugs. auth-service, not a UUID. An agent can create a node and reference it in an edge in the same call, with no read-back round trip.

3. There is one write door, and it is batched. No create_node. A forty-node plan is one call, applied inside a single Y.transact, so it appears on every open canvas at once.

Tool

Purpose

list_workspaces()

Workspaces the key can act in

list_projects({ workspace? })

Projects the key can reach, across the account or narrowed

list_plans({ workspace? })

Plans, grouped by workspace, project and the folder each one is filed in

search(query, { workspace?, projectSlug? })

Words across every plan the key reaches — titles, slugs, tags and bodies — answered with the plans and folders they are in. The way to find the plan that already covers something before drawing a second one

trace(id, from, { direction, depth })

Follows the flow from one node, hop by hop, upstream or downstream, with what sets each hop off and what it carries. The way to read a plan: it answers with the thread, not the document

get_plan(id, { view })

view: outline | graph | markdown. Positions and styling are excluded by default to keep responses small. The answer ends with the project's vocabulary — the kinds and statuses it offers, by id and name with what each status means, and the tags already used — and the plan's revision

read_nodes(id, slugs)

The full bodies of the nodes named, as Markdown, with what each is wired to and what holds it

next_task(id)

Where a plan has got to and what can be started now. It goes by status category and the kind's work flag, not by fixed ids: work in an active status is listed as already started, blocked work with the notes left on it, and todo work is ready once everything flowing into it and everything it depends on is settled (done or cancelled). Ready tasks come with their bodies

plan_history(id)

Who changed what, newest first, people and agents alike

create_project(name)

A project to draw in, rather than everything piling into the workspace default

create_plan(spec)

Opens an empty plan and hands back its id; drawing is apply_ops. Takes a workspace and project slug, and a folder path to file it in at once; with one workspace reachable neither slug is needed, and with several it names them rather than guessing. The answer carries the project's vocabulary, so the first batch can use its words

apply_ops(id, ops[])

How a plan grows after that, and the only write door. Upsert by slug, so retries never duplicate. Each batch reaches every open canvas at once, so drawing in pieces is what a person watching actually sees. A kind or status is any string, checked against the project's vocabulary before anything is written: an id or a name is accepted (In review is written as in-review), a value the node already carries is always accepted even if archived, and anything else refuses the whole batch with the valid ones listed. A body is Markdown, applied to the node's block document as a minimal change. rename_node goes through it too, and is the one operation that is strict where the upserts are lenient: renaming a node that is not there, or onto an identifier another node answers to, fails the batch rather than being skipped. upsert_comment goes through the same door: an agent unsure of something leaves a note where a person will see it instead of drawing confidently around the guess. The server signs such a note <owner>'s agent — the surface has no author field, because nothing an agent put there would be worth trusting

set_plan_sources(id, ids)

Which plans this one was written from. Replaces the whole set, so two callers cannot half-agree about it; sources must be plans in the same project. Reading the plan back names each source, the drawer it is filed in, and whether it still resolves — a source that is deleted or moved is reported, never removed

layout(id, { scope })

Re-run layout over everything that is not pinned

export_plan(id)

Markdown tree plus .canvas. The download link it returns opens with the same key, because the content is what get_plan already hands over

delete_plan(id, confirmTitle)

To the trash, where a person can restore it. The exact title is required as well, so a wrong id cannot take somebody else's work

list_folders({ project? })

The drawers inside a project, each by its path from the top (Specs/Billing), and how many plans are filed directly in each

create_folder(path)

Makes a drawer. Folders nest: Specs/Billing makes Billing inside Specs, and any folder on the way that is missing is made too. Asked for one already there, it hands that one back rather than making a second of the same name

rename_folder(folder, to)

Renames a drawer. to is a name, not a path: renaming does not move it, and nothing inside it moves

delete_folder(folder, confirmName)

To the trash, with the folders and plans inside it, which come back with it. The exact name — the last part of the path — is required as well, so a wrong path cannot take the wrong drawer

move_plan(id, { project?, folder? })

Files a plan elsewhere: another drawer, another project, a project in another workspace. Naming only a folder keeps it in its project; null takes it to the top level. Crossing a workspace drops the plan's share link, and the answer says so

Every tool that takes a folder takes its path from the project's top level, Specs/Billing. A bare name, or the end of a path, still works when only one folder in the project answers to it, so prompts written before folders nested keep working; anything that would mean two folders is refused with their paths rather than guessed at.

Agents use the project's vocabulary but cannot change it: adding a status is a person's decision, made in the project's settings.

Authentication is a hosted Remote MCP endpoint: the settings page hands over the whole client configuration as JSON, ready to paste — including type: "http", without which a client has no way to know the URL is a remote server rather than a command to run. Nothing to install, nothing to keep updated, and a self-hosted instance simply hands out its own URL.

A key belongs to a person, not a workspace. Someone who works across several should not have to issue, paste and revoke one per workspace, and an agent holding such a key could not see the others exist. A key acts as its owner wherever they are a member, which is why the tools take a workspace argument and the endpoint lives under the account rather than a workspace.

A plan says what it is at, and a batch may say what it was written against. get_plan reports an opaque revision; apply_ops takes an optional expectedRevision and refuses the whole batch — applying nothing — if the plan has moved since. It matters because apply_ops writes whole fields: Yjs merges characters inside a body but not two setters of one field, so an agent acting on a minute-old read really can overwrite a person. The revision is the document's own state vector rather than a stored column, which means it cannot fall out of step and is right during the window in which updatedAt is not, since plans persist on a debounce. The honest cost: a state vector moves for a change get_plan does not report, such as a drag — an occasional false conflict, never a false agreement. Omitting the field behaves exactly as before.

Provenance is a field, not a sentence. A plan written from a spec used to say so in its description, which a person could follow and nothing else could. sourceSpecIds is an ordered, de-duplicated list of plan ids in the same project, stored as an array rather than a join table — because the rule is that a source which is destroyed keeps its id and is reported as missing, and a foreign key could only cascade or null the column, and both lose what they were told to keep. Validity is computed when the plan is read and never written down, so a spec trashed today and restored tomorrow reads as missing and then as present again with no write in between. The product validates that a source is a plan in the same project and deliberately does not validate which folder it sits in: a folder name is a string somebody may change at any moment, and freezing a filing convention into the schema would refuse a legitimate link from anyone who spells it differently. The folder each source sits in is reported, so a client can enforce whatever convention it keeps.

A plan is deliberately not parsed out of Markdown on the server. An agent converting its own prose into the structured spec does a far better job than a parser guessing at headings. (A node's body is parsed — into its blocks — but that is the shape of one body, not of the plan.)

The export format

Containment edges become directory nesting. Dependency edges become a topological order, which becomes the numeric filename prefix. A cycle does not block the export — it is broken deterministically and reported as a warning.

plan-export.zip
├── README.md              overview and table of contents
├── 01-Foundation/
│   ├── 01-Database.md     frontmatter: slug · status · depends_on · related
│   └── 02-Auth.md
├── 02-Editor/
│   └── Canvas.md          no prefix: nothing here depends on anything here
├── plan.canvas            Obsidian Canvas, original coordinates preserved
└── plan.json              machine-readable source of the same content

An unfurl is rendered for everybody in the channel somebody pasted the link into, so the application's card is the same everywhere and says nothing about what is behind it — a plan's title there would hand it to people who cannot open the plan. A share link is the one address where that runs the other way: it is a capability given out on purpose, and whoever holds it can already read the whole plan, so naming it gives away nothing the link does not.

The proxy sends a known link unfurler asking about /share/* to share/:token/preview, which renders a small card, and sends everybody else to the application. Social unfurlers only, deliberately not search crawlers: a capability URL is not something to hand to an index, and the card carries the plan's name.

That card is the one place in the product where a plan's own text reaches a browser as markup rather than as text a framework escapes, which is why sharePreviewHtml is a pure function with its own tests and escapes all five characters — an apostrophe included, because an attribute may be quoted either way and a reader should not have to check which.

A node cannot take a name the bundle has already written. The overview is README.md, every container owns the README.md of its own directory, and the root also holds plan.canvas and plan.json. A zip keeps the last entry written for a path and says nothing, so a node titled README used to replace the overview silently. Those names are reserved per level now.

Titles and comment authors are escaped where the export builds a construct around them — a Contents link, a [[target|label]], a Notes blockquote. A node body is emitted verbatim, because a body is prose and prose is written in Markdown; a title dropped into the middle of a link is not, and a ] or a newline in one ended the construct early and left a broken vault. This is tidiness, not a security measure: the body beside it can say anything, and is meant to.

A file is named after its title, not its slug. The slug is the identity — stable, ASCII, safe in a URL — and it stays in frontmatter. It makes a poor filename: a vault written in Korean has no ASCII slug that resembles it, so the export renamed every file and broke every [[wikilink]] already written between them. Only what a filesystem or a wikilink cannot carry is removed, and two notes of the same title are told apart by the thing that tells them apart, their slug.

The prefix is applied only where it says something. It exists to carry an order a name cannot. Where no sibling depends on another there is no order to carry, and numbering anyway invents one — over the order the author already put in the names, which is how a vault reading 00-overview, 01-core, 02-plugins came back as 01-app, 02-business, 04-core.

The title is written once. The filename says it, so a leaf note has no H1 repeating it. A container's file is the README.md of a folder, whose name says nothing about what it holds, so that one keeps its heading.

meta carries frontmatter this product has no opinion about. A vault written by a person has keys of its own — owner, reviewed, a status vocabulary that is not this one's — and with nowhere to put them an import has to throw them away. They are written back beside the fields the export does understand and can never shadow one.

An invitation is shown before it is accepted

Opening the link used to be the whole of accepting it: InvitePage called acceptInvite from a useEffect, and a link forwarded into a chat joined whoever clicked it while signed in. It is a screen now — what workspace, from whom, as what — and Accept is the only thing on it that writes.

GET /invites/:token is public, which looks wrong and is not: the token is already the capability, so whoever holds it can accept and then read the whole workspace. Naming it first gives away nothing the button does not, and being asked to sign in before being told what for is what the screen exists to fix. The response carries workspace.id alongside its name — load-bearing for the already-a-member row below, not an access grant, since every workspace route still runs through AccessService regardless of who knows the id.

The proxy's unfurl matcher stays on /share/* and must not grow to cover /invite/*. A share link is handed out deliberately; an invitation is addressed to one person, and a card rendered for everybody in the channel it was pasted into would name a workspace to people who were never asked. The two look alike and are opposites.

An address that does not match the account signed in is a warning, not a block — a work address and a sign-in address differ often enough that refusing would strand somebody holding a good invitation. Declining writes Invite.declinedAt, which takes it out of the workspace's open list: that list answers "what would still let somebody in", and a refusal is as closed as a membership.

The export has to open as a vault, not as a directory

Obsidian builds its graph view, its backlinks and its unlinked mentions out of [[wikilinks]] and nothing else. An export whose relationships live only in frontmatter is complete to a parser and inert to a reader, so every note also carries a ## Links section written as real wikilinks, by full path — container notes are all named README.md, and a bare basename would be ambiguous between them.

relates_to used to be the one relation the export simply lost: it becomes no directory and no file number, so nothing was written for it at all. It is now related: in frontmatter and a line under Links, which matters more than its lack of structure suggests — in a vault a plain link between two notes is most of what the vault is.

Comments never become files. An anchored one travels as a blockquote inside the note it is about, one about the plan as a whole goes on the cover, and every one of them is in plan.json. On plan.canvas an open note is a text card tied to what it is about, and a settled one is left off.

A node holding others becomes a JSON Canvas group — a labelled frame — because that is what a box around other boxes is. Exported as an ordinary card, which is what used to happen, the nesting the whole plan is built on was not in the picture. Its own note is still the README.md of the folder the frame corresponds to.

The whole transform is a pure function over a PlanDoc. It touches no database, no filesystem and no network, which is why it is the most heavily tested part of the repo.

Repository layout

pnpm workspaces, orchestrated by Turborepo.

apps/
  api/          NestJS + Prisma + Postgres
                auth · workspaces · projects · plans · sharing · API keys
                rate limiting · Hocuspocus sync gateway · Remote MCP endpoint
  web/          React + Vite + TypeScript — the application itself
                React Flow + shadcn/ui, client-side rendered
  www/          Next.js — landing, docs, guides, legal
                the SEO surface, and nothing else
packages/
  schema/       zod schemas, graph primitives, repair, and the pure write path.
                The shared vocabulary — everything else depends on it
  body/         A node body's block schema (Tiptap), Markdown ⇄ blocks, and
                Y.XmlFragment ⇄ Markdown. The editor's schema and the server's are one
  ydoc/         PlanDoc ⇄ Y.Doc bindings. Shared by web and api — must stay shared
  layout/       ELK.js auto-layout. Shared by the web "arrange" button and the MCP
                layout tool, so the two can never disagree
  exporter/     PlanDoc → directory tree → zip + .canvas. Pure, no I/O
deploy/         Caddyfile, the web image, and the production Compose file
tooling/        shared tsconfig and eslint configuration

Two module-format facts worth knowing before editing a build file:

  • apps/api is ESM, because NestJS 12 ships ESM only. Its tsconfig uses nodenext and every relative import carries a .js extension.

  • The packages publish both, ESM and CJS, through tsup. That costs nothing and keeps them usable from either side if a consumer ever needs CommonJS.

Dependency direction

Dependencies flow one way. Nothing in packages/ may import from apps/.

schema  ←  ydoc      ←  web, api
body    ←  ydoc
body    ←  web                      the block editor
schema  ←  layout    ←  web, api
schema  ←  exporter  ←  api

body depends on no other package here — it is Tiptap, the Markdown parser and Yjs — and ydoc is what brings it to the API. The web app imports it directly as well, because the editor has to be built from the same schema the server converts with.

Where new code goes

Use this order when deciding where something belongs:

  1. Is it a type or a validation rule? → packages/schema

  2. Is it a pure transform over a plan? → packages/exporter or packages/layout

  3. Does it change how the collaborative document is structured? → packages/ydoc, and remember both clients now depend on it. What a node's body can hold, or how it reads as Markdown? → packages/body

  4. Is it an authorisation decision? → apps/api/src/workspaces/access.service.ts, which is the only place that decides who may see what

  5. Does it need a database, a request, or a session? → apps/api

  6. Is it something a person looks at and clicks? → apps/web

  7. Does it need to be found by Google? → apps/www

If a change seems to need code in both apps/web and apps/api, that is usually a sign it belongs in a package instead.

Performance notes

The canvas is the hard part. A plan of a few hundred nodes with live collaborators will expose all three of these immediately:

  • Drag positions do not go into the document. An in-flight drag is broadcast over the Yjs Awareness channel, which is ephemeral. Only onNodeDragStop commits to the Y.Doc. Without this, a single drag writes sixty CRDT updates per second, inflating document history and saturating the socket.

  • Subscribe to Y.Map changes per node, not per document. Rebuilding the whole node array because one node moved makes React Flow re-render everything. A Zustand store swaps only the changed ids.

  • nodeTypes and edgeTypes are module-level constants. Defining them inside a component remounts every node on every render. This is the most common and most destructive React Flow mistake.

On top of that: memoized custom node components, onlyRenderVisibleElements for large graphs, and a level-of-detail switch that stops drawing node interiors below 0.55 zoom, where they are unreadable anyway.

packages/schema and apps/web both carry tests for the identity rule — the store test asserts that changing one node leaves every other node object === what it was. It is the kind of property that silently regresses, so it is pinned down.

One build, every environment

apps/web reads its API and collaboration URLs at runtime from public/config.js, not from build-time Vite variables. One built bundle therefore runs anywhere: a deployment replaces that one small file instead of rebuilding the application. The dev server falls back to VITE_API_URL when the file leaves the values blank.

A build copies the blank public/config.js over dist/config.js, so rebuilding the application resets it and the deployment's values have to be written back. Behind the one-origin proxy that file is:

window.__SCHEMATIC_CONFIG__ = { apiUrl: '/api', collabUrl: '/api/collab' };

How it is drawn

One design system across the application and the marketing site, defined once in apps/web/src/styles/index.css and mirrored in apps/www/src/app/globals.css. Read those two files before changing anything visual; nothing below is decoration.

Dark only. The surface stepping, the translucent borders and the focus glow are tuned against an obsidian ground. A light counterpart that merely inverted them would be a different design wearing the same names, so there is no theme switch.

Depth is stepping, not shadow. Five surfaces — ground for the canvas, surface for rails and panels, surface-2 for cards and rows, surface-3 for hover and popovers, surface-4 for what is selected — separated by 1px translucent borders (rule at 6% white, rule-strong at 14%). Only level 3 and above carry the elevated utility, which is a rim highlight and a wide soft spread rather than a drop shadow.

Colour is data. Electric indigo (accent) means "this is where you are" and nothing else. Everything else coloured is telemetry: a status's colour and a tag's, each one of the vocabulary's ten named colours (--palette-*) — by default slate for an idea, indigo for planned, amber for in progress, crimson for blocked, emerald for done. Violet (collab) is reserved for agents and live collaborators.

Corners are tight and technical. rounded-sm (4px) for tags and indicators, rounded-md (6px) for inputs, buttons and rows, rounded-lg (8px) for cards and popovers, rounded-xl (12px) for modals, which is the widest the system allows.

Two faces. Geist for interface text, JetBrains Mono for anything a person copies or an agent types — slugs, keys, shortcuts, counts in a column. The .slug and .rail-heading classes carry those uses.

Avatars are squares with a 4px curve, not circles: the interface is modules on a grid, and one disc in a row of them reads as borrowed from elsewhere.

Screens are built from Page, Panel and Table in apps/web/src/components/ui, so a list is a list wherever it appears. Anything reaching for a raw <select>, a native title tooltip or a hand-rolled avatar has skipped a component that already exists.

The canvas is handled like a drawing tool

The pointer and the wheel do what they do in Figma rather than what React Flow does by default:

Input

Does

Drag on empty canvas

Box selection; takes every node the box touches. Shift adds to it

Middle-button drag, or Space + drag

Pans

Wheel, two-finger scroll

Pans; Shift+wheel pans sideways

Ctrl/⌘ + wheel, pinch

Zooms at the pointer

Click a node

Selects it; Shift, Ctrl or ⌘ + click adds or removes it

Double-click empty canvas

Nothing — it no longer zooms

Backspace or Delete

Deletes the selection

A wheel over a card's body or a note that can scroll still scrolls it, as before.

A node is made where it is wanted, in one gesture. Dragging from a node's outgoing terminal and letting go on empty canvas makes a node there, its left terminal on the point, joined from the source by a flows_to line — the rule every new line follows: it is a flow, and what it means is changed afterwards on the line. Let go inside a box and the new node is in that box. The new card opens straight into naming, on the card itself: Enter or leaving the field keeps it, and Escape or an empty title takes the node and its line away again. Making and naming are one undo step. Add node in the title block and Add node here in the canvas menu name their node the same way, and double-clicking any card's title renames it in place.

Copies. Alt/⌥ + drag leaves the originals where they were and drops copies where the drag ends. Ctrl/⌘ + C copies the selection and X cuts it; V pastes at the pointer, or 20 px from the originals when the pointer is off the canvas; D duplicates in place. A copy carries the selected nodes, everything inside a selected box, the lines whose both ends were copied, and each node's title, kind, status, tags, body, size and pin — not the notes on it. Copies get fresh slugs, keep their arrangement, become the selection, and are one undo step. The clipboard is the system's (clipboard.ts): JSON under a type of its own, and a plain list of titles for anything else, so a copy pastes into another plan or another tab, and lines of plain text pasted onto the canvas become nodes. A copy also carries what its kinds, statuses and tags mean where they came from: pasted into a project that lacks one, it is added to that project's vocabulary when the person pasting may edit it, and the node falls back to the default kind or status when they may not. Tags stay on the node by name either way.

Moving a selection commits every dragged node's position in one transaction and resolves each one's box on drop as a single node's is. Snapping treats the selection as one block and ignores its own members, and people watching see every node move, not only the one under the pointer.

Equal spacing. While dragging, the block snaps so that its gap to a neighbour equals a gap already on the canvas between two nodes lined up on that axis, within the same 6px-at-100% reach as the alignment guides; where an alignment and a spacing snap compete on one axis, the nearer wins. Matched gaps are drawn as pink measures with their size, on the pair it matched and on the new gap (spacing.ts).

Gap handles. Two or more selected nodes in a row or a column with equal gaps (±1 px) get a handle in each gap. Dragging one changes every gap by the same amount, live, with the first node held still and the size shown; letting go is one undo step. A selection that is not evenly spaced has no handles; Tidy up in the canvas menu spaces it evenly along its longer axis at its mean gap, and then they appear.

Grid snapping, its step and the anchor settings are unchanged.

A line can be bent round what is in the way

Two cards with a third between them get a line drawn through it, and no amount of automatic routing knows which crossing a reader minds. So a line can be pushed aside by hand: take hold of one of its straight runs and move it. A vertical run goes sideways, a horizontal one up and down, and the cursor says which before anything is clicked. The two runs that touch a card are pinned to their handles and offer no grip, which is why two cards at the same height have a line with nothing to take hold of — there is nothing there to move.

This replaced dropping points on a line one at a time. Points were more expressive and worse in every other way: three overlapping hit areas, a shape that jumped the instant a line was grabbed, and a staircase where a person had asked for a bend.

waypoints on an edge is the only geometry in the document that only a person ever writes. A node has a position because something has to, and layout will happily compute one; there is no equivalent here, because the router already draws the shortest sensible path and has no opinion worth storing. So an empty list means "not pushed aside" rather than "not laid out yet", and nothing on the server clears one.

What is stored is the corners of the route, and legalize in edge-path.ts turns whatever is stored into a legal one. Every node has one target handle on its left and one source handle on its right, so a line always leaves rightwards and arrives leftwards, and every route is an alternation — horizontal, vertical, horizontal — beginning and ending horizontal. The two corners that touch a card are pinned to the handle heights as the line is drawn, which is what keeps a route square when a node moves, and it is also how the free points of the older gesture are read without migrating anything. One router draws every line, bent or not.

When a node moves, the writing on the lines it touches moves with it, weighted by how far along it sits: a point a fraction t of the way travels from·(1−t) + to·t, and the writing sits at t = ½. The route is deliberately left alone. A run somebody placed stays where they put it — which is what every other drawing tool does — and the pinning above keeps the line square without nudgeEdges doing arithmetic on it. Straightening a line is a button in its inspector, because a straight line has no run to push back and the control cannot live on the line itself.

Bends do not survive the export. A JSON Canvas edge is fromNode, toNode and a side at each end; the format has no way to say "via here", and inventing one would produce a file Obsidian reads differently from every other canvas. They are a property of this drawing, like which notes are collapsed. They are also absent from the MCP surface, for the same reason the grid step is: that surface takes structure and never coordinates.

The grid a drag lands on

One switch turns both the lines and the snapping on: the grid is what the snapping is, and lines nothing lands on are decoration pretending to be a tool. It is the fourth button under the zoom controls, and it is in the canvas menu as well. The step is chosen from that menu — 10, 20, 40 or 80 — and the fine lines are drawn at exactly that step with the coarse one every fifth, so the drawing never promises an intersection a node would not take. It is a preference of the person looking, kept in localStorage beside the plan rail's own state, because two people with the same plan open can disagree about it and both be right. 20 is the default, which is the fine division the canvas has always drawn.

Snapping is applied to a node's absolute position, in handleDragStop, and not left to React Flow alone. React Flow quantises the position relative to whatever a node sits in, so a node inside a group that layout left off the grid would land on a lattice of its own; the absolute snap puts every node on one grid however deeply it is nested, and doing it before the drag's shift is measured is what carries the group's children and the writing on the lines by the same amount. React Flow's own snapToGrid stays on for the feel of it during the drag.

The clamp outranks the grid. resolveDrop pulls a dropped node wholly inside the group it landed in, and that runs last: a node is never left straddling the edge of a group to save half a step. group-drop.test.ts records this.

For that to cost nothing, there has to be a grid line inside a group, and that is why the layout draws on the same lattice. packages/layout snaps every position to a 20-pixel grid, rounds container sizes up to it, and snaps the translation that anchors a fresh layout onto pinned work — a fractional one would take everything straight back off. Children are snapped against their container rather than against the world, so rounding can never carry one across the border of what holds it, and CONTAINER_PADDING is a multiple of the grid on every side so the room inside a container starts on it too. With 16 down the sides it did not, and a container drawn snugly around a single card had no intersection inside it at all: the card could be on the grid or inside its group, never both.

The step is fixed at 20 there rather than offered. The layout runs on the server, where nobody's preference is in scope, and 20 is both the default and the fine division the canvas has always drawn — choosing 40 or 80 for yourself still works, it just puts the laid-out nodes on every other line. It is deliberately not on the MCP surface: that surface takes structure and never coordinates, and a grid step is a coordinate an agent has nothing to decide with. Positions already stored are left alone; they come onto the grid the next time the plan is laid out or the node is dragged.

The content sets a card's height; a person may set its width

A card was 260 pixels wide whatever was in it and showed two lines of its body with the Markdown stripped before truncating. The rest was readable only in the inspector, which makes a canvas that draws the flow and hides what flows.

The first answer to that was a resize handle on every card, and it was wrong. Not because it did not work — because it asked a person to do, node by node, something the machine already knew. Worse, it left the two halves disagreeing: the server measured each body to reserve room for it while the browser drew eighty pixels, so a node with ten lines of body had a hundred-and-thirty-pixel hole under it and its body still could not be read.

So a card is as tall as what it has to say, up to a ceiling past which it is drawn full and scrolls rather than becoming a wall the lines have to go round. Nothing stores that height and nothing has to, which is why every plan drawn before this is correct the moment it is opened — there was nothing to migrate.

The width is a person's to choose, and a card offers exactly one handle for it, on the right edge. A bottom edge would offer a height the next render overrules, and a handle that does not hold is worse than no handle. Choosing a width re-measures the height at that width: wider cards wrap less and so stand shorter. Use the standard width gives 260 back.

One measurement serves all of it. cardBounds lives in @schematic/schema because three parties need the same answer and none can ask the others — the browser draws the card, ELK has to place it before any browser has seen the plan, and the Obsidian Canvas export writes a box for a reader that is neither. It is an estimate, deliberately generous, and a body that outruns it scrolls rather than clipping.

A box grows with what it holds. A boundary is drawn at the larger of the bounds somebody gave it and the bounds its contents need, on each axis independently. That replaces a narrower rule — that layout must not touch a stored size — which was right against an arrange rewriting a person's box and wrong against a child that has outgrown it, and a child can outgrow one now simply by being typed into. It is computed where the plan is projected rather than only when layout runs, so a box makes room as a body is written into what it holds.

An agent never sets any of this, for the same reason it never sets a position: a model shown a numeric field fills it in, with a number chosen for how it reads in a payload rather than from anything it measured. It writes the body; the server measures it.

A box is something you can draw, not only something you end up with

A node holding others is drawn as the boundary around them rather than as a card: the box already says what a containment line would, and drawing both produced long dashed paths wandering across the canvas reading as phantom boxes.

For a long time that was the only way a box existed. Whether to draw one was inferred from whether the node already held something, so there was no first move — a box appeared once it had contents, and contents could only be dragged into a box. kind: 'group' was in the schema and read nowhere but a border colour. A group was therefore something only auto-layout or an agent could produce.

The question is asked in one place now, isGroup, and answered two ways: a node is a group because it says it is, or because it holds something. An empty declared group is drawn at bounds a node can be dropped into rather than not drawn at all. The canvas, the store and the Obsidian Canvas export all ask that one function, so the picture, the hit test and the export cannot disagree about what a box is.

Two gestures make one. Dropping a node on another node puts it inside — a box already drawn as a box takes the drop on sight, while an ordinary card has to be held still for half a second first and lights up when it will take it. That difference is the safety of the gesture rather than a flourish: joining a visible box is ordinary aiming, but turning a card into a box changes the shape of the plan, and on a dense canvas a card is something you pass over on the way somewhere else. The other gesture is to select several nodes and ask for a box round them, which arrives as one batch so that undo takes the box and its contents back together instead of leaving an empty group behind.

Membership is exclusive, because a node sits in one box. Grouping a selection takes each node out of whatever held it; and when everything selected came out of the same box, the new one is put back inside that box rather than beside it.

A box is resized from its right and bottom edges only. The other handles move the box's own corner, and everything inside is placed against that corner, so growing down and to the right is the one gesture that leaves the contents where they are. A note resizes from its corner too — nothing computes a note's bounds and nothing ever will, since layout places the drawing and a note is a remark beside it.

A note can ask a question, and a box can answer it

A node's body and a note were written as Markdown and drawn as plain text, asterisks and all. They are rendered now, in the subset the block editor makes: headings, emphasis, strikethrough, code and code blocks, links, lists, task lists, block quotes, rules, tables, toggles and callouts. The card draws them compactly with the same shapes the editor uses — a toggle opens and closes, a callout carries its type's colour. Images are not drawn, and raw HTML is not rendered at all; a link may only be http, https or mailto — a plan opens through a share link with no login, so a link in a document somebody else wrote reaches a reader who never agreed to trust its author. Nothing on this path builds an HTML string, which is why there is no dangerouslySetInnerHTML anywhere in the app.

A body is written in the block editor in the inspector (features/plan/editor): # makes a heading, - a list, [] a to-do, > a quote and three backticks a code block as you type, / opens a menu of every block, a handle beside each block drags it, and other people's cursors show while they write. A note keeps its plain text field, open while you write and drawn when you are not.

The reason it was worth doing: a note can carry a task list, so an agent unsure of something can ask rather than guess — the question, then the answers as - [ ] items. A person ticks one, and the agent reads it back out of the same body. Ticking is a single character in the shared document, so two people answering the same note merge instead of overwriting each other, and the history records it as an answer rather than as a rewrite. That is the only place the answer is attributable: the body says what was chosen and nothing about who chose it.

The checkbox a task list renders is drawn by the app and not by the Markdown renderer, whose own is inert by design. One place therefore decides what a checkbox is, what it does when clicked, and that it is dead for a reader who may not edit the plan.

Getting started

Requires Node 20+ and pnpm 9+ (developed on Node 26 / pnpm 11).

pnpm install

cp .env.example .env                       # then generate the two JWT secrets
docker compose up -d postgres              # local database

pnpm --filter @schematic/api db:deploy     # applies prisma/migrations
pnpm dev                                   # api, web and www together

The API is at http://localhost:3001, the app at http://localhost:5173, the marketing site at http://localhost:3000.

Deploying

cp .env.example .env       # set SITE_URL and the two JWT secrets
docker compose -f deploy/compose.yaml up -d --build

Three containers behind one address: Postgres, the API, and Caddy serving both front ends and proxying /api/*. One origin is a deliberate choice, not a convenience — it makes the session cookie same-site, which removes CORS and cross-site cookie rules from the picture instead of configuring around them. Migrations run to completion in their own container before the API starts.

Script

Does

pnpm dev

All apps in watch mode

pnpm build

Full Turborepo build

pnpm typecheck

TypeScript across every workspace

pnpm lint

ESLint across every workspace

pnpm test

Vitest across every workspace

pnpm check

typecheck + lint + test — run this before you call something done

pnpm --filter @schematic/api smoke

End-to-end check against a running server

pnpm --filter @schematic/web canvas-check

Drives the canvas in a real browser

pnpm --filter @schematic/api db:migrate

Create a migration from a schema change

pnpm --filter @schematic/api db:deploy

Apply existing migrations

There is no CI pipeline. pnpm check passing locally is the bar.

On NixOS, pnpm check stops at the API typecheck and it is not your change. That script runs prisma generate first, and Prisma publishes no engine for linux-nixos — it tries to download one and gets a 404. The generated client is not committed, so it has to be generated once before the API typechecks. Point Prisma at the engine nixpkgs has:

export PRISMA_SCHEMA_ENGINE_BINARY=$(nix-build '<nixpkgs>' -A prisma-engines --no-out-link)/bin/schema-engine
export PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1

A refresh whose answer is lost does not end the session

The access token is short-lived and the refresh cookie rotates on every use, and reuse of a spent refresh token revokes the session — that is how a stolen one is caught. The cost used to be that a refresh whose answer never reached the browser (a page load in the middle of it, a dropped connection) spent the cookie and left the screen signed in with no token, answering Missing access token to everything.

So rotation marks the old session replacedAt instead of deleting it, and a refresh token rotated in the last 30 seconds is accepted once more and answered with its successor's session (ROTATION_GRACE_MS in auth.service.ts). Presented after the window it is refused and the session revoked, as before; replaced rows past the window are removed lazily. The browser refreshes a minute before the access token expires rather than after a 401, serialises refreshes across tabs with a Web Lock so two tabs never spend the same cookie, and when a refresh really fails it signs the screen out and sends you to sign in with the address to come back to.

On Kubernetes

deploy/k8s is the same three parts as a Deployment, a StatefulSet and a pair of Services, with nothing cluster-specific in it: no host, no storage class, no ingress controller, no secret. An installation supplies those in an overlay — deploy/k8s/README.md says exactly which.

deploy/release.sh is what puts a commit into a cluster that has no registry in front of it. It builds the images on the node that will run them, loads them straight into containerd, and commits the resulting tag to the private repository the cluster's GitOps controller reads. Building is not deploying — the commit at the end is. Every step skips what has already been done, so an interrupted release is finished by running it again.

Two shapes there are not preferences:

  • The API runs one replica and replaces rather than rolls. The collaboration server is embedded in it and every open plan is a live document in that process's memory. A second replica would hold its own copy of the same plan and the two would drift apart, silently, each browser seeing whichever it had connected to.

  • Migrations are an init container, not a Job. They have to finish before the process that serves the schema starts, on every rollout and not only the first.

A new route needs telling the proxy about it

deploy/Caddyfile names the application's paths one by one, because everything else on the origin is the marketing site. A route not on that list is answered by the site's 404 — with the whole screen sitting behind it, built and deployed and unreachable. This has cost a day twice.

The smoke check

pnpm check cannot see the seams — the websocket upgrade path, whether the sync server is actually being fed frames, whether the CRDT reaches Postgres. Each of those broke at least once during the first build and no unit test noticed, so there is a script that exercises them against a running instance:

pnpm --filter @schematic/api smoke                      # against localhost:3001
SMOKE_API_URL=https://your-instance.example pnpm --filter @schematic/api smoke

It registers two throwaway accounts and removes them, and the workspaces they own, at the end — a check that left them behind turned the instance it was pointed at into a junk drawer of real accounts holding real keys. Point it at a development instance anyway: it spends the sign-in allowance on purpose. Run it after anything that touches collaboration, authentication, or the MCP surface.

SMOKE_INVITE_CODE lets it register against an instance that has a REGISTRATION_CODE set, and it checks that signing up without one is refused. An instance with no code is invitation-only and there is nothing for the check to sign up with, which is a good reason to keep it pointed at a development one.

The canvas check

The smoke check speaks the protocol, which is not enough. Three defects reached a running server without a single test noticing: the canvas rendered nothing on a shared link because its container had no height, containers were drawn on top of their own children, and a container's handles sat behind the edge layer so a node could not be dragged into a group at all. None of that is visible from the API.

CANVAS_CHECK_URL=http://127.0.0.1:8443 pnpm --filter @schematic/web canvas-check

It signs in, walks workspace to project to plan, and checks the things that actually broke: that the canvas has height, that containers are drawn at their own bounds, and that a container's handle can be reached rather than buried behind the edges — and it drives the canvas's gestures: a box selection that does not pan, a dragged selection written whole and undone in one step, the wheel, a line let go on the canvas making a node named on its card, copying a node into another plan, Alt+drag, equal spacing, gap handles and Tidy up. It finds a browser itself — Chromium, Chrome or Firefox, whichever the machine has — and CHROME_PATH names one if you would rather choose. Needs a seeded plan to look at.

Who runs an instance

Standing in the instance is separate from standing in a workspace. The first account made owns it — somebody has to, and the alternative is a flag set by hand in the database before anyone can look at anything. An owner gets one more screen than everybody else, at /admin:

  • Usage — what has been drawn, who is active, how much of it agents did, which keys are working, how big the database is, and a fortnight of edits split between people and agents. Every figure is a count of something that exists or a moment that was recorded; nothing is sampled or estimated.

  • Invitations — ways in, issued rather than shared. A label, a limit, an expiry, and the accounts that came in through each one. Only the hash is kept, so a link is shown once. Withdrawing one stops it working and keeps that record; deleting takes the record with it.

  • People — everyone here, what they hold, when they last changed anything, and whether they may still sign in.

REGISTRATION_CODE is the operator's own way in rather than a way to run an instance: it is how an empty one is claimed, and how an automated check makes a throwaway account. It is a fixed string with no expiry, no limit and no record of who used it, so while it is set the invitations screen says so — and clearing it makes the links the only door.

Conventions

  • Never run pnpm format across the repository. It reformats files nobody touched — seventy-two of them, once, of which forty-one had to be picked back out by hand. Format what you changed.

  • Everything written into this repository is in English. Code, comments, commit messages, PR titles and bodies, test names, documentation. The one exception is the translations themselves, where another language is the content.

  • Every word a person reads comes from a catalog. The application's text lives in apps/web/src/i18n/messages/<language>/, one namespace per area of the screen; a component reads it through useT(), and code outside a component through t(). English is the source, and the type checker refuses a language that is missing a message, so a new string is written in all five (English, Korean, Japanese, Simplified and Traditional Chinese) in the same change. Anything that takes a value — a count, a name, a link — is a function, because word order and plurals differ by language. The site's pages are translated whole (apps/www/src/content/<page>/<language>.tsx), so a change to an English page is a change to its four siblings too.

  • Comments explain why, and only where a reader would otherwise be puzzled. Do not narrate what the code already says. Sparse and load-bearing beats thorough.

  • Commits follow Conventional Commits: feat(web): ..., fix(api): ..., refactor(exporter): ....

  • No AI attribution in git history. No Co-Authored-By trailers for agents, no "generated with" banners in commit bodies or pull request descriptions. The human who opened the change is its author.

  • Pure packages are tested properly. schema, layout and exporter have no excuse for thin coverage — they need no database and no network.

  • Never commit agent scratch files. .gitignore already excludes them; see its header for the reasoning and the escape hatch.

Project status

Pre-alpha, and specific about what has been exercised.

pnpm check runs 137 tests across 25 tasks. Beyond that the stack runs against a real Postgres instance and is driven end to end by pnpm --filter @schematic/api smoke, whose 83 assertions cover registration, the access-token guard, projects, batched operations, rejection of an invalid batch, layout, the export zip, the MCP surface behind a real key, the trash and what comes back out of it, share links, the permission boundary, workspace and account management, throttling, and two live clients converging on one document with their edits merging.

A browser check (pnpm --filter @schematic/web canvas-check) drives the canvas itself in 44 assertions, at a desktop width and again at 390px, and every screen has been rendered and reviewed.

Running it for the first time is what found the real defects — the collaboration socket was never fed frames so every client sat connected and silent; the canvas rendered nothing on a shared link; containers were drawn on top of their own children; a rejected batch answered 500; nodes added by an agent arrived unplaced; the export was not reproducible. All fixed, all now covered.

Still missing:

  • Social sign-in is configured but not implemented. /auth/providers reports which providers an instance holds credentials for; the callback routes are not written. Email and password work.

  • No email is ever sent. An invitation produces a link you send yourself, and an email address cannot be changed because there is nothing to verify it with. For the same reason an instance on a public address is held behind a shared code rather than per-person invitations: set REGISTRATION_CODE and a new account must type it. Blank means anyone may sign up; ALLOW_REGISTRATION=false means nobody may.

  • The containers have never been built. Neither machine available here has a working container runtime. The Caddy arrangement itself is verified against a real Caddy — every route, and a sign-in whose cookie survives the proxy — but the images are not.

  • Rate limiting is per instance. The counters live in memory, so several API instances each get their own allowance. A shared store is the fix when there is more than one.

  • The editor has had little real use. It works and is covered at the protocol level, but nobody has sat and planned something substantial with it.

Roadmap

  • The pure packages: schema, exporter, layout, ydoc

  • API, canvas, real-time collaboration, Remote MCP, export, sharing

  • Run it against a real database and fix what that finds

  • An end-to-end smoke check covering the seams unit tests cannot reach

  • Rate limiting on anything that answers a guess

  • Workspace > Project > Plan, with readable addresses

  • Member, invitation, workspace and account management

  • Connections that can be created, changed and removed on the canvas

  • One origin behind a reverse proxy, in containers

  • A trash: deleting hides, and only emptying destroys

  • Build and run the images, and a release that reaches a cluster

  • Undo and redo on the canvas

  • Folders inside a project, rearranged from the rail

  • Comments: notes on a plan, readable by a person and by an agent

  • Live cursors, and a row that says who else is on the plan

  • Export filenames from titles, so a vault keeps the names it had

  • A canvas handled like a drawing tool: box selection, copy and paste, equal spacing

  • Folders that nest, and one screen with the explorer beside every page

  • Kinds, statuses and tags a project defines for itself

  • A node's detail as a block editor several people write in at once

  • GitHub and Google sign-in callbacks

  • Email: invitations, address changes, password reset

  • Plan version history and restore

Non-goals

Deliberately out of scope, so nobody spends time proposing them:

  • No importer. A vault comes back in through an agent over MCP, reading the Markdown and drawing the plan, rather than through a packages/importer that would have to guess at structure the export never promised to round-trip. The export is a handover to Obsidian, not a serialisation of this document.

  • Not an issue tracker. Plans hand off to Jira, Linear or GitHub Issues; they do not replace them.

  • Not a general diagramming tool. The graph carries plan semantics. If you want free-form boxes and arrows, use Excalidraw.

  • Not a code generator. It produces the plan your agent reads. The agent writes the code.

  • No offline-first local application. The server holds the document; portability is guaranteed by export and by self-hosting, not by running without a server.

Contributing

  1. Branch from main; do not commit to it directly.

  2. Read Conventions before your first commit — the English-only and no-AI-attribution rules are enforced in review.

  3. Run pnpm check and make it pass.

  4. Keep pull requests to one concern. A PR that reformats while it fixes is two PRs.

License

Copyright © 2026 ruma.

Licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-only).

AGPL was chosen on purpose. Anyone may run, modify and self-host this — including commercially — but a modified version offered to others over a network must publish its source. That keeps self-hosting a real promise rather than a marketing line.


Schematic Planner · © 2026 ruma · AGPL-3.0-only

Maintenance

ActivityMaintained
ResponsivenessResponsive

Related MCP Connectors

Related MCP Servers