skriva / statamic
Statamic publish endpoints for Skriva: token-authenticated collection discovery and entry create/update.
Package info
Type:statamic-addon
pkg:composer/skriva/statamic
Requires
- php: ^8.2
- statamic/cms: ^5.0 || ^6.0
Requires (Dev)
- orchestra/testbench: ^9.0 || ^10.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Statamic addon that lets Skriva publish generated articles into your
collections. Statamic's REST and GraphQL APIs are read-only, so publishing needs a small addon on
the Statamic side — the same shape as the Payload plugin,
speaking the same wire contract (FieldNode schema tree + mapped publish data). Block sets, SEO
fields, relations and enums are discovered from your blueprint, never hardcoded.
Requirements
- PHP 8.2+
- Statamic 5 or 6
Install
From the Statamic project root:
composer require skriva/statamic
Then set the shared token in the Statamic site's .env:
AI_CONTENT_TOKEN=<openssl rand -hex 32>
Configuration
Optional, all via .env:
# Links leaving this site open in a new tab (default true). See /publish below.
AI_CONTENT_EXTERNAL_LINKS_NEW_TAB=false
Publish the config file with php artisan vendor:publish --tag=ai-content-config to edit the
values directly.
Limitation: the new-tab treatment only applies to bard fields. A markdown fieldtype stores the
markdown source verbatim and markdown has no target attribute — add the
ExternalLink extension to your
site's own markdown parser config if you want the same there.
Connect to Skriva
In Skriva, open your site's Connection page, choose destination "Statamic", paste the site's base URL and the same token, and pick a collection.
Endpoints return 503 while the token is unset — nothing is exposed without it.
Endpoints
All require Authorization: Bearer <token> and are rate-limited (60/min). No session, no CSRF.
GET /ai-content/manifest
Connection test + collection discovery. Unlike Payload (plugin owns one target) this addon serves every collection; Skriva lists them and the user picks.
{
"ok": true,
"addon": "skriva/statamic",
"statamic": "6.24.2",
"meta": { "version": "0.2.0", "capabilities": ["draft_flow", "conditions", "defaults"] },
"collections": [{ "handle": "landing", "title": "Landing pages", "dated": false, "hasRoute": true }]
}
meta.version is this addon's version; meta.capabilities are the behavior flags Skriva gates on
(it never compares version numbers). The same meta rides along on /schema.
GET /ai-content/schema?collection=<handle>
The collection's default entry blueprint as a FieldNode tree — identical wire shape to the
Payload plugin's /schema. Fieldtype mapping:
| Statamic | FieldNode |
|---|---|
| bard, markdown | richText (markdown slot; bard converted on publish) |
| text, slug | text — textarea → textarea |
| integer, float, range | number — toggle → checkbox, date → date |
| select, radio, button_group, checkboxes | select with options (+ hasMany) |
| replicator | blocks (sets = block palette, grouped sets flattened) |
| group | group — grid → array |
| entries, terms | relationship + candidate options (cap 100, cappedOut above) |
| assets | upload + isMedia (never AI-filled) |
| users, link, section, html, … | omitted |
Sets/groups whose required fields include media are flagged unsatisfiable so Skriva's compiler
drops them instead of letting the AI attempt them. Reserved handles (slug, date, parent) are
omitted — the entry's slug and date are managed by /publish itself.
Each field may also carry:
default— the blueprint's default value. Skriva leaves such fields to Statamic instead of having the AI invent one.condition— a normalized form ofif/unless/if_any/show_when/hide_when/unless_any:{ negate?: true, when: "all"|"any", rules: [{ field, operator, value }] }. The operator is split off the rule string ("not published"→{operator: "not", value: "published"}); acustom fn:paramsrule becomes{operator: "custom"}, which Skriva knows it cannot evaluate. Fields withalways_save: trueget nocondition— Statamic stores them regardless.requiredIf—required_if:field,value[,value…]validate rules as[{field, value}](multiple values are OR, as in Laravel).
POST /ai-content/publish
{
"collection": "landing",
"slug": "post-slug",
"status": "published",
"externalId": "optional-entry-id",
"lenient": false,
"data": {
"title": "…",
"intro": "…markdown…",
"page_builder": [{ "blockType": "hero", "heading": "…" }],
"seo": { "meta_title": "…", "meta_description": "…" }
}
}
data follows the /schema semantics: richText slots carry GitHub-flavored markdown, replicator
values are {blockType, …fields} rows. Conversion on write:
- bard ← markdown → HTML → ProseMirror via the field's own editor config (identical to a CP save),
then degraded to what the field's
buttonsenable — the CP editor rejects the whole value on any node type its buttons don't cover. Disallowed leaves (horizontal rules) drop, disallowed containers (tables, lists, code blocks) unwrap to paragraphs, headings clamp to the nearest enabled level, disallowed marks strip.markdownfields store the source verbatim - links in a bard value that leave this site get
target="_blank" rel="noopener noreferrer"in the stored ProseMirror JSON, so a reader following a cited source keeps the article open. "External" is an absolute http(s) URL whose host (minuswww.) is none of this installation's site hosts; relative paths, anchors,mailto:/tel:and Statamic references (entry::…) are never touched - replicator rows become
{id, type, enabled, …}; grid rows get ids; groups recurse - entries/terms normalize to id arrays
Behavior:
externalIdpresent → updates that entry (404 if it no longer exists). Absent → looks the slug up in the collection and updates in place, else creates. Republishing never duplicates.- Dated collections get
now()as the entry date on create;status: "draft"saves unpublished. Statamic forces a requireddatefield onto such collections, but it is a reserved handle that never reaches the request — the endpoint satisfies that rule on its own. - The blueprint validates the converted values. Failures answer
422with"The following fields are invalid: a, b"(detailscarries the full errors). Fields the request omits are validated against their blueprint default, so a required field with a default passes; the stored entry still omits it, leaving Statamic's default behavior in charge. lenient: trueskips blueprint validation entirely, so an article Skriva cannot get past it is parked as a draft for you to finish in the control panel. Only allowed with"status": "draft"— combining it withpublishedis refused (422). Request validation and the slug format still apply.
Response:
{ "ok": true, "id": "<entry-id>", "url": "<absolute-url>", "editUrl": "<cp-url>", "created": true }.
GET /ai-content/entry?id=<entry-id>
Whether one entry is live yet — how Skriva notices that a parked draft was published by hand.
{ "ok": true, "id": "…", "published": true, "url": "https://…", "editUrl": "https://…/cp/…" }
published reflects the entry's real visibility, not just its published flag: a scheduled
(future-dated) or expired entry answers false. url is null for collections without a route,
and only meaningful while published — a draft's public URL 404s. A deleted entry answers 404.
Development
composer install vendor/bin/phpunit
Releasing
The addon's version lives in ServiceProvider::VERSION (there is no version field in
composer.json — releases are git tags). Bump that constant, add the CHANGELOG entry, then tag the
commit with the same number; meta.version on /manifest and /schema must match the tag.