| title | SOP Structured Markdown Format: design notes | |
|---|---|---|
| summary | Why our SOP markdown files use HTML-comment markers, what the format looks like, and the tooling we built to keep files valid. | |
| doc_type | reference | |
| tags | ||
| systems | ||
| related_docs |
|
This is the design log for the structured-markdown format used across all
240 SOPs in this repository. The strict spec lives at
sop-format.md; this document explains why the format
exists, how we arrived at the current shape, and what tooling
keeps it valid.
Our SOPs follow a recognisable shape: a series of numbered steps, each with a short instruction and (almost always) a screenshot. The structure is consistent enough that a script could in principle pull a step sequence out of any SOP and feed it to an agent, a renderer, or a checklist runner.
In practice this was hard to do reliably because nothing in the file
enforced or signalled the structure. Step numbering was ad-hoc (a few
files restarted 1. mid-document, or duplicated numbers across
sub-procedures). Image captions sat as sibling paragraphs prefixed with
Image note: rather than as data attached to the image. Sub-procedures
were ### headings whose semantics had to be inferred. Every parser would
have had to re-invent heuristics, and every editor could break them
without noticing.
We wanted two things at once:
- A parsed tree of each SOP — sections, groups, steps with attributes, screenshots with captions — so agents and the frontend can work with structured data instead of regex-guessing.
- Native GitHub rendering — the file you open on github.com still looks like a clean SOP with the images displayed inline.
- Pure heading/list convention.
### Step Nfor steps, strict ordering, no markers. Cannot carry per-step metadata (system, action, stable id). Silently breaks when an editor inserts or renames a section. Rejected. - YAML frontmatter for the entire body. Lossless and easy to parse, but GitHub renders the YAML as code — images don't display, formatting is gone. Rejected because the rendering goal is non-negotiable.
- MDX or a custom file extension. GitHub renders unknown formats as plain text. Rejected.
HTML comments as boundary markers. GitHub strips them from the
rendered output (invisible to readers) but a parser finds them with a
regex. Every marker is prefixed with sop- so it cannot collide with
comments from other tools, and uses explicit -start / -end suffixes
rather than the slash-close HTML convention so the source reads
naturally for humans hand-editing.
We considered self-closing markers like <!-- sop-step 1 --> before each
numbered item. Rejected because they make body boundaries ambiguous: if
prose appears between two steps, the parser cannot tell whether it
belongs to the previous step, the next step, or neither. Explicit
<!-- sop-step-start --> ... <!-- sop-step-end --> removes the
ambiguity at the cost of one extra line per step.
A snippet — full spec in sop-format.md:
<!-- sop-section-start: procedure -->
## Procedure
<!-- sop-group-start: "Getting the audio file" -->
### Getting the audio file
<!-- sop-step-start id=1 -->
1. Open the YouTube video and click "Edit video".
<!-- sop-screenshot-start -->

