{"components":{"schemas":{"Error":{"properties":{"error":{"type":"string"}},"type":"object"},"GenerateResult":{"properties":{"items":{"description":"Non-empty on success. Text / data-file sources return 1\u20132 items\n(depending on `include_alt` and whether an alternative was\nproposed); the image source always returns a single item.\n","items":{"properties":{"component":{"description":"Chart component used (`bar`, `line`, `pie`, \u2026).","type":"string"},"id":{"description":"Canonical chart identity \u2014 uuid7 hex (32 lowercase chars).\nSame value as Mongo `charts._id`, CR `chart_id`, SVG\n`chart-meta.id`, and the S3 object leaf. Routes that name\n*another* chart across the wire call it `chart_id`; inside\nan item envelope it is `id`.\n","type":"string"},"modality":{"description":"Always `chart` on chart generate routes.\n","enum":["chart"],"type":"string"},"role":{"description":"Which branch of the router produced this chart.","enum":["primary","alternative"],"type":"string"},"subtitle":{"description":"Short takeaway (\u226410 words) off the IR headline; handled like `title`.","type":"string"},"title":{"description":"Chart title lifted off the normalized IR. NOT baked into the\nSVG \u2014 the consumer draws it as a separate text layer. Empty\nwhen the model produced no title.\n","type":"string"},"url":{"description":"Public S3 URL of the generated SVG.","format":"uri","type":"string"}},"type":"object"},"type":"array"},"kind":{"description":"Always `chart` on chart generate routes. Additive vs the older\n`{items}`-only shape.\n","enum":["chart"],"type":"string"},"warnings":{"description":"Present when the request carried ignored fields (e.g. `slide_data`\non generate). Each entry is a human-readable hint for the caller.\n","items":{"type":"string"},"type":"array"}},"type":"object"},"HealthResponse":{"properties":{"version":{"description":"Service release from VERSION.md.","type":"string"}},"required":["version"],"type":"object"},"RejectError":{"properties":{"error":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"SlideData":{"description":"Slide narration context for `POST /api/v1/chart/animate`. When it\ncarries per-word `word_boundaries`, the engine re-times the chart's\nbaked animation to the narration. Accepted on generate routes for\nforward compatibility but ignored there \u2014 the response includes a\n`warnings` entry pointing callers at `/animate`.\n","properties":{"duration_ms":{"description":"Final slide audio duration in ms.","type":"integer"},"language":{"description":"Narration language.","type":"string"},"text":{"description":"Narration with SSML stripped (the same string sent to TTS).","type":"string"},"word_boundaries":{"description":"Word-level speech timing from the slide audio start.","items":{"properties":{"offset":{"description":"Word start time in ms from slide audio start.","type":"integer"},"text":{"description":"The spoken word.","type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"StyleObject":{"description":"Editor-supplied visual overrides, applied deterministically AFTER the\nLLM produces the IR. All fields are optional and invalid values are\nignored (the engine/LLM defaults stand). `theme` and `font` are\naccepted for forward-compatibility but not yet consumed by the engine.\n","properties":{"accent_color":{"description":"Primary accent `#rrggbb` hex (a comma-separated list is tolerated).","type":"string"},"chart_background":{"description":"Keyword (`light`/`dark`/`transparent`) or `#rrggbb` hex for the chart pane.","type":"string"},"font":{"description":"Reserved \u2014 accepted but not yet applied by the engine.","type":"string"},"text_color":{"description":"Chrome text `#rrggbb` hex (axis / legend / title).","type":"string"},"theme":{"description":"Reserved \u2014 accepted but not yet applied by the engine.","type":"string"}},"type":"object"},"TextCR":{"description":"Finalized research IR `CR` (`schema_version` + `ar`). Canonical:\nhttps://github.com/elai-io/research-ai-text-animation/blob/main/docs/IR.md\nResting glyph colour is **not** in `CR` \u2014 it lives on fabric /\nresponse `style.fill`.\n","properties":{"ar":{"properties":{"category":{"type":"string"},"effects":{"description":"Exactly one effect in v1.","items":{"properties":{"enabled":{"default":true,"type":"boolean"},"id":{"description":"Platform-minted (`fx-1`).","type":"string"},"params":{"additionalProperties":true,"type":"object"},"type":{"type":"string"}},"required":["id","type","params"],"type":"object"},"maxItems":1,"minItems":1,"type":"array"},"playback":{"properties":{"delay_ms":{"default":0,"type":"integer"},"duration_ms":{"nullable":true,"type":"integer"},"loop":{"description":"Finalized playback loop. Server may rewrite via\n`NO_LOOP_MODE` (`LAST_FRAME`\u2192`once`, `LOOP`\u2192`loop`,\n`DISAPPEAR`\u2192`disappear`).\n","enum":["once","loop","disappear"],"type":"string"},"speed":{"description":"Host slider scale used by TextAnim (`very_slow`=1, `slow`=2,\n`normal`=3, `fast`=5). LLM Input still uses the named enum;\nfinalize maps names to this number.\n","maximum":5,"minimum":1,"type":"integer"},"start_state":{"enum":["default","autoplay"],"type":"string"}},"type":"object"},"preset_id":{"type":"string"}},"required":["effects","playback"],"type":"object"},"schema_version":{"enum":["1"],"type":"string"},"scope":{"description":"Optional intra-text scope (`content_ref`, `selection`).","type":"object"}},"required":["schema_version","ar"],"type":"object"},"TextEditResult":{"description":"Same item envelope as generate, plus the echoed `element_id`.","properties":{"element_id":{"description":"Opaque canvas element id from the request (number or string).","oneOf":[{"type":"string"},{"type":"integer"}]},"items":{"items":{"properties":{"component":{"enum":["text"],"type":"string"},"cr":{"$ref":"#/components/schemas/TextCR"},"id":{"type":"string"},"role":{"enum":["primary"],"type":"string"},"style":{"$ref":"#/components/schemas/TextStyleResponseObject"},"text":{"type":"string"}},"required":["id","role","component","text","cr"],"type":"object"},"minItems":1,"type":"array"}},"required":["element_id","items"],"type":"object"},"TextGenerateResult":{"properties":{"items":{"description":"Always one primary item on the text generate path.","items":{"properties":{"component":{"enum":["text"],"type":"string"},"cr":{"$ref":"#/components/schemas/TextCR"},"id":{"description":"`t3-<uuid7-hex>` (wire / optional meta.animationId).","type":"string"},"role":{"enum":["primary"],"type":"string"},"style":{"$ref":"#/components/schemas/TextStyleResponseObject"},"text":{"description":"Request copy if non-empty; otherwise the fixed stub\n`\"Your text\"`. Lives on fabric `animated-text.text`, not in `cr`.\n","type":"string"}},"required":["id","role","component","text","cr"],"type":"object"},"minItems":1,"type":"array"}},"type":"object"},"TextStyleObject":{"description":"Editor typography prefs sent on the **request** `style` field\n(research `snake_case`, mirrors `Input.sr`). `fill_color` is the\nresting glyph colour (research color model). The response echoes\nthese back mapped to fabric `camelCase` \u2014 see\n`TextStyleResponseObject`.\n","properties":{"fill_color":{"description":"Glyph colour (`#rrggbb`) \u2192 response `style.fill`.","type":"string"},"font_family":{"type":"string"},"font_size_px":{"type":"number"},"font_weight":{"description":"Numeric weight or CSS keyword.","oneOf":[{"type":"number"},{"type":"string"}]},"line_height":{"type":"number"},"text_align":{"enum":["left","center","right","justify"],"type":"string"}},"type":"object"},"TextStyleResponseObject":{"description":"Typography on `items[].style` in the generate **response** \u2014\nfabric/canvas-compatible `camelCase`, mapped once server-side\n(`text_engine.finalize._merge_style`) from the request `style`\n(`TextStyleObject`) merged with the LLM's `Input.sr` hints. Not\nstored inside `cr`. `fill` is the resting glyph colour, defaulting\nto `#000000` (readable on the editor's light slide canvas).\n","properties":{"fill":{"description":"Glyph colour (`#rrggbb`) for fabric `fill`.","type":"string"},"fontFamily":{"type":"string"},"fontSize":{"type":"number"},"fontWeight":{"description":"Numeric weight or CSS keyword.","oneOf":[{"type":"number"},{"type":"string"}]},"lineHeight":{"type":"number"},"textAlign":{"enum":["left","center","right","justify"],"type":"string"}},"type":"object"}},"securitySchemes":{"elaiToken":{"description":"Shared secret matching `AI_CHARTS_API_TOKEN` on the service. Unset disables the gate.","in":"header","name":"X-Elai-Token","type":"apiKey"},"elaiUserId":{"description":"The calling user, set by the backend proxy from the authenticated\nsession and never forwarded from a client request. Required on every\nchart route; there is no disabled mode, because the owner scopes the\nartifact key and cannot be defaulted.\n","in":"header","name":"X-Elai-User-Id","type":"apiKey"}}},"info":{"description":"LLM-backed SVG chart generation (`chart_engine`) and text-animation\ngeneration (`text_engine`). Chart generate and chart edit each write a\nrow into one MongoDB request log, tagged by `kind`; it is write-only\ntelemetry and has no read endpoint.\n\nText animation: `POST /api/v1/text/generate/from-text` returns a finalized\nresearch IR `CR` item envelope. `POST /api/v1/text/edit` is a stateless\nchat-edit of an existing CR.\n\nThe `/api/v1/chart/*` and `/api/v1/text/*` routes are gated by\n`X-Elai-Token`. The components proxy (`/components/<key>`) is\nintentionally open so `<img src>` tags work without header control.\n\nEvery `/api/v1/chart/*` route additionally requires `X-Elai-User-Id`,\nwhich the backend proxy sets from the authenticated session. It is the\nonly source of caller identity: a `user_id` in a request body is ignored,\nand a chart is owned by the user whose header minted it.\n","title":"AI Charts","version":"1.4.0"},"openapi":"3.0.3","paths":{"/api/v1/chart/animate":{"post":{"description":"Runs the narration-alignment stage over an already-baked chart so\nits reveal / highlights / out land on the spoken words \u2014 without\nre-running the producer. The `chart_id` in the body names the immutable\nsource; on success a fresh `chart_id` + `url` are minted with the\nre-timed schedule and the source CR and S3 object are left untouched\n(render pinning). Alignment is best-effort: when the narration cannot\nre-time the chart (no usable `word_boundaries`, no CSS animation, the\ndetection pass finds nothing) the call returns `422` and nothing is\nwritten. The source must have been generated with a baked animated\nartifact (a `charts` document exists) \u2014 legacy / unknown ids return\n`404`. Semantic markup is content-addressed by narration timing, the\nplanner target catalog, configured model, and prompt version. A matching\nrequest reuses stored markup and skips detection; a cache miss detects\nfresh markup and persists its key on the new chart fork. Alignment-cache\nfailures do not prevent a successful fork, but leave `markup_key` null.\n","requestBody":{"content":{"application/json":{"schema":{"properties":{"chart_id":{"description":"The chart's canonical id (must match a stored CR).","type":"string"},"slide_data":{"$ref":"#/components/schemas/SlideData"}},"required":["chart_id","slide_data"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"applied":{"description":"Always `true` on a 200 (the schedule was re-timed).","type":"boolean"},"chart_id":{"description":"The **new** chart minted for the re-timed artifact (a\nfresh uuid7 hex). Differs from the request's `chart_id`.\n","type":"string"},"source_chart_id":{"description":"The source chart this alignment forked from (equals the\nrequest body's `chart_id`).\n","type":"string"},"title":{"description":"Chart title lifted off CR semantics, if any.","nullable":true,"type":"string"},"url":{"description":"Public URL of the new baked SVG (a new immutable key; the\nsource object's URL is unchanged).\n","format":"uri","type":"string"}},"type":"object"}}},"description":"Alignment applied; a new forked chart was written (source untouched)."},"400":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"Missing `chart_id`, or `slide_data` has no `word_boundaries`."},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"Missing or invalid `X-Elai-Token` / `X-Elai-User-Id` header."},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"No editable `charts` record for `chart_id`."},"422":{"content":{"application/json":{"schema":{"properties":{"applied":{"type":"boolean"},"error":{"type":"string"},"reason":{"type":"string"}},"type":"object"}}},"description":"Alignment declined to re-time the chart for this narration (e.g. the\nchart baked no CSS animation, or no narration states were detected).\nBody carries a human-readable `reason` and `applied: false`.\n"},"503":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"MongoDB unreachable \u2014 the chart store could not be read/written."}},"security":[{"elaiToken":[],"elaiUserId":[]}],"summary":"Re-time a stored chart to narration as a copy-on-write fork.","tags":["charts"]}},"/api/v1/chart/edit":{"post":{"description":"One-shot edit against a stored chart. A router LLM classifies the\nmessage into an ordered list of intents; each unique supported\ndomain (`style.color`, `chart_type`, `data`, `data_shape`,\n`settings`) runs a specialist and applies through the same\ndeterministic CCR sync path. Several domains in one message apply\nin router order as a single copy-on-write fork, or not at all.\nA `200` with `changed: true` is a **copy-on-write** fork with a new\n`chart_id` + `url` (the source stays pinned). A `200` with\n`changed: false` means the chart already holds the requested state.\nAnimation requests return `422` `edit_not_supported`. Unrelated or\nclosed-surface messages return `422` `not_an_edit_request`.\nSpecialist/CCR/re-render failures return `422` `edit_failed`.\nEvery `4xx` from the edit pipeline also carries `actionable`\n(`true` only for `ambiguous_target`). The request is self-contained:\nthe service does not read prior turns. Ownership is checked against\nthe chart's stored S3 scope. A missing chart and a chart owned by\nsomeone else are indistinguishable (`404` `chart_not_found`).\n","requestBody":{"content":{"application/json":{"schema":{"properties":{"chart_id":{"description":"Source chart id (must have an editable `charts` record).","type":"string"},"message":{"description":"Self-contained edit prompt. May name several supported\ndomains; they apply together as one fork or not at all.\n","type":"string"}},"required":["chart_id","message"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"changed":{"description":"`true` when a new chart was minted; `false` when the\nchart already held the requested state.\n","type":"boolean"},"chart_id":{"description":"The **new** forked chart id (present when `changed` is true).","type":"string"},"color_catalog":{"description":"Authoritative colour slots for editor swatches.","items":{"properties":{"hex":{"type":"string"},"label":{"type":"string"},"role":{"type":"string"},"token":{"type":"string"}},"type":"object"},"type":"array"},"intents":{"description":"Ordered router intents for the message.","items":{"properties":{"domain":{"enum":["style.color","animation","data","data_shape","chart_type","settings","reject"],"type":"string"},"raw_phrase":{"type":"string"},"reply":{"type":"string"}},"type":"object"},"type":"array"},"reply":{"description":"Truthful reply shown after the fork is written (or a\nno-op is confirmed). Concatenates each domain's applied\nor no-op summary when several domains apply.\n","type":"string"},"source_chart_id":{"description":"The source chart this request forked from.","type":"string"},"url":{"description":"Public URL of the new baked SVG (present when `changed` is true).","format":"uri","type":"string"}},"required":["changed","reply","intents"],"type":"object"}}},"description":"The chart is in the requested state. `changed` says whether a new\nversion was minted. Retrying a successful edit mints a second fork.\n"},"400":{"content":{"application/json":{"schema":{"properties":{"code":{"enum":["invalid_request"],"type":"string"},"error":{"type":"string"}},"type":"object"}}},"description":"Missing `chart_id` or `message`,\nor a non-object body (`code: invalid_request`).\n"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` / `X-Elai-User-Id` header."},"404":{"content":{"application/json":{"schema":{"properties":{"actionable":{"description":"Always `false` for this code (dead end).","type":"boolean"},"code":{"enum":["chart_not_found"],"type":"string"},"error":{"type":"string"}},"required":["error","code","actionable"],"type":"object"}}},"description":"No editable chart for `chart_id`, or the chart is owned by\nanother user. The two cases are byte-identical so chart ids\ncannot be probed. Body includes `actionable: false`.\n"},"422":{"content":{"application/json":{"schema":{"properties":{"actionable":{"description":"`true` when the caller has a concrete next step\n(`ambiguous_target`); `false` for a dead end this turn.\n","type":"boolean"},"candidates":{"description":"Slot labels offered on `ambiguous_target`.","items":{"type":"string"},"type":"array"},"code":{"enum":["ambiguous_target","edit_not_supported","not_an_edit_request","edit_failed"],"type":"string"},"color_catalog":{"items":{"type":"object"},"type":"array"},"error":{"description":"Self-contained instruction or decline to show the user.","type":"string"},"intents":{"items":{"type":"object"},"type":"array"}},"required":["error","code","actionable"],"type":"object"}}},"description":"The chart was not changed. `code` is `ambiguous_target`\n(`actionable: true`), `edit_not_supported`, `not_an_edit_request`,\nor `edit_failed` (all `actionable: false`). `actionable` is not\nderivable from `code` or status alone \u2014 a single `422` is shared\nby both buckets.\n"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Edit pipeline error."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"MongoDB unreachable \u2014 the chart store could not be read."}},"security":[{"elaiToken":[],"elaiUserId":[]}],"summary":"Apply one chart-edit prompt (copy-on-write when the chart changes).","tags":["charts"]}},"/api/v1/chart/generate/from-file":{"post":{"description":"`multipart/form-data` with a `file` part (CSV / TSV / Excel `.xlsx` /\nJSON / NDJSON) and a JSON `data` part carrying the common generation\nfields. The file is normalised to CSV text before the engine sees it;\nan unparseable file is a `422` rejection.\n","requestBody":{"content":{"multipart/form-data":{"schema":{"properties":{"data":{"description":"JSON-encoded object with the optional `prompt` (free-text\nguidance), `include_alt`, and `style` (see `StyleObject`).\n","type":"string"},"file":{"description":"The data file (CSV / TSV / Excel / JSON / NDJSON).","format":"binary","type":"string"}},"required":["file","data"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateResult"}}},"description":"JSON envelope `{ kind: \"chart\", items: [...] }` with\n`modality: \"chart\"` on each item (1\u20132 charts).\n"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing `file` or malformed `data`."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` / `X-Elai-User-Id` header."},"413":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Uploaded file exceeds `AI_CHARTS_MAX_FILE_BYTES`."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectError"}}},"description":"File could not be parsed into a table, or rejected as not chartable."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Pipeline failure \u2014 no SVG produced, or the engine crashed."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"MongoDB unreachable \u2014 we never silently drop a row the editor expects."}},"security":[{"elaiToken":[],"elaiUserId":[]}],"summary":"Generate SVG chart(s) from an uploaded data file","tags":["generate"]}},"/api/v1/chart/generate/from-image":{"post":{"description":"`multipart/form-data` with a `file` part (SVG export or raster\nscreenshot / photo of a chart) and a JSON `data` part carrying the\ncommon generation fields. The image contract reproduces a SINGLE\nsource chart, so `include_alt` has no effect and the response always\ncarries exactly one item.\n","requestBody":{"content":{"multipart/form-data":{"schema":{"properties":{"data":{"description":"JSON-encoded object with the optional `prompt` (free-text\nguidance) and `style` (see `StyleObject`).\n","type":"string"},"file":{"description":"The chart image (SVG, PNG, JPEG, \u2026).","format":"binary","type":"string"}},"required":["file","data"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateResult"}}},"description":"JSON envelope `{ kind: \"chart\", items: [...] }` with a single\nchart item (`modality: \"chart\"`).\n"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing `file` or malformed `data`."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` / `X-Elai-User-Id` header."},"413":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Uploaded image exceeds `AI_CHARTS_MAX_IMAGE_BYTES`."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectError"}}},"description":"Image rejected as not a chart, or could not be interpreted."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Pipeline failure \u2014 no SVG produced, or the engine crashed."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"MongoDB unreachable \u2014 we never silently drop a row the editor expects."}},"security":[{"elaiToken":[],"elaiUserId":[]}],"summary":"Generate an SVG chart from an uploaded chart image","tags":["generate"]}},"/api/v1/chart/generate/from-text":{"post":{"description":"Runs the chart_engine pipeline (LLM \u2192 IR \u2192 Vega SVG) from a free-text\nprompt and/or slide body, returning the public S3 URL(s) of the\ngenerated chart(s). At least one of `prompt` / `text` must be\nnon-empty; both are concatenated for the engine. Charts are baked with\ntheir default animation; for narration-aligned timing, call\n`POST /api/v1/chart/animate` after generation. POST-only and called\nserver-to-server (the `X-Elai-Token` is never shipped to the browser).\n","requestBody":{"content":{"application/json":{"schema":{"properties":{"include_alt":{"default":true,"description":"When `true` (default) the pipeline produces BOTH the primary\nand the alternative component (up to 2 items). When `false`\nonly the primary ships. Truthy `1|true|yes|on`; falsy\n`0|false|no|off`.\n","type":"boolean"},"prompt":{"description":"Natural-language instruction for the chart. Optional if `text` is set.","type":"string"},"slide_data":{"allOf":[{"$ref":"#/components/schemas/SlideData"}],"description":"Accepted for forward compatibility but **ignored** at\ngenerate; the response includes a `warnings` entry. Use\n`POST /api/v1/chart/animate` for narration-aligned timing.\n"},"style":{"$ref":"#/components/schemas/StyleObject"},"text":{"description":"Slide body / source text to chart. Optional if `prompt` is set.","type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerateResult"}}},"description":"JSON envelope `{ kind: \"chart\", items: [...] }` with\n`modality: \"chart\"` on each item (1\u20132 charts).\n"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Neither `prompt` nor `text` was supplied."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` / `X-Elai-User-Id` header."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectError"}}},"description":"Upstream classifier rejected the request as not a chart query."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Pipeline failure \u2014 no SVG produced, or the engine crashed."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"MongoDB unreachable \u2014 we never silently drop a row the editor expects."}},"security":[{"elaiToken":[],"elaiUserId":[]}],"summary":"Generate SVG chart(s) from free text","tags":["generate"]}},"/api/v1/chart/sync":{"post":{"description":"Applies a sparse, whitelisted Change Request (CCR) to the source\nchart's stored composed representation (CR) and writes the patched\nbaked SVG as a **new** chart (copy-on-write). The `chart_id` in the body\nnames the immutable source; on success a fresh `chart_id` + `url`\nare minted and the source CR and S3 object are left untouched. This lets\nthe editor keep editing while a long-running render stays pinned to the\nid it was dispatched with. The patch rewrites CSS custom properties +\nthe embedded `chart-meta` (including the new id) \u2014 no re-render for v1\nsurfaces. v1 edits cover explicit `palette` / `background` /\n`text_color` and animation feel levers only. The source must have been\ngenerated with a baked animated artifact (a `charts` document exists)\n\u2014 legacy / unknown ids return `404`. The source is identified by\n`chart_id` in the request body only.\n","requestBody":{"content":{"application/json":{"schema":{"properties":{"changes":{"description":"Sparse, layer-grouped patch. Only whitelisted groups\nappear; missing groups mean unchanged. Forbidden surfaces\n(`data`, `semantics`, `settings`, `component`, raw\n`meta` / `svg`) are rejected at the parse boundary.\n","properties":{"animation":{"description":"AR patch (in-place). Keys: `speed`, `target_total_ms`,\n`stagger_ms`, `step_ms`, `hold_ms`, `pulse_dur_ms`,\n`start_state`, `reveal_axis`, `schedule` (with\n`schedule.track` replacing the track array wholesale).\nNot editable: `preset`.\n","type":"object"},"style":{"description":"SR patch (in-place). Keys: `palette` (non-empty list,\nsame length as artifact colour tokens), `background`,\n`text_color`. Not editable: `accent_color`, `theme`,\n`font` \u2014 send an explicit `palette` instead.\n","type":"object"}},"type":"object"},"chart_id":{"description":"The chart's canonical id (must match a stored CR).","type":"string"},"client_context":{"description":"Optional UI telemetry (never affects the patch).","properties":{"interaction_id":{"type":"string"},"reason":{"type":"string"},"source":{"type":"string"}},"type":"object"},"schema_version":{"description":"CCR contract version (v1 = `\"1\"`).","type":"string"}},"required":["schema_version","chart_id","changes"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"chart_id":{"description":"The **new** chart minted for this edit (a fresh uuid7\nhex). Differs from the request's `chart_id`.\n","type":"string"},"rendered":{"description":"Always `false` for v1 edits (geometry unchanged).\n","type":"boolean"},"source_chart_id":{"description":"The source chart this edit forked from (equals the\nrequest body's `chart_id`).\n","type":"string"},"title":{"description":"Chart title lifted off CR semantics, if any.","nullable":true,"type":"string"},"url":{"description":"Public URL of the new baked SVG (a new immutable key; the\nsource object's URL is unchanged).\n","format":"uri","type":"string"}},"type":"object"}}},"description":"CCR applied; a new forked chart was written (source untouched)."},"400":{"content":{"application/json":{"schema":{"properties":{"code":{"type":"string"},"error":{"type":"string"},"field":{"type":"string"}},"type":"object"}}},"description":"Malformed or rejected CCR. Body carries a machine-readable `code`\n(e.g. `ccr_forbidden_surface`,\n`ccr_empty_changes`, `ccr_invalid_field`, `ccr_unknown_target_ref`,\n`ccr_null_not_allowed`, `ccr_structural_change`) and an optional\n`field` path.\n"},"401":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"Missing or invalid `X-Elai-Token` / `X-Elai-User-Id` header."},"404":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"No editable `charts` record for `chart_id`."},"503":{"content":{"application/json":{"schema":{"properties":{"error":{"type":"string"}},"type":"object"}}},"description":"MongoDB unreachable \u2014 the chart store could not be read/written."}},"security":[{"elaiToken":[],"elaiUserId":[]}],"summary":"Sync a stored chart (apply a CCR) as a copy-on-write fork.","tags":["charts"]}},"/api/v1/components/{key}":{"get":{"description":"Read-through Redis \u2192 S3. Not gated by `X-Elai-Token` so `<img src>`\ntags work; the uuid7 key shape is the guard.\n","parameters":[{"description":"Full S3 key, e.g. `projects/<v>/<u>/components/<uuid7>.svg`.","in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"image/svg+xml":{"schema":{"type":"string"}}},"description":"SVG bytes. `X-Cache` header is `HIT` or `MISS`."},"400":{"description":"Key shape doesn't match the upload pattern."},"404":{"description":"No object at the given key."},"500":{"description":"S3 fetch failed for non-404 reasons."}},"summary":"Stream a stored SVG from the artifact bucket.","tags":["components"]}},"/api/v1/debug/chart/{chart_id}":{"get":{"description":"Read-only inspection endpoint for an admin debug UI. Returns the\ncomposed representation (`cr`) plus lineage metadata for the chart.\nNot part of the editing contract; caller-level authorization (admin\naccounts only) is enforced by the backend proxy, this service only\nrequires `X-Elai-Token`.\n","parameters":[{"description":"Chart id whose CR to return.","in":"path","name":"chart_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"chart_id":{"type":"string"},"cr":{"description":"Composed representation (DR / CS / SR / AR).","type":"object"},"created_at":{"format":"date-time","nullable":true,"type":"string"},"markup":{"description":"Validated semantic periods used to derive the chart's committed schedule.","items":{"properties":{"end_s":{"format":"float","type":"number"},"start_s":{"format":"float","type":"number"},"state":{"description":"Animation state. Highlight periods address a planner target by `target_id`.","enum":["in","out","highlight"],"type":"string"},"target_id":{"description":"Opaque AR target id. Required for `highlight` and null for global states.","nullable":true,"type":"string"},"triggers":{"items":{"properties":{"end_s":{"format":"float","type":"number"},"start_s":{"format":"float","type":"number"},"type":{"enum":["name","value","implicit"],"type":"string"}},"required":["type"],"type":"object"},"type":"array"}},"required":["state","start_s","end_s"],"type":"object"},"nullable":true,"type":"array"},"markup_key":{"description":"Content address of the narration-alignment markup, or null when the chart is not aligned.","nullable":true,"type":"string"},"parent_chart_id":{"nullable":true,"type":"string"},"s3_key":{"type":"string"}},"type":"object"}}},"description":"The chart's CR (state-of-record) plus lineage metadata."},"401":{"description":"Missing or bad `X-Elai-Token`."},"404":{"description":"No charts document for the id."},"503":{"description":"Charts or semantic markup persistence (MongoDB) unavailable."}},"security":[{"elaiToken":[],"elaiUserId":[]}],"summary":"Admin/debug \u2014 return the stored CR for a chart id.","tags":["debug"]}},"/api/v1/health":{"get":{"description":"Returns 200 when the service is up and MongoDB accepts a ping (same\n`AI_CHARTS_MONGO_URI` as history/charts). Not gated by `X-Elai-Token`.\n","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}},"description":"Service healthy and database reachable."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Persistence not configured or MongoDB unreachable."}},"summary":"Liveness/readiness probe","tags":["system"]}},"/api/v1/text/edit":{"post":{"description":"Stateless edit: the caller sends the current finalized `cr` (and\noptional canvas `text` / `style`) plus a prompt. Runs the edit\nproducer, thin finalize, and merges omitted fields onto the request\nCR. `element_id` is echoed; nothing is stored. See\n`docs/text-animation/`.\n","requestBody":{"content":{"application/json":{"example":{"cr":{"ar":{"effects":[{"enabled":true,"id":"fx-1","params":{},"type":"fade"}],"playback":{"loop":"once","speed":3}},"schema_version":"1"},"element_id":48291,"prompt":"Make it faster","style":{"fill":"#000000","fontSize":32},"text":"Hello world","user_id":"demo-user","video_id":"demo-vid"},"schema":{"properties":{"cr":{"$ref":"#/components/schemas/TextCR"},"element_id":{"description":"Opaque canvas element id; echoed on success.","oneOf":[{"type":"string"},{"type":"integer"}]},"model":{"type":"string"},"prompt":{"type":"string"},"style":{"description":"Current typography. Fabric camelCase (`fill`, `fontSize`)\nor research snake_case (`fill_color`, `font_size_px`).\n","type":"object"},"text":{"type":"string"},"user_id":{"type":"string"},"video_id":{"type":"string"}},"required":["element_id","prompt","cr","video_id","user_id"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextEditResult"}}},"description":"Updated text-animation item (`cr` + `text` + `style`)."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing fields or invalid `cr`."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` header."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Classifier rejection (`not_animatable` / `malformed`)."},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"LLM transport failure."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Service misconfigured (e.g. missing OPENROUTER_API_KEY)."}},"security":[{"elaiToken":[]}],"summary":"Chat-edit an existing text-animation CR","tags":["generate"]}},"/api/v1/text/generate/from-text":{"post":{"description":"Runs `text_engine.produce_input` then thin finalize (mints\n`effects[].id`, merges `style` / `sr`) and returns one research IR\n`CR` item. No S3 / history yet. See\n`docs/plan/2026-08-06-text-generate-api-contract.md`.\n\nCaller identity is the `X-Elai-User-Id` header, same as the chart\nroutes. The body does not accept `video_id` or `slide_id`.\n","requestBody":{"content":{"application/json":{"example":{"prompt":"Gold shimmer on the title","style":{"fill_color":"#ffffff"},"text":"Build Bold Brands That Last"},"schema":{"properties":{"model":{"description":"Optional model override for `create_llm_client`.","type":"string"},"prompt":{"type":"string"},"style":{"$ref":"#/components/schemas/TextStyleObject"},"text":{"type":"string"}},"required":["prompt"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"example":{"items":[{"component":"text","cr":{"ar":{"effects":[{"enabled":true,"id":"fx-1","params":{"shimmer_color":"#ffd700","speed":1.0},"type":"color_shimmer"}],"playback":{"delay_ms":0,"duration_ms":3000,"loop":"once","speed":3,"start_state":"default"}},"schema_version":"1"},"id":"t3-01959c4fabc1234567890abcdef12","role":"primary","style":{"fill":"#ffffff"},"text":"Build Bold Brands That Last"}]},"schema":{"$ref":"#/components/schemas/TextGenerateResult"}}},"description":"One text-animation item (`cr` + `text` + `style`)."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Empty `prompt`."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Missing or invalid `X-Elai-Token` / `X-Elai-User-Id` header."},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Classifier rejection (`not_animatable` / `malformed`)."},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"LLM transport failure."},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Service misconfigured (e.g. missing OPENROUTER_API_KEY)."}},"security":[{"elaiToken":[],"elaiUserId":[]}],"summary":"Generate text-animation CR from a free-text prompt","tags":["generate"]}}},"tags":[{"description":"Health and service metadata","name":"system"},{"description":"Chart generation from text, file, or image","name":"generate"},{"description":"Chart sync, animate, and chat edit","name":"charts"},{"description":"Public SVG component proxy","name":"components"},{"description":"Admin / debug helpers","name":"debug"}]}
