Skip to content

Content + Design team workflow

This workflow exists because in most small agencies, the person writing the copy is not the same as the person deciding what platform / template / layout the copy goes onto. We use GitHub as the shared inbox: content writes markdown into a repo, design reads it back out and deploys it as a draft to the right CMS.

Why GitHub instead of a Google Doc / Notion / Trello?

  • Diffs. Every edit produces a commit. You can see who changed what.
  • Frontmatter as state. A simple status: key in the YAML header encodes “draft”, “ready for design”, “deployed”, “archived” — no separate project management tool needed.
  • Deploy targets are explicit. A deployments array in the frontmatter records every site this piece was pushed to, and what the post/page ID was. No more “wait, did we deploy this to the Boston site or the Cambridge site?”.
  • The MCP worker already has a GitHub App for the Astro flow. We get this workflow for free.
ToolUsed byPurpose
save_md_to_githubContentCreate or update a markdown file in the site’s linked repo
list_md_from_githubDesignList markdown files, optionally filtered by status:
read_md_from_githubBothPull a single file’s frontmatter + body
deploy_md_to_siteDesignRender the markdown body to HTML and create a draft on a target WP/Duda site
update_md_statusBothPatch frontmatter — flip status:, append to deployments, etc.

All five operate on the site’s linked GitHub repo, configured at /admin/sites → edit:

  • github_repo (owner/repo)
  • github_branch (defaults to main)
  • github_path_prefix (e.g. content/blog)

The GitHub App must be installed on the repo’s account — see Architecture for the App secrets and Users & RBAC for permissions.

Every markdown file starts with a YAML header. Minimum required:

---
title: How ceramic coating works
slug: how-ceramic-coating-works
status: draft
type: post # "post" or "page" — controls which adapter on deploy
---

Recommended additions:

tags: [ceramic-coating, paint-protection, Boston]
categories: [Services]
excerpt: A friendly intro to ceramic coating for car owners who've heard the term and want to know if it's worth it.
seo:
title: Ceramic coating, explained — Acme Detail
description: A 4-minute read on what ceramic coating actually does, how it differs from wax, and when it's worth the money.
deployments: []

The worker’s parser is lenient (any YAML map) but the tools above are sensible only when these keys exist. The required-frontmatter check fires in save_md_to_github — passing markdown without a --- header throws E_MISSING_FRONTMATTER.

We use the status key as a simple lifecycle:

┌──── save_md_to_github ────┐
│ │
▼ │
┌──────────┐ │
│ draft │ content is still │
└─────┬────┘ drafting │
│ │
update_md_status { status: "ready-for-design" }
┌─────────────────────┐
│ ready-for-design │ design queue
└──────────┬──────────┘
deploy_md_to_site (returns post_id)
update_md_status { status: "deployed", deployments: [...]}
┌──────────┐
│ deployed │ done; publish in WP/Duda dash
└─────┬────┘
│ (project ends, etc.)
┌──────────┐
│ archived │ hidden from default queries
└──────────┘

list_md_from_github { status: "ready-for-design" } is the design team’s shared inbox.

The writer’s prompt-shaped life:

Draft a blog post for blog-acme titled “How ceramic coating works”. Friendly tone, 600 words, ends with a call-to-action to book a consultation. Save it to GitHub with status “ready-for-design”.

Claude (typically):

  1. Composes the body.
  2. Writes the frontmatter (title, slug, type=post, tags, etc.).
  3. Calls save_md_to_github with:
    • site_id: "blog-acme"
    • path: "blog/how-ceramic-coating-works.md"
    • markdown: <full file content with frontmatter>

If the file already exists, the writer should provide the sha (Claude gets it from read_md_from_github). On create, omit sha.

If the writer needs to edit a piece they already pushed:

Update blog/how-ceramic-coating-works.md on blog-acme: add a paragraph about 9H hardness rating right after the intro.

Claude calls read_md_from_github, makes the edit, calls save_md_to_github with the returned sha. Concurrent edits return a 409 from GitHub which surfaces as E_UPSTREAM — refetch and retry.

When the writer’s happy with the piece:

Mark blog/how-ceramic-coating-works.md on blog-acme as ready-for-design.

Claude calls update_md_status:

{
"site_id": "blog-acme",
"path": "blog/how-ceramic-coating-works.md",
"patch": { "status": "ready-for-design" }
}

The worker:

  1. Reads the file.
  2. Merges patch into the existing frontmatter.
  3. Re-serializes and commits.

patch is a merge — pass null to remove a key (e.g. { "draft_notes": null }).