<!-- sop-caption-start -->
Look for the Edit video button below the player.
<!-- sop-caption-end -->
<!-- sop-screenshot-end -->
<!-- sop-step-end -->
<!-- sop-group-end -->
<!-- sop-section-end -->Key properties:
- Six fixed sections wrap the body:
summary,prerequisites,procedure,validation,troubleshooting,references. The marker name is the parser key — the visible## Headingtext is just for rendering. - Groups are optional inside Procedure. A flat procedure (no sub-groups) is valid. When groups are used, every step must live inside one.
- Step
idis the stable identifier, independent of the renderedN.number. Numbering is continuous across the whole procedure (not reset per group), so a step keeps the same id even if groups are renamed or reordered. schema_version: 1in the frontmatter opts a file into strict validation. Files without it are treated as legacy.- Escape hatch:
<!-- sop-section-start: procedure raw -->marks a section as opaque markdown. Useful for SOPs whose body is a template text or copy bank rather than a step-by-step procedure.
Three stdlib-only Python scripts under scripts/. Each one does one
thing and is callable from the shell or imported as a library.
Reads a marked-up SOP and emits the parsed tree as JSON.
python3 scripts/sop_parse.py path/to/sop.md --prettyReturns a dict with frontmatter, schema_version, and sections. The
procedure section contains groups (each with steps), flat_steps
(when there are no groups), prose blocks, and todos. Each step
exposes its id, attrs (systems, action, tool), body_md, and
screenshots (each with src, alt, and caption).
This is the entry point for any agent or tool that wants structured SOP data.
Validates a file against the spec. Used in CI gating once we wire it up.
python3 scripts/sop_lint.py path/to/sop.mdChecks:
- Frontmatter has
titleanddoc_type(soporchecklist). - All six required sections are present (even if empty).
- Marker open/close pairs balance.
- Step ids are unique and sequential
1..N. - A procedure is either all-grouped or all-flat — not mixed.
- Step
actionattribute, if present, is from the allowed vocabulary. - Step
systemsattribute, if present, is a subset of the doc's frontmattersystems. - Every
<!-- sop-screenshot-start -->block contains exactly one image.
Files without schema_version: 1 are skipped — migration is
incremental.
Converts a legacy SOP into the marked-up form. This is what we used to migrate all 240 files in one pass.
python3 scripts/sop_normalize.py path/to/sop.md -o path/to/sop.mdHeuristics:
- Detects the six standard section headings and wraps each in section markers.
- Inside Procedure, treats
### subheadingsas group boundaries and numberedN. textitems as step boundaries. - Pairs each image with its following
Image note:paragraph into a screenshot+caption block. - Re-indents step-body lines to the list-item indent so GitHub keeps the screenshot inside the rendered numbered list.
- Auto-renumbers steps from a running counter — legacy files with
duplicate
1. 2. 3.numbering across sub-procedures come out clean. - Adds
schema_version: 1to the frontmatter.
We didn't trust the format until we'd round-tripped real files through it. The validation pipeline:
- Spec drafted alongside a parser and a linter.
- Round-tripped three representative shapes — a small flat SOP, a mid-sized sub-grouped SOP, and the 479-line podcast-transcription SOP with 7 sub-groups and multi-image steps. All three parsed and linted clean.
- Bulk-tested on 50 random SOPs to catch edge cases the
representatives missed. Found three real classes of issue:
doc_type: checklistwas rejected (fixed: lint now accepts bothsopandchecklist, which share the same shape).- Legacy duplicate
1. 2. 3.numbering across sub-procedures (fixed: normalizer assigns sequential ids from a running counter and rewrites the rendered numbers to match). - Mixed groups + flat steps where flat steps appear before/after the groups (left as a lint error — semantically ambiguous, needs human judgement).
- Ran on all 240 SOPs. 236 normalized cleanly on first pass. The
remaining 4 had legitimate structural ambiguities and were
hand-edited:
fill-in-the-sponsored-block-in-the-newsletter.md— wrapped the leading1. Open the newsletter draftin a new "Preparation" group, and merged a Word-imported duplicate H3 back into intro prose.creating-invoices-in-finom.md— removed the lone trailing group so all 23 steps are flat.github-guide-common-errors-and-solutions.md— wrapped the 4 leading troubleshooting steps in a "General approach" group, mirroring the four Scenario groups that follow.filling-newsletter-statistics.md— wrapped the 14 leading flat steps in a "Newsletter delivery statistics" group, mirroring the existing "LinkedIn Sponsored Post Statistics" group.
- All 240 SOPs now pass lint clean.
- The
actionattribute vocabulary (navigate,click,type,upload, ...) is a first-pass guess. We should look at real SOPs and refine the enumeration. - Template-text SOPs (no real procedure, just copy banks — e.g.
post-podcast-guest-recommendations.md) currently parse as "fake steps" because the copy templates use a numbered list. Therawescape hatch is the right answer but the normalizer doesn't yet auto-detect these. - The frontmatter parser is a minimal YAML subset that handles the patterns in current files. If we ever want richer frontmatter, swap for PyYAML.
- Lint is not yet wired into CI — for now it's a manual gate.