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/.