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:

SectionPurpose
mappingsRequired. Maps folders to integrations.
defaultOptional. Fallback integration and parent applied to any mapping that omits them.
specsOptional. 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-305

Distributed maps

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
falseDirect 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-docs

mappings:

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.

FieldRequiredWhat it does
integrationNoTarget: notion, confluence, clickup, or s3.
parentNoTarget 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").
targetNoFor ClickUp only: document (default) or task. task publishes specs as ClickUp tasks.
depthNoMax subfolder depth. 1 = direct children only. Omit for unlimited depth.
maintain_hierarchyNoS3 only. true preserves the spec's subfolder path under the alias prefix; false (default) flattens to the basename only.
skipNoGlob patterns for files to exclude. Matched against filename and path relative to this file's location.
list_idNoClickUp list ID for task_list mode. Use id:<listId> prefix. Required when target: task.
parent_docNoClickUp doc that specs publish inside as pages. Use id:<docId> prefix. Doc mode only.
space_idNoClickUp space or folder ID. Use id:<spaceId> prefix. Omit for workspace root.
custom_task_idsNotrue to use ClickUp custom task IDs. task_list mode only.
agentNoAgent 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 Template

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

FieldWhat it does
integrationFallback integration type: clickup, notion, confluence, or s3.
parentFallback alias name used as the parent container.
targetFallback target mode: document (default) or task.
agentFallback 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 needed

specs:

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.

FieldWhat it does
titlePage title in the target tool. Overrides H1 heading and filename derivation.
agentAgent template name to apply before publishing. Set to none to opt out of a folder-level agent.
idNative 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

PrioritySource
1specs[path].title in .mdspecmap
2First # H1 heading in the file
3Filename 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-305

Renames

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:

From the Dashboard

Go to your project's Map page and click Download .mdspecmap. The file is generated from your current folder mappings and aliases.

From the CLI

MDSPEC_TOKEN=mds_xxx npx mdspeci init --project <project-id>

Fetches your project config and aliases, then writes a starter .mdspecmap to the current directory.

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

VariableRequiredDescription
MDSPEC_TOKENYesProject token — generate in Dashboard → Project → Settings → Tokens
GITHUB_EVENT_BEFORENoPrevious commit SHA. Set automatically by GitHub Actions.
MDSPEC_API_URLNoAPI 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

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.

IntegrationDefault keyResolves to
ClickUpclickup_idTask ID (task_list mode) or doc page ID (doc mode)
Notionnotion_page_idNotion page ID
Confluenceconfluence_page_idConfluence page ID
S3s3_keyFull 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:

FieldOrder (highest first)
Titlefrontmatter `title:` → `.mdspecmap` `specs[].title` → first H1 → filename
Native IDfrontmatter 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.md

Patterns 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
ValueWhat syncs
omittedEverything under the folder, at any depth
depth: 1Direct children only — docs/specs/auth.md syncs, docs/specs/api/auth.md does not
depth: 2One 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-specs

Each 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:

FieldDescription
AWS Access Key IDIAM access key ID
AWS Secret Access KeyIAM secret access key
Bucket nameMust already exist — mdspec does not create buckets
Regione.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

BehaviourDetail
Always overwritesS3 PutObject is idempotent — every publish replaces the object at that key. No create vs update distinction.
Content-unchanged skipIf the spec's content hash is unchanged and the object key is already stored in the ledger, the upload is skipped.
No deletionDeleting a spec file from the repo does not delete the S3 object. The object is orphaned. V1 constraint.
No bucket provisioningThe bucket must already exist. mdspec does not create or configure buckets.
external_urlStored 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

  1. Open the S3 consoleCreate bucket.
  2. Enter a bucket name (e.g. acme-specs). Names must be globally unique.
  3. Choose the AWS region closest to your team (e.g. us-east-1). Use the same region when connecting in mdspec.
  4. Leave Block all public access enabled (default). mdspec accesses the bucket via IAM credentials — the bucket does not need to be public.
  5. Leave versioning, encryption, and all other settings at their defaults. Click Create bucket.

Step 2 — Create an IAM policy

  1. Open the IAM consolePoliciesCreate policy.
  2. Switch to the JSON editor and paste the policy below. Replace acme-specs with 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"
    }
  ]
}
  1. 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

  1. In the IAM console → UsersCreate user.
  2. Enter a name (e.g. mdspec-publisher). No console access needed — leave that unchecked.
  3. On the permissions screen, choose Attach policies directly and select the policy you just created.
  4. Complete the wizard and click Create user.

Step 4 — Generate access keys

  1. Open the user you just created → Security credentials tab → Create access key.
  2. Select Application running outside AWS as the use case.
  3. Click through and copy both the Access Key ID and Secret Access Key. The secret is shown only once.

Step 5 — Connect in mdspec

  1. Go to Dashboard → Integrations → Connect → S3.
  2. Paste in your Access Key ID, Secret Access Key, bucket name, and region.
  3. Click Connect S3. mdspec runs a health check and saves the credentials if it succeeds.
  4. 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.
  5. Reference the alias in your .mdspecmap as parent: 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:

PropertyTypeRequired
Nametitleyes
Contentrich_textyes

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

  1. Create an internal integration at notion.so/my-integrations and grant it access to the page or database you want to publish under.
  2. Go to Dashboard → Integrations → Connect → Notion.
  3. Paste the integration token, choose Pages or Database rows, and provide the root page ID (Pages) or database ID (Database rows).
  4. 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_*.md

Result: 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 body

Result: 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 needed

Result: 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 Template

Result: 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.