diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..30a7964
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,3 @@
+[ -d .venv ] || python -m venv .venv
+. .venv/bin/activate
+pip install preserves
diff --git a/.gitignore b/.gitignore
index 3006b27..fc70fff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
 book/
+.venv/
+.mdbook-ditaa.sqlite
diff --git a/book.prb b/book.prb
new file mode 100644
index 0000000..e198c54
--- /dev/null
+++ b/book.prb
@@ -0,0 +1 @@
+´³schema·³version‘³definitions·³Book´³dict·±sections´³named³sections´³seqof´³refµ„³BookItem„„„±__non_exhaustive´³lit³null„„„³Chapter´³dict·±name´³named³name´³atom³String„„±path´³named³path´³refµ„³	MaybePath„„±number´³named³number´³refµ„³MaybeSectionNumber„„±content´³named³content´³atom³String„„±	sub_items´³named³	sub_items´³seqof´³refµ„³BookItem„„„±source_path´³named³source_path´³refµ„³	MaybePath„„±parent_names´³named³parent_names´³seqof´³atom³String„„„„„³Context´³dict·±root´³named³root´³atom³String„„±config´³named³config³any„±renderer´³named³renderer´³atom³String„„±mdbook_version´³named³mdbook_version´³atom³String„„„„³BookItem´³orµµ±chapter´³dict·±Chapter´³named³value´³refµ„³Chapter„„„„„µ±	separator´³lit±	Separator„„µ±	partTitle´³dict·±	PartTitle´³named³value´³atom³String„„„„„„„³	MaybePath´³orµµ±absent´³lit³null„„µ±present´³atom³String„„„„³PreprocessorInput´³tupleµ´³named³context´³refµ„³Context„„´³named³book´³refµ„³Book„„„„³MaybeSectionNumber´³orµµ±absent´³lit³null„„µ±present´³seqof´³atom³
SignedInteger„„„„„„³embeddedType€„„
\ No newline at end of file
diff --git a/book.prs b/book.prs
new file mode 100644
index 0000000..dffd242
--- /dev/null
+++ b/book.prs
@@ -0,0 +1,34 @@
+version 1 .
+
+PreprocessorInput = [@context Context, @book Book] .
+
+Context = {
+  "root": @root string,
+  "config": @config any,
+  "renderer": @renderer string,
+  "mdbook_version": @mdbook_version string,
+} .
+
+Book = {
+  "sections": @sections [BookItem ...],
+  "__non_exhaustive": =null,
+} .
+
+BookItem =
+/ @chapter { "Chapter": @value Chapter }
+/ @separator "Separator"
+/ @partTitle { "PartTitle": @value string }
+.
+
+Chapter = {
+  "name": @name string,
+  "content": @content string,
+  "number": @number MaybeSectionNumber,
+  "sub_items": @sub_items [BookItem ...],
+  "path": @path MaybePath,
+  "source_path": @source_path MaybePath,
+  "parent_names": @parent_names [string ...],
+} .
+
+MaybeSectionNumber = @absent =null / @present [int ...] .
+MaybePath = @absent =null / @present string .
diff --git a/book.toml b/book.toml
index 6a0639c..7937552 100644
--- a/book.toml
+++ b/book.toml
@@ -4,3 +4,6 @@ language = "en"
 multilingual = false
 src = "src"
 title = "The Synit Manual"
