Für einen einzelnen Artikel ist der Adminbereich von EmDash der richtige Ort. Willst du aber vierzig Beiträge auf einmal umbenennen, Inhalte aus einem anderen System übernehmen, einen nächtlichen Bericht ziehen oder einen KI-Agenten Entwürfe schreiben lassen, wird Klicken schnell mühsam. Dafür bringt EmDash drei Wege mit, die ohne Adminbereich auskommen: die REST-API, die Kommandozeile emdash und einen eingebauten MCP-Server für KI-Agenten.
Ich zeige dir, wie du die drei Wege nutzt, worin sie sich unterscheiden und wo sie dich überraschen können. Grundlage ist EmDash 1.3.0. Die Abläufe sind gegen EmDash 1.2.0 durchgespielt und am Quelltext von 1.3.0 nachgeprüft. Wo 1.3 etwas geändert hat, steht es dabei. Am Ende stehen die Stolpersteine, auf die wir beim Betrieb von The Weekly Dash gestoßen sind.
Drei Wege, ein Rechtesystem
Alle drei Wege landen unter /_emdash/api/ und melden sich mit einem Bearer-Token an. Ein Token trägt Bereiche, im Code Scopes genannt, und gehört zu einem Benutzer. EmDash prüft bei jeder Anfrage beides: Der Bereich muss passen, und die Rolle des Benutzers muss die Aktion erlauben. Ein Token kann also nie mehr als sein Benutzer.
Im Alltag unterscheiden sich die drei Wege trotzdem deutlich:
REST-API | CLI | MCP | |
|---|---|---|---|
Anmeldung | API-Token | API-Token oder | API-Token oder OAuth |
Rich Text schreiben | nur Portable Text als JSON | Markdown oder Portable Text | Markdown oder Portable Text |
Neuer Eintrag | Entwurf | sofort veröffentlicht, außer mit | Entwurf, außer mit |
Ändern ohne | möglich | nicht möglich | nicht möglich |
Die Zeile mit dem neuen Eintrag solltest du dir merken. Wer von der API zur CLI wechselt, veröffentlicht sonst aus Versehen.
Das API-Token
Tokens legst du im Adminbereich unter Einstellungen → API-Tokens an. Das dürfen nur Admins, und jedes Token gehört dem Admin, der es erstellt. Du vergibst einen Namen, wählst die Bereiche und ein Ablaufdatum: kein Ablaufdatum, 7, 30 oder 90 Tage oder ein Jahr. Danach zeigt EmDash das Token genau einmal an. Es beginnt mit ec_pat_, gespeichert wird nur sein Hash. Kopierst du es nicht sofort, legst du ein neues an.
Diese Bereiche gibt es in 1.3.0:
content:readundcontent:writefür Inhalte und Revisionen, über MCP auch für Autorenangaben.taxonomies:manageundmenus:managefür Taxonomien und Menüs.content:writeschließt beide mit ein, damit ältere Tokens weiter funktionieren.media:read,media:write,schema:read,schema:write,settings:readundsettings:manage.mcp:toolsfür die MCP-Werkzeuge von Plugins, dazu drei Bereiche für den Site-Transfer.adminfür alles andere.
Für alles ohne eigenen Bereich verlangt EmDash admin, und das betrifft mehr, als du denkst: die Verwaltung der Autorenangaben unter /_emdash/api/admin/bylines, GET /_emdash/api/auth/me und das OpenAPI-Dokument unter /_emdash/api/openapi.json. Umgekehrt deckt admin die Plugin-Werkzeuge über MCP nicht ab. Die brauchen immer mcp:tools oder den Bereich des einzelnen Plugins.
Widerrufen geht in derselben Liste mit Token widerrufen. Im Test antwortete die API danach sofort mit 401 INVALID_TOKEN, genauso wie bei einem abgelaufenen Token. In der Liste steht außerdem, wann ein Token zuletzt genutzt wurde. Auf Cloudflare blieb das Feld bis 1.1.0 oft leer oder veraltet, weil der Schreibvorgang nach der Antwort abgebrochen werden konnte. Seit EmDash 1.2 wird er gespeichert (#3767). „Zuletzt genutzt“ heißt allerdings nur „erfolgreich angemeldet“: Im Test bekam auch ein Token einen Zeitstempel, dessen Schreibversuch am fehlenden Bereich scheiterte.
Meine Regeln für Tokens:
- Ein Token pro Zweck. Dann kannst du eins widerrufen, ohne alles andere lahmzulegen, und „Zuletzt genutzt“ sagt dir etwas.
- So wenig Bereiche wie möglich. Für einen Bericht reicht
content:read. Ein Redaktionsskript brauchtcontent:readundcontent:write, mit der CLI zusätzlichschema:read(dazu unten mehr).admingebe ich nur, wenn eine Aufgabe es wirklich verlangt. - Ablaufdatum für alles Einmalige. Für einen Import am Wochenende reichen 7 Tage.
- Nicht ins Repository. Das Token gehört in eine Umgebungsvariable oder einen Secret-Speicher.
Bedenke dabei: Ein Token handelt mit der Rolle seines Admins. Ein Token mit content:write darf deshalb die Beiträge aller Autoren ändern, nicht nur die eigenen. Wird der Admin deaktiviert, hören auch seine Tokens auf zu funktionieren.
Die REST-API
Die Grundform ist eine Anfrage mit dem Token im 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=de&limit=100"In vielen Beispielen steht zusätzlich der Header X-EmDash-Request: 1. Mit einem Token brauchst du ihn nicht. Er ist ein Schutz gegen Cross-Site Request Forgery für Anfragen mit Session-Cookie: Ein Browser schickt das Cookie auch dann mit, wenn eine fremde Seite die Anfrage auslöst. Einen eigenen Header kann eine fremde Seite dagegen nicht setzen, also beweist er, dass keine fremde Seite die Anfrage ausgelöst hat. Ein Token reist nicht automatisch mit, deshalb prüft EmDash den Header bei Anfragen mit gültigem Token nicht. Im Test ging ein POST mit Token und ohne Header durch, derselbe POST mit Cookie und ohne Header endete mit 403 CSRF_REJECTED. Schaden tut der Header nicht.
Listen und blättern
limit darf höchstens 100 sein, ohne Angabe gilt 50. Ein Aufruf mit limit=200 liefert kein gekürztes Ergebnis, sondern 400 VALIDATION_ERROR mit „Too big: expected number to be <=100“. Für mehr Einträge blätterst du. Bis 1.1.0 ging das nur mit dem nextCursor aus der vorigen Antwort. Seit 1.2.0 nimmt die Liste auch eine Seitenzahl (#3773):
curl -s -H "Authorization: Bearer $EMDASH_TOKEN" \
"$EMDASH_URL/_emdash/api/content/posts?limit=100&page=2"Eine Antwort mit page hat keinen nextCursor. Wie viele Einträge es insgesamt gibt, steht in jeder Antwort unter total. page und cursor zusammen lehnt die API mit 400 ab. Für Skripte nehme ich trotzdem den Cursor, weil er stabil bleibt, wenn zwischendurch jemand einen Beitrag anlegt. Du findest ihn in der Antwort unter data.nextCursor und hängst ihn an die nächste Anfrage an. Fehlt er, bist du durch:
curl -s -H "Authorization: Bearer $EMDASH_TOKEN" \
"$EMDASH_URL/_emdash/api/content/posts?limit=100&cursor=<nextCursor>"Anlegen, ändern, veröffentlichen
Ein neuer Eintrag ist über die API immer ein Entwurf. Die Feldnamen kommen aus deinem Inhaltstyp, wie du sie im eigenen Inhaltstyp festgelegt hast:
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": "de",
"data": {
"title": "API-Test",
"content": [
{ "_type": "block", "_key": "a1", "style": "normal", "markDefs": [],
"children": [{ "_type": "span", "_key": "a2", "text": "Hallo aus der API.", "marks": [] }] }
]
}
}'Rich-Text-Felder erwartet die REST-API als Portable Text, also als JSON-Array von Blöcken. Ein Markdown-String im Feld content endet mit 400 und „expected array, received string“. Markdown verstehen nur CLI und MCP.
Die Antwort enthält den Eintrag unter data.item, seine ID also unter data.item.id. Daneben steht unter data._rev ein Versionsstempel, den du beim Ändern mitschickst. Mit jq -r .data._rev liest du ihn aus:
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, geändert" }, "_rev": "<_rev aus der letzten Antwort>" }'PUT ersetzt bei Inhalten nicht den ganzen Eintrag. Felder, die du in data weglässt, behalten ihren Wert. Im Test blieben Teaser und Text nach einem PUT mit nur dem Titel erhalten. Hat jemand den Eintrag seit deinem Lesen geändert, kommt 409 CONFLICT. Dann liest du neu und versuchst es noch einmal. Hat jemand den Eintrag gerade im Adminbereich offen, kommt 409 ENTRY_LOCKED mit seinem Namen. Mit "overrideLock": true im Body schreibst du trotzdem. Das _rev ist in der REST-API optional. Ich schicke es trotzdem immer mit, denn ohne überschreibt dein Skript stillschweigend, was die Redaktion gerade gespeichert hat.
Ändern ist nicht veröffentlichen. Bei einem veröffentlichten Beitrag legt PUT einen Entwurf an, und die Website zeigt weiter die alte Fassung. Erst dieser Aufruf macht ihn sichtbar:
curl -s -X POST -H "Authorization: Bearer $EMDASH_TOKEN" \
"$EMDASH_URL/_emdash/api/content/posts/<id>/publish"Daneben gibt es eigene Endpunkte für Zurückziehen, Planen, Vergleichen und Verwerfen des Entwurfs. Die vollständige Liste steht im OpenAPI-Dokument, das du allerdings nur mit einem admin-Token oder angemeldet im Browser abrufen kannst. Öffentlich lesbar ist die REST-Referenz in der Doku.
Die CLI
Die Kommandozeile emdash steckt im Paket emdash, das in jedem EmDash-Projekt installiert ist. Neben Befehlen für die lokale Datenbank hat sie content, schema, media, search, taxonomy, menu und site für eine laufende Site. Sie spricht dieselbe REST-API an, Adresse und Token liest sie aus Umgebungsvariablen:
export EMDASH_URL=https://example.com
export EMDASH_TOKEN=ec_pat_…
pnpm exec emdash content list posts --status draft --locale de
pnpm exec emdash content get posts api-testDer große Vorteil ist Markdown. content get gibt Rich-Text-Felder als Markdown aus, mit --raw als Portable Text. content create und content update nehmen Markdown entgegen, etwa aus einer Datei:
{
"title": "CLI-Test",
"excerpt": "Angelegt mit emdash content create.",
"content": "## Zwischentitel\n\nEin Absatz mit **fett** und einem [Link](https://emdashcms.com)."
}pnpm exec emdash content create posts --file post.json --slug cli-test --locale de --draftDas --draft ist kein Zierrat. Ohne das Flag veröffentlicht content create den neuen Eintrag sofort, im Test stand er ohne --draft direkt auf published. Dasselbe gilt laut Quelltext für content update. Die REST-API und der MCP-Server legen dagegen Entwürfe an. Ich setze --draft deshalb in jedem Skript und veröffentliche bewusst mit emdash content publish.
Zum Ändern verlangt die CLI das _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":"Neuer Titel"}'
pnpm exec emdash content publish posts cli-testAuf mehrsprachigen Sites mit gleichem Slug in jeder Sprache gibst du bei get und update --locale mit. content publish kennt die Option nicht und erwischt mit einem Slug die Sprache, die im Alphabet zuerst kommt. Dort nimmst du deshalb die ID aus der Ausgabe von content create.
Vier Dinge sind beim Testen und im Quelltext aufgefallen:
- Die CLI braucht
schema:read. Für die Umwandlung in Markdown liest sie die Felder des Inhaltstyps. Fehlt der Bereich, sagt sie das nicht immer.content getgibt dann stillschweigend Portable Text aus, undcontent createschickt dein Markdown unverändert, worauf die API mit400und „expected array, received string“ antwortet. Nur bei Einträgen mit offenem Entwurf scheitertcontent getmit „Token lacks required scope: schema:read“. content getzeigt den Entwurf. Hat ein veröffentlichter Beitrag einen offenen Entwurf, gibt die CLI dessen Inhalt aus, auch wennstatusaufpublishedsteht. Das liegt nicht an der CLI: Schon die REST-API liefert indataden Entwurf und die Live-Fassung daneben inliveData. Ein Skript, das etwa Titel für einen Bericht liest, sieht also unveröffentlichte Änderungen.--publishedändert daran nichts.- Markdown ist nicht ganz Markdown. Überschriften, Listen, Links, Code-Blöcke,
**fett**und_kursiv_werden zu Portable Text.*kursiv*mit Sternchen bleibt dagegen wörtlich im Text stehen. Und jeder Zeilenumbruch beginnt einen neuen Absatz, auch ohne Leerzeile. Wer Absätze im Editor umbricht, bekommt also viele kurze Absätze. loginundlogoutignorierenEMDASH_URL. Anders alscontentfragen sie ohne--urlimmerhttp://localhost:4321. Einen Fix dafür habe ich eingereicht (#4180), gemergt ist er noch nicht. Beiwhoamiwar das bis 1.2.0 genauso, seit 1.3.0 liest es die Variable. Es braucht aber weiter ein Token mitadmin, sonst meldet es „Failed to fetch user info: 403“.
Statt eines Tokens kannst du dich auch mit emdash login --url https://example.com anmelden. Die CLI zeigt dann einen Code, den du im Browser bestätigst, und speichert die Zugangsdaten in ~/.config/emdash/auth.json. Laut Quelltext gilt der Zugang eine Stunde und wird bis zu 90 Tage automatisch erneuert. Die CLI nimmt ihn aber nur, wenn kein EMDASH_TOKEN gesetzt ist und EMDASH_URL oder --url auf dieselbe Adresse zeigt. Angefordert wird dabei nur der Bereich admin. Für Redakteure und andere Nicht-Admins bleibt davon nichts übrig, und EmDash lehnt die Bestätigung mit INSUFFICIENT_ROLE ab, so steht es in Quelltext und Tests. emdash login ist also nur etwas für Admins. Redakteure kommen per OAuth über MCP an einen Zugang, dazu gleich mehr.
emdash seed und emdash export-seed arbeiten übrigens auf einer lokalen SQLite-Datei, nicht auf deiner Live-Datenbank. Für eine Kopie der ganzen Site gibt es emdash site export. Wie das in eine Backup-Strategie passt, steht in Backups bei EmDash.
MCP für KI-Agenten
Der MCP-Server ist in jede EmDash-Site eingebaut, ohne Plugin. Wie das gegenüber WordPress dasteht, hat Kevin im Faktencheck zum Lock-in eingeordnet. Der Endpunkt ist /_emdash/api/mcp, er ist standardmäßig aktiv und lässt sich in astro.config.mjs mit mcp: false abschalten.
Anmelden kann sich ein Client auf zwei Arten: mit einem API-Token im Header oder per OAuth. Bei OAuth meldet sich der Benutzer im Browser an und wählt die Bereiche selbst. So kommen auch Redakteure an einen Zugang, obwohl sie keine API-Tokens anlegen dürfen. Ein Session-Cookie allein reicht nicht. Im Test antwortete der Endpunkt darauf mit 401, genau wie ohne jede Anmeldung, jeweils mit einem Hinweis auf die OAuth-Metadaten im Header WWW-Authenticate.
Für Claude Code sieht das mit Token so aus:
claude mcp add --transport http emdash https://example.com/_emdash/api/mcp \
--header 'Authorization: Bearer ${EMDASH_TOKEN}'Die einfachen Anführungszeichen sind Absicht. So speichert Claude Code nur den Verweis auf die Variable und setzt das Token erst beim Start ein. Mit doppelten Anführungszeichen setzt die Shell es sofort ein, und es steht im Klartext in ~/.claude.json. Im Test meldete claude mcp list den Server danach als verbunden. Fehlte die Variable, antwortete EmDash mit 401, und Claude Code wich nicht auf OAuth aus. Steht ein Authorization-Header in der Konfiguration, ist dieser Weg abgeschaltet.
Lässt du den Header weg, startet der Client die OAuth-Anmeldung von EmDash. Claude Code zeigt den Server dann mit „Needs authentication“ an, die Anmeldung startest du in einer Sitzung mit /mcp. Im Test registrierte sich Claude Code dabei selbst als OAuth-Client bei EmDash und öffnete die Zustimmungsseite im Browser. Dort stehen alle 15 Bereiche, die EmDash kennt, admin eingeschlossen, und jeder hat schon ein Häkchen. Wer einfach bestätigt, gibt dem Agenten alles, was die eigene Rolle hergibt, als Admin also volle Admin-Rechte. Die Häkchen kannst du einzeln entfernen. Danach meldete claude mcp list den Server als verbunden. Das Zugangstoken galt eine Stunde, das Token zum Erneuern 90 Tage. In Claude Desktop und claude.ai trägst du die Adresse als benutzerdefinierten Connector ein, die EmDash-Doku beschreibt das. Diese beiden Clients haben wir nicht getestet.
Der Server bietet 72 Werkzeuge für Inhalte, Autorenangaben, Schema, Medien, Suche, Taxonomien, Menüs, Revisionen, Einstellungen und den Site-Transfer. Dabei gilt:
- Die Liste zeigt alles.
tools/listlieferte im Test auch einem reinen Lese-Token alle 72 Werkzeuge. Erst beim Aufruf kommtINSUFFICIENT_SCOPE. Ein Agent sieht also Werkzeuge, die er nicht benutzen darf, und muss mit der Ablehnung umgehen. - Entwurf zuerst.
content_createlegt einen Entwurf an, Rich Text darf Markdown sein. Übergibt der Agent allerdingsstatus: "published", veröffentlicht er den Eintrag gleich mit.content_getgibt mitmarkdown: trueMarkdown zurück. - Kein Schreiben ohne Lesen.
content_update,content_publish,content_unpublish,content_scheduleundcontent_discard_draftverlangen das_revauscontent_get. Ohne lehnt der Server den Aufruf schon bei der Prüfung der Parameter ab. - Rücksicht auf die Redaktion. Hat jemand den Eintrag im Adminbereich offen, scheitern Schreibzugriffe wie bei der REST-API mit
ENTRY_LOCKEDund nennen den Namen. Erst mitoverrideLock: trueschreibt der Agent trotzdem. - Markdown mit denselben Grenzen. Der Server wandelt Markdown genauso um wie die CLI:
*kursiv*bleibt stehen, und jeder Zeilenumbruch wird ein eigener Absatz.
Plugins können eigene Werkzeuge mitbringen. Ein Admin muss sie in der Pluginverwaltung mit MCP-Tools des Plugins aktivieren freischalten, und das Token braucht mcp:tools oder mcp:tools:<pluginId>, bei Plugin-IDs mit npm-Scope nur mcp:tools. Sie heißen <pluginId>__<werkzeug>. Bis 1.2.0 entstanden so bei Plugin-IDs mit npm-Scope wie @acme/calendar Namen mit @ und /, an denen MCP-Clients scheitern konnten. Seit 1.3.0 heißt das Werkzeug acme__calendar__createEvent (#988). Hast du Aufrufe mit dem alten Namen gespeichert, musst du sie anpassen.
Mein Rat für Agenten: ein eigenes Token mit content:read und content:write, ohne admin, mit Ablaufdatum. Bei OAuth nimmst du dafür auf der Zustimmungsseite alle anderen Häkchen weg. Dann kann der Agent Entwürfe schreiben und veröffentlichen, aber nicht das Schema ändern. Endgültig löschen kann er trotzdem, denn ein API-Token gehört immer einem Admin, und mit dessen Rolle reicht dafür content:write. Soll das ausgeschlossen sein, verbindest du den Agenten per OAuth mit einem Konto, das nur Redakteur ist. Veröffentlichen würde ich aber selbst, nach einem Blick in den Entwurf.
Stolpersteine aus unserem Betrieb
Zwei Fallen haben uns beim Aufbau von The Weekly Dash Zeit gekostet.
Firewall-Regeln vor der API
Vor dem Launch lief theweeklydash.com hinter zwei WAF-Regeln von Cloudflare. Die eine ließ nur Anfragen aus Deutschland und der Schweiz durch, die andere stellte jedem Aufruf eine Managed Challenge voran. Für Menschen im Browser ist das ein kurzer Check. Ein Skript, die CLI oder ein MCP-Client können die Challenge aber nicht lösen und bekommen statt JSON eine HTML-Seite mit Status 403. Unsere Challenge-Regel ließ deshalb alle Pfade aus, die mit /_emdash/api/ beginnen:
(http.host eq "theweeklydash.com" and not starts_with(http.request.uri.path, "/_emdash/api/"))Die naheliegende Alternative, ein Service-Token von Cloudflare Access, hilft hier nicht. Die WAF-Regeln laufen vor Access, die Challenge schlägt also zu, bevor Access das Token überhaupt sieht. Die Option --header der CLI ist für Sites gedacht, die wirklich hinter Access stehen, nicht für eine Challenge.
Verallgemeinert heißt das: Steht vor deiner Site eine Challenge, braucht die API eine Ausnahme, und die meisten Endpunkte schützt dann das Token: Ohne gültiges Token antworten sie mit 401. Ein paar Routen sind aber absichtlich öffentlich, etwa für Kommentare und Mediendateien. Sie verlieren mit der Ausnahme auch den Schutz der Challenge. Eine Geo-Sperre trifft auch deine Automatisierung. Läuft ein Skript auf einem Server oder in einer CI außerhalb der erlaubten Länder, kommt es nicht durch. Dasselbe gilt für Connectors in Claude Desktop und claude.ai, denn die verbinden sich von den Servern von Anthropic aus, nicht von deinem Rechner. Und laut Quelltext liegen nicht alle Anmeldewege unter /_emdash/api/: emdash login fragt zuerst /_emdash/.well-known/auth, OAuth-Clients für MCP fragen /.well-known/oauth-protected-resource und /.well-known/oauth-authorization-server/_emdash. Unsere Regel hätte emdash login also schon beim ersten Schritt abgefangen. Wer diese Wege hinter einer Challenge braucht, nimmt sie in die Ausnahme auf. Mit API-Tokens bist du davon unabhängig.
Autorenangaben: PUT löscht, was fehlt
Bei Inhalten lässt PUT weggelassene Felder in Ruhe. Bei Autorenangaben nicht. Wer unter PUT /_emdash/api/admin/bylines/{id} nur den Anzeigenamen schickt, löscht damit bio, avatarMediaId, websiteUrl und userId. Die Route setzt jedes fehlende Feld auf null. Wir sind unter 1.1.0 darüber gestolpert. In 1.2.0 ist es unverändert: Im Test war die Bio nach einem PUT mit nur displayName leer. 1.3.0 hat den Code nicht angefasst.
Laut Doku gehört diese Route nicht zur öffentlichen REST-API. Für Autorenangaben verweist die Doku auf die MCP-Werkzeuge. Dort verhält sich byline_update wie erwartet: Im Test blieben Bio und Website nach einer Änderung nur des Namens erhalten. Musst du doch die REST-Route nehmen, liest du die Autorenangabe vorher mit GET und schickst alle Felder zurück. Dazu braucht dein Token admin.
Fazit
Die drei Wege ergänzen sich. Die REST-API ist der Unterbau und für Skripte gedacht, die Portable Text ohnehin als JSON verarbeiten. Die CLI ist der bequemste Weg für Menschen im Terminal und für Shell-Skripte, weil sie Markdown spricht. Dafür veröffentlicht sie ohne --draft sofort. Der MCP-Server ist für Agenten gebaut, mit Entwürfen als Standard und Pflicht-_rev.
Unabhängig vom Weg gilt: ein Token pro Zweck, so wenig Bereiche wie möglich, ein Ablaufdatum, wo es geht. Liegt vor deiner Site eine Challenge, braucht /_emdash/api/ eine Ausnahme. Und schreib Autorenangaben nie halb über die REST-Route zurück.

Kommentare
Erstkommentare erscheinen nach unserer Freigabe. Was mit deinen Angaben passiert