Developer Guide
Run Twig, find your way around the code, change it under the rules the tests hold, and build on its API.
- Run it
- The sync server, locally
- A server in a test
- The shape of the code
- Sync
- The server
- Plugins and the catalog
- The docs
- Rules the tests hold
- Add a field to a type
- Add a / command
- Add a screen
- Add a docs section
- The API and MCP
- Deploy
- Report and review
Run it
The code is on the forge, at forge.unfuck.cloud/private/twig, for people with access. You need pnpm and Node 24, the version the images build with (Dockerfile, server/Dockerfile). The dev server is bound to every address, so a phone on the same network can open it.
pnpm install
pnpm dev # http://localhost:5190
pnpm test # vitest, the whole suite
pnpm typecheck # tsc --noEmit, strict
pnpm build && pnpm preview # http://localhost:5191The tests run in Node, with no browser. pnpm build typechecks, then builds the app, each built-in plugin on its own, the service worker and these docs into dist/.
The sync server, locally
The dev server sends /api to 127.0.0.1:5192. The sync server is TypeScript that Node runs as it is, with nothing to install:
PORT=5192 TWIG_DB=./twig-sync-dev.db \
[email protected] \
node server/index.ts- Sign-in
- Works at http://localhost:5190, the address the sign-in service sends back to; the preview’s port is not one, and an address on the network is not a secure context at all.
- Your account
- An address on
TWIG_ALLOWED_EMAILSis Cloud Sync for life, so sync, sharing and assistants work against the local server. - Files
- Uploads need the
TWIG_S3_*settings too; without them a file stays on the device it was added on.
A server in a test
No test reaches the sign-in service. A server test opens the database in memory with the tables the routes need, builds the handler, listens on port 0, and answers the userinfo call itself, so an account is one line. This is the top of a server test, in server/:
import { createServer } from 'node:http'
import { vi } from 'vitest'
import { ensureAccountTables } from './accounts.ts'
import { createHandler } from './app.ts'
import { ensureFileTables } from './files.ts'
import { ensurePluginTables } from './plugins.ts'
import { ensureSiteTables } from './publish.ts'
import { ensureSettingsTables } from './settings.ts'
import { ensureShareTables } from './share.ts'
import { ensureSnapshotTables } from './snapshots.ts'
import { openDb } from './sync.ts'
import { ensureTokenTables } from './tokens.ts'
const db = openDb(':memory:')
for (const make of [ensureAccountTables, ensureFileTables, ensurePluginTables,
ensureSiteTables, ensureSettingsTables, ensureShareTables,
ensureSnapshotTables, ensureTokenTables]) make(db)
const handler = createHandler(db, {
logto: 'https://id.test', allowed: new Set(['me@test']),
maxBody: 1024, m2m: null,
})
const server = createServer((req, res) => { void handler(req, res) })
await new Promise<void>((done) => server.listen(0, done))
// Logto's userinfo, answered here: every bearer is me@test
vi.stubGlobal('fetch', async (url: string | URL) =>
String(url).includes('/oidc/me')
? Response.json({ sub: 'u1', email: 'me@test', email_verified: true })
: new Response('', { status: 404 }))With fetch stubbed, ask the server with node:http’s request. server/app.test.ts is the full version, with Stripe and the office’s fetches answered the same way. On the client, the sync engine’s tests mock src/sync/auth.ts and stub fetch with what the server would answer.
The shape of the code
| Where | What |
|---|---|
| src/store/ | The graph: every page, block and whiteboard shape, in memory (graph.ts), changed by the edits in ops.ts. Dexie, over IndexedDB, is only where it is kept, written behind the editor and read once at boot (db.ts). No screen reads Dexie: the screens read the graph, and what only a file’s record holds (its bytes, its size) comes through files/store.ts. |
| src/parser/ | Block text into tokens (inline.ts) and what is derived from them: references, tags, the task marker, dates, properties (derive.ts). readable.ts draws a block’s words as the app shows them, for rows, cards, the API and plugins. Pure; a new syntax starts as a test. |
| src/editor/, src/views/, src/render/ | The outliner, the screens, and how a block draws its words, files and embeds. |
| src/sync/ | The sync engine and sign-in, and the app’s half of sharing, publishing, Enrich and tokens. |
| src/files/ | Attachments, uploads, import, export and the backup zip. |
| src/lib/ | What the rest share: dates, the router, the keys, the types and their fields, the page sidebar’s state. |
| src/plugins/ | The plugin API (types.ts, api.ts), the loader, and the built-ins under catalogue/. |
| src/docs/ | These pages, as data, and the renderer that draws them. |
| server/ | The sync server: accounts and plans, sync, files, snapshots, shared pages and sites, notifications, MCP and the API. |
Sync
- Records
- Pages, blocks and shapes, each a record with an id and an
updatedAt. - The newer wins
- The server keeps the later
updatedAtfor each record, and answers a stale push with what it holds (server/sync.ts). A device pushes two seconds after an edit, and pulls when it starts, every minute, when its window takes focus and when the network comes back. - Words kept apart
- A block whose words changed on two devices is never overwritten: both are kept until someone picks one (src/sync/conflicts.ts).
- Days
- A journal page’s id is its date,
journal-20260930, so two devices that open the same day make one record, not two. - Repair
- After each pull,
repairGraphin src/store/ops.ts folds two pages with one name into one, and puts blocks whose page is gone under Recovered blocks.
The server
node:http and node:sqlite, and no dependencies: server/ imports nothing but Node and src/. Its image, from server/Dockerfile, copies both folders whole, since the API reads the graph with the app’s own graph and query code, and its build imports server/app.ts once, so a file the image lacks fails the build rather than the pod. createHandler in server/app.ts is every route; server/index.ts reads the environment, opens the database and listens.
Plugins and the catalog
A plugin is an ES module whose default export takes the API typed in src/plugins/types.ts. The built-ins are src/plugins/catalogue/<id>/index.ts, listed in src/plugins/catalogue.ts. The build bundles each on its own to /plugins/<id>.js, dependencies and all, and the service worker keeps them, so the catalog installs offline. Writing one, and the API.
A built-in that becomes part of the app is retired, not deleted: its id goes into src/lib/retired.ts, the catalog never offers it again, no plugin may take its views, and its old addresses open what replaced it. Calendar and Calendar view became the app’s Calendar this way, and Backlinks and Export page the page sidebar’s two tabs.
The docs
- The pages are data: the reference in src/docs/content.ts, these two guides in src/docs/guides.ts, drawn by src/docs/render.ts.
pnpm buildwrites each page to dist/docs/<slug>/ as static HTML that links the app’s own stylesheet, so it wears the app’s tokens. The User Guide is dist/docs/index.html, and its pictures are in public/docs/guide/.- src/docs/content.test.ts holds them to the code: the API reference to its type, the numbers on How Twig behaves to the constants that decide them, the keys table to the
?sheet, every link to a page and an anchor that exist, every picture to its file. pnpm docs:twigwrites the same pages as Twig pages, docs/twig-docs.zip, for Settings › Data › Import a zip. Published as a site in the Docs layout and named byTWIG_DOCS_SITEon the app’s nginx, that site becomes /docs; its home, chosen in Settings › Your site, should be the User Guide.
Rules the tests hold
| Rule | Held by |
|---|---|
| Colors come from the twenty tokens in src/styles.css, each with a dark and a light value, and the quiet text and the fields’ edges keep their contrast in both | src/styles.test.ts; src/docs/tokens.test.ts for the docs |
| Red for what destroys or is late, amber for what needs care | Review |
Every :hover sits inside @media (hover: hover), so a finger leaves nothing lit | src/styles.test.ts, over the app, the docs and the plugins |
| Under a finger, the named targets (checkboxes, Today’s due rows, the rail, the docs’ bar) are 44px, and a field is never under 16px | src/styles.test.ts, src/docs/content.test.ts |
A row that wraps never turns into a column without nowrap, so no phone page grows wider than the phone | src/styles.test.ts |
| The format Twig imports from goes unnamed in what people read, outside the first run’s import link and Settings › Data | src/copy.test.ts |
| A built-in plugin never reaches React | src/plugins/catalogue/bundle.test.ts |
| A retired plugin id is never a catalog entry again | src/plugins/catalogue.test.ts |
| src/lib imports no code from src/plugins (types aside) | src/lib/layers.test.ts |
| Nothing that draws (src/views, and every .tsx but App.tsx, the shell) imports Dexie or the database module, but for the write queue’s flush and restart | src/lib/layers.test.ts |
| Every Restart, and Try again, is a RestartButton, which saves first; a bare reload stands only in restart, the failed-save bar and the reset | src/lib/layers.test.ts |
| Every release has notes: the first entry in src/changes.ts is package.json’s version | pnpm build, which stops, and src/changes.test.ts |
| The docs say what the code does: the plugin API, the numbers, the keys, every link and picture | src/docs/content.test.ts |
| … and the API and MCP pages say what the server does, and every example is one the server gives | server/app.test.ts, server/restWrites.test.ts, server/apiExamples.test.ts |
Add a field to a type
TYPES, in src/lib/types.ts, gives each type its label, its plural and a fields array. An entry in a type’s array, { key, label, kind }, is enough for the page head to show and edit it, and for MCP and the API to read and write it by key or by label. quiet: true keeps it off the head until it holds something, as Enrich’s fields are; Enrich fills a field only where server/enrich.ts maps a source to it.
Add a / command
One entry in COMMANDS, src/editor/commands.ts: a name, a glyph of up to three characters, a hint of a few words, keywords to find it by, and run, handed { g, id, el, trigger }: the graph, the block’s id, its textarea, and the text that opened the menu. The popup ranks a name that starts with what was typed first, then one that holds it, then a keyword. A plugin adds its own with commands.add instead.
Add a screen
- Name it in
Route, src/lib/router.ts, and read and write its address inparseRouteandrouteHref; give it a tab title inrouteTitle. - Draw it in the switch in
Main, src/App.tsx. - A place in the rail is one line in
RAIL, and a row under More one inMORE, both in src/views/Rail.tsx.
A plugin gets a screen without any of this, at #/x/<slug>, through views.add.
Add a docs section
- A section is an
hblock and what follows it, on a page in src/docs/content.ts; a new page is aDocPageinPAGES. - Say only what the code does. Where a number is a constant, have content.test.ts read it from the constant, as it does for How Twig behaves.
- Link as
[words](/docs/<slug>/#anchor): an anchor is the heading in lowercase, each run of anything but letters and digits one hyphen, none at either end. A link that goes nowhere fails the test. - A picture is two files in public/docs/,
<name>-dark.webpand<name>-light.webp, and animgblock with its size and its alt text. - Look at it after
pnpm buildat 393 wide and at 1280, and runpnpm docs:twigfor the zip.
The API and MCP
One token from Settings › Assistants opens both. MCP, at /api/mcp, answers a model in Markdown; the API, under /api/v1, answers a program in JSON. Every route, a real request and its answer, is on Twig’s API, and the OpenAPI 3.1 document at /api/v1/openapi.json has them all, with an example call to each MCP tool. From nothing to a typed field written takes four steps.
First, a token. In Settings › Assistants (with Cloud Sync), choose Read and write, name it for where it goes, and choose Make. It is shown once: keep it in an environment variable, never in a file you share.
export TWIG_TOKEN=twig_…Then read one of your pages by its name or an alias, Ada Lovelace below. It comes with its type, its fields by label, its aliases and tags, its other properties, whether it has a picture, and its blocks; a list answers total and next, the offset of the rest.
curl -H "Authorization: Bearer $TWIG_TOKEN" \
"https://usetwig.app/api/v1/notes/pages/Ada%20Lovelace"Give an assistant the same token over MCP. Claude Code takes the command, or the JSON in its project file, .mcp.json, which reads the token from the environment, so the file can be committed without it. Another client takes the same address and header, in its own config’s form.
claude mcp add --transport http twig \
https://usetwig.app/api/mcp \
--header "Authorization: Bearer $TWIG_TOKEN"{
"mcpServers": {
"twig": {
"type": "http",
"url": "https://usetwig.app/api/mcp",
"headers": { "Authorization": "Bearer ${TWIG_TOKEN}" }
}
}
}Last, write typed fields on a page you have, Charles Babbage below: several in one change, which Revert in Settings puts back whole. type goes first, so the other keys are read as that type’s fields, by key or by label; GET /api/v1/notes/types lists every type’s fields and how each is written. A key that is no field of the type is kept as a property of its own, as the app keeps one, so check keys and fields in the answer. A value a field cannot take, such as a birthday that is not YYYY-MM-DD, is a 400 that names it, and nothing is written.
curl -X PATCH -H "Authorization: Bearer $TWIG_TOKEN" \
-H "Content-Type: application/json" \
-d '{"properties": {
"type": "person",
"Birthday": "1791-12-26",
"Knows": "Ada Lovelace"
}}' \
"https://usetwig.app/api/v1/notes/pages/Charles%20Babbage/properties"An error from the API answers error in words, a code a program can act on (read_only_token, not_found, rate_limited and every other error code) and a hint that says what to do; over MCP, a JSON-RPC error carries the code and hint in error.data, and a tool that refuses answers in words, marked isError. The code and the hint are new beside error, which keeps its words, so a script written before them works as it did: an additive change, listed with the changes since 0.60. This puts the open tasks due by today, overdue ones too, on today’s page, with curl and jq. Today is the token’s, so it asks the server for the day first:
#!/bin/sh
set -eu
API=https://usetwig.app/api/v1/notes
AUTH="Authorization: Bearer $TWIG_TOKEN"
day=$(curl -sf -H "$AUTH" "$API/days/today" | jq -r .date)
due=$(curl -sf -H "$AUTH" "$API/tasks?due_by=$day" |
jq -r '[.tasks[].text | sub("^[A-Z]+ (\\[#.\\] )?"; "")] | join("; ")')
[ -n "$due" ] || exit 0
jq -n --arg t "Due by today: $due" '{text: $t}' |
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d @- "$API/pages/today/blocks" > /dev/null- Never
- No route and no tool deletes, moves or renames anything, writes a file’s address or id, or changes a picture Twig keeps. A token made to read is refused every write.
- Where to look
- the errors, the limits, and MCP’s tools.
Deploy
- A push to main
- .forgejo/workflows/notify-unfuck-cloud.yml asks the cluster’s repository, unfuck.cloud, to build and roll Twig. Merging is deploying, so changes land through a pull request.
- Two images
twig, from Dockerfile: the app built with pnpm and served by nginx.twig-sync, from server/Dockerfile: the server run by Node, its database on a volume at /data.- Its environment
- The sync server is set up by environment variables.
PORT,TWIG_DBandLOGTO_ENDPOINTare in its deployment; the allowlist, the admins, open sign-up, Stripe, the S3 store and the agent’s M2M settings are in a SOPS-encrypted secret beside it, in unfuck.cloud’s k8s/base/twig/. A push builds images; a change to a manifest or the secret is applied from unfuck.cloud by hand. - The docs
- /docs is the build’s own pages until
TWIG_DOCS_SITE, on the app’s deployment, names a published site; then nginx hands /docs to the sync server, which draws that site in the Docs layout (docker/40-twig-docs.sh). - A release
- A new version in package.json, its notes at the top of src/changes.ts for the update bar, and an entry in docs/log.md.
Report and review
- Issues
- On the forge, forge.unfuck.cloud, in private/twig. From a terminal,
fj issue create. - Pull requests
- A branch from origin/main for one change, opened with
fj pr create. Nothing is pushed to main directly. - The bar
pnpm typecheckand the whole suite clean; a test that fails when the change is undone; the screen looked at 393 and 412 wide and at 1280, dark and light; the docs changed wherever what a person sees changed.