Skip to content

Writing documentation

ankka's documentation is one tree of plain Markdown files under docs/, and every way of reading it is a rendering of that tree. People read the site. Models read llms.txt, llms-full.txt, a Markdown copy of each page, or the agent skill that ships with the template and the Claude Code plugin. None of those has its own copy of the content, so a correction made once reaches every reader.

The most common reader of any single page is a model that retrieved it alone, with no navigation around it and no memory of the page before. Most of the rules here exist for that reader. They also make pages better for people, which is why there is one set of rules rather than two.

The build

Everything runs through one command, from the repository root:

uv run --project tools/docs docs check    # every rule on this page; exits 1 on a problem
uv run --project tools/docs docs sync     # refresh included samples, generated tables and the skill
uv run --project tools/docs docs build    # check, build the site, write the machine renderings
uv run --project tools/docs docs serve    # the site with live reload, while writing

just docs, just docs-sync and just docs-serve are the same commands. The site and its renderings land in target/docs-site:

File For
index.html and one directory per page people
<page>.md beside every page a model reading one page
llms.txt a model choosing pages: every page's title, link and one-sentence description, by section
llms-full.txt a model that can take everything: every page, in navigation order, in one file
docs-index.json a retriever: every page's metadata, headings and addresses

The agent skills are rendered into marketplace/plugins/ankka/skills/ and into the template at ankka.g8/src/main/g8/.claude/skills/. Both are committed, and docs check fails when either is stale.

Some reference content is generated by the JVM rather than by tools/docs. The CLI reference and the control plane's route table are enumerated from the code by test suites, which fail when the page is stale and rewrite it when asked:

sbt -Dankka.docs.update=true 'cli/testOnly *CliReferenceSuite' 'controlPlane/testOnly *ControlPlaneRoutesReferenceSuite'

Where a page goes

Every page has one job, named by its kind:

Kind Directory The reader wants to Example
tutorial get-started/ be walked from nothing to something working Your first service in Scala
concept concepts/ understand how something works and why Effects are data
guide build/, deploy/, operate/, platform/ get one task done Expose a service
reference reference/ look one fact up Service descriptor
contributing contributing/ change ankka itself this page

A page that is drifting into a second kind is two pages. A guide that stops to explain a design at length links to the concept page instead. A concept page that needs a table of every option links to the reference page instead.

A new page is added to the nav in mkdocs.yml. docs check fails on a page the navigation does not list, and on a navigation entry with no page.

Frontmatter

Every page starts with this block:

---
title: Views
description: Build a queryable projection of an entity's changes, query it with SQL, and choose between entity, key value and topic sources.
kind: guide
languages: [scala, python]
components: [view]
related: [concepts/consistency.md, build/topics.md]
---
Field Required Meaning
title yes The page's name. The body's first line is # and exactly this.
description yes One sentence, under 240 characters, ending with a full stop. It is what llms.txt and the skill show beside the link, so it says what the reader will be able to do or will understand, not what the page "covers".
kind yes tutorial, concept, guide, reference or contributing.
languages no scala, python, or both. Leave it out for a page that is not about writing code.
components no The component kinds the page is about: event-sourced-entity, key-value-entity, view, consumer, workflow, timed-action, agent, http-endpoint.
related no Paths under docs/ of the pages a reader of this one most often needs next.

Rules for every page

