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 inhreforsrc. Onlyhttp://,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.
Wikilinks#
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:
- Parses all
[[...]]tokens from the new body. - Deletes existing
wiki_linkrows wheresource_page_id = this_page.id. - For each parsed slug, looks up a candidate:
- For a project-scoped source page: project-scoped candidates win, then global candidates.
- For a global source page: only global candidates resolve. This prevents cross-project leaks.
- Inserts a
wiki_linkrow with the resolvedtarget_page_id, or withNULLif the slug doesn't match a live page.
This happens in a single transaction so backlink counts never observe a partial state.
Backlinks#
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."
Broken links#
A [[wikilink]] to a slug that doesn't (yet) exist renders as a styled placeholder:
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.
Wikilink autocomplete#
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:
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:
- Try a live wiki page with that slug in the ticket's project.
- Try a live global page with that slug.
- 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:
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_atis set.- Inbound links break (
target_page_idis nulled). - The page disappears from
/wiki, list endpoints, search, and backlinks. - Revisions are preserved forever.
- The
wiki_linkrows where this page was the source stay in place but are filtered out of joins by thesrc.deleted_at IS NULLpredicate.
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_onlyeventwiki.render_failedis 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.