The designer’s prompt-shaped life:

What’s ready for design on blog-acme?

Claude calls list_md_from_github { site_id: "blog-acme", status: "ready-for-design" } and returns the list.

Then for a specific file:

Read blog/how-ceramic-coating-works.md on blog-acme.

Claude calls read_md_from_github to see the body + frontmatter. The designer reviews — maybe asks for an edit, or maybe deploys.

Deploy blog/how-ceramic-coating-works.md from blog-acme to blog-acme as a blog post.

Claude calls deploy_md_to_site:

{
"source_site_id": "blog-acme",
"source_path": "blog/how-ceramic-coating-works.md",
"target_site_id": "blog-acme"
}

The worker:

  1. Pulls the markdown.
  2. Reads frontmatter.typepost (default) dispatches to create_draft, page dispatches to create_page_draft.
  3. Renders the body markdown to HTML via the same renderer used by create_draft.
  4. Resolves tag/category names to integer IDs (creates missing ones via create_taxonomy), then writes the draft.
  5. Returns the post/page ref.

You can also deploy to a different site than the source — the source is just where the markdown lives:

Deploy blog/how-ceramic-coating-works.md from content-shared to both blog-acme and blog-boston.

Claude calls deploy_md_to_site twice (or for each), records each result, then patches the file’s deployments array via update_md_status:

{
"site_id": "content-shared",
"path": "blog/how-ceramic-coating-works.md",
"patch": {
"status": "deployed",
"deployments": [
{ "site": "blog-acme", "post_id": "1234", "date": "2026-05-26" },
{ "site": "blog-boston", "post_id": "567", "date": "2026-05-26" }
]
}
}

You decide your own shape for deployments[] — the worker treats it as opaque. Recording at least site + post_id + date is recommended so future audits can trace what ran where.

If the frontmatter has type: page, deploy_md_to_site dispatches to create_page_draft instead. Two caveats:

  • WordPress + Elementor: markdown can’t generate an Elementor JSON layout. The worker forces Gutenberg if the target site has Gutenberg enabled. If it has Elementor only, you get E_BUILDER_MISMATCH — either add Gutenberg to the site’s available_builders, or skip the markdown flow and use create_page_draft with elementor_template_id directly.
  • Multi-builder sites: pass page_builder on the deploy call to lock in the choice. Default is Gutenberg when available.

WP REST is strict — it wants integer term IDs, not names. Frontmatter naturally uses names (tags: [ceramic-coating, paint-protection, Boston]). The worker bridges this in deploy_md_to_site:

  1. Lists existing terms once per dispatch.
  2. Case-insensitive match by name or slug.
  3. For names with no match, creates a new term via create_taxonomy.
  4. Substitutes the resolved IDs into the WP REST payload.

If you pre-resolved IDs yourself, you can still pass them — strings that look like ^\d+$ are passed through unchanged.

Names get resolved per target site — the same tags: [Boston] on a post deployed to two sites creates a Boston tag on each (independent IDs).

One pattern this enables: keep a content-shared site that exists only as a GitHub link (no WP/Duda creds). All your evergreen posts live there. Design picks them up and deploys to whichever client site needs them.

content-shared/blog/ceramic-coating.md
---
title: How ceramic coating works
slug: how-ceramic-coating-works
status: ready-for-design
type: post
tags: [ceramic-coating]
deployments:
- { site: blog-acme, post_id: "1234", date: "2026-05-26" }
- { site: blog-boston, post_id: "567", date: "2026-05-26" }
---
Lorem ipsum...

list_md_from_github { site_id: "content-shared", status: "ready-for-design" } becomes the multi-tenant inbox.

ErrorWhenFix
E_MISSING_FRONTMATTERsave_md_to_github got markdown without a --- headerAdd YAML frontmatter (at least title + slug).
E_GITHUB_NOT_LINKEDSite has no github_repo/admin/sites → edit → link the repo.
E_GITHUB_NOT_INSTALLEDGitHub App not installed on the repo’s owner/admin/github → install.
E_NOT_FOUNDPath doesn’t exist on the branchRun list_md_from_github to see what’s there.
E_BUILDER_MISMATCHDeploying a page to a site that only has ElementorEither add Gutenberg or use the Elementor template flow directly.
409 from GitHub via E_UPSTREAMConcurrent editRefetch via read_md_from_github, retry with new sha.
  • Blog posts on WordPress — what deploy_md_to_site actually calls when type: post.
  • WordPress pages — same, but for type: page.
  • Users & RBAC — how to grant the content team write access to just the content-shared site while design has access everywhere.