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
deploymentsarray 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.
| Tool | Used by | Purpose |
|---|---|---|
save_md_to_github | Content | Create or update a markdown file in the site’s linked repo |
list_md_from_github | Design | List markdown files, optionally filtered by status: |
read_md_from_github | Both | Pull a single file’s frontmatter + body |
deploy_md_to_site | Design | Render the markdown body to HTML and create a draft on a target WP/Duda site |
update_md_status | Both | Patch 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 tomain)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.
Frontmatter convention
Section titled “Frontmatter convention”Every markdown file starts with a YAML header. Minimum required:
---title: How ceramic coating worksslug: how-ceramic-coating-worksstatus: drafttype: 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.
The four-status state machine
Section titled “The four-status state machine”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.
Content team flow
Section titled “Content team flow”The writer’s prompt-shaped life:
Draft a blog post for
blog-acmetitled “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):
- Composes the body.
- Writes the frontmatter (title, slug, type=post, tags, etc.).
- Calls
save_md_to_githubwith: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.mdonblog-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.
Flipping to “ready-for-design”
Section titled “Flipping to “ready-for-design””When the writer’s happy with the piece:
Mark
blog/how-ceramic-coating-works.mdonblog-acmeas 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:
- Reads the file.
- Merges
patchinto the existing frontmatter. - Re-serializes and commits.
patch is a merge — pass null to remove a key (e.g.
{ "draft_notes": null }).
Design team flow
Section titled “Design team flow”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.mdonblog-acme.
Claude calls read_md_from_github to see the body + frontmatter. The
designer reviews — maybe asks for an edit, or maybe deploys.
Deploying
Section titled “Deploying”Deploy
blog/how-ceramic-coating-works.mdfromblog-acmetoblog-acmeas 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:
- Pulls the markdown.
- Reads
frontmatter.type—post(default) dispatches tocreate_draft,pagedispatches tocreate_page_draft. - Renders the body markdown to HTML via the same renderer used by
create_draft. - Resolves tag/category names to integer IDs (creates missing ones via
create_taxonomy), then writes the draft. - 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.mdfromcontent-sharedto bothblog-acmeandblog-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.
Pages instead of posts
Section titled “Pages instead of posts”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’savailable_builders, or skip the markdown flow and usecreate_page_draftwithelementor_template_iddirectly. - Multi-builder sites: pass
page_builderon the deploy call to lock in the choice. Default is Gutenberg when available.
Auto tag/category resolution
Section titled “Auto tag/category resolution”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:
- Lists existing terms once per dispatch.
- Case-insensitive match by name or slug.
- For names with no match, creates a new term via
create_taxonomy. - 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).
Cross-site content reuse
Section titled “Cross-site content reuse”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.
---title: How ceramic coating worksslug: how-ceramic-coating-worksstatus: ready-for-designtype: posttags: [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.
Common errors
Section titled “Common errors”| Error | When | Fix |
|---|---|---|
E_MISSING_FRONTMATTER | save_md_to_github got markdown without a --- header | Add YAML frontmatter (at least title + slug). |
E_GITHUB_NOT_LINKED | Site has no github_repo | /admin/sites → edit → link the repo. |
E_GITHUB_NOT_INSTALLED | GitHub App not installed on the repo’s owner | /admin/github → install. |
E_NOT_FOUND | Path doesn’t exist on the branch | Run list_md_from_github to see what’s there. |
E_BUILDER_MISMATCH | Deploying a page to a site that only has Elementor | Either add Gutenberg or use the Elementor template flow directly. |
409 from GitHub via E_UPSTREAM | Concurrent edit | Refetch via read_md_from_github, retry with new sha. |
What’s next
Section titled “What’s next”- Blog posts on WordPress — what
deploy_md_to_siteactually calls whentype: post. - WordPress pages — same, but for
type: page. - Users & RBAC — how to grant the content team write access
to just the
content-sharedsite while design has access everywhere.