Munin
Munin developer portal
Get a key →

Upload an asset and embed it

CMS assets are uploaded out-of-band: the server hands you a presigned upload, you send the binary directly to object storage, then you tell the server the upload is done. This avoids streaming binaries through the MCP/HTTP layer. The exact HTTP shape depends on the storage backend — read uploadMethod and branch.

TL;DR

  1. cms_request_asset_upload — server creates an uploaded: false row, returns uploadUrl, uploadMethod, uploadFields, uploadExpiresAt.
  2. Send the binary to uploadUrl using uploadMethod (PUT raw body for local disk storage; POST multipart for S3, including every field from uploadFields plus a file part). On S3 the embedded policy enforces Content-Length-Range so an oversized body is rejected by the bucket itself.
  3. cms_complete_asset_upload — verifies the on-storage size matches what was declared, then flips uploaded: true. On size mismatch the storage object is deleted; the row stays at uploaded:false and you can retry from step 1.
  4. Embed by writing the asset id into an entry's data field via cms_update_entry.

Step 1 — request the upload

{
  "name": "cms_request_asset_upload",
  "arguments": {
    "name": "spring-launch-hero.jpg",
    "mime": "image/jpeg",
    "sizeBytes": 482301,
    "altText": "Spring launch hero image — three product shots on a sunny patio.",
    "metadata": { "campaign": "spring-2026" }
  }
}

Response (PUT-style, local disk storage):

{
  "id": "<assetId>",
  "name": "spring-launch-hero.jpg",
  "mime": "image/jpeg",
  "sizeBytes": 482301,
  "uploaded": false,
  "uploadUrl": "http://localhost:3001/static/assets/upload?key=...&sig=...",
  "uploadMethod": "PUT",
  "uploadFields": {},
  "uploadExpiresAt": "2026-05-01T12:34:56Z"
}

Response (POST-style, S3):

{
  "id": "<assetId>",
  "uploadUrl": "https://s3.example.com/bucket/",
  "uploadMethod": "POST",
  "uploadFields": {
    "key": "org_x/abc.jpg",
    "Content-Type": "image/jpeg",
    "Cache-Control": "public, max-age=31536000, immutable",
    "policy": "<base64-policy>",
    "x-amz-algorithm": "AWS4-HMAC-SHA256",
    "x-amz-credential": "AKIA.../20260501/eu-west-1/s3/aws4_request",
    "x-amz-date": "20260501T123456Z",
    "x-amz-signature": "<hex>"
  },
  "uploadExpiresAt": "2026-05-01T12:34:56Z"
}

Step 2 — send the binary

If uploadMethod === "PUT":

curl --upload-file ./spring-launch-hero.jpg \
     -H "Content-Type: image/jpeg" \
     "<uploadUrl>"

If uploadMethod === "POST":

curl -X POST "<uploadUrl>" \
     $(printf -- '-F %s=%s ' key "$KEY" Content-Type image/jpeg policy "$POLICY" x-amz-algorithm AWS4-HMAC-SHA256 ...) \
     -F "file=@./spring-launch-hero.jpg"

The file part must come last in the multipart body. Send every key in uploadFields as a form field — each one is a signed policy condition, so omitting or altering any of them makes S3 reject the POST. The embedded policy fixes the allowed byte size to exactly sizeBytes, so an oversized body is rejected at S3. Must complete before uploadExpiresAt (typically ~15 min).

Step 3 — complete

{ "name": "cms_complete_asset_upload", "arguments": { "id": "<assetId>" } }

Flips uploaded: true. Until you call this, the asset is invisible to other tools and entries can't reference it. You can call complete as long as the row exists — the URL expires, but the row doesn't.

Step 4 — embed in an entry

There are two ways to embed an asset, depending on whether it's a standalone field (a cover image, a gallery) or an image placed within prose. Both also work inside a block (skill://cms/author-with-blocks) — an asset used in a block, as a typed prop or an inline asset:// token in block prose, is tracked the same way and is covered by the delete guard below.

As a typed field

A field whose collection type is asset (or array of asset) stores the asset id. Read the entry, write the field, send the update:

{ "name": "cms_get_entry", "arguments": { "id": "<entryId>" } }
{
  "name": "cms_update_entry",
  "arguments": {
    "id": "<entryId>",
    "ifVersion": 12,
    "data": { "...all other fields...": "...", "heroImage": "<assetId>" }
  }
}

Inline in a markdown/rich_text body

To place an image within the prose of a markdown or rich_text field, embed an asset://<assetId> reference — in markdown, as the URL of an image:

Intro paragraph.

![Spring launch hero](asset://<assetId>)

More copy.

The asset must already be uploaded (uploaded: true) — an inline reference to an unknown or unconfirmed asset is rejected when you create/update the entry. On read, the delivery API and cms_get_entry/cms_search_entries rewrite each asset://<assetId> to the asset's publicUrl and attach an _assets map keyed by asset id so you can also read altText, mime, and sizeBytes.

(Use skill://cms/publish-entry for the full update + publish dance.)

Auditing unused assets

{ "name": "cms_list_assets", "arguments": { "limit": 200 } }

To see which entries use a given asset — as a typed field or inline in a body — call:

{ "name": "cms_list_asset_usage", "arguments": { "assetId": "<assetId>" } }

It returns one row per reference with fromEntryId, fieldName, and kind (field or inline). An empty result means the asset is safe to delete.

What NOT to do

  • Don't call cms_complete_asset_upload before the binary PUT succeeds. The asset will be marked uploaded: true with no actual file — entries referencing it will render broken.
  • Don't lose the id. Without it you can't complete the upload, and the half-uploaded row sits as an orphan (no automatic GC).
  • Don't reuse one presigned URL for multiple files. Each cms_request_asset_upload mints a new URL bound to the size and mime you declared.
  • Don't expect to delete an asset that's still in use. cms_delete_asset fails with a conflict while any entry references the asset (field or inline). Call cms_list_asset_usage first, then remove the references from those entries before deleting.

Related

  • skill://cms/publish-entry — the update + publish dance for the entry that embeds the asset.
  • skill://cms/migrate-content — when you're moving assets along with entries.