@machopost/payload-plugin
v0.1.4
Published
Payload CMS plugin exposing the machopost publishing contract: candidates, targets, and write-schema discovery.
Maintainers
Readme
@machopost/payload-plugin
The CMS-side counterparty for machopost
Payload channels. Installing it in a Payload app exposes the machopost
publishing contract, authenticated by the app's own API-key auth (Payload
useAPIKey):
GET /api/machopost/candidates— the Publishable scopes the calling key may write to, as machopost connect-picker candidates. Default: one candidate for the whole site; multi-tenant apps supply acandidatesresolver.GET /api/machopost/targets?scope=— the Publish targets a scope offers: a name, a flat composer field schema, and media constraints, under a content-hashedschemaVersionechoed as theETag(If-None-Match→ 304).POST /api/machopost/publish— the execute half:{ deliveryId, scope, target, content: { caption, media }, fields }publishes into the CMS and returns{ documentId, url? }. The plugin re-validatesfieldsagainst the target schema (authoritative; machopost's own validation is best-effort UX), fetches each media pull-URL server-side into fresh per-delivery media docs, then creates the primary doc live. Idempotent perdeliveryId.GET /api/machopost/schema— generic write-input JSON Schema introspection per granted collection:create/updateviews pruned to client-settable fields, richText advertised as markdown, relationships as bare ids,?locale=selection, and the sameschemaVersion/ETagfreshness model.- The agent surface (below):
GET /api/machopost/list,GET /api/machopost/get,POST /api/machopost/write, andPOST /api/machopost/ingest-media— generic scope-fenced reads and writes over the granted collections, for machopost's MCP/CLI agent tools. No delete, by doctrine.
The two concepts
Candidates answer the connect-time question: where can this key publish? When a user connects your CMS in machopost (base URL + API key), machopost fetches candidates and shows a picker; each selected candidate becomes one channel. A single-site app never writes this — the default is one whole-site candidate. Only a multi-tenant app (one instance hosting many sites/projects) supplies a resolver, and it answers per calling key, from data — never a hardcoded list.
Targets answer the compose-time question: what shapes can be published there? A target like "Blog post" is the curated form machopost's composer renders for the channel: the extra fields to fill and the media it accepts. Targets are declared, not inferred from your collections, because publishing one post may fan out across several collections (a media doc, then the post doc referencing it) and only your app knows that choreography — the target is the small publishing surface you choose to expose, not your storage schema.
When a target's fields are backed by one collection, derive them with
targetFieldsFromCollection
instead of redeclaring types by hand — curation stays manual, but types,
required, and options can't drift from the real collection.
Install
Single-site app — no candidates; connecting a key yields one whole-site
channel:
import { machopostPlugin } from "@machopost/payload-plugin";
export default buildConfig({
// ...
plugins: [
machopostPlugin({
authCollections: ["service-accounts"],
targets: [
{
id: "blog-post",
name: "Blog post",
fields: {
type: "object",
properties: {
title: { type: "string", description: "The post's headline." },
excerpt: { type: "string" },
},
required: ["title"],
additionalProperties: false,
},
media: { kinds: ["image"], maxCount: 1, required: false },
publish: {
collection: "posts",
mediaCollection: "media",
document: ({ content, fields, mediaIds }) => ({
title: fields.title,
excerpt: fields.excerpt,
body: content.caption,
hero: mediaIds[0] ?? null,
}),
url: ({ doc }) => `https://blog.example/posts/${doc.slug}`,
},
},
],
}),
],
});Multi-tenant app — a candidates resolver derives the calling key's scopes
from real data, and targets may vary per scope:
machopostPlugin({
authCollections: ["service-accounts"],
candidates: async ({ req }) => {
const { docs } = await req.payload.find({
collection: "projects",
where: { id: { in: req.user?.projects ?? [] } },
});
return docs.map((project) => ({
id: String(project.id),
name: project.title,
avatarUrl: project.logoUrl ?? undefined,
}));
},
targets: ({ scope }) => targetMenuFor(scope),
});Deriving target fields from a collection
targetFieldsFromCollection is the preferred way to declare a target's
fields when they're backed by a real collection. Pass the raw
CollectionConfig you already have in scope plus the field names to expose,
in menu order; it returns a complete fields schema derived from the
collection's own definitions:
import { targetFieldsFromCollection } from "@machopost/payload-plugin";
import { Posts } from "./collections/Posts";
targets: [
{
id: "blog-post",
name: "Blog post",
fields: targetFieldsFromCollection(Posts, ["title", "excerpt"], {
excerpt: { description: "Shown on the listing page." },
}),
media: { kinds: ["image"], maxCount: 1, required: false },
publish: {
collection: "posts",
mediaCollection: "media",
document: ({ fields, mediaIds }) => ({ ...fields, hero: mediaIds[0] }),
},
},
],Mapping: text/textarea/email/code → string (hasMany text →
string-array); number → number; checkbox → boolean; select/radio
→ string + enum from the option values (hasMany select →
string-array). required: true propagates to the schema's required;
admin.description (or a plain-string label) becomes the field's
description. Fields inside rows, collapsibles, and unnamed tabs are found —
they're top-level data.
The optional third argument overrides description, maxItems, or enum
per field; an enum override may only narrow the collection's options.
Field types with no flat representation (richText, relationship, upload,
group, array, blocks…) throw at config build time — declare those fields
explicitly instead.
Adopting with an agent
The package ships an adoption skill —
skills/machopost-payload-adoption/SKILL.md in the installed package — that
walks an agent through this whole setup as a decision checklist: auth
collection, single-site vs multi-tenant candidates, target curation, publish
mapping, verification, connect handoff. Point your agent at it, or make it
discoverable in the repo:
mkdir -p .claude/skills
ln -s ../../node_modules/@machopost/payload-plugin/skills/machopost-payload-adoption .claude/skills/machopost-payload-adoptionWhether or not an agent drives, the bundled verification proves the live contract end-to-end (auth statuses, response shapes, the ETag→304 dance, and an idempotent throwaway publish — everything it creates is deleted). With the dev server running:
MACHOPOST_VERIFY_AUTH_COLLECTION=<auth-collection-slug> \
npx payload run node_modules/@machopost/payload-plugin/skills/machopost-payload-adoption/scripts/verify.mjsIt exits non-zero on any contract violation. If the auth collection has
required fields the script cannot invent (relationships, arrays), or your
candidates resolver derives scopes from key data, pass that data via
MACHOPOST_VERIFY_KEY_DATA (JSON keyed by collection slug — documented with
the other env knobs at the top of the script).
Options
| Option | Required | Description |
| --- | --- | --- |
| authCollections | yes | Slugs of the auth collections whose API keys may call the endpoints (Authorization: <collection> API-Key <key>). Other callers get 403. |
| targets | yes | The Publish targets offered — a static array, or a resolver ({ scope, req }) when the menu varies per scope. Each carries its discovery shape plus a publish fan-out config (below). |
| candidates | no | Resolver ({ req }) returning the scopes the calling key may write to. Omitted: one whole-site candidate named from the Payload config. |
| grants | no | Resolver ({ req }) returning { collection, operations } grants for /api/machopost/schema and the agent surface. Omitted: the key doc's own grants array field, or nothing. |
| scopeField | no | ({ collection }) → the field name holding the Publishable scope id on that collection's docs, or null for unscoped collections. Fences the agent surface's reads and writes to the calling channel's scope. Omitted: nothing is scope-fenced — correct for whole-site apps, required for multi-tenant apps. |
The agent surface
machopost exposes the CMS's granted write surface to agents (MCP tools, CLI
subcommands) through four generic endpoints. All of them name a scope (the
channel's Publishable scope) and a collection, and run under the app's own
Payload access rules (overrideAccess: false) on top of the plugin's checks:
GET /api/machopost/list?scope=&collection=&limit=&page=&sort=&locale=— paginated docs at depth 0 (relationships stay bare ids, matching the introspected schemas). Page size caps at 100.GET /api/machopost/get?scope=&collection=&id=&locale=— one doc, depth 0.POST /api/machopost/write—{ scope, collection, operation: "create"|"update", id?, data, locale? }→{ documentId, doc }. Server- managed fields (id,createdAt,updatedAt, the delivery stamp) are rejected.POST /api/machopost/ingest-media—{ scope, collection, url, alt?, filename? }fetches a machopost pull-URL server-side into a new doc of a granted upload collection and returns{ documentId, doc }for relationship fields.
Semantics:
- Grants gate everything. Writes need the exact operation granted
(
create/update); reads needread— or any write grant, which implies read (an agent can always see what it can write). There is no delete operation (retraction-by-unpublish doctrine). - Auth collections are untouchable, whatever the grants say — writing them would let a key mint itself a broader one.
- The scope fence (
scopeField): scoped collections have list filtered, get/update answering 404 outside the scope, and creates get the scope injected server-side (a conflicting value indatais a 400). machopost always sends its channel's scope; the fence is what makes a write unable to land outside it. - richText accepts markdown. The plugin wraps every richText field with a
beforeValidatehook converting string values markdown → Lexical (via the field's own editor config), so the shape/api/machopost/schemaadvertises holds on write. Non-string values pass through untouched, and apps without@payloadcms/richtext-lexical(an optional peer) never load the converter.
Publishing
A target's publish block is the fan-out only your app can know — which
collections a publish touches and how the request becomes a doc. It never
appears on the discovery wire:
| Key | Required | Description |
| --- | --- | --- |
| collection | yes | Where the primary doc is created; its id becomes the delivery's documentId. |
| mediaCollection | when the target accepts media | Upload collection for ingested media docs. The post's per-media alt snapshot is written to each doc. If that collection's alt field is required, declare media: { ..., altRequired: true } on the target — the publish then refuses alt-less media with a readable error (and machopost blocks it at compose time) instead of failing on Payload's field validation mid-fan-out. |
| document | yes | ({ deliveryId, scope, content, fields, mediaIds, req }) → the primary doc's data. mediaIds are the created media docs' ids, in request order. |
| url | no | ({ doc, scope, req }) → a public permalink for the created doc, when the app can produce one. Omitted → the delivery has no external URL. |
Semantics the plugin owns, not the app:
- Publish means make-it-live. On draft-enabled collections the created doc
is forced to
_status: "published". A draft-review workflow is a status field you declare as a target field, not a machopost concept. - Fresh media docs per delivery. Media is fetched from machopost's pull-URLs server-side and created as new docs every delivery — alt-text snapshots stay per-post, no cross-post doc sharing.
- Idempotency per
deliveryId. Every created doc is stamped in a hidden, uniquemachopostDeliveryIdtext field the plugin injects into each non-auth collection (pruned from/api/machopost/schema; expect a schema migration on install) — the primary doc carries the delivery id, media docs<deliveryId>#<index>. A replayed delivery finds the stamped primary doc and returns the original{ documentId, url? }without creating anything; a retry after a crash mid-fan-out adopts the stamped media docs it finds instead of duplicating them; and because the stamp is unique, even concurrent retries cannot double-publish — machopost's delivery workflows retry, so this is load-bearing. - Failures create nothing. Invalid
fieldsand media-constraint violations are rejected (400, with a human-readable detail machopost surfaces as the delivery's error) before anything is created; if creating the primary doc fails, the fan-out is rolled back (transaction where the adapter supports one, compensating deletes otherwise).
