---
name: writeforge
description: Read, search, write and organize a WriteForge workspace with the wf command line (npx -y @writeforge/cli), which keeps the user's API token so you never handle sessions, or through the REST API directly. WriteForge holds markdown notes, database tables, kanban boards and diagrams (shapes, ER, UML, architecture) in nested folders. Use when the user asks to find, read, create, update, append to, move, reorder, delete, restore or export WriteForge notes, folders or documents; to query, add or change rows of a WriteForge table or cards of a board; to read, draw or change a WriteForge diagram or export it as SVG; to add an image to a note; or to export a note to PDF.
---

# WriteForge

WriteForge is a notes app: a **workspace** holds nested **folders** and **documents**. A document is a markdown **note**, a **database** (a table of text values, optionally shown as a kanban **board**) or a **diagram** (shapes, text, ER tables, UML classes and architecture components joined by edges). This skill explains how an AI agent uses WriteForge safely: what to call for each task, what the answers look like and what to do when something fails.

Use the **`wf` command line** when you can run shell commands: it keeps the session on this machine, sends the token for you and prints the API's JSON. Every REST operation in this skill also works through `wf api`. Call the REST API yourself only when you cannot run commands.

## Where to find things

All paths in this skill are relative to the API base URL: the WriteForge site plus `/api`, for example `https://notes.example.com/api`.

| What | Where |
|---|---|
| This skill (markdown) | `GET /skill` (also at the site root: `https://notes.example.com/skill`) |
| This skill as a folder for your skills directory, with `references/markdown.md`, `references/tables.md`, `references/diagrams.md` and `references/openapi.yaml` | `GET /skill/download` (zip) |
| The full contract: every operation, request and response schema, with examples | `GET /openapi.yaml` or `GET /openapi.json` |
| The `wf` command line | `npx -y @writeforge/cli <command>` (npm package `@writeforge/cli`; `npm install -g @writeforge/cli` to type `wf`) |
| A short index of the documentation for language models | `/llms.txt` at the site root |
| The API reference for people | `/docs/api` on the site |
| Tools instead of HTTP calls, for an MCP client | `/mcp` on the API (MCP Streamable HTTP, same token) |

Read the contract when you need the exact shape of a request or response that this skill does not show; schema names are given below (for example `Document`, `TableQueryResult`, `DiagramDescription`).

## Quick start

Below, `wf` stands for `npx -y @writeforge/cli` (or `wf` itself when installed globally). Output is JSON on stdout; an error prints the problem's title, detail and invalid fields on stderr and exits 1 (2 for a wrong command line).

1. `wf whoami`: if it says "not signed in", sign in once (see Authentication). The session is kept, so later runs and later conversations need nothing.
2. `wf workspace` → every folder and document with its `id`, `title` and `kind`; or `wf search <words>` to find one by title or content.
3. Read the document the way its `kind` needs (table below), then act with the matching command or workflow.
4. On an error, read the problem and follow the Errors table.

Without a shell: get a token from the user, send `Authorization: Bearer wfg_…` on every call, `GET /me` → `workspaces[0].id` is the workspace, then `GET /workspaces/{workspaceId}/tree`.

## Document kinds

| `kind` | What it is | Read it with | Change it with |
|---|---|---|---|
| `note` | Markdown (HackMD-style, see Markdown dialect) | `GET /documents/{documentId}` (`contentMd`) | `POST /documents/{documentId}/append`, `PATCH /documents/{documentId}` (workflows 6–7) |
| `database` | A table: CSV with typed columns | `GET /documents/{documentId}/table`, `POST /documents/{documentId}/table/query` | the table row operations (workflow 11) |
| `database` with `board` | The same table shown as a kanban board: rows are cards | as a database | as a database (workflow 12) |
| `diagram` | Nodes and edges as JSON | `GET /documents/{documentId}/diagram` (with an `outline`) | `POST /documents/{documentId}/diagram/changes` (workflow 13) |

Every kind can be renamed, moved, shared, deleted and restored the same way. Using an operation on the wrong kind answers 409 `wrong-kind`.

## What to call for each task

