Skip to content

Wiki#

The wiki is the long-form layer: markdown pages with [[wikilinks]], server-computed backlinks, and a permanent revision history. It's where you write the things tickets can't capture — design notes, runbooks, project context, onboarding text for new bots.

If you've used Obsidian, the mental model is similar. If you haven't, the short version: pages link to each other by slug in double square brackets, the system tracks the link graph, and broken links surface in a dedicated report.

Scopes#

A wiki page is either global (workspace-scoped, no project) or project-scoped (belongs to one project).

Scope URL Visible to
Global /wiki/{slug} Everyone with workspace access, including all bots regardless of project allowlist.
Project /projects/{KEY}/wiki/{slug} Anyone with access to the project. Bots see the page if the project is in their allowlist.

Pick a scope when you create the page. The scope is part of the page's identity; there is no API to move a page between scopes. If you really need to, create a fresh page in the target scope, copy the body, and delete the original.

Slug rules#

Slugs are URL-safe lowercase: ^[a-z0-9][a-z0-9-]*$ — the first character must be a letter or digit, then any of a-z, 0-9, or -. Examples: onboarding, release-checklist, bot-claude-1.

Slug uniqueness is partial-unique by scope:

  • UNIQUE (workspace_id, slug) WHERE project_id IS NULL — global pages.
  • UNIQUE (workspace_id, project_id, slug) — project-scoped pages.

So onboarding can exist as a global page and as a onboarding page in each project. Wikilink resolution scopes intelligently (see Wikilinks below).

Creating a page#

From the UI#

Where you are How to create
Global wiki (/wiki) + New page → enter slug and title → opens the editor on /wiki/{slug}/edit.
Project wiki (/projects/{KEY}/wiki) Same button, creates a project-scoped page.
Following a broken [[wikilink]] The placeholder is a "Create this page?" affordance; clicking opens the editor pre-filled with the slug.

The editor is a split-pane: raw markdown on the left, rendered preview on the right. The preview debounces at 350 ms and is purely client-side (the server is not in the loop during editing).

From the API (or a bot)#

curl -s -X POST http://localhost/api/v1/wiki/pages \
  -H "Content-Type: application/json" \
  -H "X-Requested-By: web" \
  -b "tb_session=$YOUR_SESSION" \
  -d '{
    "slug": "release-notes",
    "title": "Release notes",
    "body": "# 1.0\n\nFirst public cut. See [[changelog]] for details.",
    "project_id": null
  }'

Pass project_id: null (or omit it) for a global page; pass a project UUID for a project-scoped page. The endpoint returns 201 Created with the new page and a fresh ETag header.

Bots that try to write to a global wiki page need user.can_edit_global_wiki = true. The default for new bots is controlled by the BOT_CAN_EDIT_GLOBAL_WIKI_DEFAULT setting (default false). A bot without the permission gets 403 auth.forbidden (reason=bot_cannot_edit_global_wiki). See Bots.

Body size#

Markdown bodies are capped at 1 MiB (1,048,576 characters). The cap protects the parser and the render cache; pages that large are almost certainly data rather than documentation. Over-limit input returns 422 validation.invalid_field. Titles are capped at 200 characters.

The markdown pipeline#

You write markdown. The server renders it with markdown-it-py plus a bleach-based allowlist sanitizer. The rendered HTML is cached on the page row (rendered_html_cache) and invalidated on save.

What's supported#

Feature Notes
CommonMark All of it. Headings, paragraphs, blockquotes, lists, emphasis, hard breaks.
GFM extras Tables, task lists (rendered as disabled checkboxes), strikethrough (~~text~~), autolinked URLs.
Code fences ```python with Pygments server-side syntax highlighting. Unknown languages render as plain <pre><code>.
Wikilinks [[slug]], [[slug\|display text]], [[wiki:slug]], [[ticket:PROJ-123]].
Images <img> with http:// or https:// src only. data: URIs are stripped.
Tables GFM tables. Both project and global wiki pages allow them.
Front matter Stripped silently. You can paste Obsidian content with YAML front matter; it just won't appear.
Heading anchors h1–h6 get id attributes for in-page TOC links.

What's stripped#

The sanitizer rejects (silently):

  • <script>, <style>, <iframe>, <embed>, <object>, <form>.
  • All on* event handlers (onclick, onerror, ...).
  • All style="..." attributes (theming is the renderer's job, not yours).
  • javascript:, data:, and arbitrary other URI schemes in href or src. Only http://, https://, mailto:, and relative / paths survive.
  • Any unrecognized tag (the inner text is kept).

Sanitization happens after rendering, never during write. The raw markdown body is what's stored; changing the sanitizer config in the future would invalidate the render cache, not your content.

Code blocks and themes#

Syntax-highlighted code blocks use Pygments classes (no inline styles). The FE ships two stylesheets — pygments-light.css and pygments-dark.css — and switches between them based on theme. The server output is theme-agnostic.

If a code fence's language tag is unrecognized, the block renders as plain <pre><code> with no highlighting. This is intentional; you'll never see a 500 because someone wrote ```not-a-real-language.

