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.

What the tool knows about this repository

Everything that names a path, a vocabulary or a target in this repository is extra.docs in mkdocs.yml, not the tool's code. The tool finds the repository by the nearest mkdocs.yml at or above the directory it runs in, and reads that block for: the frontmatter keys a page may carry beyond the required ones and the values each allows (facets); which facet names a page's languages and which fence labels mean each of them (samples), so the check can hold every sample to every language; where the curated skills are and every directory the rendered skills are written to (skills); which generator owns each generated block (generated); and which files outside docs/ link to the site, with the hosts the site no longer lives on (site-links). A new frontmatter vocabulary, a new skill target or a new configuration file is a line in that block.

The same tool documents satisfactory and ankka-flow, and any repository whose pages follow this page can use it. Each one's tools/docs/pyproject.toml depends on ankka-docs from this repository's tools/docs directory, its mkdocs.yml carries its own extra.docs, and the commands are the same:

[project]
name = "satisfactory-docs"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = ["ankka-docs @ git+https://github.com/thinkmorestupidless/ankka@main#subdirectory=tools/docs"]

The tool's own tests run it against a repository that is not ankka, so a change to a rule is a change for every repository that uses it:

uv run --project tools/docs pytest tools/docs

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, typescript]
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 Any of scala, java, kotlin, python, typescript, rust, go: every language a reader of the page may be writing in, which is every language that can do what the page shows. 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, java, kotlin, python, ts, rust, go, 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.

Every language, in one tab set. A page shows what it shows in every language it names. When a page's languages name more than one, each code sample is a tab set with one tab per language, in the order mkdocs.yml lists them: Scala, Java, Kotlin, Python, TypeScript, Rust, Go. A sample in one language alone tells every other reader that the feature is not theirs, and the gap is invisible to a writer who reads one language at a time, so the check refuses it: a tab set with one of the page's languages missing, and a block in one of those languages outside a tab set, both fail. Each tab opens with /// tab | <name> and closes with /// on its own line (shortened to three languages here):

/// tab | Scala

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

///

/// tab | Python

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

///

/// tab | TypeScript

```ts
getCart: query("get-cart", ShoppingCart, (cart: ShoppingCartEntity) => cart.effects.reply(cart.state)),
```

///

On the site the reader picks a language once and every tab set on every page follows, remembered between visits. Everywhere else — the raw Markdown, llms-full.txt, the agent skills — the markers are flattened back to a bold language label above each block, so a page still reads correctly with no renderer at all.

This is the only renderer syntax a page may use. No admonitions, no snippet includes, no other extensions. Tabs earn the exception because the alternative is the same example three times down the page; they are written in the /// blocks syntax rather than the indented one precisely so the fenced blocks stay flush left and a reader with no renderer still sees ordinary Markdown.

A tab set that shows the same idea in each language must show the same example in each: one domain, one set of names. Three languages solving three different problems under one set of tabs reads as a translation and is not one.

A language is left out only when the feature is not available in it, and Limitations says so. Say so where the check can read it, in a comment on the line before the tab set or the block, naming exactly the languages shown and the reason:

<!-- languages: scala, java, kotlin; only a service in the runtime's own JVM registers a blueprint -->

The prose beside it says the same thing in words a reader sees. A page's languages are every language the page's subject exists in, so a page whose every sample needs the comment has its languages wrong, and a comment naming every language the page does is refused as noise. A page about one language, such as a first-service tutorial or an SDK reference, names that language alone and needs no tabs.

A diagram is an SVG whose alt text says everything it shows. Diagrams live in docs/assets/diagrams/ as hand-written SVG, each with its own background and a prefers-color-scheme rule for dark mode, so one file reads correctly on the site, on GitHub and in either theme. Only the site shows the picture: a model reading the raw Markdown, llms-full.txt or a skill gets the alt text and nothing else, so the alt text names every part and every path the diagram draws, and the prose beside it still stands on its own. The check refuses an image with no alt text, and one whose file does not exist under docs/.

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. The markers are comment lines: // in Scala, Java, Kotlin, TypeScript, Rust and Go, # in Python.

Every language has a tested example tree to mark regions in, so a tab set's seven tabs come from seven tested sources: samples/ and the modules' tests for Scala, samples/shopping-cart-java and the Java SDK's tests for Java, sdks/kotlin for Kotlin, and sdks/python, sdks/typescript, sdks/rust and sdks/go, each with an examples/ shopping cart and a conformance service, for the rest. A sample that has no home there yet gets one before it gets a tab.

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 ankka plugin under marketplace/plugins/ankka/, which the release workflow pushes to the thinkmorestupidless/ankka-marketplace repository on every tag, and the template's .claude/skills/. The pr-review plugin beside it is hand-written, not rendered from pages, and is pushed by the same job.