| The user wants to… | With `wf` | REST |
|---|---|---|
| Find a document | `wf search <words> [--limit n]`, or `wf workspace` and match titles | `GET /workspaces/{workspaceId}/search?q=…` |
| Read a note | `wf read <documentId>` | `GET /documents/{documentId}` |
| Add to a note (log, meeting notes, a new section) | `wf append <documentId> --content -` with the markdown on stdin | `POST /documents/{documentId}/append` |
| Edit part of a note | `wf read`, change the text, `wf update <documentId> --version <n> --content -` | `PATCH /documents/{documentId}` with the `version` you read |
| Rename a document | `wf update <documentId> --version <n> --title <t>` | `PATCH /documents/{documentId}` |
| Create a note, database, board or diagram | `wf create --title <t> [--kind database\|diagram] [--folder <id>] [--content -] [--json <more fields>]` (workflows 5, 11, 12, 13) | `POST /workspaces/{workspaceId}/documents` |
| Look up or filter rows | `wf table query <documentId> [--json <TableQuery>]` | `POST /documents/{documentId}/table/query` |
| Add rows or cards | `wf table add <documentId> --json '{"rows":[…]}'` | `POST /documents/{documentId}/table/rows` |
| Change a row; move a card | `wf table update <documentId> <row> --json '{"version":7,"values":{…}}'` | `PATCH /documents/{documentId}/table/rows/{row}` |
| Delete a row | `wf table delete <documentId> <row> --version <n>` | `DELETE /documents/{documentId}/table/rows/{row}?version=…` |
| Understand a diagram | `wf diagram read <documentId>` and read `outline` | `GET /documents/{documentId}/diagram` |
| Draw or change a diagram | `wf diagram change <documentId> --json <DiagramChanges>` | `POST /documents/{documentId}/diagram/changes` |
| Get a diagram as an image | `wf api GET /documents/{documentId}/diagram/svg --output diagram.svg` | `GET /documents/{documentId}/diagram/svg` |
| Get a note as a PDF | `wf api GET /documents/{documentId}/pdf --output note.pdf` | `GET /documents/{documentId}/pdf` |
| Put an image in a note | workflow 9 (upload, then insert its markdown) | same |
| Organize | move, rename or reorder (workflow 8) with `wf api` | workflow 8 |
| Delete or undo a deletion | `wf delete <documentId>`; restore from the trash (workflow 8) | `DELETE …`, then restore |
| Use a share link the user gave you | workflow 14 (REST only: it uses the link's token, not the session) | workflow 14 |
| Anything about members, tokens, settings or deleting forever | not possible with a token: ask the user to do it in the web app | same |

**Any other operation** in this skill or the Endpoints table: `wf api <METHOD> <path> [--json <body>]`. The path is relative to `/api`; write `{workspaceId}` literally and wf fills in the session's workspace (replace every other `{…}` parameter, such as `{documentId}`, with its id), for example `wf api POST /workspaces/{workspaceId}/folders --json '{"name":"Meetings"}'`. `--json`, `--content` take text, `-` for stdin or `@file` for a file's contents (only pass files the user meant to send). `--output <file>` saves a PDF, SVG or image instead of printing it. `wf help` lists every command.

## Authentication

1. The user creates a personal API token in WriteForge under **Settings › API tokens › Create token**, choosing the workspace, the access (Read only, Read & write or Admin) and an expiry. It starts with `wfg_` and is shown once.
2. **With wf**, sign in once and the session is kept on this machine (in the user's config directory, `writeforge/config.json`, readable only by the user): ask the user to run `wf login --url <their WriteForge site>` and paste the token when it waits for it, or run it yourself with the token on stdin: `printf '%s\n' "$TOKEN" | wf login --url https://notes.example.com`. Prefer stdin over `--token`, which stays in the shell history. `wf logout` forgets it. `WRITEFORGE_URL` and `WRITEFORGE_TOKEN` override the stored session (for CI or a one-off workspace).
3. **Without wf**, send `Authorization: Bearer wfg_…` on every request.
4. Never print the token, log it or write it into a document or URL.
5. A token reaches one workspace and acts with the lower of its access and the user's current role there. A Read only token gets 403 `forbidden` on changes.
6. Tokens cannot manage tokens, members, invitations, the profile or settings (403 `cookie-required`): ask the user to do that in the web app.

## Requests and responses

- **Requests.** JSON bodies with `Content-Type: application/json` (an image upload sends raw bytes, see workflow 9). Ids are UUIDs (diagram node and edge ids are short strings such as `n3`). Send only the fields you want to set; `null` means "none" where a field allows it (a folder id of `null` is the root).
- **Responses.** JSON, except files: `text/markdown` (this skill), `application/pdf`, `image/svg+xml`, images and the zip. Times are RFC 3339 in UTC (`2026-10-25T14:03:00Z`). Lists come in a stable order (siblings by `position`; search results best first).
- **Names.** Names are unique per folder, ignoring case: two documents (or two folders) in the same folder can't share one; the trash doesn't count. A document created without `title` gets "Untitled", "Untitled 2"…, and one restored next to its name comes back as "Name (restored)".
- **Versions.** Every document has a `version` that goes up on each change. Changes that could overwrite someone else's work take the `version` you read and answer 409 `version-conflict` (with the current version in `detail`) when it moved: read again, reapply, retry once.
- **Idempotency.** On creates, appends, row additions and asset completion, send `Idempotency-Key: <new UUID>` (with wf: `--idempotency-key <uuid>` on `create`, `append`, `table add` and `api`); retrying the same request with the same key returns the first answer instead of doing it twice.
- **Errors.** `application/problem+json`: `{"type": "urn:writeforge:problem:<kind>", "title", "status", "detail", "errors": [{"field", "message"}]}`. `detail` says how to fix it; `errors` (422 only) names every invalid field. The `type` suffixes are in the Errors table.

The main response shapes (field names as returned):

| Schema | Returned by | Fields |
|---|---|---|
| `MeResponse` | `GET /me` | `user` (`id`, `email`, `name`), `workspaces` (`id`, `name`, `role`) |
| `WorkspaceTree` | `GET /workspaces/{workspaceId}/tree` | `folders` (`id`, `parentId`, `name`, `position`), `documents` (`id`, `folderId`, `title`, `kind`, `position`, `updatedAt`) |
| `SearchResult[]` | `GET /workspaces/{workspaceId}/search` | `id`, `title`, `folderId`, `snippet`, `updatedAt` |
| `Document` | reading, creating and changing a document | `id`, `workspaceId`, `folderId`, `title`, `kind`, `contentMd`, `columnTypes`, `columnOptions`, `board`, `version`, `position`, `createdBy`, `updatedBy`, `createdAt`, `updatedAt` |
| `TableStructure` | `GET /documents/{documentId}/table` | `documentId`, `title`, `version`, `columns`, `columnTypes`, `columnOptions`, `board`, `rowCount` |
| `TableQueryResult` | `POST /documents/{documentId}/table/query` | `version`, `columns`, `total`, `offset`, `rows` (`row`, `values`) |
| `TableRowResult` | reading or changing one row | `version`, `row`, `values` |
| `TableRowsChanged` | adding or deleting rows | `version`, `rowCount`, `rows` |
| `DiagramDescription` | `GET /documents/{documentId}/diagram` | `version`, `diagram` (`version`, `nodes`, `edges`), `outline` (strings) |
| `DiagramChanged` | `POST /documents/{documentId}/diagram/changes` | the same plus `created` (`nodes`, `edges`: the new ids in request order) |
| `TrashItem[]` | `GET /workspaces/{workspaceId}/trash` | `kind`, `id`, `name`, `deletedAt`, `deletedBy`, `itemCount`, `originalParentId` |
| `CreatedAsset` | `POST /documents/{documentId}/assets` | `asset` (`id`, `status`, `markdown`, …), `upload` (`method`, `url`, `headers`, `expiresAt`) |
| `SharedDocument` | `GET /shared` | `permission`, `expiresAt`, `document` |

## Core workflows

The workflows show the REST calls. With wf, run the matching command from the table above, or `wf api <METHOD> <path> --json '<body>'` for the rest; wf adds the token and the base URL.

**1. Find the workspace.** `GET /me` returns `user` and `workspaces`; with a token it holds only the token's workspace: use `workspaces[0].id` and `workspaces[0].role`.

**2. Browse.** `GET /workspaces/{workspaceId}/tree` returns two flat lists: `folders` (`id`, `parentId`, `name`) and `documents` (`id`, `folderId`, `title`, `kind`, `updatedAt`). `null` means the root; order siblings by `position`. `kind` says how to read each document (see Document kinds).

**3. Search.** `GET /workspaces/{workspaceId}/search?q=roadmap&limit=10` returns `[{id, title, folderId, snippet, updatedAt}]`, title matches first, then the most recent. It is a plain case-insensitive substring match.

**4. Read.** `GET /documents/{documentId}` returns the markdown in `contentMd` and its `version`.

**5. Create.** With an `Idempotency-Key` header:

```http
POST /workspaces/{workspaceId}/folders
{"name": "Meetings", "parentId": null}

POST /workspaces/{workspaceId}/documents
{"title": "Weekly sync", "folderId": "<folder id or null>", "contentMd": "# Weekly sync\n\n- [ ] Agenda"}
```

**6. Add to a document.** `POST /documents/{documentId}/append` with `{"markdown": "## 2026-10-20\n\n- Decided to ship search"}` adds it after a blank line and returns the updated document. Add `"version": 7` to append only if the document is still at the version you read.

**7. Rewrite a document.** `PATCH /documents/{documentId}` with `{"version": 7, "contentMd": "…", "title": "…"}` replaces the whole content (send only the fields you change, always with `version`).

**8. Organize.** `POST /documents/{documentId}/move` `{"folderId": "<id>"}` (or `null` for the root), `POST /folders/{folderId}/move` `{"parentId": "<id>"}`, `PATCH /folders/{folderId}` `{"name": "…"}`. A moved item goes last among its new siblings. To put it at a given place, add `"beforeId"`: the id of the folder (for folders) or document (for documents) directly in the destination that it must go right before; send the current `parentId`/`folderId` with `beforeId` to reorder in place. `DELETE /documents/{documentId}` and `DELETE /folders/{folderId}` move items to the trash; deleting a folder deletes everything inside it.

**Trash.** `GET /workspaces/{workspaceId}/trash` lists what was deleted, one entry per deletion (`kind`, `id`, `name`, `deletedAt`, `itemCount`, `originalParentId`). Undo a deletion with `POST /trash/folders/{folderId}/restore` or `POST /trash/documents/{documentId}/restore` (it comes back with everything deleted with it; at the root if its folder is gone). Items are deleted forever 30 days after deletion. Deleting forever is not possible with a token: ask the user to do it in the web app.

**9. Add an image.** Only images uploaded to WriteForge are shown in notes (PNG, JPEG, GIF or WebP, at most 10 MiB; no SVG). Three calls, then insert the markdown:

1. `POST /documents/{documentId}/assets` with `{"contentType": "image/png", "sizeBytes": 48213, "fileName": "diagram.png"}` (the exact size of the file in bytes). It returns `asset` (with `id` and `markdown`) and `upload` (`method`, `url`, `headers`, `expiresAt`).
2. Send the raw file bytes with `upload.method` (PUT) to `upload.url` with exactly the headers in `upload.headers` (at least `Content-Type`) and no `Authorization` header. A `url` starting with `/api` is relative to the WriteForge site; otherwise it is an object storage link. It works for 10 minutes. For example: `curl -X PUT --data-binary @diagram.png -H "Content-Type: image/png" "<site><upload.url>"`.
3. `POST /assets/{assetId}/complete` checks the file (size and real image type) and returns the asset with `status: "ready"`, `width` and `height`. Send an `Idempotency-Key`.
4. Insert `![A short description](/api/assets/{assetId})` into the document with `POST /documents/{documentId}/append` or `PATCH /documents/{documentId}`. Write a meaningful alt text; `asset.markdown` only suggests one from the file name.

`GET /assets/{assetId}` returns the image (or a short-lived redirect to it) to any member of the workspace.

**10. Export to PDF.** `GET /documents/{documentId}/pdf` (any member, token allowed) returns the note as `application/pdf`, rendered on the server: the title, the content with its images and the note's `<style>` CSS, laid out on the page of its front matter (`page`, `orientation`, `margins`; without them, or with `page: none`, it prints on A4 portrait with normal margins). Save the body as a binary file (with wf: `--output note.pdf`); the name is in `Content-Disposition`. To change the paper, update the front matter first (see Markdown dialect). Rendering takes a few seconds: each user can export 10 notes per minute.

**11. Databases (tables).** A document whose `kind` is `database` is a table: every value is text and the first line names the columns (it is stored as CSV). Do not read or rewrite the whole CSV; use the table operations:

1. `GET /documents/{documentId}/table` returns `columns`, `columnTypes`, `rowCount` and `version`. Read it first: column names are case-sensitive in queries, and each column's type says which values it takes and which operators work.
2. `POST /documents/{documentId}/table/query` with `{"columns": ["Name", "Email"], "where": [{"column": "City", "op": "eq", "value": "lima", "ignoreCase": true}], "match": "all", "orderBy": "Name", "order": "asc", "offset": 0, "limit": 100}` (every field optional) returns `rows` as `{"row": 12, "values": {"Name": "…", "Email": "…"}}` and `total`, the number of matching rows (send `"limit": 1` to count). At most 1,000 rows per call: page with `offset`. Operators by column type:
   - `text`: `eq`, `ne`, `contains`, `notContains`, `startsWith`, `endsWith`, `isEmpty`, `isNotEmpty` (add `"ignoreCase": true` to ignore case).
   - `integer` (`-42`), `decimal` (`3.25`) and `date` (`2026-10-23`): `eq`, `ne`, `gt`, `gte`, `lt`, `lte` by value, `isEmpty`, `isNotEmpty`. Use `gte` + `lte` for a range.
   - `select` (one value; with options, one of them): `eq`, `ne`, `isEmpty`, `isNotEmpty`.
   - `tags` (`office; urgent`): `hasAnyTag` (at least one of them), `hasAllTags`, `notHasTag` (none of them) (value: tags separated by `;`, case-insensitive), `isEmpty`, `isNotEmpty`.
   Another operator for a column's type is a 422.
   `orderBy` sorts numbers by value and dates by date; empty values go last.
3. `GET /documents/{documentId}/table/rows/{row}?columns=Name,Email` reads one row by its number.
4. `POST /documents/{documentId}/table/rows` with `{"rows": [{"Name": "Ana", "City": "Lima"}]}` adds rows (missing columns are empty, unknown columns are a 422). Send an `Idempotency-Key`.
5. `PATCH /documents/{documentId}/table/rows/{row}` with `{"version": 7, "values": {"City": "Cusco"}}` changes one row; `DELETE /documents/{documentId}/table/rows/{row}?version=7` deletes it. Row numbers move up after a delete, so both need the `version` you read; on 409 read again (query the row by its values) and retry.

To create a database: `POST /workspaces/{workspaceId}/documents` with `{"title": "Orders", "kind": "database", "contentMd": "Item,Qty,Due,Tags\n", "columnTypes": {"Qty": "integer", "Due": "date", "Tags": "tags"}}` (CSV: quote values with commas, quotes or line breaks and double inner quotes; at most 100 columns and 5,000 rows; unique column names; columns without a type are text). Every value must fit its column's type (422 naming the row and column otherwise); write numbers without thousands separators, dates as `YYYY-MM-DD`. Change types with `PATCH /documents/{documentId}` and `columnTypes` (with the version). In the tree, `kind` tells notes and databases apart. `append` and PDF export do not work on databases (409 `wrong-kind`); the table operations do not work on notes. Every column type, operator and CSV rule, with examples: `references/tables.md` in the skill zip.

**12. Boards (kanban).** A database whose `board` is set (in the document and in `GET /documents/{documentId}/table`) shows as a kanban board: `board.lane` is a `select` column whose `columnOptions` are the lanes, in order; `board.title` and `board.description` are the card's title and description; every other column is a card field. To list a lane's cards, query with `{"where": [{"column": "<lane>", "op": "eq", "value": "In progress"}]}`. To add a card, `POST /documents/{documentId}/table/rows` with `{"rows": [{"Title": "…", "Description": "…", "Status": "To do"}]}`. To move a card, `PATCH /documents/{documentId}/table/rows/{row}` with `{"version": 7, "values": {"Status": "Done"}}`. Values of `select` and `tags` columns with options must be among them (422 otherwise); read `columnOptions` first. Create a board with `POST /workspaces/{workspaceId}/documents` and `"kind": "database"`, `"contentMd": "Title,Description,Status\n"`, `"columnTypes": {"Status": "select"}`, `"columnOptions": {"Status": ["To do", "In progress", "Done"]}`, `"board": {"lane": "Status", "title": "Title", "description": "Description"}`.

**13. Diagrams.** A document of kind `diagram` holds its drawing as JSON in `contentMd`: `{"version": 1, "nodes": [...], "edges": [...]}`. Read it with `GET /documents/{documentId}/diagram`: besides the diagram it returns `outline`, one line per element (frames with what they contain, then connections); read that first. Change it with `POST /documents/{documentId}/diagram/changes` and `{"version": 3, "add": {"nodes": [...], "edges": [...]}, "update": [{"id": "n2", "text": "Orders"}], "remove": ["e4"]}`: all or nothing; omit `x`/`y` and the node is placed for you, omit `id` and one is made. Node types: `shape` (`shape`: rectangle, rounded, ellipse, diamond, cylinder, parallelogram, hexagon; `text`), `text` (`text`, `size` s/m/l/xl), `note` (sticky, `text`), `frame` (`text` title; contains the nodes inside its box), `entity` (ER table: `name`, `fields` `[{"name", "type", "key": "pk"|"fk"|"pkfk"}]`), `class` (UML: `name`, `stereotype`, `attributes`, `methods`), `component` (architecture: `icon` user, browser, mobile, server, service, function, container, database, cache, queue, storage, cdn, loadBalancer, gateway, auth, mail, analytics, cloud, external; `text`, `description`). Any node takes `color` (gray, red, orange, yellow, green, teal, blue, purple, pink). Edges: `from`, `to`, `label`, `line` (solid, dashed, dotted), `path` (smooth, straight, step), `start`/`end` markers (none, arrow, triangle, diamond, filledDiamond, one, many, zeroOne, zeroMany, oneMany): an ER one-to-many is `"start": "one", "end": "zeroMany"`; UML inheritance `"end": "triangle"`. Create a diagram with `POST /workspaces/{workspaceId}/documents` and `"kind": "diagram"`. To show or share a diagram as an image, `GET /documents/{documentId}/diagram/svg` (optional `?theme=dark`) returns it as SVG. The full format (every node type and field, the edge markers, how the outline reads, placement, limits) with examples: `references/diagrams.md` in the skill zip.

**14. Share links.** A user can share one note with anyone through a link that expires (1 hour, 1 day, 7 days or 30 days), to view or to edit. Links are created and revoked in the web app (the Share button in the note's header): a token cannot create, list or revoke them. If the user gives you a link `…/s/wfs_…`, the part after `/s/` is the link's token: send it as `Authorization: Bearer wfs_…` and call `GET /shared` (what the link allows, when it expires, and the note), then `PATCH /documents/{documentId}` on that note only if `permission` is `write`. A link reaches that one note and nothing else (403 `share-link-forbidden`); a link that expired or was revoked answers 401 `invalid-share-link`.

## Markdown dialect

Standard markdown (headings, lists, `- [ ]` task lists, tables, code blocks, quotes, links) plus:

- Callouts: a line `:::info` (or `success`, `warning`, `danger`), the content, and a closing `:::` line.
- Highlight: `==important==`.
- Text color: `<span style="color: #d92d20">red text</span>` with exactly one `color` declaration (hex, `rgb()`/`rgba()` or a CSS color name).
- Embeds on their own line: `{%youtube dQw4w9WgXcQ %}`, `{%vimeo 76979871 %}`, `{%figma https://www.figma.com/design/KEY/Name %}`.
- Images: `![alt text](/api/assets/{assetId})` with an optional title (`![alt](/api/assets/{assetId} "Title")`), only for images uploaded with workflow 9.
- Per-note CSS: a top-level `<style>…</style>` block styles only that note. `url()`, `@import` and `@font-face` are removed.
- Page layout: YAML front matter at the very top (`---` on the first line) with `page` (`A4`, `Letter`, `Legal`, `A5`, `A3` or `none`), `orientation` (`portrait`, `landscape`) and `margins` (`narrow`, `normal`, `wide`). Other keys (`title`, `tags`…) are kept. Order: front matter, then `<style>`, then the body.

Anything else (other HTML, images from other URLs or `data:` URIs, unknown `:::` blocks or `{% %}` embeds) is kept, but the note then opens in Hybrid mode instead of the visual editor, and external images are never loaded. Full rules with examples: `references/markdown.md` in the skill zip (`GET /skill/download`).

## Rules

- Prefer `append` over rewriting: it cannot overwrite someone else's edits and sends less text.
- Always send the `version` you read with `PATCH /documents/{documentId}`. On 409 `version-conflict` read the document again, reapply your change to the new content and retry once.
- Send an `Idempotency-Key` header (with wf, `--idempotency-key`; a new UUID per action) on every create and append; reuse the same key only to retry that same request after a timeout or network error.
- Keep the user's markdown as it is: change only what you were asked to, keep the front matter, `<style>` blocks, callouts and embeds intact.
- Ask before deleting anything or rewriting a whole document. If you deleted something by mistake, restore it from the trash.
- Stay under 1,000,000 bytes per document; start a new document and link to it instead.
- Never link images from other sites; upload them (workflow 9) so readers' browsers make no outside requests.
- Respect rate limits: 120 requests per minute per token, 30 image uploads per hour and 10 PDF exports per minute per user. On 429 (or 503 `busy`) wait `Retry-After` seconds.

## Errors

| Status | `type` | What to do |
|---|---|---|
| 400 | `bad-request` | The JSON or a parameter is malformed; fix it as `detail` says. |
| 401 | `invalid-token` | The token is wrong, revoked or expired, or the user left the workspace. Ask for a new token (with wf, sign in again with `wf login`). |
| 403 | `forbidden` | The token or role can only read. Ask for a Read & write token. |
| 403 | `email-not-verified` | The account has not confirmed its email. Tell the user to open the activation link emailed to them; do not retry. |
| 400 | `invalid-reset-link` | A password reset link is unknown, used or expired. The user asks for a new one on the sign-in page ("Forgot your password?"); do not retry. |
| 403 | `cookie-required` | Not possible with a token; the user must do it in the web app. |
| 401 | `invalid-share-link` | The share link expired, was revoked or its note was deleted. Ask the user for a new link. |
| 403 | `share-link-forbidden` | A share link opens only its note. Use an API token for anything else. |
| 409 | `wrong-kind` | The operation needs another kind of document: notes take append and PDF, databases the table operations, diagrams the diagram operations. Check `kind` and use the matching workflow. |
| 409 | `invalid-diagram` | The diagram stored in the document is not valid JSON for the format (written by hand). Rewrite `contentMd` with `PATCH /documents/{documentId}`. |
| 403 | `invalid-upload-link` | The image upload link was changed or expired. Start again with `createAsset`. |
| 404 | `not-found` | The id does not exist, was deleted or is in another workspace. List the tree (or the trash) again. |
| 409 | `version-conflict` | Someone changed the document. Read it again, reapply and retry. |
| 409 | `name-taken` | Another document (or folder) in that folder has this name, ignoring case. Pick another name (or leave out `title` to get "Untitled", "Untitled 2"…); don't retry the same one. |
| 409 | `idempotency-key-in-use` | The same request is still running. Wait `Retry-After` seconds and retry it. |
| 413 | `too-large` | The body is too big. Split the content. |
| 422 | `validation` | Fix every field listed in `errors`. |
| 422 | `idempotency-key-reused` | That key was used for a different request. Use a new key. |
| 422 | `upload-missing` | The image bytes were not uploaded yet. Upload them, then complete again. |
| 422 | `invalid-image` | The file is not a valid PNG, JPEG, GIF or WebP of the declared type and size. It was deleted; start again. |
| 429 | `rate-limited` | Wait `Retry-After` seconds, then retry. |
| 502 | `pdf-render-failed` | The PDF render failed or timed out. Retry once a minute later. |
| 503 | `busy` | Too many PDFs are rendering. Wait `Retry-After` seconds, then retry. |
| 503 | `pdf-unavailable` | This server cannot export PDFs. Tell the user; do not retry. |
| 507 | `storage-full` | The workspace owner's storage is full; nothing was saved. Do not retry: tell the user, who can free space by deleting documents or images and emptying the trash. Edits that keep or shrink a document still work. |

## Endpoints

Generated from the API contract. **Token** marks operations that accept `Authorization: Bearer wfg_…`; the others need a browser session or are public.

| Method | Path | operationId | Summary | Token |
|---|---|---|---|---|
| GET | `/health` | `getHealth` | Check that the API is running | Public |
| GET | `/auth/registration` | `getRegistration` | Check whether new accounts can be created | Public |
| POST | `/auth/register` | `registerUser` | Create an account | Session only |
| POST | `/auth/activate` | `activateAccount` | Activate an account with its emailed link | Session only |
| POST | `/auth/password/forgot` | `requestPasswordReset` | Email a link to set a new password | Session only |
| POST | `/auth/password/reset` | `resetPassword` | Set a new password with an emailed link | Session only |
| POST | `/auth/activation/resend` | `resendActivation` | Email a new activation link | Session only |
| POST | `/auth/login` | `loginUser` | Sign in with email and password | Session only |
| POST | `/auth/logout` | `logoutUser` | Sign out | Session only |
| POST | `/auth/passkey/options` | `startPasskeyLogin` | Start signing in with a passkey | Session only |
| POST | `/auth/passkey` | `loginWithPasskey` | Sign in with a passkey | Session only |
| GET | `/me` | `getCurrentUser` | Get the signed-in user and their workspaces | Token |
| PATCH | `/me` | `updateMe` | Change the signed-in user's display name | Session only |
| POST | `/me/password` | `changeMyPassword` | Change the signed-in user's password | Session only |
| GET | `/me/storage` | `getMyStorage` | Get how much storage the signed-in user uses | Session only |
| POST | `/bug-reports` | `reportBug` | Report a bug to the people who run this server | Session only |
| GET | `/me/settings` | `getMySettings` | Get the signed-in user's preferences | Session only |
| PATCH | `/me/settings` | `updateMySettings` | Change some of the signed-in user's preferences | Session only |
| GET | `/me/tokens` | `listMyTokens` | List your API tokens | Session only |
| POST | `/me/tokens` | `createMyToken` | Create an API token | Session only |
| DELETE | `/me/tokens/{tokenId}` | `revokeMyToken` | Revoke an API token | Session only |
| GET | `/me/passkeys` | `listMyPasskeys` | List your passkeys | Session only |
| POST | `/me/passkeys` | `createMyPasskey` | Add a passkey | Session only |
| POST | `/me/passkeys/options` | `startPasskeyRegistration` | Start adding a passkey | Session only |
| PATCH | `/me/passkeys/{passkeyId}` | `renameMyPasskey` | Rename a passkey | Session only |
| DELETE | `/me/passkeys/{passkeyId}` | `deleteMyPasskey` | Delete a passkey | Session only |
| GET | `/workspaces/{workspaceId}/tree` | `getWorkspaceTree` | List every folder and document of a workspace | Token |
| GET | `/workspaces/{workspaceId}/trash` | `listTrash` | List the trash of a workspace | Token |
| POST | `/trash/folders/{folderId}/restore` | `restoreFolder` | Restore a deleted folder with everything deleted with it | Token |
| POST | `/trash/documents/{documentId}/restore` | `restoreDocument` | Restore a deleted document | Token |
| DELETE | `/trash/folders/{folderId}` | `purgeFolder` | Delete a folder in the trash forever | Session only |
| DELETE | `/trash/documents/{documentId}` | `purgeDocument` | Delete a document in the trash forever | Session only |
| GET | `/workspaces/{workspaceId}/search` | `searchWorkspace` | Search documents by title and content | Token |
| POST | `/workspaces/{workspaceId}/folders` | `createFolder` | Create a folder | Token |
| PATCH | `/folders/{folderId}` | `renameFolder` | Rename a folder | Token |
| DELETE | `/folders/{folderId}` | `deleteFolder` | Delete a folder with everything inside it | Token |
| POST | `/folders/{folderId}/move` | `moveFolder` | Move a folder | Token |
| POST | `/workspaces/{workspaceId}/documents` | `createDocument` | Create a markdown document | Token |
| GET | `/documents/{documentId}` | `getDocument` | Read a document | Token |
| PATCH | `/documents/{documentId}` | `updateDocument` | Update a document's title or content | Token |
| DELETE | `/documents/{documentId}` | `deleteDocument` | Delete a document | Token |
| GET | `/documents/{documentId}/table` | `describeTable` | Read a database's structure | Token |
| POST | `/documents/{documentId}/table/query` | `queryTable` | Query a database's rows | Token |
| POST | `/documents/{documentId}/table/rows` | `appendTableRows` | Add rows to a database | Token |
| GET | `/documents/{documentId}/table/rows/{row}` | `getTableRow` | Read one row of a database | Token |
| PATCH | `/documents/{documentId}/table/rows/{row}` | `updateTableRow` | Change values of one row | Token |
| DELETE | `/documents/{documentId}/table/rows/{row}` | `deleteTableRow` | Delete one row of a database | Token |
| GET | `/documents/{documentId}/diagram` | `describeDiagram` | Read a diagram | Token |
| GET | `/documents/{documentId}/diagram/svg` | `exportDiagramSvg` | Export a diagram as SVG | Token |
| POST | `/documents/{documentId}/diagram/changes` | `changeDiagram` | Add, change and remove diagram elements | Token |
| POST | `/documents/{documentId}/move` | `moveDocument` | Move a document to another folder | Token |
| POST | `/documents/{documentId}/append` | `appendToDocument` | Append markdown to the end of a document | Token |
| GET | `/documents/{documentId}/pdf` | `exportDocumentPdf` | Export a document as PDF | Token |
| POST | `/documents/{documentId}/assets` | `createAsset` | Start an image upload for a document | Token |
| PUT | `/assets/{assetId}/content` | `uploadAssetContent` | Upload an image's bytes (signed link) | Public |
| POST | `/assets/{assetId}/complete` | `completeAsset` | Finish an image upload | Token |
| GET | `/assets/{assetId}` | `getAsset` | Read an uploaded image | Token |
| GET | `/documents/{documentId}/shares` | `listShares` | List a document's share links | Session only |
| POST | `/documents/{documentId}/shares` | `createShare` | Create a share link | Session only |
| DELETE | `/documents/{documentId}/shares/{shareId}` | `revokeShare` | Revoke a share link | Session only |
| GET | `/shared` | `getSharedDocument` | Open a shared document | Share link |
| GET | `/shares/{shareToken}/assets/{assetId}` | `getSharedAsset` | Read an image of a shared document | Public |
| GET | `/workspaces/{workspaceId}/members` | `listMembers` | List the members of a workspace | Token |
| PATCH | `/workspaces/{workspaceId}/members/{userId}` | `changeMemberRole` | Change a member's role | Session only |
| DELETE | `/workspaces/{workspaceId}/members/{userId}` | `removeMember` | Remove a member, or leave the workspace | Session only |
| GET | `/workspaces/{workspaceId}/invitations` | `listInvitations` | List pending invitations | Session only |
| POST | `/workspaces/{workspaceId}/invitations` | `createInvitation` | Invite someone by email and get a shareable link | Session only |
| POST | `/invitations/{invitationId}/resend` | `resendInvitation` | Get a new link for an invitation | Session only |
| DELETE | `/invitations/{invitationId}` | `revokeInvitation` | Revoke an invitation | Session only |
| GET | `/invitations/preview` | `previewInvitation` | Describe the invitation behind a token | Session only |
| POST | `/invitations/accept` | `acceptInvitation` | Join a workspace with an invitation | Session only |
| GET | `/skill` | `getSkill` | Get the agent skill (SKILL.md) | Public |
| GET | `/skill/download` | `downloadSkill` | Download the agent skill as a zip | Public |
| GET | `/openapi.yaml` | `getOpenApiYaml` | Get this API contract as YAML | Public |
| GET | `/openapi.json` | `getOpenApiJson` | Get this API contract as JSON | Public |
| GET | `/llms.txt` | `getLlmsTxt` | Get llms.txt | Public |