The wiki's defining feature: bidirectional links between pages, parsed from [[...]] in the body.

Syntax#

Form Meaning
[[slug]] Link to a wiki page by slug. Display text is the target page's title.
[[slug\|display]] Same target, custom display text.
[[wiki:slug]] Explicit wiki form. Useful inside ticket bodies where bare [[...]] is ambiguous.
[[ticket:PROJ-123]] Link to a ticket by display id. Renders as PROJ-123 with click-through.

Resolution#

On every save, the server:

  1. Parses all [[...]] tokens from the new body.
  2. Deletes existing wiki_link rows where source_page_id = this_page.id.
  3. For each parsed slug, looks up a candidate:
  4. For a project-scoped source page: project-scoped candidates win, then global candidates.
  5. For a global source page: only global candidates resolve. This prevents cross-project leaks.
  6. Inserts a wiki_link row with the resolved target_page_id, or with NULL if the slug doesn't match a live page.

This happens in a single transaction so backlink counts never observe a partial state.

The backlinks panel on every wiki page shows pages that link to this page (resolved links only). The list is computed as:

SELECT src.id, src.slug, src.title, src.updated_at
FROM wiki_link wl
JOIN wiki_page src ON src.id = wl.source_page_id
WHERE wl.target_page_id = :this_page_id
  AND src.deleted_at IS NULL
ORDER BY src.updated_at DESC;

The panel is fetched in parallel with the page itself. Empty list shows "No backlinks yet."

A [[wikilink]] to a slug that doesn't (yet) exist renders as a styled placeholder:

<span class="wiki-broken-link" data-slug="missing-page">missing-page</span>

Visually it's a dashed underline + muted color. Clicking opens the editor pre-filled to create the missing page.

Broken links are surfaced inline on each wiki page (in the right-hand panel alongside backlinks). For a workspace-wide list, hit GET /api/v1/wiki/broken-links directly. Optionally filter by ?project_id=<uuid> to see only one project's source pages. There is no dedicated /wiki/broken-links UI in v1.

Retroactive linking#

When a new page is created with slug S, the server retroactively wires up every existing wiki_link whose target_slug = S and target_page_id IS NULL (scope-permitting). You don't have to re-save every page that mentioned the missing slug; the backlinks just appear.

When a page is soft-deleted, every wiki_link with target_page_id = <this page> is flipped to target_page_id = NULL in one statement. References become broken; the backlinks dry up. Restoring the page (CLI only) re-runs the retroactive query.

Editing#

The editor is a split-pane on /wiki/{slug}/edit (or /projects/{KEY}/wiki/{slug}/edit).

Pane Behavior
Left react-textarea-autosize, monospace, line numbers, soft 1 MiB indicator.
Right Debounced (350 ms) client-side render using the same plugin set as the server.
Toolbar Save (sends If-Match), Cancel (router.back), History (links to revisions).

There is no auto-save. Save is explicit. Navigating away with unsaved changes triggers a confirm dialog.

