# chartlink chartlink — an agent-first platform for charts and tables with live-updating embed links. WHAT AN ASSET IS An asset = type + data + config (+ brand). data holds typed columns and row-arrays; config holds everything about presentation (frame texts, encodings, options). They are updated independently: PUT /assets/{id}/data replaces the numbers; PATCH /assets/{id} deep-merges config. Never resend what you are not changing. Tables (type "table") are assets too: embeds render sortable HTML, while image surfaces (.png, e.g. Substack) snapshot the first options.maxRowsPng rows (default 15). THE ITERATION LOOP 1. POST /assets — create a draft. The response includes urls.previewPng (renders the CURRENT draft, never cached; requires the same Authorization header as the API — it is NOT a public URL). A 200/201 means the config validated AND rendered. Public URLs (urls.png, urls.page, urls.embedUrl) serve only the published snapshot. 2. Look at the preview. PATCH /assets/{id} with a minimal configPatch to adjust. Deep-merge rules: objects merge recursively, arrays replace wholesale, explicit null clears a key. 3. POST /assets/{id}/publish — snapshots an immutable version and flips every embed/PNG to it. 4. Paste urls.embedIframe (iframe platforms) or urls.png + urls.page (Substack: insert the PNG as an image, link it to the page). URLs come from the response — never construct them yourself. DATA UPDATES (the core flow) PUT /assets/{id}/data?publish=auto replaces rows. If the asset is published, it republishes automatically — every embed, PNG, and the share page serve the new numbers immediately. publish=false stages the change on the draft instead; publish=true force-publishes a draft. The body wraps the payload in a "data" key — a bare {columns, rows} object is rejected: {"data": {"columns": [{"id": "year", "type": "date"}, {"id": "value", "type": "number"}], "rows": [["2024", 1.2], ["2025", 1.4]]}} DRAFT vs PUBLISHED Embeds always serve the last published snapshot. Draft edits are invisible to readers until publish. Pinned URLs (/a/{id}.png?v=3) are immutable forever. Unpublish → embeds return 410. Propagation after (re)publish: the share page and embed update on the next request; the bare .png/.svg URLs sit behind the CDN and can serve the previous render for up to ~2 minutes. Need the new render guaranteed? Use the pinned ?v=N URL from the publish response. CONCURRENCY Mutations take expectedVersion. On 409 you get {currentVersion, current}: re-merge your intent onto current and retry with the new expectedVersion. Do not blindly retry. PUT data may omit expectedVersion (wholesale refresh semantics). BRANDS A brand is a style-only token set (palette, fonts, text styles, logo) merged UNDER your config. Leave style out of config unless the story demands it — the brand keeps everything on-brand. Pass brand: "" on create. Brand edits restyle drafts immediately; published assets pick them up on their next publish. Text styles accept ANY Google Fonts family by its exact name (fetched, measured, and cached server-side on first use); a family Google doesn't have fails loudly at brand save with a FontUnavailableError — Inter, "Source Serif 4", and "IBM Plex Mono" are always available. GET /api/brands/schema serves the full generated JSON Schema for tokens — read it instead of guessing key names. Token groups (unknown keys are rejected loudly — these are the real names): palette, colors, textStyles, branding (footer text/logo), shape, chrome (grid/axis/plotBackground/border), and layout — spacing lives HERE: layout.padding {top,right,bottom,left} (outer frame padding), layout.titleGap, layout.headerGap (header block → plot), layout.footerGap (plot → source/footer row). Example: more air above the title and a closer footer = layout: {padding:{top:28,bottom:8}, footerGap:24}. All groups work per-type too: {perType:{line:{layout:{...}}}}. ERRORS unauthorized (check Authorization header) · validation_error (issues[] carries paths, enum options, and a schema link — fix and retry) · not_found (wrong id or other workspace) · version_conflict (see CONCURRENCY). Errors are never punitive; when stuck, POST /api/feedback so the owner can fix the gap — do not silently give up. CONVENTIONS List responses: {data, total, limit, offset}; list rows elide config/data (fetch one asset for full bodies). Timestamps are ISO-8601 UTC. Deletes are soft (POST /assets/{id}/restore). Data cap: 2 MB per asset — aggregate, don't paginate charts. ## Endpoints - https://chartlink.app/api — discovery envelope (unauthenticated) - https://chartlink.app/api/openapi.json — full OpenAPI 3.1 spec - https://chartlink.app/api/docs — human-readable reference - https://chartlink.app/mcp — MCP endpoint (Streamable HTTP, bearer = API key)