{"openapi": "3.1.0", "info": {"title": "CMS Caddie Agent API", "version": "1.0.0", "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."}, "servers": [{"url": "https://agents.cmscaddie.com"}], "components": {"securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "cmsk_*"}}}, "security": [{"bearerAuth": []}], "paths": {"/projects/{project}/content/search": {"get": {"operationId": "content.search", "summary": "Full-text search across all content groups in a project.", "security": [{"bearerAuth": ["content:read"]}], "x-required-scope": "content:read", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}, "parameters": [{"name": "q", "in": "query", "description": "string (required)", "schema": {"type": "string"}}, {"name": "limit", "in": "query", "description": "int (optional, default 25)", "schema": {"type": "string"}}]}}, "/projects/{project}/groups": {"get": {"operationId": "groups.list", "summary": "List all content groups in a project (name, slug, description). Use this first to resolve a human-friendly group name to its id.", "security": [{"bearerAuth": ["content:read"]}], "x-required-scope": "content:read", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}}}, "/projects/{project}/groups/{group}/content": {"get": {"operationId": "content.list", "summary": "List content items in a group.", "security": [{"bearerAuth": ["content:read"]}], "x-required-scope": "content:read", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}}, "post": {"operationId": "content.create", "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.", "security": [{"bearerAuth": ["content:write"]}], "x-required-scope": "content:write", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}, "requestBody": {"required": true, "content": {"application/json": {"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"}}}}}}}}, "/projects/{project}/groups/{group}/content/{id}": {"get": {"operationId": "content.get", "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.", "security": [{"bearerAuth": ["content:read"]}], "x-required-scope": "content:read", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}}, "patch": {"operationId": "content.update", "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.", "security": [{"bearerAuth": ["content:write"]}], "x-required-scope": "content:write", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}, "requestBody": {"required": true, "content": {"application/json": {"example": {"fields": {"title": {"name": "Title", "type": "plain-text", "value": "Updated headline"}}}}}}}, "delete": {"operationId": "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.", "security": [{"bearerAuth": ["content:delete"]}], "x-required-scope": "content:delete", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}}}, "/projects/{project}/files": {"post": {"operationId": "files.upload", "summary": "Request a presigned POST for uploading a file directly to S3. Returns {upload, s3_key, expires_in, max_bytes}.", "security": [{"bearerAuth": ["files:write"]}], "x-required-scope": "files:write", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}, "requestBody": {"required": true, "content": {"application/json": {"example": {"filename": "spec.pdf", "content_type": "application/pdf"}}}}}, "delete": {"operationId": "files.delete", "summary": "Delete a file under this project's agents/ prefix. s3_key must begin with 'agents/{project}/'.", "security": [{"bearerAuth": ["files:delete"]}], "x-required-scope": "files:delete", "responses": {"200": {"description": "Success envelope ({data, request_id})"}, "400": {"description": "bad_request"}, "401": {"description": "unauthorized"}, "403": {"description": "forbidden (missing scope or wrong project)"}, "404": {"description": "not_found"}, "409": {"description": "conflict (idempotency replay with different body)"}}, "requestBody": {"required": true, "content": {"application/json": {"example": {"s3_key": "agents/<project>/<agent>/<file_id>/spec.pdf"}}}}}}}, "x-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."}, "x-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"}}}}