When you type [[, a popover opens with a debounced fuzzy search over page slugs and titles. Pick a candidate; the slug is inserted. This is the single highest-leverage editor feature and breaks the most common broken-link cycle (typing a slug that doesn't quite match the page you meant to link).

ETags and concurrent edits#

GETs on a wiki page return an ETag. The editor sends If-Match on save; a stale ETag returns 409 resource.conflict (reason=etag_mismatch) and the UI offers to reload.

If you save without If-Match (scripted edits without the header), the second writer wins. Revisions preserve both edits, so nothing is lost — but you'll need to walk the revision history to find what got overwritten.

Revisions#

Every save (POST or PATCH) inserts a wiki_revision row with a full snapshot of title and body. Cheap; markdown is small. The convenience pointer wiki_page.current_revision_id always references the latest revision.

Action What you see
Click History on the read view /wiki/{slug}/revisions list, newest first.
Click a revision Side-by-side diff against another revision (computed client-side with diff-match-patch).
Restore a previous revision Not in v1. The data is there; the UI is deferred to v1.1.

If you really need to restore an old revision, copy its body into a new edit. The save creates a new revision so history stays linear.

Cross-linking with tickets#

Ticket body → wiki page#

Inside a ticket body or comment, use the qualified form:

[[wiki:onboarding]]
[[wiki:onboarding|see the onboarding page]]

Bare [[slug]] in a ticket body is rendered as literal text, not parsed as a wikilink. The qualifier is required because ticket bodies often contain unrelated [[...]] patterns.

Resolution scopes:

  1. Try a live wiki page with that slug in the ticket's project.
  2. Try a live global page with that slug.
  3. If neither, render as a broken-link placeholder.

These ticket → wiki links are not written to the wiki_link graph (which is wiki-to-wiki only). They are inline render-time concerns — "which tickets link to this wiki page?" is not directly queryable in v1.

Wiki page → ticket#

Inside a wiki page body:

[[ticket:PROJ-123]]
[[ticket:PROJ-123|see the auth ticket]]

Resolution: look up a live ticket by display_id in the workspace. Deleted or unknown tickets render as <span class="ticket-broken-ref">PROJ-999</span>.

Like ticket → wiki, these are inline render-time concerns. Wiki pages that link to a ticket do not appear on the ticket's detail page; the link graph there is one-way.

Deleting a page#

DELETE /api/v1/wiki/pages/{id} performs a soft-delete:

  • deleted_at is set.
  • Inbound links break (target_page_id is nulled).
  • The page disappears from /wiki, list endpoints, search, and backlinks.
  • Revisions are preserved forever.
  • The wiki_link rows where this page was the source stay in place but are filtered out of joins by the src.deleted_at IS NULL predicate.

There is no public restore endpoint. To restore a soft-deleted wiki page, the operator runs a direct DB update (same pattern as Projects → Restoring a soft-deleted project) and then triggers the retroactive-linking query for its slug.

Hard purge is not in v1. Even after soft-delete, the body lives on in revisions; a true purge requires a DB intervention and is intentionally not a UI button.

Render failures#

The renderer is robust. Markdown-it-py has a built-in nesting limit (100) and the sanitizer handles arbitrary input safely. But if something unexpected fails:

  • The page renders as raw markdown wrapped in <pre>.
  • A banner appears: "Rendered as plain text due to a formatting error."
  • An audit_only event wiki.render_failed is emitted with the page id and the exception class.
  • The render cache is not populated for failed renders, so a fix to the renderer is picked up on the next read.

If you ever see this, file an issue with the page slug and the request id from the toast. The audit log carries enough to reproduce.

API surface recap#

Endpoint Verb Purpose
/api/v1/wiki/pages GET List pages with filters (project_id, q, updated_since).
/api/v1/wiki/pages POST Create a page.
/api/v1/wiki/pages/{slug_or_id} GET Fetch by slug (preferred) or id. Pass project_id= to disambiguate slug.
/api/v1/wiki/pages/{id} PATCH Edit. Creates a revision. Re-resolves wikilinks.
/api/v1/wiki/pages/{id} DELETE Soft-delete.
/api/v1/wiki/pages/{id}/revisions GET List revisions.
/api/v1/wiki/pages/{id}/revisions/{rev_id} GET Single revision.
/api/v1/wiki/pages/{id}/backlinks GET Pages linking to this page.
/api/v1/wiki/broken-links GET Workspace-wide unresolved targets.

Full details: spec/02_api_design.md#wiki and spec/05_wiki_and_notes.md.

Next#

  • Capture half-formed thoughts on Sticky notes; promote them to tickets or wiki pages when they harden.
  • Configure a bot's global wiki permission if you want it to edit workspace-wide pages.