{"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."}, "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": "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": "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."}}