A page stands alone. It will often be read with no other page in view. Never point at other text by its position on the page or in the site; a phrase such as see below means nothing to a reader holding one chunk. Link to the section by its anchor instead: [the journal](../concepts/consistency.md#the-journal-is-the-source-of-truth). The check refuses the common forms of the positional phrase.

Define a term on first use, or link to the glossary. "Wire name", "effect", "session" and "sidecar" mean specific things here. Use them exactly as the glossary defines them, and never use two words for one thing.

Open each section with its answer. A heading is a question, and a retriever cuts pages at headings. The first sentence under a heading answers it, so a chunk that begins at that heading is useful on its own. Reasons and qualifications follow the answer.

Explain why, as a property of the system. ankka makes deliberate choices, and a reader who knows the reason can apply it to a case the page does not cover. State the reason as how the system behaves: "a query cannot persist, because query accepts only a read-only effect". Do not tell the project's history: no feature numbers, specification references, research notes or "a test caught this". The check refuses the common forms.

Show, then say. A concept is followed by code, and a command by what it prints. Output goes in the same block as a comment, or in a text block after it.

Every code block names its language. scala, python, bash, json, yaml, hocon, protobuf, or text for output. A block with no language fails the check.

Complete samples, or say it is an excerpt. A sample a reader can copy should compile or run as shown, imports included. Where a sample is an excerpt of something larger, the sentence before it says so and links to the whole.

Both languages, labelled. When a page shows a component in both Scala and Python, show the Scala block, then the Python block, each preceded by a line that is only the language name in bold:

**Scala**

```scala
val getCart = query("get-cart")(_.getCart)
```

**Python**

```python
@query("get-cart")
def get_cart(self) -> ReadOnlyEffect[ShoppingCart, ShoppingCartEvent, ShoppingCart]: ...
```

No tabs, admonitions or other renderer syntax. A page must read correctly as raw Markdown, because that is how a model most often receives it.

Link to repository files by GitHub URL. Links between pages are relative (../build/views.md) and the check verifies both the page and the anchor. A link to a source file uses its full URL on https://github.com/thinkmorestupidless/ankka/blob/main/, because a relative path out of docs/ does not exist on the site.

Say what is not there. A limitation the reader will hit belongs on the page where they hit it, stated plainly, and on Limitations.

Samples come from tested code

A sample that the build compiles and tests is included from its source rather than typed into the page. Mark the region in the source file with comment lines:

// docs:start add-item
def addItem(item: LineItem): Effect[Done] =
  if currentState.checkedOut then effects.error("cart is already checked out", ErrorCode.Conflict)
  else effects.persist(ItemAdded(item)).thenReply(_ => Done)
// docs:end add-item

Name the region in a comment on the line before the page's code block:

<!-- include: samples/shopping-cart/src/main/scala/shoppingcart/application/ShoppingCartEntity.scala#add-item -->
```scala
```

docs sync fills the block with the region, dedented. docs check fails when a block has drifted from its source, so a renamed method breaks the documentation build rather than silently leaving a page that no longer compiles. The copy lives in the page on purpose: a page must be complete as raw Markdown, and an include resolved only at render time would leave a model reading the source with an empty block. Leave out the region name to include a whole file. Python sources use # docs:start name and # docs:end name.

Samples that are not from a tested source are written by hand. Keep them short, and prefer to add a region to a sample or test that exists.

Generated reference

Reference facts that the code already knows are generated from it, between two comments:

<!-- generated:start configuration -->
| Variable | Configuration key | Default | Applies in |
...
<!-- generated:end configuration -->

The content between the comments belongs to the generator, and hand edits there are overwritten. The prose around the block is written by hand, and a coverage check fails when the prose does not describe every fact the table lists. Adding a configuration key or a CLI command is therefore a build failure until someone writes a sentence about it.

Block Page Generated from By
configuration reference/configuration.md the runtime's HOCON files docs sync
protocol reference/sidecar-protocol.md the protocol's .proto files docs sync
cli reference/cli.md the CLI's command tree CliReferenceSuite
control-plane-routes reference/control-plane-api.md the control plane's endpoints ControlPlaneRoutesReferenceSuite

Descriptors are checked

A json code block whose title is service.json is a complete service descriptor:

```json title="service.json"
{ "name": "cart", "service": { "image": "cart:1.0.0" } }
```

DocumentationDescriptorsSuite in controlplane-api reads every such block in docs/ and applies the same validation ankka services apply does, so no page can show a descriptor the platform would refuse. Show a deliberately invalid descriptor with no title.

The skills

The documentation renders into several Agent Skills, one per kind of task, under tools/docs/skill/: each directory holds one hand-written SKILL.md whose frontmatter names the skill, says when an agent should load it, and lists in pages: the documentation pages it carries. The body holds the rules an agent must hold for that task, the questions to settle before writing, and the mistakes to check for. Keep it to what the pages do not say in one place; everything else is generated: an index of the named pages with their descriptions, and the pages themselves under references/.

A page may belong to several skills, and every public page must belong to at least one, so adding a page means naming the skill it serves or docs check fails. A page with a good description is therefore a page an agent can find. The rendered skills are committed in two places and checked against the sources: the plugin under marketplace/, which the release workflow pushes to the thinkmorestupidless/ankka-marketplace repository on every tag, and the template's .claude/skills/.