Agent Workflow
Phase 1 — Planning only, no edits yet Scan the document and identify every Scala code block missing an mdoc modifier (skip pseudocode / type-signature-only blocks). For each block found:
- Create a parent task: "Fix mdoc modifier – :"
- Under it, create two child tasks:
- "1. Choose and apply modifier"
- "2. Run
sbt \"docs/mdoc --in <file>.md --out website/<file>.md\"and confirm zero errors"
Do not touch any source file until the full task tree is created and you have listed it for confirmation.
Phase 2 — Execution, one leaf task at a time Work through the tree top-to-bottom. For each parent task:
- Mark parent
in_progress - Mark child 1
in_progress→ apply modifier → mark child 1completed - Mark child 2
in_progress→ runsbt mdoc→ confirm exit 0 → mark child 2completed - Mark parent
completedOnly then move to the next parent task.
Phase 3 — Mechanical validation
After all tasks are completed, run:
bash ${CLAUDE_PLUGIN_ROOT}/skills/docs-mdoc-conventions/check-mdoc-conventions.sh <file.md>
Verify exit code is 0. If not, re-open the relevant tasks and fix.
Modifiers & Rules
Each modifier has a specific role. Choose based on whether you need scope sharing and whether output should render:
mdoc:compile-only— Renders source code only, isolated scope (no definitions carry over). This is the default for self-contained examples where you want to show the structure but not the evaluated output. Each block compiles alone; subsequent blocks cannot reference definitions from acompile-onlyblock.mdoc:silent— Renders nothing (hidden), scope shared with subsequent blocks. Use to define types, values, or imports that later blocks will reference. Scope persists until you usesilent:reset. You cannot redefine the same name in a later block — usemdoc:silent:nestfor that.mdoc:silent:nest— Renders nothing, scope shared, code wrapped in anonymousobject. Likesilent, but allows you to shadow/redefine names from earlier blocks (e.g., redefiningPersonwith different fields in a later section). Use whensilentwould fail due to name collision.mdoc:silent:reset— Renders nothing, clears all prior scope. Wipes the entire accumulated scope and starts fresh. Use when switching to a completely different context (new domain, new imports) mid-document andsilent:nestwouldn't suffice.mdoc(no qualifier) — Renders source + evaluated output, scope shared with subsequent blocks. Shows both the code and its REPL-style result (as if you'd written// Right(42L)by hand, but evaluated). Can build on definitions from priorsilent/silent:nestblocks, or run standalone for self-contained examples that just need to show their output.mdoc:invisible— Invisible code block, scope shared with subsequent blocks. Signals "hidden imports only" — rare in practice. Prefer including imports directly in amdoc:silentsetup block (so they're visible in scope) or inside acompile-onlyblock (for self-contained examples). Useinvisibleonly when you need imports shared across blocks but must not appear anywhere in the rendered output.mdoc:embed:<path>— Custom modifier: replaces the block (leave its body empty) with the file at<path>(repo-root relative) rendered as a titled code fence. Append:showLineNumbersfor line numbers. Use for a tutorial's "Putting It Together" final block, pointing at the companion example file that is the single source of truth for that complete example — never paste the same code inline when a companion file already carries it. Requires the docs subproject to depend on"dev.zio" %% "zio-sbt-source"— add it if missing.No mdoc (plain
```scala) — Renders source code only, not compiled. Use for pseudocode, ASCII diagrams, type signatures for illustration, or non-Scala syntax (e.g., sbt configuration).Public API only. An mdoc block compiles in the default package, exactly like user code — reference only PUBLIC symbols. A
private/private[pkg]/protectedtype or member won't compile in a snippet, so never demonstrate a closed extension point: ✅ implement the publicLogRecordProcessor❌ implementLogFormatterwhose required parameter type isprivate[telemetry]. Verify visibility against the real source before drafting; if the only path needs a package-private symbol, pick a public alternative or drop the example.-Never hardcode expression output in comments: Let mdoc render output automatically, don't add comments like
// Noneor// "hello". Use baremdocto show all vals; only usemdoc:silentwhen output is verbose boilerplate.
Bad vs. Good:
- ❌
val x = 42 // 42
✅val x = 42(mdoc renders the output)
Choosing the Right Modifier
Use this decision tree to pick the right modifier:
Is this real executable Scala code?
│
├─ NO → Use plain ```scala (pseudocode, ASCII art, type signatures)
│
└─ YES → Do later blocks need these definitions?
│
├─ NO → Do you want to show the output/result?
│ │
│ ├─ NO → Use mdoc:compile-only (source only, isolated)
│ │
│ └─ YES → Use mdoc (source + output, isolated)
│
└─ YES → Is this a later block showing a result?
│
├─ YES → Use mdoc (source + output)
│
└─ NO → Are you redefining a name from an earlier block?
│
├─ YES → Use mdoc:silent:nest (shadow existing names)
│
└─ NO → Use mdoc:silent (regular setup)
After any mdoc:silent block, if you later need a completely different context (new domain, new imports), use mdoc:silent:reset to clear all state.
Common Patterns
Pattern 1: Silent Setup + Output Rendering (Query DSL SQL Guide)
import zio.blocks.schema._
case class Product(
name: String,
price: Double,
category: String,
inStock: Boolean,
rating: Int
)
object Product extends CompanionOptics[Product] {
implicit val schema: Schema[Product] = Schema.derived
val price: Lens[Product, Double] = optic(_.price)
}
def columnName(optic: zio.blocks.schema.Optic[?, ?]): String = {
val nodes = optic.toDynamic.nodes
nodes.collect { case f: DynamicOptic.Node.Field => f.name }.mkString("_")
}
Now with columnName in scope, we can call it and see the result:
columnName(Product.price)
columnName(Product.name)
This pair shows the two-block pattern: silent for setup (which doesn't render), then mdoc for expressions where the output is meaningful to show.
Pattern 2: Redefining with Nesting
When you need to redefine a name (e.g., Person), use nest modifier:
Block A: mdoc
├─ case class Person(...) ← in scope
└─ val alice = ... ← in scope
Block B: mdoc
├─ Can reference alice ✓
└─ Can reference Person ✓
Block C: mdoc:nest
├─ All prior scope accessible ✓
└─ Can redefine Person ✓
Block D: mdoc
├─ Can reference new Person ✓
└─ Cannot reference old Person ✗
Pattern 3: Self-Contained
case class User(name: String, age: Int)
val user = User("Alice", 30)
Each compile-only block stands alone. The next example in the document doesn't have access to person.
Pattern 4: Setup + Show Output
Only use this pattern when the first block defines multiple larger setup code (e.g., multiple case classes, imports) that later blocks will reference and the later block should be evaluated to show output.
def add(a: Int, b: Int): Int = a + b
Now call it and show the result:
add(2, 3)
If the setup is just a single line or two, it's often cleaner to combine it with the output block:
def add(a: Int, b: Int): Int = a + b
add(2, 3)
Pattern 5: Multi-Step Guide
- Setup block →
mdoc:silent(case classes, imports) - Example 1 →
mdoc(show output) - Building on Example 1 →
mdoc(reuse prior definitions) - New Topic →
mdoc:silent:reset+mdoc:silent(fresh context) - Final Copy-Paste →
mdoc:compile-only(standalone)
Pattern 6: Multi-Example Documentation Suite
When a document contains many independent, self-contained examples (e.g., each type in a library having 4–6 usage examples), every example's first code block must use mdoc:silent:reset to prevent variable name collisions across examples.
Without reset, variables like val file, val config, or val user defined in Example 1 conflict with identically-named vars in Example 2—producing "Conflicting definitions" errors even when the examples are conceptually separate.
import zio.blocks.codegen.ir._
import zio.blocks.codegen.emit._
val user = CaseClass("User", List(Field("id", TypeRef.Long)))
val file = ScalaFile(
packageDecl = PackageDecl("com.example"),
types = List(user)
)
ScalaEmitter.emit(file, EmitterConfig())
The next example resets again:
val order = CaseClass("Order", List(Field("id", TypeRef.Long)))
val file = ScalaFile(
packageDecl = PackageDecl("com.example"),
types = List(order)
)
ScalaEmitter.emit(file, EmitterConfig())
Rule: In multi-example documents, :reset is not "sparingly used"—it is used once per independent example. The "use sparingly" advice in the Tips section applies to within a single example (where :nest is usually better).
When to Use :reset
- Switching to a completely different domain (Product → JSON → User)
- Starting a new tutorial section with independent examples
- Isolating independent examples in a multi-example document — when each
###section is a fresh, self-contained example that reuses common variable names likefile,config, oruser - Avoid if: just defining a new helper function (doesn't need reset)
Tips
- Never manually write
// resultcomments — usemdocto show real output - Test locally with
sbt docsbefore committing mdoc blocks - Group related setup blocks — define all prerequisites in one
silentblock if possible - Use
:resetat the right scope — once per independent example in multi-example documents; prefer:nestfor minor redefinitions within a single example
Mechanical Validation
Before submitting changes, run the mdoc-conventions checker against the file. It flags any plain ```scala block that should carry an mdoc modifier:
bash ${CLAUDE_PLUGIN_ROOT}/skills/docs-mdoc-conventions/check-mdoc-conventions.sh <file.md>
Run with --help for full usage. Exit codes:
| Code | Meaning |
|---|---|
0 |
No violations — every executable Scala block has an mdoc modifier. |
1 |
One or more code blocks are missing mdoc modifiers. |
2 |
Invocation error (missing/extra arguments, file not found). |
Docs Classpath
mdoc compiles against the docs project's .dependsOn(...). If the documented module is missing there,
add it to build.sbt (match sibling style, e.g. <module>.jvm) and reload — never downgrade real code
to plain ```scala over a missing dependency, and never leave a runnable example uncompiled to
work around a build gap.
A build's gap is fixed in the build, never in the page. key not found: VERSION means the docs
project defines no mdocVariables, so add mdocVariables += "VERSION" -> version.value there and
re-run: ✅ mdocVariables += "VERSION" -> version.value" in build.sbt ❌ writing % "0.1.0" into the
page (it reads as fixed, and it breaks writing-style rule 25's @VERSION@ placeholder).
Verifying the Full Compile
The mechanical checker above only catches a missing modifier — it cannot catch a real compile error.
Always follow it with an actual mdoc compile, scoped to the file you touched, never the whole docs set
(unscoped sbt docs/mdoc recompiles every doc — minutes of sbt for a one-file change):
sbt "docs/mdoc --in <file> --out website/<file>"
mdoc is an sbt task, not a shell binary: quote the whole docs/mdoc … as one argument (never bare
mdoc, never unquoted). One --in/--out pair per file; --out is the same path prefixed with
website/, e.g. docs/reference/x.md → website/docs/reference/x.md. If the failure output only
shows a stack trace with "stack trace is suppressed; run 'last '", run that sbt "last <scope>"
command to get the real error.
Troubleshooting
If mdoc produces more than 3 compilation errors, the blocks are likely not properly isolated. Check for missing :reset or :nest modifiers, or name collisions between blocks. If the issue persists, strip all mdoc modifiers from the reported lines, confirm the errors are gone, then re-apply the correct modifiers one by one using the decision tree — running sbt "docs/mdoc --in <path> --out website/<path>" after each change to verify before continuing.
Common mistakes when writing mdoc blocks? See references/troubleshooting.md for solutions to.
Tabbed Scala 2 / Scala 3 Examples
When a section shows syntax that differs between Scala 2 and Scala 3, use Docusaurus tabs instead of sequential prose blocks. This lets readers pick their version once and have all tab groups on the page sync together.
Required MDX imports
Add these two lines at the top of any .md file that uses tabs (right after the closing
--- of the frontmatter, before any prose):
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Tab structure
<Tabs groupId="scala-version" defaultValue="scala2">
<TabItem value="scala2" label="Scala 2">
```scala mdoc:compile-only
// Scala 2 syntax here
```
</TabItem>
<TabItem value="scala3" label="Scala 3">
```scala mdoc:compile-only
// Scala 3 syntax here
```
</TabItem>
</Tabs>
Rules
- Always use
groupId="scala-version"— this syncs all tab groups on the page when the reader picks a version. - Always use
defaultValue="scala2"— Scala 2 is shown first by default. - Blank lines inside
<TabItem>are required for mdoc to process fenced code blocks correctly. mdoc:compile-onlyis the correct modifier for code inside tabs (same as everywhere else).- mdoc passes JSX components through unchanged — only fenced
scala mdoc:*blocks are rewritten. - Do not use tabs for examples that are identical in both versions — only use them when the syntax genuinely differs.
Docusaurus Admonitions
Use Docusaurus admonition syntax for callouts: (Titles are optional)
:::note[Title of the note]
Additional context or clarification.
:::
:::tip[Title of the tip]
Helpful shortcut or best practice.
:::
:::warning[Title of the warning]
Common mistake or gotcha to avoid.
:::
:::info[Title of the info]
Background information that is useful but not essential.
:::
:::danger[Title of the danger]
Serious risk of data loss, incorrect behavior, or security issue.
:::
Use admonitions sparingly — at most 3–4 in a typical document. They should highlight genuinely important information, not decorate every section.