For a single article, EmDash's admin is the right place. But when you want to rename forty posts at once, pull content in from another system, run a nightly report or have an AI agent write drafts, clicking gets old fast. EmDash has three ways to do that without the admin: the REST API, the emdash command-line tool and a built-in MCP server for AI agents.
I'll show you how to use each of them, how they differ and where they'll surprise you. It's based on EmDash 1.3.0. The workflows were run against EmDash 1.2.0 and re-checked against the 1.3.0 source. Where 1.3 changed something, it says so. At the end are the pitfalls we hit running The Weekly Dash.
Three routes, one permission model
All three routes end up under /_emdash/api/ and authenticate with a bearer token. A token carries scopes and belongs to a user. EmDash checks both on every request: the scope has to cover the action, and the user's role has to allow it. So a token can never do more than its user.
Day to day, though, they behave quite differently:
REST API | CLI | MCP | |
|---|---|---|---|
Sign-in | API token | API token or | API token or OAuth |
Writing rich text | Portable Text JSON only | Markdown or Portable Text | Markdown or Portable Text |
New entry | draft | published right away, unless | draft, unless |
Update without | allowed | refused | refused |
Remember the "new entry" row. Move from the API to the CLI without knowing it and you'll publish by accident.
The API token
You create tokens in the admin under Settings → API Tokens. Only admins can, and each token belongs to the admin who created it. You give it a name, pick its scopes and an Expiry: no expiry, 7, 30 or 90 days, or one year. EmDash then shows the token exactly once. It starts with ec_pat_, and only its hash is stored. If you don't copy it right then, make a new one.
These are the scopes in 1.3.0:
content:readandcontent:writefor content and revisions, and over MCP for bylines too.taxonomies:manageandmenus:managefor taxonomies and menus.content:writecovers both, so older tokens keep working.media:read,media:write,schema:read,schema:write,settings:readandsettings:manage.mcp:toolsfor MCP tools that plugins provide, plus three scopes for site transfer.adminfor everything else.
Anything without its own rule requires admin, and that covers more than you'd expect: byline management under /_emdash/api/admin/bylines, GET /_emdash/api/auth/me and the OpenAPI document at /_emdash/api/openapi.json. The reverse is true too: admin doesn't cover plugin tools over MCP. Those always need mcp:tools.
You revoke a token from the same list with Revoke token. In testing, the API answered 401 INVALID_TOKEN immediately afterwards, the same as for an expired token. The list also shows when each token was last used. On Cloudflare, that column often stayed empty or out of date up to 1.1.0, because the write could be cut off after the response went out. Since EmDash 1.2 it sticks (#3767). "Last used" only means "authenticated successfully", though: in testing, a token whose write was rejected for a missing scope got a timestamp too.
My rules for tokens:
- One token per job. Then you can revoke one without breaking everything else, and "last used" actually tells you something.
- As few scopes as possible. A report needs
content:read. An editorial script needscontent:readandcontent:write, plusschema:readfor the CLI (more on that below). I only hand outadminwhen a job really needs it. - An expiry for anything one-off. Seven days are plenty for a weekend import.
- Keep it out of the repository. The token belongs in an environment variable or a secret store.
Keep in mind that a token acts with its admin's role. A token with content:write can therefore edit every author's posts, not just its owner's. If the admin is disabled, their tokens stop working too.
The REST API
The basic shape is a request with the token in a header:
export EMDASH_URL=https://example.com
export EMDASH_TOKEN=ec_pat_…
curl -s -H "Authorization: Bearer $EMDASH_TOKEN" \
"$EMDASH_URL/_emdash/api/content/posts?status=draft&locale=en&limit=100"Plenty of examples also send X-EmDash-Request: 1. With a token, you don't need it. The header protects requests that use a session cookie against cross-site request forgery: a browser sends the cookie along even when another site triggers the request, but another site can't set a custom header, so its presence proves no other site triggered the request. A token doesn't travel along automatically, so EmDash skips the header check for requests with a valid token. In testing, a POST with a token and no header went through, while the same POST with a cookie and no header got 403 CSRF_REJECTED. Sending the header anyway does no harm.
Listing and paging
limit tops out at 100 and defaults to 50. Ask for limit=200 and you don't get a trimmed result, you get 400 VALIDATION_ERROR with "Too big: expected number to be <=100". For more, you page. Up to 1.1.0 the only way was the nextCursor from the previous response. Since 1.2.0, lists also take a page number (#3773):
curl -s -H "Authorization: Bearer $EMDASH_TOKEN" \
"$EMDASH_URL/_emdash/api/content/posts?limit=100&page=2"A numbered page has no nextCursor. Every response tells you the overall count in total. Sending page and cursor together gets you a 400. For scripts I still use the cursor, because it stays stable when someone adds a post halfway through. You'll find it in the response under data.nextCursor and pass it with the next request. When it's missing, you're done:
curl -s -H "Authorization: Bearer $EMDASH_TOKEN" \
"$EMDASH_URL/_emdash/api/content/posts?limit=100&cursor=<nextCursor>"Create, update, publish
A new entry created through the API is always a draft. The field names come from your collection, the ones you set up for your custom content type:
curl -s -X POST "$EMDASH_URL/_emdash/api/content/posts" \
-H "Authorization: Bearer $EMDASH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "api-test",
"locale": "en",
"data": {
"title": "API test",
"content": [
{ "_type": "block", "_key": "a1", "style": "normal", "markDefs": [],
"children": [{ "_type": "span", "_key": "a2", "text": "Hello from the API.", "marks": [] }] }
]
}
}'The REST API expects rich-text fields as Portable Text, a JSON array of blocks. A Markdown string in content gets a 400 with "expected array, received string". Only the CLI and MCP understand Markdown.
Besides the entry, the response contains _rev, a version stamp you send back when you update:
curl -s -X PUT "$EMDASH_URL/_emdash/api/content/posts/<id>" \
-H "Authorization: Bearer $EMDASH_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "data": { "title": "API test, edited" }, "_rev": "<_rev from your last response>" }'For content, PUT doesn't replace the whole entry. Fields you leave out of data keep their values: in testing, the excerpt and body survived a PUT that only sent the title. If someone changed the entry since you read it, you get 409 CONFLICT, so you read it again and retry. If someone has the entry open in the admin right now, you get 409 ENTRY_LOCKED with their name. Add "overrideLock": true to the body to write anyway. _rev is optional in the REST API. I send it every time anyway, because without it your script silently overwrites whatever an editor just saved.
Updating isn't publishing. On a published post, PUT creates a draft and the site keeps showing the old version. This call makes it live:
curl -s -X POST -H "Authorization: Bearer $EMDASH_TOKEN" \
"$EMDASH_URL/_emdash/api/content/posts/<id>/publish"There are separate endpoints for unpublishing, scheduling, comparing and discarding the draft. The full list is in the OpenAPI document, but you can only fetch that with an admin token or while signed in through the browser. The REST reference in the docs is public.
The CLI
The emdash command comes with the emdash package, which every EmDash project has installed. Alongside commands for the local database, it has content, schema, media, search, taxonomy and menu for a running site. It talks to the same REST API and reads the address and token from environment variables:
export EMDASH_URL=https://example.com
export EMDASH_TOKEN=ec_pat_…
pnpm exec emdash content list posts --status draft --locale en
pnpm exec emdash content get posts api-testIts big advantage is Markdown. content get prints rich-text fields as Markdown, or as Portable Text with --raw. content create and content update accept Markdown, for example from a file:
{
"title": "CLI test",
"excerpt": "Created with emdash content create.",
"content": "## A subheading\n\nA paragraph with **bold** text and a [link](https://emdashcms.com)."
}pnpm exec emdash content create posts --file post.json --slug cli-test --locale en --draftThat --draft isn't decoration. Without it, content create publishes the new entry immediately: in testing, it went straight to published. According to the source, content update does the same. The REST API and the MCP server create drafts instead. So I put --draft in every script and publish deliberately with emdash content publish.
To update, the CLI insists on _rev:
REV=$(pnpm exec emdash content get posts cli-test --json | jq -r ._rev)
pnpm exec emdash content update posts cli-test --rev "$REV" --draft --data '{"title":"New title"}'
pnpm exec emdash content publish posts cli-testOn multilingual sites that reuse the same slug in every language, pass --locale to get and update. content publish doesn't have that option and, given a slug, picks whichever language comes first alphabetically. So use the ID from the output of content create there.
Four things stood out in testing and in the source:
- The CLI needs
schema:read. To convert to Markdown, it reads the collection's fields. Without that scope, it doesn't always tell you.content getsilently prints Portable Text instead, andcontent createsends your Markdown unconverted, which the API rejects with400and "expected array, received string". Only for entries with a pending draft doescontent getfail with "Token lacks required scope: schema:read". content getshows the draft. If a published post has a pending draft, the CLI prints the draft's content even thoughstatussayspublished. That isn't the CLI's doing: the REST API already returns the draft indata, with the live version next to it inliveData. A script that reads titles for a report, say, sees unpublished changes.--publisheddoesn't change that.- The Markdown isn't quite Markdown. Headings, lists, links, code blocks,
**bold**and_italic_turn into Portable Text.*italic*with asterisks stays in the text literally, though. And every line break starts a new paragraph, blank line or not. If you hard-wrap your paragraphs, you'll end up with lots of short ones. loginandlogoutignoreEMDASH_URL. Unlikecontent, they always go tohttp://localhost:4321unless you pass--url. I've submitted a fix (#4180), but it isn't merged yet.whoamidid the same up to 1.2.0 and reads the variable since 1.3.0. It still needs anadmintoken, though, otherwise it reports "Failed to fetch user info: 403".
Instead of a token, you can sign in with emdash login --url https://example.com. The CLI shows a code that you confirm in the browser and stores the credentials in ~/.config/emdash/auth.json. According to the source, that access lasts an hour and is refreshed automatically for up to 90 days. The CLI only uses it when EMDASH_TOKEN isn't set and EMDASH_URL or --url points at the same address. It asks only for the admin scope. For editors and other non-admins, nothing of it is left, and EmDash rejects the confirmation with INSUFFICIENT_ROLE, as the source and its tests show. So emdash login is for admins only. Editors get access through OAuth over MCP, more on that below.
By the way, emdash seed and emdash export-seed work on a local SQLite file, not on your live database. For a copy of the whole site there's emdash site export. Where that fits into a backup plan is covered in Backing up EmDash.
MCP for AI agents
The MCP server is built into every EmDash site, no plugin needed. Kevin compares that with WordPress in his lock-in fact check. The endpoint is /_emdash/api/mcp. It's on by default, and mcp: false in astro.config.mjs turns it off.
A client can sign in two ways: with an API token in a header, or through OAuth. With OAuth, the user signs in through the browser and picks the scopes. That's also how editors get access, since they can't create API tokens. A session cookie alone isn't enough: in testing, the endpoint answered 401, just as it does with no credentials at all, with a pointer to the OAuth metadata in the WWW-Authenticate header.
For Claude Code, with a token, it looks like this:
claude mcp add --transport http emdash https://example.com/_emdash/api/mcp \
--header 'Authorization: Bearer ${EMDASH_TOKEN}'The single quotes are deliberate. That way, Claude Code stores only a reference to the variable and fills in the token when it starts. With double quotes, the shell fills it in right away, and the token ends up in plain text in ~/.claude.json. In testing, claude mcp list then reported the server as connected. With the variable missing, EmDash answered 401, and Claude Code didn't fall back to OAuth: once there's an Authorization header in the config, that route is off.
Leave out the header and the client starts EmDash's OAuth sign-in instead. Claude Code then lists the server as "Needs authentication", and you start the sign-in from a session with /mcp. In testing, Claude Code registered itself with EmDash as an OAuth client and opened the consent page in the browser. It lists all 15 scopes EmDash knows, admin included, and every one of them is already ticked. Click Approve without looking and the agent gets everything your role allows, so full admin rights if you're an admin. You can untick them one by one. After that, claude mcp list reported the server as connected. The access token lasted an hour, the refresh token 90 days. In Claude Desktop and on claude.ai, you add the address as a custom connector, as the EmDash docs describe. We didn't test those two clients.
The server offers 72 tools for content, bylines, schema, media, search, taxonomies, menus, revisions, settings and site transfer. A few things to know:
- The list shows everything. In testing,
tools/listreturned all 72 tools even to a read-only token. TheINSUFFICIENT_SCOPEonly comes when a tool is called. So an agent sees tools it isn't allowed to use, and has to cope with the refusal. - Drafts first.
content_createcreates a draft, and rich text can be Markdown. If the agent passesstatus: "published", though, it publishes the entry in the same call.content_getreturns Markdown withmarkdown: true. - No writing without reading.
content_update,content_publish,content_unpublish,content_scheduleandcontent_discard_draftneed the_revfromcontent_get. Without it, the server rejects the call while still validating the arguments. - Editors come first. If someone has the entry open in the admin, writes fail with
ENTRY_LOCKED, just as in the REST API, and name that person. Only withoverrideLock: truedoes the agent write anyway. - Markdown with the same limits. The server converts Markdown the same way the CLI does:
*italic*stays literal, and every line break becomes a paragraph of its own.
Plugins can bring their own tools. An admin has to switch them on in the plugin manager with Enable plugin MCP tools, and the token needs mcp:tools or mcp:tools:<pluginId>, or only mcp:tools for plugin IDs with an npm scope. They're named <pluginId>__<tool>. Up to 1.2.0, a plugin ID with an npm scope such as @acme/calendar produced names with @ and /, which MCP clients could choke on. Since 1.3.0, the tool is acme__calendar__createEvent (#988). If you saved calls using the old name, update them.
My advice for agents: a token of their own with content:read and content:write, no admin, and an expiry. With OAuth, that means unticking everything else on the consent page. That lets an agent write and publish drafts, but not change the schema. It can still delete entries for good, because an API token always belongs to an admin, and with that role content:write is enough. If you want to rule that out, connect the agent through OAuth with an account that's only an editor. I'd still do the publishing myself, after a look at the draft.
Pitfalls from running our own site
Two traps cost us time while building The Weekly Dash.
Firewall rules in front of the API
Before launch, theweeklydash.com sat behind two Cloudflare WAF rules. One only let in requests from Germany and Switzerland, the other put a managed challenge in front of every request. For a person in a browser, that's a quick check. A script, the CLI or an MCP client can't solve it, though, and gets an HTML page with a 403 instead of JSON. So our challenge rule left out every path starting with /_emdash/api/:
(http.host eq "theweeklydash.com" and not starts_with(http.request.uri.path, "/_emdash/api/"))The obvious workaround, a Cloudflare Access service token, doesn't help here. WAF rules run before Access, so the challenge fires before Access ever sees the token. The CLI's --header option is meant for sites that really are behind Access, not for getting past a challenge.
The general lesson: if there's a challenge in front of your site, the API needs an exception, and from then on the token is what protects most of those endpoints: without a valid one, they answer 401. A few routes are public by design, though, such as those for comments and media files. With the exception, they lose the challenge's protection too. A geo block hits your automation too: a script running on a server or in CI outside the allowed countries won't get through. The same goes for connectors in Claude Desktop and on claude.ai, because they connect from Anthropic's servers, not from your machine. And going by the source, not every sign-in route lives under /_emdash/api/. emdash login first asks /_emdash/.well-known/auth, and OAuth clients for MCP ask /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server/_emdash. So our rule would have stopped emdash login at the very first step. If you need those routes behind a challenge, add them to the exception. API tokens don't need those routes.
Bylines: PUT wipes what's missing
For content, PUT leaves omitted fields alone. For bylines, it doesn't. Send only the display name to PUT /_emdash/api/admin/bylines/{id} and you wipe bio, avatarMediaId, websiteUrl and userId, because the route sets every missing field to null. We tripped over this on 1.1.0. It's unchanged in 1.2.0: in testing, the bio was empty after a PUT with just displayName. 1.3.0 didn't touch the code.
According to the docs, this route isn't part of the public REST API. For bylines, the docs point to the MCP tools instead. There, byline_update behaves the way you'd expect: in testing, the bio and website survived a name-only change. If you do have to use the REST route, read the byline with GET first and send every field back. Your token needs admin for that.
Conclusion
The three routes complement each other. The REST API is the foundation, made for scripts that handle Portable Text as JSON anyway. The CLI is the most comfortable route for people in a terminal and for shell scripts, because it speaks Markdown, but it publishes immediately unless you pass --draft. The MCP server is built for agents, with drafts by default and a mandatory _rev.
Whichever route you take: one token per job, as few scopes as possible, an expiry wherever you can. If there's a challenge in front of your site, /_emdash/api/ needs an exception. And never write a byline back half-filled through the REST route.

Comments
First-time comments appear once we've approved them. How we handle your details