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 disk storage; 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 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.

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_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.