+
+[preprocessor.ditaa]
+command = "./mdbook-ditaa figures/ditaa"
diff --git a/mdbook-ditaa b/mdbook-ditaa
new file mode 100755
index 0000000..205b4c6
--- /dev/null
+++ b/mdbook-ditaa
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+# -*- python -*-
+
+import sys
+import subprocess
+import re
+import os
+import sqlite3
+
+from preserves import preserve, parse, stringify
+from preserves.schema import load_schema_file, extend
+
+cache = sqlite3.connect('.mdbook-ditaa.sqlite')
+try:
+    cache.cursor().execute('CREATE TABLE diagrams(source text, svg text)')
+except:
+    pass
+
+try:
+    schema = load_schema_file('./book.prb').book
+except FileNotFoundError:
+    subprocess.check_output(['sh', '-c', 'preserves-schemac --no-bundle .:book.prs > book.prb'])
+    schema = load_schema_file('./book.prb').book
+
+@extend(schema.Book)
+def expand(self, context):
+    for i in self.sections:
+        i.expand(context)
+
+@extend(schema.BookItem.chapter)
+def expand(self, context):
+    self.value.expand(context)
+
+@extend(schema.BookItem._ALL)
+def expand(self, context):
+    pass
+
+@extend(schema.Chapter)
+def expand(self, context):
+    if self.source_path.VARIANT.name == 'present':
+        directory = os.path.dirname(self.source_path.value)
+        self.content = expand_codeblock(DITAA_TAG, expand_ditaa, self.content, context, directory)
+    for i in self.sub_items:
+        i.expand(context)
+
+def expand_codeblock(language_re, expander, s, *args):
+    return re.sub('^```' + language_re + '\n(.*?)\n```\n', lambda m: expander(m, *args), s, 0, re.M | re.S)
+
+figure_count = 0
+def next_filename():
+    global figure_count
+    figure_count = figure_count + 1
+    return f'figure_{figure_count}'
+
+DITAA_TAG = r'ditaa(?: ((?:\w|-)+))?'
+def expand_ditaa(m, context, directory):
+    sourcedir = context['config']['book']['src']
+    baseurl = context['config']['output']['html']['site-url']
+    filename = os.path.join(directory, (m.group(1) or next_filename()) + '.svg')
+    sys.stderr.write(f'ditaa {filename} ...')
+
+    ditaa_source = m.group(2)
+
+    cached = list(cache.cursor().execute('SELECT svg FROM diagrams WHERE source = ?', (ditaa_source,)))
+    if cached:
+        svg = cached[0][0]
+    else:
+        svg = subprocess.check_output(['ditaa', '--svg', '-'], input=ditaa_source.encode('utf-8'))
+        svg = svg.decode('utf-8')
+        cache.cursor().execute('INSERT INTO diagrams VALUES (?, ?)', (ditaa_source, svg))
+        cache.cursor().execute('COMMIT')
+
+    svgfilename = os.path.join(sourcedir, output_prefix, filename)
+    os.makedirs(os.path.dirname(svgfilename), exist_ok=True)
+
+    need_write = True
+    if os.path.exists(svgfilename):
+        with open(svgfilename, 'rt') as f:
+            existing = f.read()
+        if existing == svg:
+            need_write = False
+    if need_write:
+        sys.stderr.write('updated\n')
+        with open(svgfilename, 'wt') as f:
+            f.write(svg)
+    else:
+        sys.stderr.write('unchanged\n')
+
+    return f'<p><img class="ditaa" alt="{filename}" src="{os.path.join(baseurl, output_prefix, filename)}"></p>\n'
+
+def structure(items):
+    answer = []
+    for i in items:
+        if i.VARIANT.name == 'chapter':
+            a = structure(i.value.sub_items)
+            answer.append({ i.value.name: a })
+    return answer
+
+if __name__ == '__main__':
+    if sys.argv[-2:-1] == ['supports']:
+        sys.exit(0 if sys.argv[-1] == 'html' else 1)
+    output_prefix = sys.argv[1] if len(sys.argv) > 1 else 'figures'
+
+    raw = parse(sys.stdin.read())
+    # sys.stderr.write(stringify(raw, indent=2) + '\n')
+    i = schema.PreprocessorInput.decode(raw)
+    # sys.stderr.write(stringify(preserve(i.context), indent=2) + '\n')
+    i.book.expand(i.context)
+    print(stringify(preserve(i.book), with_commas=True))
diff --git a/src/figures/.gitignore b/src/figures/.gitignore
new file mode 100644
index 0000000..189c4d9
--- /dev/null
+++ b/src/figures/.gitignore
@@ -0,0 +1 @@
+ditaa/
diff --git a/src/operation/index.md b/src/operation/index.md
index d2fb04e..b137f13 100644
--- a/src/operation/index.md
+++ b/src/operation/index.md
@@ -6,6 +6,31 @@ All processes in the system are arranged into a [supervision
 tree](../glossary.md#supervision-tree), conceptually rooted at the [system
 bus](./system-bus.md) (NB. not at PID 1).
 
+```ditaa system-supervision-tree
+(Example)
+              +----------------------------------+
+              |Root System Bus (syndicateâ€“server)|
+              +----------------+-----------------+
+                               |
+   +--------+--------+---------+----------+---------------+
+   |        |        |         |          |               |
++--+--+ +---+---+ +--+--+ +----+----+ +---+---+     +-----+-----+
+|init | |console| |udevd| |Network  | |Wifi   |     |Session bus|
++-----+ |getty  | +-----+ |Interface| |Daemon | ... +-----+-----+
+        +-------+         |Monitor  | |(wlan0)|           |
+                          |Daemon   | +-------+           |
+                          +---------+                     |
+                                                          |
+                  +----------+----------------------------+
+                  |          |                            |
+              +---+---+   +--+--+                    +----+---+
+              |Browser|   |Email|       . . .        |X Server|
+              +-------+   +-----+                    +--------+
+
+```
+
+Here's an example of `ps` output from a Synit prototype running on a mobile phone:
+
       PID TTY      STAT   TIME COMMAND
         1 ?        Ssl    0:00 /sbin/synit-pid1
      1034 ?        Sl     0:00 /usr/bin/syndicate-server --inferior --config /etc/syndicate/boot
