{"name": "CMS Caddie Agent API", "version": "1.0.0", "base_url": "https://agents.cmscaddie.com", "description": "Programmatic, scope-gated access to a CMS Caddie project for autonomous agents. Each agent key is bound to a single project and carries one or more scopes.", "auth": {"scheme": "Bearer", "header": "Authorization", "description": "Agent keys are minted from the dashboard under 'Agent API Keys'. The raw key is shown once at creation time; only a sha256 hash is stored. Send it as `Authorization: Bearer <key>` on every call.", "token_format": "cmsk_* (45 chars)"}, "scopes": {"content:read": "Retrieve and search content items.", "content:write": "Create and update content items.", "content:delete": "Delete content items.", "content:publish": "Set `published` to a timestamp at or before now (makes content live).", "files:write": "Request a presigned S3 upload URL.", "files:delete": "Delete a file the agent (or another agent on this project) uploaded.", "group:write": "Create and update content groups (the content model: fields, types, public flag).", "group:delete": "Request deletion of a content group (export-then-confirm; a human must confirm).", "project:write": "Update project metadata (name, description). Creating a project needs only an active key.", "project:delete": "Request deletion of an entire project (export-then-confirm; a human must confirm).", "webhook:read": "List a project's webhooks (event + endpoint).", "webhook:write": "Create and update webhooks (event + endpoint) on a project's groups.", "webhook:delete": "Delete a webhook.", "member:read": "List the accounts (members) with access to a project.", "member:write": "Add, invite, and remove project members (accounts).", "apikey:read": "List a project's agent keys, connected apps + integration keys (masked metadata; never secrets).", "apikey:write": "Create, rotate, and update machine credentials (agent keys + third-party integration keys) for a project.", "apikey:delete": "Delete / revoke a machine credential.", "backup:read": "List a project's backup snapshots.", "backup:write": "Create a project backup snapshot (non-destructive).", "backup:delete": "Delete a backup snapshot file (export-then-confirm)."}, "conventions": {"idempotency": {"header": "Idempotency-Key", "ttl_hours": 24, "behavior": "Any write accepts an Idempotency-Key header. Replaying the same key with the same body returns the cached response. Replaying with a different body returns 409 conflict."}, "request_id": {"header": "X-Request-ID", "behavior": "If you send one, it is echoed in responses and audit logs. Otherwise we generate one."}, "dry_run": {"query": "dry_run=true", "behavior": "Validates and returns the would-be result without persisting."}, "publish_semantics": {"field": "published", "format": "YYYYMMDDHHMM (UTC)", "rules": ["published <= now_utc  -> item is LIVE (auto-publishes on the next minute).", "published >  now_utc  -> item is scheduled for that time.", "published far in the future (e.g. 999901010000) -> effectively unpublished.", "Any write that sets `published` to a time <= now requires the `content:publish` scope."]}, "error_envelope": {"shape": {"error": {"code": "bad_request|unauthorized|forbidden|not_found|conflict|rate_limited|internal|upstream_unavailable", "message": "string", "retryable": "bool", "request_id": "string", "details": "object (optional)"}}}, "success_envelope": {"shape": {"data": "object", "request_id": "string"}}}, "routes": [{"id": "projects.create", "method": "POST", "path": "/projects", "scope": "any", "summary": "Provision a NEW project \u2014 the top-level container for content groups and items. Any active key may create one (there is no project to scope-check yet); the new project is attached to your account and your token is granted full operational access to it, so you can immediately create groups and author content with no re-consent. REQUIRED: `name`. OPTIONAL: `description`. Returns the new project `id` (8-hex). Typical cold-start flow: projects.create -> groups.create -> content.create. `?dry_run=true` validates without writing.", "body_schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string", "description": "Human-readable project name."}, "description": {"type": "string"}}}, "body_example": {"name": "My New Site", "description": "Marketing site content"}}, {"id": "projects.update", "method": "PATCH", "path": "/projects/{project}", "scope": "project:write", "summary": "Update a project's metadata. Only `name` and `description` are updatable (groups, content, and keys have their own endpoints). `?dry_run=true` previews the merged project without writing.", "body_schema": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string"}}}, "body_example": {"description": "Updated project description"}}, {"id": "projects.list", "method": "GET", "path": "/projects", "scope": "any", "summary": "List the projects this key can act on, each with its name, description, and the scopes the key holds on it. Call this FIRST in a session to discover the 8-hex project ids every other tool needs. Any active key."}, {"id": "projects.get", "method": "GET", "path": "/projects/{project}", "scope": "content:read", "summary": "Get one project's details plus its content-group inventory (`group_count` + each group's id/slug/name/public/field_count) \u2014 the setup-state overview for a project before you author into it."}, {"id": "webhooks.list", "method": "GET", "path": "/projects/{project}/webhooks", "scope": "webhook:read", "summary": "List the project's webhooks: `event` (create/update/delete/publish) + `endpoint` URL."}, {"id": "webhooks.create", "method": "POST", "path": "/projects/{project}/webhooks", "scope": "webhook:write", "summary": "Register a webhook on a GROUP: when a content item in that group fires the given `event`, its payload is POSTed to `endpoint`. Webhooks are group-scoped (they only fire for their own group's content). REQUIRED: `group` (group id or slug), `event` (one of create/update/delete/publish), `endpoint` (http(s) URL). OPTIONAL: `name`. Returns the new webhook `id`. `?dry_run=true` validates without writing.", "body_schema": {"type": "object", "required": ["group", "event", "endpoint"], "properties": {"group": {"type": "string", "description": "Group id (\"<project>-<slug>\") or bare slug."}, "event": {"type": "string", "description": "create | update | delete | publish"}, "endpoint": {"type": "string", "description": "http(s) URL to POST the payload to."}, "name": {"type": "string"}}}, "body_example": {"group": "76b1b6e5", "event": "publish", "endpoint": "https://example.com/hooks/cms", "name": "Rebuild site"}}, {"id": "webhooks.update", "method": "PATCH", "path": "/projects/{project}/webhooks/{webhook}", "scope": "webhook:write", "summary": "Update a webhook's `event`, `endpoint`, or `name` (by its full id from webhooks.list). `?dry_run=true` previews the merge.", "body_schema": {"type": "object", "properties": {"event": {"type": "string", "description": "create | update | delete | publish"}, "endpoint": {"type": "string"}, "name": {"type": "string"}}}, "body_example": {"endpoint": "https://example.com/hooks/cms-v2"}}, {"id": "webhooks.delete", "method": "DELETE", "path": "/projects/{project}/webhooks/{webhook}", "scope": "webhook:delete", "summary": "Delete a webhook by its full id (from webhooks.list). Idempotent \u2014 deleting a missing webhook is a successful no-op. `?dry_run=true` previews without deleting."}, {"id": "members.list", "method": "GET", "path": "/projects/{project}/members", "scope": "member:read", "summary": "List the accounts (members) with access to this project \u2014 email + name."}, {"id": "members.add", "method": "POST", "path": "/projects/{project}/members", "scope": "member:write", "summary": "Add an EXISTING CMS Caddie account as a member of this project. Confirm-first: without `?confirm=true` returns a preview identifying the account so a human can verify it's the right person. If no account exists for the email, returns `needs_invite` \u2014 use members.invite to create one. Idempotent (already-a-member is fine).", "body_schema": {"type": "object", "required": ["email"], "properties": {"email": {"type": "string"}}}, "body_example": {"email": "teammate@example.com"}}, {"id": "members.invite", "method": "POST", "path": "/projects/{project}/members/invite", "scope": "member:write", "summary": "Invite someone to this project. Existing account -> added directly. New email -> a CMS Caddie account is CREATED joined to this project; since login is passwordless (email + a 6-digit code), they can sign in immediately \u2014 no accept page. Confirm-first (`?confirm=true`). OPTIONAL: `first_name`, `last_name`.", "body_schema": {"type": "object", "required": ["email"], "properties": {"email": {"type": "string"}, "first_name": {"type": "string"}, "last_name": {"type": "string"}}}, "body_example": {"email": "newperson@example.com", "first_name": "Sam", "last_name": "Lee"}}, {"id": "members.remove", "method": "DELETE", "path": "/projects/{project}/members/{email}", "scope": "member:write", "summary": "Remove a member from this project. Confirm-first (`?confirm=true`). Strips the project from the account's `projects` AND from the user's live token grants (agents lose access to this project immediately). The account row is ALWAYS kept (they can be re-added later), even if this drops them to 0 projects."}, {"id": "apikeys.list", "method": "GET", "path": "/projects/{project}/api-keys", "scope": "apikey:read", "summary": "List the project's machine credentials as MASKED metadata (agent_id, name, scopes or read/write, status, dates, issuer) in three buckets: `agent_keys`, `connected_apps` (OAuth), and `integration_keys` (legacy third-party keys for the integration-* content API \u2014 these show `read`/`write` and a `key_last4` hint). Never returns the raw key, hash, or any secret material: a lost credential is re-issued with apikeys.rotate, never re-read."}, {"id": "audit.list", "method": "GET", "path": "/projects/{project}/audit", "scope": "apikey:read", "summary": "List recent agent-activity audit entries for the project (who did what, when \u2014 action, target, status, dry_run, timestamp), newest first. `?limit=N` (default 50, max 200)."}, {"id": "apikeys.create", "method": "POST", "path": "/projects/{project}/api-keys", "scope": "apikey:write", "summary": "Mint a machine credential for this project. ONE plane, TWO kinds \u2014 pick with `kind`:\n\u2022 `kind=\"agent\"` (default) \u2014 a scoped `cmsk_` key for THIS agent API. REQUIRED: `name`, `scopes` (a non-empty subset of the scopes YOU hold \u2014 you cannot grant more than you have).\n\u2022 `kind=\"integration\"` \u2014 a key for the LEGACY third-party content API at api.cmscaddie.com/integration/{group}, used by non-agent systems (websites, ETL). It carries `read`/`write` BOOLEANS instead of scopes and works ONLY on that API \u2014 never on this one. REQUIRED: `name`; optional `read` (default true), `write` (default false).\nReturns the credential EXACTLY ONCE either way. `?dry_run=true` validates without minting.", "body_schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}, "kind": {"type": "string", "enum": ["agent", "integration"], "default": "agent"}, "scopes": {"type": "array", "items": {"type": "string"}, "description": "kind=agent ONLY. Subset of your own project scopes."}, "read": {"type": "boolean", "description": "kind=integration ONLY (default true)."}, "write": {"type": "boolean", "description": "kind=integration ONLY (default false)."}}}, "body_example": {"name": "CI publisher", "scopes": ["content:read", "content:write", "content:publish"]}}, {"id": "apikeys.update", "method": "PATCH", "path": "/projects/{project}/api-keys/{agent_id}", "scope": "apikey:write", "summary": "Update a credential (by its agent_id from apikeys.list): `name`, `status` ('active'|'revoked' \u2014 revoke invalidates the key immediately), plus `scopes` for an agent key OR `read`/`write` for an integration key (the two are mutually exclusive; using the wrong one returns a 400 telling you which applies). Revoking an integration key also drops its read/write booleans, since the integration API authenticates on those and ignores `status`. `?dry_run=true` previews.", "body_schema": {"type": "object", "properties": {"name": {"type": "string"}, "status": {"type": "string", "description": "active | revoked"}, "scopes": {"type": "array", "items": {"type": "string"}, "description": "agent keys ONLY."}, "read": {"type": "boolean", "description": "integration keys ONLY."}, "write": {"type": "boolean", "description": "integration keys ONLY."}}}, "body_example": {"status": "revoked"}}, {"id": "apikeys.rotate", "method": "POST", "path": "/projects/{project}/api-keys/{agent_id}/rotate", "scope": "apikey:write", "summary": "Rotate a credential's secret (by agent_id) \u2014 works for both long-lived agent keys and integration keys: mints a new secret, the old one stops working immediately. This is also how you recover a LOST integration key, since keys are never re-readable. Returns the new credential EXACTLY ONCE. OAuth credentials can't be rotated here (they rotate via the OAuth flow). `?dry_run=true` previews."}, {"id": "apikeys.delete", "method": "DELETE", "path": "/projects/{project}/api-keys/{agent_id}", "scope": "apikey:delete", "summary": "Delete / revoke a credential (by agent_id). If it also covers OTHER projects, only this project's grant is removed (it keeps working elsewhere); otherwise the credential is deleted. Idempotent \u2014 a missing credential is a no-op. `?dry_run=true` previews."}, {"id": "backups.create", "method": "POST", "path": "/projects/{project}/backups", "scope": "backup:write", "summary": "Create a full backup snapshot of the project (all groups, content, assets, config) as a downloadable zip. Non-destructive \u2014 deletes nothing. Returns a `download_url`. Optional `?keep=N` prunes older snapshots to the newest N after creating."}, {"id": "backups.list", "method": "GET", "path": "/projects/{project}/backups", "scope": "backup:read", "summary": "List the project's backup snapshots (id, size, created, download_url), newest first."}, {"id": "backups.delete", "method": "DELETE", "path": "/projects/{project}/backups/{backup}", "scope": "backup:delete", "summary": "Delete a backup snapshot FILE (no live data). Returns a `confirm_url` \u2014 a human opens it, can download the snapshot one last time, then confirms. `?dry_run=true` previews. `{backup}` is the backup_id from backups.list."}, {"id": "content.search", "method": "GET", "path": "/projects/{project}/content/search", "scope": "content:read", "summary": "Full-text search across all content groups in a project.", "query": {"q": "string (required)", "limit": "int (optional, default 25)"}}, {"id": "groups.list", "method": "GET", "path": "/projects/{project}/groups", "scope": "content:read", "summary": "List all content groups in a project (name, slug, description). Use this first to resolve a human-friendly group name to its id."}, {"id": "groups.create", "method": "POST", "path": "/projects/{project}/groups", "scope": "group:write", "summary": "Create a content group \u2014 CMS Caddie's content model. A group defines the typed `fields` every content item in it carries, plus a `public` flag (whether items are served anonymously). Create the group BEFORE authoring content into it. REQUIRED: `name`. OPTIONAL: `description`, `public` (bool, default false), and `fields` (array). Each field is `{name, type, required?, example?}` \u2014 `type` is one of plain-text, html, number, image, video, audio, file, code, multiple-files, or `content::<group_id>` for a relation. A url-safe `slug` is derived from `name` unless you pass one. Returns the new group `id` (\"<project>-<suffix>\") and `slug`. `?dry_run=true` validates without writing.", "body_schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string", "description": "Human-readable group name."}, "description": {"type": "string"}, "public": {"type": "boolean", "description": "If true, items are served anonymously via the public endpoint. Default false."}, "fields": {"type": "array", "description": "Field definitions for the content model.", "items": {"type": "object", "required": ["name", "type"], "properties": {"name": {"type": "string"}, "type": {"type": "string", "description": "plain-text, html, number, image, video, audio, file, code, multiple-files, or content::<group_id>."}, "required": {"type": "boolean"}, "example": {"type": "string", "description": "Optional operator format hint shown to content authors."}, "slug": {"type": "string", "description": "Optional; derived from name if omitted."}}}}}}, "body_example": {"name": "Blog Posts", "description": "Long-form articles", "public": true, "fields": [{"name": "Title", "type": "plain-text", "required": true, "example": "Hello World"}, {"name": "Body", "type": "html"}, {"name": "Hero Image", "type": "image"}]}}, {"id": "groups.update", "method": "PATCH", "path": "/projects/{project}/groups/{group}", "scope": "group:write", "summary": "Update a content group: rename, edit `description`, toggle `public`, or edit the content model via `fields` (which REPLACES the field list). Adding fields or changing only name/description/public applies immediately. REMOVING or RETYPING a field that existing content items carry is confirm-first: without `?confirm=true` the call writes nothing and returns `{needs_confirm, field_changes, impact}` (impact = how many content items carry each affected field). Re-call with `?confirm=true` to apply. Content bytes are never rewritten \u2014 data under a removed field is preserved in the item but leaves the model. `{group}` accepts a bare slug or the full \"<project>-<slug>\" id. `?dry_run=true` previews the merge + impact.", "body_schema": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "public": {"type": "boolean"}, "fields": {"type": "array", "description": "Full replacement of the field definition list. Same item shape as groups.create.", "items": {"type": "object", "required": ["name", "type"], "properties": {"name": {"type": "string"}, "type": {"type": "string"}, "required": {"type": "boolean"}, "example": {"type": "string"}, "slug": {"type": "string"}}}}}}, "body_example": {"public": false, "fields": [{"name": "Title", "type": "plain-text", "required": true}, {"name": "Body", "type": "html"}, {"name": "Author", "type": "plain-text"}]}}, {"id": "groups.delete", "method": "DELETE", "path": "/projects/{project}/groups/{group}", "scope": "group:delete", "summary": "Request permanent deletion of a content group and ALL its content + webhooks. SAFE: deletes NOTHING directly \u2014 it builds a downloadable export bundle and returns a `confirm_url`; a human must open it, review the blast radius, and click confirm before anything is torn down. If the group is public, deleting it stops public serving of its content. `?dry_run=true` returns the blast radius only. Surface the `confirm_url` to the user."}, {"id": "projects.delete", "method": "DELETE", "path": "/projects/{project}", "scope": "project:delete", "summary": "Request permanent deletion of an ENTIRE project \u2014 every group, all content (including public), assets, webhooks, and credentials, plus detachment from all member accounts. SAFE: deletes NOTHING directly \u2014 builds an export bundle and returns a `confirm_url` a human must confirm. `?dry_run=true` returns the blast radius only. Surface the `confirm_url` to the user."}, {"id": "content.list", "method": "GET", "path": "/projects/{project}/groups/{group}/content", "scope": "content:read", "summary": "List content items in a group."}, {"id": "content.get", "method": "GET", "path": "/projects/{project}/groups/{group}/content/{id}", "scope": "content:read", "summary": "Retrieve a single content item. The `{id}` path arg accepts either the short suffix or the full compound id (\"<project>-<group>-<suffix>\") from content.create."}, {"id": "content.create", "method": "POST", "path": "/projects/{project}/groups/{group}/content", "scope": "content:write", "summary": "Create a content item. REQUIRED top-level keys: `published` (string, YYYYMMDDHHMM, UTC) and `fields` (object). Draft convention: to create an unpublished draft, set `published` to the far-future sentinel `999901010000` \u2014 this keeps the item out of public feeds and does NOT require the content:publish scope. Setting `published` to a past/now time publishes immediately and requires content:publish. The `fields` object is a dict keyed by field slug; each value MUST be a `{name, type, value}` dict (NOT a bare string or number). Per-type value rules: `plain-text`/`html`/`code` \u2192 value is a string; `number` \u2192 value is a STRING-encoded number (e.g. \"42\" or \"3.14\"), not a raw int/float; `image`/`video`/`audio`/`file` \u2192 value is the S3 URL returned by files.upload, and these types MAY include an additional sibling key `alt` (string) alongside name/type/value \u2014 `alt` is the documented exception to the {name,type,value} shape and applies only to media types; `multiple-files` \u2192 value is a JSON array of URL strings; `content::<group_id>` \u2192 value is the referenced item's id. PENDING-UPLOAD SHORTCUT: for `image` and `file` (and `\"@pending\"` entries inside `multiple-files` arrays), you MAY pass `value: \"@pending\"` instead of a real URL when the file would be too large to base64-encode in the tool call. The server swaps the sentinel for a canonical placeholder URL (the item is renderable immediately) and the response includes a `pending_uploads` array `[{field, type, upload_url, expires_in}]`. Surface each `upload_url` to the user \u2014 they drop the file via that page and it auto-binds back to the field. Use this whenever the bytes would blow your output budget. Always call groups.list first to discover the field slugs, names, and types for the target group. If a field in groups.list includes a non-empty `example` string, treat it as an operator-authored format hint: match the shape, units, and precision of the example (e.g. `\"$19.99\"` vs `\"19.99\"` vs `\"1999\"`; `\"2026-04-13\"` vs `\"04/13/2026\"`; `\"Acme, Inc.\"` vs `\"acme-inc\"`). Do NOT copy the example's literal value into the content \u2014 only mirror its format. If no `example` is present, fall back to the per-type defaults above and treat the value as freeform within the type's constraints.", "body_schema": {"type": "object", "required": ["published", "fields"], "properties": {"published": {"type": "string", "pattern": "^[0-9]{12}$", "description": "UTC publish timestamp, YYYYMMDDHHMM. Use '999901010000' for a draft (unpublished). A value <= now publishes immediately and requires the content:publish scope."}, "fields": {"type": "object", "description": "Dict keyed by field slug. Each value is a {name, type, value} dict. Media types (image/video/audio/file) may also include an `alt` string.", "additionalProperties": {"type": "object", "required": ["name", "type", "value"], "properties": {"name": {"type": "string", "description": "Human-readable field label (from groups.list)."}, "type": {"type": "string", "description": "Field type: plain-text, html, number, image, video, audio, file, code, multiple-files, content::<group_id>."}, "value": {"description": "The field value. Stringified for numbers; S3 URL for media; HTML string for html."}, "alt": {"type": "string", "description": "Alt text. ONLY valid on image/video/audio/file types."}}}}, "metadata": {"type": "object", "description": "Optional free-form metadata attached to the item."}}}, "body_example": {"published": "999901010000", "fields": {"title": {"name": "Title", "type": "plain-text", "value": "Hello world"}, "body": {"name": "Body", "type": "html", "value": "<p>Hello.</p>"}, "word-count": {"name": "Word Count", "type": "number", "value": "128"}, "thumbnail-image": {"name": "Thumbnail Image", "type": "image", "value": "@pending", "alt": "Description of the image"}}}}, {"id": "content.update", "method": "PATCH", "path": "/projects/{project}/groups/{group}/content/{id}", "scope": "content:write", "summary": "Merge-patch a content item. Same field schema as content.create \u2014 each entry under `fields` must be a `{name, type, value}` dict keyed by field slug. Number values are string-encoded (e.g. \"42\"). Media types (image/video/audio/file) may include `alt` alongside name/type/value. When updating one field, send the full `{name, type, value}` object for that field (not just the new value). Changing `published` to now-or-past (publishing, or un-drafting by moving off the '999901010000' draft sentinel to a real past/now timestamp) requires content:publish. Moving `published` BACK to '999901010000' reverts the item to an unpublished draft. The `{id}` path arg accepts either the short suffix or the full compound id (\"<project>-<group>-<suffix>\") returned by content.create.", "body_schema": {"type": "object", "properties": {"published": {"type": "string", "pattern": "^[0-9]{12}$", "description": "Optional. YYYYMMDDHHMM UTC. '999901010000' = draft. Past/now requires content:publish."}, "fields": {"type": "object", "additionalProperties": {"type": "object", "required": ["name", "type", "value"], "properties": {"name": {"type": "string"}, "type": {"type": "string"}, "value": {}, "alt": {"type": "string", "description": "ONLY valid on image/video/audio/file types."}}}}, "metadata": {"type": "object"}}}, "body_example": {"fields": {"title": {"name": "Title", "type": "plain-text", "value": "Updated headline"}}}}, {"id": "content.delete", "method": "DELETE", "path": "/projects/{project}/groups/{group}/content/{id}", "scope": "content:delete", "summary": "Delete a content item and fire the `delete` webhook. The `{id}` path arg accepts either the short suffix (e.g. \"abc12345\") or the full compound id returned by content.create (\"<project>-<group>-<suffix>\"); pass whichever you have. Likewise `{group}` accepts a bare slug or the compound \"<project>-<slug>\" form \u2014 groups.list returns both."}, {"id": "files.upload", "method": "POST", "path": "/projects/{project}/files", "scope": "files:write", "summary": "ADVANCED \u2014 DO NOT use this for images/files that will attach to a content field. Use the `@pending` value sentinel in content_create/content_update instead; that's the supported path and avoids your output budget entirely. Only use this tool when uploading bytes the agent itself produced AND the asset is NOT going to be set on a content field. Returns a presigned POST {upload, s3_key, expires_in, max_bytes}; the agent must then POST to *.s3.amazonaws.com directly.", "body_example": {"filename": "spec.pdf", "content_type": "application/pdf"}}, {"id": "files.upload_direct", "method": "POST", "path": "/projects/{project}/files/upload", "scope": "files:write", "summary": "ADVANCED \u2014 STOP and re-read this before calling. If you are about to upload an image or file that will be ATTACHED to a content field (image/file/multiple-files type), do NOT call this tool. Instead, pass `value: \"@pending\"` in your content_create/content_update call \u2014 the response will hand you an `upload_url` to give the user, and bytes never leave their browser. Calling this tool with base64 means YOU emit every byte in your tool args, which will exhaust your output budget on anything larger than ~50 KB and will hang the call. Only use this tool when the bytes are <4 MB AND the asset is NOT being set on a content field. Body field `data_base64` is the file's bytes base64-encoded. Returns {s3_key, url, size_bytes}. Hard cap: 4 MB raw (Lambda sync limit).", "body_example": {"filename": "spec.pdf", "content_type": "application/pdf", "data_base64": "<base64-encoded file bytes>"}}, {"id": "files.upload_init", "method": "POST", "path": "/projects/{project}/files/upload/init", "scope": "files:write", "summary": "ADVANCED. Same warning as files.upload_direct: if the file will attach to a content field, use `value: \"@pending\"` in content_create/content_update instead \u2014 bytes never leave the user's browser, you emit none of them. Chunking does NOT save your output budget; you still emit every chunk's base64 in your tool args. Open a chunked upload session for files larger than 4 MB without needing direct S3 egress. Returns {upload_id, s3_key, max_part_bytes, max_parts, expires_in}. Follow with N calls to files.upload_part (part_number 1..N, each <= max_part_bytes raw), then files.upload_complete. Session expires in 1 hour; abandoned sessions are cleaned up automatically.", "body_example": {"filename": "video.mp4", "content_type": "video/mp4"}}, {"id": "files.upload_part", "method": "POST", "path": "/projects/{project}/files/upload/part", "scope": "files:write", "summary": "Upload one chunk of a chunked session. `part_number` is 1-indexed and defines the chunk's position in the final file; resending the same part_number overwrites that chunk (idempotent retry). `data_base64` is the raw chunk bytes base64-encoded; raw size must be <= max_part_bytes (4 MB) reported by files.upload_init.", "body_example": {"upload_id": "<from files.upload_init>", "part_number": 1, "data_base64": "<base64-encoded chunk bytes>"}}, {"id": "files.upload_complete", "method": "POST", "path": "/projects/{project}/files/upload/complete", "scope": "files:write", "summary": "Finalize a chunked upload session. `total_parts` is the count of chunks you sent (parts 1..total_parts must all be present). Returns {s3_key, url, size_bytes, parts}. After success, reference s3_key/url in content writes the same way as files.upload_direct.", "body_example": {"upload_id": "<from files.upload_init>", "total_parts": 7}}, {"id": "files.delete", "method": "DELETE", "path": "/projects/{project}/files", "scope": "files:delete", "summary": "Delete an agent-uploaded file under this project. s3_key must begin with 'assets/{project}/agents/' (the literal `agents/` segment marks agent uploads and keeps dashboard-uploaded assets at 'assets/{project}/<file_id>/' un-deletable).", "body_example": {"s3_key": "assets/<project>/agents/<file_id>/spec.pdf"}}], "mcp": {"endpoint": "https://mcp.cmscaddie.com", "transport": "streamable-http", "description": "MCP-compatible server exposing every route above as a tool. Register this URL once with your MCP client (Claude Desktop, Claude Code, etc.) and new routes will appear automatically as we add them."}}