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
cms_request_asset_upload— server creates anuploaded: falserow, returnsuploadUrl,uploadMethod,uploadFields,uploadExpiresAt.- Send the binary to
uploadUrlusinguploadMethod(PUT raw body for local self-host; POST multipart for S3, including every field fromuploadFieldsplus afilepart). On S3 the embedded policy enforcesContent-Length-Rangeso an oversized body is rejected by the bucket itself. cms_complete_asset_upload— verifies the on-storage size matches what was declared, then flipsuploaded: true. On size mismatch the storage object is deleted; the row stays atuploaded:falseand you can retry from step 1.- Embed by writing the asset id into an entry's
datafield viacms_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 self-host):
{
"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",
"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; 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.

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 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_uploadbefore the binary PUT succeeds. The asset will be markeduploaded: truewith 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_uploadmints 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_assetfails with a conflict while any entry references the asset (field or inline). Callcms_list_asset_usagefirst, 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.