Getting started
Two steps. That's it.
Step 1 — Create your .mdspecmap file
A .mdspecmap file can be placed in any folder in your repo. Its location defines its scope — the folder it lives in and all subfolders will be synced according to the mappings declared inside it. All routing, IDs, titles, and task wiring live in these files. Spec files are plain markdown with no special syntax.
The file has three top-level sections:
| Section | Purpose |
|---|---|
mappings | Required. Maps folders to integrations. |
default | Optional. Fallback integration and parent applied to any mapping that omits them. |
specs | Optional. Per-spec config keyed by file path — title, agent, task link. |
A full example:
# docs/specs/.mdspecmap
version: 1
sync_all_on_first_run: false
# Optional — applies to all mappings that don't specify their own integration/parent
default:
integration: clickup
parent: alias:eng-docs # alias: prefix → dashboard alias
mappings:
- skip: # inherits default integration + parent
- DRAFT_*.md
# Optional — per-spec config keyed by repo-relative file path
specs:
docs/specs/auth/sso-setup.md:
title: SSO Setup Guide
docs/specs/checkout-retry.md:
title: Checkout Retry Policy
agent: task_template
id: CU-182
docs/specs/sla-policy.md:
id: CU-305Distributed maps
.mdspecmap in a sub-folder takes precedence over any ancestor map for files in that subtree. If a sub-folder has no map of its own, the nearest ancestor's mappings apply recursively — unless the ancestor opts out with sub_folders: false.Place a .mdspecmapin any folder — you are not limited to one at the root. Each file owns the subtree it sits in. Teams in a monorepo can manage their own map files independently without touching each other's config.
repo/ ├── docs/ │ ├── api/ │ │ ├── .mdspecmap ← syncs docs/api/ and subfolders │ │ └── auth.md │ └── tasks/ │ ├── .mdspecmap ← syncs docs/tasks/ and subfolders │ └── sprint-24.md └── .mdspecmap ← syncs root (same rules as any other)
The nearest ancestor wins. A file in docs/api/ is governed by the map file there, not by any file higher up in the tree.
sub_folders
By default, a .mdspecmap syncs its folder and all subfolders recursively. Set sub_folders: false to restrict it to direct children only, or pass a list of micromatch globs to allow recursion only into specific subfolders. Globs match the file path relative to the scope; files at the scope root are always included.
# docs/tasks/.mdspecmap
version: 1
sub_folders: false # only files directly in docs/tasks/ — no deeper
mappings:
- integration: clickup
parent: alias:sprint-tasks
target: task# docs/.mdspecmap
version: 1
sub_folders: # include docs/api/** and docs/guides/** only
- api/**
- guides/**
mappings:
- integration: notion
parent: alias:api-docs| `sub_folders` | What syncs |
|---|---|
| omitted or `true` | This folder and all subfolders recursively |
false | Direct children only — equivalent to depth: 1 |
string[] | Scope-root files plus subfolders matching any glob (e.g. `api/**`) |
No folder: key
Mappings have no folder:field. The file's location is its scope — place the .mdspecmap inside the folder you want to sync. To route a subfolder differently, put a separate .mdspecmap inside that subfolder.
# docs/api/.mdspecmap
version: 1
mappings:
- integration: notion
parent: alias:api-docsmappings:
Each entry maps a folder in your repo to an integration. mdspec routes each spec to exactly one mapping — the most specific (longest prefix) folder that matches. Subfolders with their own mapping are never double-published by a parent mapping.
| Field | Required | What it does |
|---|---|---|
integration | No | Target: notion, confluence, clickup, or s3. |
parent | No | Target container. Three forms: alias:<name> (dashboard alias), id:<nativeId> (raw ID directly), or bare value (tries alias first, falls back to raw ID). For S3, the alias resolves to a key prefix (the "parent directory"). |
target | No | For ClickUp only: document (default) or task. task publishes specs as ClickUp tasks. |
depth | No | Max subfolder depth. 1 = direct children only. Omit for unlimited depth. |
maintain_hierarchy | No | S3 only. true preserves the spec's subfolder path under the alias prefix; false (default) flattens to the basename only. |
skip | No | Glob patterns for files to exclude. Matched against filename and path relative to this file's location. |
list_id | No | ClickUp list ID for task_list mode. Use id:<listId> prefix. Required when target: task. |
parent_doc | No | ClickUp doc that specs publish inside as pages. Use id:<docId> prefix. Doc mode only. |
space_id | No | ClickUp space or folder ID. Use id:<spaceId> prefix. Omit for workspace root. |
custom_task_ids | No | true to use ClickUp custom task IDs. task_list mode only. |
agent | No | Agent template name to apply before publishing. Must match a template defined in Dashboard → Map → Templates. |
# src/.mdspecmap — governs src/ and all subfolders
mappings:
- integration: clickup
parent_doc: id:2kzm3ftx-5278 # specs publish as pages inside this doc
---
# src/utils/.mdspecmap — governs src/utils/ (nearest ancestor wins)
mappings:
- integration: clickup
target: task
list_id: id:901812098656
custom_task_ids: true
agent: Task TemplateA file at src/utils/SPEC7.md is governed by src/utils/.mdspecmap, not src/.mdspecmap — nearest ancestor wins.
default:
The default: block sets a fallback integration and parent for any mapping that omits them. Useful when most or all folders publish to the same integration.
| Field | What it does |
|---|---|
integration | Fallback integration type: clickup, notion, confluence, or s3. |
parent | Fallback alias name used as the parent container. |
target | Fallback target mode: document (default) or task. |
agent | Fallback agent template applied to all mappings that don't specify one. |
Per-mapping fields always win over the default. Set any field on a specific mapping to override only that field — the rest still inherit from default:.
# docs/specs/.mdspecmap
default:
integration: clickup
parent: alias:eng-docs # alias: — references a dashboard alias
mappings:
- {} # uses clickup + eng-docs from default
---
# docs/tasks/.mdspecmap
default:
integration: clickup
mappings:
- parent: alias:dev-tasks # overrides default parent
target: task
---
# docs/archive/.mdspecmap
mappings:
- integration: clickup
parent: id:90181844797 # id: — raw ClickUp space ID, no alias neededspecs:
The specs: section is optional. It is a map keyed by file path. Add an entry only when you need to override the title, set an agent, or link a task. Specs not listed here are auto-configured from their path.
| Field | What it does |
|---|---|
title | Page title in the target tool. Overrides H1 heading and filename derivation. |
agent | Agent template name to apply before publishing. Set to none to opt out of a folder-level agent. |
id | Native ID of an existing page, doc, or task in the target tool. On first publish, mdspec adopts it and updates it from then on. Works across all integrations. |
Title resolution order
| Priority | Source |
|---|---|
| 1 | specs[path].title in .mdspecmap |
| 2 | First # H1 heading in the file |
| 3 | Filename without extension (hyphens and underscores → spaces) |
Keys with spaces must be quoted: "docs/my auth spec.md". Unquoted keys with spaces are invalid YAML and will cause the CLI to error at publish time.
Examples
specs:
# Just override the title
docs/specs/auth/sso-setup.md:
title: SSO Setup Guide
# Title + agent template + task link
docs/specs/checkout-retry.md:
title: Checkout Retry Policy
agent: task_template
id: CU-182
# Just link a task — nothing else needed
docs/specs/sla-policy.md:
id: CU-305Renames
If a file is renamed, the key in specs: becomes stale. Title overrides, agent config, and task links stop applying until the user updates the key to the new path. Git rename detection still fires on that commit and the page in the target tool updates in-place regardless.
id: adoption details
On first publish of a spec with an id: entry, mdspec looks up that ID in the target tool and adopts the existing page, doc, or task — updating it rather than creating a new one. Works across all integrations: Notion page ID, Confluence page ID, ClickUp doc or task ID. The native ID is stored in the mdspec ledger and subsequent publishes update the same record without re-resolving. Remove the id: field to have mdspec create a new record on the next publish.
Generating the file
You don't have to write it by hand. Two options:
Step 2 — Add the CI action
Add this to your GitHub Actions workflow at .github/workflows/mdspec.yml:
name: mdspec sync
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx mdspeci publish --project <project-id>
env:
MDSPEC_TOKEN: ${{ secrets.MDSPEC_TOKEN }}
GITHUB_EVENT_BEFORE: ${{ github.event.before }}Add your MDSPEC_TOKEN as a GitHub Actions secret under Settings → Secrets → Actions.
That's it. Every push to main syncs changed specs to your connected integrations.
CLI reference
# Publish specs (reads .mdspecmap, detects changes, syncs) npx mdspeci publish --project <project-id> # Publish all specs, ignoring git diff npx mdspeci publish --project <project-id> --skip-diff # Use a specific base ref for change detection npx mdspeci publish --project <project-id> --base origin/main # Generate a starter .mdspecmap npx mdspeci init --project <project-id>
Environment variables
| Variable | Required | Description |
|---|---|---|
MDSPEC_TOKEN | Yes | Project token — generate in Dashboard → Project → Settings → Tokens |
GITHUB_EVENT_BEFORE | No | Previous commit SHA. Set automatically by GitHub Actions. |
MDSPEC_API_URL | No | API base URL. Defaults to https://mdspec.dev |
Spec files
Spec files are plain markdown. Any .md file in a mapped folder is a valid spec. YAML frontmatter is optional — see Frontmatter for declaring native IDs and titles directly in the file.
# Checkout Retry Policy This spec describes the retry behaviour for the checkout service. ## Overview On transient failures, the checkout service retries up to 3 times...
Configuration can live in .mdspecmap (centralized) or in per-file frontmatter (decentralized). Frontmatter wins when both are present — the file is the source of truth.
Frontmatter
.mdspecmap (e.g. specs[].title or specs[].id), frontmatter always wins. The user wrote it explicitly in the file, so we treat the file as authoritative and re-point bindings on every publish.Spec files may begin with a YAML frontmatter block. mdspec strips the block before publishing — the remote document never contains --- markers — and the content hash is computed from the stripped body, so editing frontmatter does not invalidate the hash on its own.
--- title: Checkout Retry Policy clickup_id: 86abc123 --- # Checkout Retry Policy On transient failures, the checkout service retries up to 3 times...
Native ID keys
Each integration has a default frontmatter key. Setting it binds the spec to an existing remote page or task — mdspec adopts that ID instead of creating a new one. The file is authoritative: if the ID changes in frontmatter, the binding re-points on the next publish.
| Integration | Default key | Resolves to |
|---|---|---|
| ClickUp | clickup_id | Task ID (task_list mode) or doc page ID (doc mode) |
| Notion | notion_page_id | Notion page ID |
| Confluence | confluence_page_id | Confluence page ID |
| S3 | s3_key | Full object key (overrides the computed key) |
ClickUp task_list mode: the value can be a custom task ID (e.g. CU-123) — mdspec resolves it to a native task ID before adoption when the mapping has custom_task_ids: true.
Copy-paste snippets
Drop one of these at the top of your spec file, replace the ID, and publish:
ClickUp
--- title: My Spec clickup_id: 86abc123 ---
Notion
--- title: My Spec notion_page_id: 1a2b3c4d5e6f7890 ---
Confluence
--- title: My Spec confluence_page_id: "123456789" ---
S3
--- title: My Spec s3_key: docs/specs/my-spec.md ---
Title
title: in frontmatter takes precedence over both specs[].title in .mdspecmap and the H1 heading in the body.
Renaming the keys (frontmatter_map)
If your team already uses a different convention (e.g. task: instead of clickup_id:), set frontmatter_map on the folder mapping. It accepts id (native ID lookup) and, for ClickUp, title.
mappings:
- folder: docs
integration: clickup
target: task
list_id: id:901812345
frontmatter_map:
id: task # look up "task:" instead of "clickup_id:"
title: heading # look up "heading:" instead of "title:"Per-mapping frontmatter_map values can also be edited directly from the project map UI.
Precedence
When more than one source declares the same value, frontmatter always wins:
| Field | Order (highest first) |
|---|---|
| Title | frontmatter `title:` → `.mdspecmap` `specs[].title` → first H1 → filename |
| Native ID | frontmatter native ID key → `.mdspecmap` `specs[].id` → DB binding |
Other frontmatter keys (numbers, booleans, arrays) are preserved on the artifact but ignored by adapters unless mapped explicitly.
Skip patterns
Exclude files with glob patterns in the skip: field of any mapping:
# docs/api/.mdspecmap
mappings:
- integration: clickup
parent: alias:eng-docs
skip:
- DRAFT_*.md # skip drafts by filename
- _*.md # skip private files
- "**/scratch/**" # skip scratch subdirectory (path relative to this file)
# Subfolder override with its own skip list
- folder: internal
integration: notion
parent: alias:api-internal
skip:
- README.mdPatterns are matched against both the filename and the path relative to the .mdspecmap file's location, not from the repo root.
Depth limiting
By default, a mapping syncs all files recursively. Use depth to cap how deep mdspec looks.
mappings:
- folder: docs/specs
integration: notion
parent: eng-docs
depth: 1 # only docs/specs/*.md — subdirectories ignored| Value | What syncs |
|---|---|
| omitted | Everything under the folder, at any depth |
depth: 1 | Direct children only — docs/specs/auth.md syncs, docs/specs/api/auth.md does not |
depth: 2 | One level of nesting — docs/specs/api/auth.md syncs, docs/specs/api/v2/auth.md does not |
Multiple integrations
The same folder can sync to multiple integrations by adding multiple mappings with the same folder path:
mappings:
- folder: docs/architecture
integration: notion
parent: alias:arch-docs
- folder: docs/architecture
integration: confluence
parent: id:12345678
- folder: docs/architecture
integration: s3
parent: alias:eng-specsEach spec is published independently to all three. Failure on one does not block the others.
Note: the most-specific-folder rule applies per integration independently. A spec can match different mappings for different integrations simultaneously.
S3 integration
When a mapping targets S3, specs are uploaded as static files to an S3 bucket. Each mapping in a .mdspecmap file declares a parent alias that resolves to an S3 key prefix — that prefix is the root container for all specs covered by that mapping.
Connect an S3 integration
Go to Dashboard → Integrations → Connect → S3. You need four fields:
| Field | Description |
|---|---|
| AWS Access Key ID | IAM access key ID |
| AWS Secret Access Key | IAM secret access key |
| Bucket name | Must already exist — mdspec does not create buckets |
| Region | e.g. us-east-1 |
The IAM user needs s3:PutObject, s3:GetObject, and s3:DeleteObject on the bucket for publishing. mdspec validates credentials on connect by putting and deleting a sentinel object. Additionally, s3:ListBucket on the bucket ARN (not /*) is needed for the parent folder picker in the mapping UI.
Parent directory — the alias
The parent alias for S3 resolves to a root key prefix — the S3 equivalent of a parent page or parent doc. Define it in Dashboard → Integrations → [S3 integration] → Aliases, giving it a prefix path like specs/. Leave blank to publish at the bucket root.
Multiple folder mappings can share the same alias — their specs all land under the same S3 root, preserving their full paths beneath it. This is the S3 equivalent of multiple ClickUp mappings sharing a parent_doc.
Key structure
The S3 object key is {alias_prefix}/{spec_path_from_repo_root}.{ext}:
# Alias eng-specs → prefix: content/ # Spec: docs/specs/payments/checkout-retry.md # maintain_hierarchy: true → S3 key: content/docs/specs/payments/checkout-retry.md → URL: https://acme-specs.s3.us-east-1.amazonaws.com/content/docs/specs/payments/checkout-retry.md
By default mdspec flattens to the basename — content/checkout-retry.md. Set maintain_hierarchy: trueon the mapping to preserve the spec's subfolder path under the alias prefix as shown above.
Example .mdspecmap
# docs/specs/.mdspecmap
mappings:
- integration: s3
parent: alias:eng-specs # resolves to prefix: content/
maintain_hierarchy: true # preserve subfolder paths under content/Publish behaviour
| Behaviour | Detail |
|---|---|
| Always overwrites | S3 PutObject is idempotent — every publish replaces the object at that key. No create vs update distinction. |
| Content-unchanged skip | If the spec's content hash is unchanged and the object key is already stored in the ledger, the upload is skipped. |
| No deletion | Deleting a spec file from the repo does not delete the S3 object. The object is orphaned. V1 constraint. |
| No bucket provisioning | The bucket must already exist. mdspec does not create or configure buckets. |
| external_url | Stored as the direct S3 URL: https://{bucket}.s3.{region}.amazonaws.com/{key} |
AWS setup walkthrough
If you don't have a bucket and IAM user yet, follow these steps. Takes about five minutes in the AWS Console.
Step 1 — Create the bucket
- Open the S3 console → Create bucket.
- Enter a bucket name (e.g.
acme-specs). Names must be globally unique. - Choose the AWS region closest to your team (e.g.
us-east-1). Use the same region when connecting in mdspec. - Leave Block all public access enabled (default). mdspec accesses the bucket via IAM credentials — the bucket does not need to be public.
- Leave versioning, encryption, and all other settings at their defaults. Click Create bucket.
Step 2 — Create an IAM policy
- Open the IAM console → Policies → Create policy.
- Switch to the JSON editor and paste the policy below. Replace
acme-specswith your bucket name.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::acme-specs/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-specs"
}
]
}- Click Next, name the policy (e.g.
mdspec-s3-acme-specs), and click Create policy.
The three object-level permissions cover publishing. s3:ListBucket on the bucket resource (not the /* path) is used by the web UI to populate the parent folder dropdown on the mapping page — without it the dropdown falls back to a text input.
Step 3 — Create an IAM user and attach the policy
- In the IAM console → Users → Create user.
- Enter a name (e.g.
mdspec-publisher). No console access needed — leave that unchecked. - On the permissions screen, choose Attach policies directly and select the policy you just created.
- Complete the wizard and click Create user.
Step 4 — Generate access keys
- Open the user you just created → Security credentials tab → Create access key.
- Select Application running outside AWS as the use case.
- Click through and copy both the Access Key ID and Secret Access Key. The secret is shown only once.
Step 5 — Connect in mdspec
- Go to Dashboard → Integrations → Connect → S3.
- Paste in your Access Key ID, Secret Access Key, bucket name, and region.
- Click Connect S3. mdspec runs a health check and saves the credentials if it succeeds.
- Go to the integration's Aliases tab and create an alias with a key prefix (e.g.
specs/) or leave it blank to publish at the bucket root. - Reference the alias in your
.mdspecmapasparent: alias:<name>.
Notion integration
Notion has two publish modes — pages (the default) and database rows. The mode is configured on the integration in the dashboard, not in .mdspecmap. A given integration uses one mode; create a second integration to publish to a different target.
API version
mdspec pins Notion-Version: 2025-09-03 on every request. This adopts Notion's data sources model — databases are containers that hold one or more data sources (the actual tables of rows). Pages in a database are created under a data source, not directly under the database.
Page mode (default)
Each spec is published as a child page under a configured root page. Repo folder structure is mirrored as intermediate pages: a spec at specs/payments/checkout-retry.md lands at <root> / specs / payments / Checkout Retry. Connect requires the integration token and the root page ID.
Database mode
Each spec is published as a row in a configured Notion data source — useful when teams manage specs through table, board, or filter views. Connect requires the integration token, the database ID, and (for multi-source databases) a picked data source.
The target data source must have at minimum:
| Property | Type | Required |
|---|---|---|
Name | title | yes |
Content | rich_text | yes |
Name receives the spec title; Content holds the spec body, chunked into 2000-character segments to fit Notion's rich_text limit. The full structured content (headings, code blocks, lists) is also appended as child blocks on the row's underlying page. mdspec does not create or modify database schemas — Connect-time validation rejects the integration if either property is missing or has the wrong type.
Frontmatter
Both modes use the same notion_page_id frontmatter key to link a spec to an existing Notion page or row. See Frontmatter.
Connect a Notion integration
- Create an internal integration at notion.so/my-integrations and grant it access to the page or database you want to publish under.
- Go to Dashboard → Integrations → Connect → Notion.
- Paste the integration token, choose Pages or Database rows, and provide the root page ID (Pages) or database ID (Database rows).
- Click Connect Notion. mdspec runs a health check — for database mode, it resolves the data sources on the database and validates the required schema before saving credentials.
Example scenarios
Worked examples for the two integrations with the most configuration surface — ClickUp and S3. Each scenario lists the repo layout, the .mdspecmap file(s), and the resulting routing.
1. ClickUp — specs as nested doc pages
Engineering specs publish as pages inside an existing ClickUp doc. Folder hierarchy is preserved — subfolders become nested page groups. Use this when you want long-form review, comments, and threaded discussion against your specs.
repo/
└── eng/
└── specs/
├── .mdspecmap
├── overview.md
├── auth.md
└── billing/
├── plans.md
└── refunds.md# eng/specs/.mdspecmap
version: 1
mappings:
- integration: clickup
parent_doc: id:2kzm3ftx-5278 # the ClickUp doc all specs publish into
skip:
- DRAFT_*.mdResult: each .md file becomes a page inside the configured doc. billing/plans.md and billing/refunds.md publish under a billing page group. Drafts are skipped at the CLI before being uploaded.
2. ClickUp — sprint markdown as tasks with custom IDs
A sprint folder where every .md file represents one work item. Each file becomes a ClickUp task in a configured list, with custom task IDs and an agent template that fills in status, priority, and tags from the markdown body.
repo/
└── eng/
└── sprints/
├── .mdspecmap
├── 2026-W18-checkout-retries.md
├── 2026-W18-payment-webhook.md
└── 2026-W18-flaky-test-cleanup.md# eng/sprints/.mdspecmap
version: 1
sub_folders: false # sprint files are flat — never recurse
mappings:
- integration: clickup
target: task
list_id: id:901812098656 # ClickUp list these tasks land in
space_id: id:90185234 # space/folder containing the list
custom_task_ids: true # use ClickUp custom-task-id (e.g. ENG-1234)
agent: Sprint Task Template # parses status/priority/tags from the bodyResult: three tasks land in the configured ClickUp list. The Sprint Task Template agent runs first and populates structured fields (status, priority, due date, tags) from the markdown — those fields then flow into ClickUp via the task adapter. sub_folders: false keeps the sprint folder strictly flat.
3. ClickUp — adopting existing tasks via specs[]
You already have ClickUp tasks for some of your specs and want mdspec to start updating them instead of creating duplicates. Use the specs: block to bind specific files to native task IDs on first publish.
repo/
└── eng/
└── tasks/
├── .mdspecmap
├── checkout-retry-policy.md ← adopt CU-182
├── sla-policy.md ← adopt CU-305
└── new-payment-flow.md ← create new task# eng/tasks/.mdspecmap
version: 1
mappings:
- integration: clickup
target: task
list_id: id:901812098656
custom_task_ids: true
specs:
checkout-retry-policy.md:
title: Checkout Retry Policy # overrides the body H1
id: CU-182 # bind to existing custom task ID
agent: Task Template
sla-policy.md:
id: CU-305 # bind only — no title override neededResult: on first publish, mdspec resolves CU-182 and CU-305 to the existing tasks and stores the binding in the ledger. Subsequent edits update those tasks. new-payment-flow.md has no id — it creates a fresh task in the configured list.
4. ClickUp — docs and tasks side by side
One repo, two ClickUp modes. Long-form specs publish as doc pages for review; tracked work items publish as tasks. Two map files keep the routing isolated.
repo/
└── eng/
├── specs/
│ ├── .mdspecmap ← doc-page mode
│ ├── auth.md
│ └── billing/
│ └── plans.md
└── sprints/
├── .mdspecmap ← task mode
└── 2026-W18.md# eng/specs/.mdspecmap
version: 1
mappings:
- integration: clickup
parent_doc: id:2kzm3ftx-5278
---
# eng/sprints/.mdspecmap
version: 1
sub_folders: false
mappings:
- integration: clickup
target: task
list_id: id:901812098656
custom_task_ids: true
agent: Sprint Task TemplateResult: spec markdown becomes nested doc pages; sprint markdown becomes tasks. The same ClickUp connection serves both — the difference is purely in the mapping config (parent_doc vs. target: task with list_id).
5. S3 — flat markdown archive
Push raw markdown to an S3 prefix for long-term storage or downstream consumption (search indexing, mirroring, etc.). With the default maintain_hierarchy: false, every spec lands as a flat object at <alias-prefix>/<basename>.md.
repo/
└── docs/
├── .mdspecmap
├── auth.md
├── billing.md
└── api/
└── ratelimit.md# docs/.mdspecmap
version: 1
mappings:
- integration: s3
parent: alias:docs-archive # alias → bucket key prefix, e.g. "docs/"
skip:
- DRAFT_*.md# resulting S3 keys (alias resolves to "docs/") docs/auth.md docs/billing.md docs/ratelimit.md ← flattened: no api/ prefix
Result: filenames are deduplicated against the alias prefix and uploaded flat — folder hierarchy is dropped by default. Use this when you want a simple, predictable file list at one prefix. To preserve subfolder structure, see scenario 6.
6. S3 — preserve folder hierarchy under the alias prefix
A handbook with nested topics. You want the S3 layout to mirror the repo so downstream consumers (a static-site renderer, a search indexer, a cross-link crawler) can navigate by path. Set maintain_hierarchy: true on the mapping.
repo/
└── handbook/
├── .mdspecmap
├── index.md
├── engineering/
│ ├── onboarding.md
│ └── oncall.md
└── people/
└── benefits.md# handbook/.mdspecmap
version: 1
mappings:
- integration: s3
parent: alias:handbook-site # alias → "handbook/" key prefix
maintain_hierarchy: true# resulting S3 keys (alias resolves to "handbook/") handbook/index.md handbook/engineering/onboarding.md handbook/engineering/oncall.md handbook/people/benefits.md
Result: the subfolder path under the mapping's scope (handbook/) is appended to the alias prefix, preserving the tree. Compare with scenario 5 where the same files would all collapse into handbook/onboarding.md, handbook/oncall.md, etc. — and basename collisions across folders would clobber each other.
Tell your agent
If you use an AI code editor (Cursor, Windsurf, Claude, Copilot), paste this prompt into your project rules or context file. It tells the agent to keep your .mdspecmap in sync as it writes and moves spec files.
This is a suggestion — adapt it to your project structure.
This project uses mdspec to publish markdown spec files to external tools (Notion, ClickUp, S3, etc.).
Rules for working with spec files:
1. Spec files are plain markdown. YAML frontmatter is optional — see rule 9 below.
Any .md file in a folder with a .mdspecmap file (or a .mdspecmap in any parent folder)
is automatically picked up by mdspec on the next CI run.
2. .mdspecmap files can live anywhere in the repo. A file governs the folder it lives in
and all subfolders. The nearest .mdspecmap ancestor wins for any given spec file.
Each .mdspecmap has two sections:
- mappings: — maps this folder (and optionally subfolders) to integrations
- specs: — optional per-spec config, keyed by file path
The parent: field in mappings supports three forms:
parent: alias:<name> # dashboard alias
parent: id:<nativeId> # raw native ID (ClickUp space/list/doc ID, Notion page ID, etc.)
parent: <bare> # tries alias first, falls back to raw ID
For S3 integrations, the parent alias resolves to an S3 key prefix (the "parent directory").
Set maintain_hierarchy: true on an S3 mapping to preserve subfolder paths under that prefix
(default false flattens to the basename).
3. When you CREATE a new spec file:
- If it needs a custom title (different from the H1 heading or filename), add an entry:
specs:
path/to/new-file.md:
title: Human Readable Title
4. When you CREATE a spec that should link to an existing page, doc, or task in the target tool:
- Add the native ID under the file path:
specs:
path/to/new-file.md:
id: CU-123 # ClickUp task/doc ID, Notion page ID, Confluence page ID, etc.
5. When you RENAME or MOVE a spec file:
- Update the key in specs: to the new path if an entry exists.
- The old key becomes stale and the title/task config stops applying.
- Example:
# Before
specs:
docs/old-name.md:
title: My Spec
id: CU-123
# After rename to docs/new-name.md
specs:
docs/new-name.md:
title: My Spec
id: CU-123
6. When you DELETE a spec file:
- Remove its entry from specs: if one exists.
- Do not remove the folder mapping — other files may still use it.
7. If a file path contains spaces, quote the key in .mdspecmap:
specs:
"docs/specs/my auth spec.md":
title: Auth Spec
Unquoted keys with spaces are invalid YAML and will cause the CLI to error.
8. .mdspecmap is the centralized place for configuration, but per-file YAML frontmatter
is also supported — see rule 9.
9. Optional YAML frontmatter (the file is the source of truth — overrides .mdspecmap):
---
title: Human Readable Title
clickup_id: 86abc123 # or notion_page_id / confluence_page_id / s3_key
---
# H1 here
- Frontmatter is stripped before publishing; remote docs never contain --- markers.
- Native ID keys (clickup_id, notion_page_id, confluence_page_id, s3_key) bind the
spec to an existing remote page/task. Changing the ID re-points the binding on the
next publish.
- title: in frontmatter overrides specs[].title in .mdspecmap and the body H1.
- To rename the keys (e.g. use `task:` instead of `clickup_id:`), set
`frontmatter_map` on the folder mapping in .mdspecmap.