{"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. 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": "https://cms-caddy.s3.us-east-2.amazonaws.com/agents/<project>/<agent>/<file_id>/thumb.jpg", "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": "Request a presigned POST for uploading a file directly to S3. Returns {upload, s3_key, expires_in, max_bytes}.", "body_example": {"filename": "spec.pdf", "content_type": "application/pdf"}}, {"id": "files.delete", "method": "DELETE", "path": "/projects/{project}/files", "scope": "files:delete", "summary": "Delete a file under this project's agents/ prefix. s3_key must begin with 'agents/{project}/'.", "body_example": {"s3_key": "agents/<project>/<agent>/<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."}}