Skip to content

Latest commit

 

History

History
1355 lines (1107 loc) · 83.3 KB

api.md

File metadata and controls

1355 lines (1107 loc) · 83.3 KB

API reference

Overview

The anyplot API is a FastAPI-based REST API serving plot data to the frontend.

Base URL: https://api.anyplot.ai

Key Principle: Database is derived from repository via sync-postgres.yml. API reads from PostgreSQL.


Core endpoints

Specs

GET /specs

Purpose: List all specs with at least one implementation

Response:

[
  {
    "id": "scatter-basic",
    "title": "Basic Scatter Plot",
    "description": "A fundamental scatter plot showing...",
    "tags": {
      "plot_type": ["scatter"],
      "domain": ["statistics"],
      "features": ["basic", "2d"],
      "data_type": ["numeric"]
    },
    "library_count": 9
  }
]

GET /specs/{spec_id}

Purpose: Get detailed spec with all implementations

Response:

{
  "id": "scatter-basic",
  "title": "Basic Scatter Plot",
  "description": "A fundamental scatter plot...",
  "applications": ["Show correlation", "Compare distributions"],
  "data": ["x: numeric values", "y: numeric values"],
  "notes": ["Use alpha for overlapping points"],
  "tags": {
    "plot_type": ["scatter"],
    "domain": ["statistics"],
    "features": ["basic"],
    "data_type": ["numeric"]
  },
  "issue": 42,
  "suggested": "CoolContributor",
  "created": "2025-01-10T08:00:00Z",
  "updated": "2025-01-15T10:30:00Z",
  "implementations": [
    {
      "library_id": "matplotlib",
      "library_name": "Matplotlib",
      "preview_url": "https://storage.googleapis.com/anyplot-images/plots/scatter-basic/matplotlib/plot.png",
      "preview_html": null,
      "quality_score": 92.0,
      "code": "import matplotlib.pyplot as plt...",
      "generated_at": "2025-01-15T10:30:00Z",
      "generated_by": "claude-opus-4-7",
      "python_version": "3.13",
      "library_version": "3.10.0",
      "review_strengths": ["Clean code structure"],
      "review_weaknesses": ["Grid could be more subtle"],
      "review_image_description": "The plot shows...",
      "review_criteria_checklist": {...},
      "review_verdict": "APPROVED"
    }
  ]
}

GET /specs/{spec_id}/images

Purpose: Get preview images for a spec across all libraries

Response:

{
  "spec_id": "scatter-basic",
  "images": [
    {
      "library": "matplotlib",
      "language": "python",
      "url": "https://storage.googleapis.com/.../plot.png",
      "html": null
    }
  ]
}

Libraries

GET /libraries

Purpose: List supported plotting libraries

Response:

{
  "libraries": [
    {
      "id": "matplotlib",
      "name": "Matplotlib",
      "version": "3.10.0",
      "documentation_url": "https://matplotlib.org",
      "description": "The classic standard..."
    }
  ]
}

GET /libraries/{library_id}/images

Purpose: Get all plot images for a library across all specs

Response:

{
  "library": "matplotlib",
  "images": [
    {
      "spec_id": "scatter-basic",
      "library": "matplotlib",
      "language": "python",
      "url": "https://storage.googleapis.com/.../plot.png",
      "html": null,
      "code": "import matplotlib.pyplot as plt..."
    }
  ]
}

Plots filter

GET /plots/filter

Purpose: Filter plots with faceted counts for all filter categories

Query Parameters (combinable):

Spec-level filters (WHAT is visualized):

  • lib - Library filter (matplotlib, seaborn, etc.)
  • spec - Spec ID filter
  • plot - Plot type tag filter
  • data - Data type tag filter
  • dom - Domain tag filter
  • feat - Features tag filter

Impl-level filters (HOW it is implemented):

  • dep - Dependencies filter (scipy, sklearn, etc.)
  • tech - Techniques filter (twin-axes, annotations, etc.)
  • pat - Patterns filter (data-generation, groupby-aggregation, etc.)
  • prep - Dataprep filter (kde, binning, regression, etc.)
  • style - Styling filter (minimal-chrome, alpha-blending, etc.)

Filter Logic:

  • Comma-separated values: OR (lib=matplotlib,seaborn)
  • Multiple params same name: AND (lib=matplotlib&lib=seaborn)
  • Different categories: AND (lib=matplotlib&plot=scatter)

Response:

{
  "total": 42,
  "images": [
    {
      "spec_id": "scatter-basic",
      "library": "matplotlib",
      "quality": 92,
      "url": "https://storage.googleapis.com/.../plot.png",
      "html": null
    }
  ],
  "counts": {
    "lib": {"matplotlib": 5, "seaborn": 3},
    "spec": {"scatter-basic": 2},
    "plot": {"scatter": 10},
    "data": {"numeric": 15},
    "dom": {"statistics": 8},
    "feat": {"basic": 12},
    "dep": {"scipy": 3},
    "tech": {"annotations": 5},
    "pat": {"data-generation": 8},
    "prep": {"kde": 2},
    "style": {"alpha-blending": 4}
  },
  "globalCounts": {...},
  "orCounts": [...]
}

Stats

GET /stats

Purpose: Platform statistics

Response:

{
  "specs": 42,
  "plots": 378,
  "libraries": 9
}

Download

GET /download/{spec_id}/{library}

Purpose: Download plot image (proxy to avoid CORS)

Response: PNG image file with Content-Disposition: attachment


Health

GET /

Purpose: Root endpoint

Response:

{
  "message": "Welcome to anyplot API",
  "version": "3.2.0",
  "docs": "/docs",
  "health": "/health"
}

GET /health

Purpose: Health check for Cloud Run, and the one place the origin gate can be observed

Response:

{
  "status": "healthy",
  "service": "anyplot-api",
  "version": "3.2.0",
  "origin_gate": "off"
}

version is the installed package's version (api/version.py), so it moves with each release rather than staying at the value written here.

origin_gate reports what the gate makes of this request — never the secret itself. /health is exempt from the gate, so every route into the service can be asked. See Origin gate for the five values.


Insights endpoints

GET /insights/dashboard

Purpose: Rich platform statistics for the public stats page

Returns aggregated data: per-library quality and LOC distributions, coverage matrix, top implementations, tag distribution, implementation timeline.

Cached with stale-while-revalidate (1h refresh, 24h TTL).

GET /insights/plot-of-the-day

Purpose: Daily featured high-quality implementation

Deterministically selects an implementation based on today's date. It prefers the featured pool (quality_score >= 92 of specs that are not a -basic variant; every implementation at 90 or more when the pool is empty), walks the pool's specs in an order shuffled per cycle so no spec repeats within a cycle, and picks the library per day among that spec's featured implementations. Returns spec info, preview URL, AI image description, and code.

GET /insights/related/{spec_id}

Purpose: Tag-based similarity recommendations

Parameter Type Default Description
limit int 6 Number of results (1-24)
mode string spec spec = spec tags only, full = spec + impl tags
library string null In full mode, match against this library's impl_tags

Returns related specs sorted by Jaccard similarity with preview thumbnails and shared tags. The catalogue is cached once as an in-memory index under a single key. The index is refreshed in the background once it is an hour old, and it is cleared with the rest of the cache after a database sync. Each request ranks against that index in about a millisecond.

GET /specs/{spec_id}/{library}/code

Purpose: Lightweight endpoint for implementation code

Returns only the code field for a single implementation. Used by the frontend to lazy-load code on demand (code is deferred in the main /specs/{spec_id} response).

Parameter Type Default Description
language string resolved from the library Optional override. Library ids are globally unique, so the language is looked up in core/constants.py when the parameter is absent — /specs/{id}/ggplot2/code works without ?language=r.

SEO endpoints

GET /llms-full.txt

Purpose: Whole-catalogue index for AI agents (llms.txt convention)

One line per spec (spec_id | title | hub URL | libraries) plus a header that documents the user-agent-independent retrieval recipes (code endpoint, GCS image URL pattern, OpenAPI, MCP). nginx serves anyplot.ai/llms-full.txt from this endpoint.


GET /sitemap.xml

Purpose: Dynamic XML sitemap for search engines

Includes: root, plots, specs, all specs with implementations, all implementation pages.


GET /seo-proxy/

Purpose: Bot-optimized home page with og:tags

Used by nginx to serve correct meta tags to social media bots.


GET /seo-proxy/plots

Purpose: Bot-optimized plots page


GET /seo-proxy/{spec_id}

Purpose: Bot-optimized spec overview page with collage og:image


GET /seo-proxy/{spec_id}/{library}

Purpose: Bot-optimized implementation page with branded og:image


OG image endpoints

All endpoints are under /og/ prefix.

GET /og/home.png

Purpose: OG image for home page (with tracking)


GET /og/plots.png

Purpose: OG image for plots page


GET /og/{spec_id}.png

Purpose: Collage OG image for spec overview (2x3 grid of top implementations)


GET /og/{spec_id}/{library}.png

Purpose: Branded OG image for implementation (1200x630 with anyplot.ai header)


Proxy endpoints

GET /proxy/html

Purpose: Proxy HTML from GCS with size reporting script injection

Query Parameters:

  • url - GCS URL (must be from anyplot-images bucket)
  • origin - Target origin for postMessage (optional)

Used to load interactive plots (plotly, bokeh, altair) in iframes with dynamic sizing.


Agent chat (admin only)

Status (2026-10-12): the routes are switched on in production since 2026-10-11: the deploy-api trigger sets AGENT_ENABLED=true and AGENT_SERVICE_URL, the AGENT_USER_ID_KEY secret is bound, and the anyplot-agents service they call runs in Cloud Run. They stay behind the admin gate. The chat page that calls these routes, /debug/agent in the app, is built only with VITE_ENABLE_AGENT_CHAT=true, which the deploy-app trigger sets since the same day. The feedback triage routes under /debug/agent/cases and the agent_feedback table behind them are built since 2026-10-12 (see Feedback triage), and so is the session language, PUT /sessions/{sid}/language (see Session language). Design: Agent network design.

The /debug/agent/* routes (api/routers/agent.py) are a backend for the frontend (BFF) in front of the private anyplot-agents Cloud Run service. The browser never calls that service directly: the BFF authenticates the admin, derives an opaque user id, accepts only allowlisted fields, and re-frames the chat stream.

Access, kill switch, and CSRF guard

Every request passes three checks, in this order:

  1. Admin gate. The same gate as the other /debug routes: a Cloudflare Access JWT for an allow-listed email, or X-Admin-Token. Without either you get 401; a valid JWT for an unlisted email gets 403, and an unconfigured gate gets 503.
  2. Kill switch. Every route answers 404 {"detail": "agent chat is not enabled"} unless AGENT_ENABLED is true and both AGENT_SERVICE_URL and AGENT_USER_ID_KEY are set. Production has deployed with all three since 2026-10-11; a deploy that lacks one answers 404 instead of breaking, and _AGENT_ENABLED=false on the deploy-api trigger switches the chat off again.
  3. CSRF guard on every method except GET, HEAD, and OPTIONS, so on POST, PUT, PATCH, and DELETE. The request needs the header X-Anyplot-Client: agent-chat/1; an Origin header, when the browser sends one, must be in the CORS allow-list or match http://localhost:<port>; and a request with a body must send it as Content-Type: application/json. A failed check answers 403 with client_header_required, origin_not_allowed, or json_required.

The deploy smoke test expects 401 on /debug/agent/status, whatever the switch says.

Identity and request ids

The agents service never sees an email address. The BFF sends X-Anyplot-User: adm_<16 hex digits>, the first 16 hex digits of an HMAC-SHA256 over the admin's email with AGENT_USER_ID_KEY; every caller on the X-Admin-Token path shares the id derived from the string token.

A request that gets past the three checks and the body validation is given an X-Request-Id, which the BFF sends upstream and returns on the response, also as ref in an error body or an SSE error event. Quote it when you report a problem. The earlier answers (401, 403, 404 from the kill switch, 422) carry no request id, and neither does the 503 of a route that needs the database (POST /sessions, POST /sessions/{sid}/library, the feedback route and the case routes) on an API without a configured database, which comes in the API-wide shape (see Standard error format). Upstream calls carry a Cloud Run ID token whose audience is AGENT_SERVICE_URL; for a localhost or 127.0.0.1 URL no token is fetched.

Routes

The routes mirror the agents service's /v1 API. All paths below start with /debug/agent.

Route Request Response
GET /status None The service's {libraries, model, location, version, provider, waiting, in_flight} plus "enabled": true; waiting counts the turns in the agents service's run queue and in_flight the runs it is running
GET /eligibility?spec=&library= Spec id and library id The agents service's answer, passed through
POST /sessions {spec_id, library, locale} {session_id, eligibility}; 404 not_found when the spec has no implementation for the library
POST /sessions/{sid}/library {spec_id, library} Switches the library; the dataset and bindings stay
POST /sessions/{sid}/dataset {text, confirmed?, source?}, at most 1 MB (1,048,576 bytes) of UTF-8 (200 KB before 2026-10-14); source is the name of the file the page read text from (1 to 1,024 characters), which the agents service sanitises, screens on its own and answers as file_name ((name withheld) when its screen refused it), never as a note and never in the text {preview, profile, bindings, warnings, context_lines, roles, suggestion, version, language} for a table, stored as version 1 of the data (an earlier paste's undo history is dropped; suggestion is the wide-table offer or null, see Data changes; language is the session language as {language, source} after the paste, see Session language): CSV, TSV, semicolon or pipe text; JSON as an array of records, an object of column lists, an object of values or an array of [category, value] pairs (the last two become the columns category, the keys kept as text, and value; a repeated key keeps its last value with a warning); JSON Lines, one JSON object per line, read as JSON records (source format json_lines; up to 10 lines above the first object, such as a title, become notes; a source named .jsonl or .ndjson is read as JSON Lines first, so a file of one record is one row; a line that is no object is 422 unparseable with its line number); a JSON-stat 2.0 dataset, as statistics offices' APIs answer it (or a JSON-stat 1.x bundle of one dataset), flattened into a long table with one column per dimension of more than one category, named by its label and holding the category labels as text, and a typed value column, the one-category dimensions and the dataset's label, source and notes coming back in context_lines (source format json_stat; an inconsistent dataset is 422 unparseable); the same shapes as a Python literal, also behind an assignment such as cat_day = {...}, with single quotes, trailing commas and tuples (text that starts with a bare ( and is no literal is read as delimited text); a column whose every cell carries the same unit (65 %, 15,6 h, € 5, also with a no-break space) is read as numbers and named <name> (<unit>) with a warning, unless the header already names the unit. One column of values (integers, numbers, dates or booleans, such as a header and 800 commute times for a histogram) is a table, stored as any paste; without a header line its first line is kept as the first value, the column is named value, and a warning says so (a list of 20,001 values without a header is 422 unparseable, never cut). A column of digit codes such as postal codes with leading zeros is a table too, kept as text, and so is a column of categories under a header where at least one category repeats; when that header has the categories' form (no _, brackets, :, /, %, # or digit, and no more words than a category), the warning read '<header>' as the column name says which line became the name, since Tier above Hund and Apfel above Birne look alike. For text that is no table (prose, label value lines without a separator, a list of names typed one per line) of at most 4,000 characters and 200 non-blank lines, {transcribed: true, text, preview, profile, warnings, note}, the table the judge model read, with its CSV in text and nothing stored: the page shows it for confirmation, and confirming posts that CSV through this route again with confirmed: true, which skips the free-text fallback (a one-column table would otherwise be read again) and keeps every row (no line is left out as a note or a total), never the parse, the pre-filter, the judge or the store. A pasted table may carry other lines: a title, metadata or notes above it, a source line or notes below it, and up to three summary rows at the end (Total, Summe…) are left out, each kind with one entry in warnings. The lines left out above and below the table (not the summary rows) come back in context_lines, one text line each and at most 2,000 characters in all: the notes the assistant reads with the data. They pass the same pre-filter as the cells and then the scope judge; when either refuses them, or the judge does not answer, context_lines is empty and warnings starts with the reason, while the table is stored all the same and no strike is counted. 413 too_long above the limit, 422 unparseable when neither the parser nor the model finds a table (with a cause that names what the data fails on: the lines with too many or too few fields, the line and column of a JSON error, the cap a table breaks, free text past the transcriber's character or line limit, which is never stored as it parsed, or the model running out of output room on the text, or out of time on a text of at least half those limits, with its line and character count), 422 formula when the text asks for a formula such as sin(x) von -pi bis pi or y = x**2 (no model reads it; ask for the formula in the chat, where the assistant computes the table, see Data changes), 422 code when the text is a program that generates data, such as Python, JavaScript or R (checked before any parse; send it to the chat, where the assistant runs it; only text within the chat's 2,000 characters can move there), 403 data_refused when a header, a cell or the raw text addresses an AI (personal data such as names, addresses, e-mail addresses, phone numbers and web addresses is allowed), 403 session_closed in a session the scope check closed, 409 run_active while a turn, a theme render or another paste is in flight in this session (your other sessions never block it), 429 rate_limited past 10 pastes in a minute and 429 daily_limit past 100 in a UTC day or past the daily token budget (only a paste that reaches the checks counts; one refused as a formula, code or unparseable text does not), 429 locked_out for the rest of the UTC day after three messages or pastes classed as attacks, 503 guard_unavailable when the judge model does not answer (an error, a quota, an answer that does not parse, or no answer in time on a short text, a stalled endpoint)
PUT /sessions/{sid}/bindings [{role, column}], at most 50 The agents service's {bindings, complete, missing_roles, warnings}; 422 no_dataset before a parse, 422 invalid with the check's errors lines for a binding the spec roles or the columns refuse, 409 run_active while a turn or a render is in flight. A change of the bindings drops the data confirmation below
PUT /sessions/{sid}/language {language}: a short language name or code such as de, Deutsch or Swedish, 1 to 40 characters that match ^[A-Za-z][A-Za-z -]{0,39}$ The agents service's {language, source}, the stored form (English comes back as en, Swedish as typed) with the source now chosen, plus, when the session has data, suggestion: the .to_long() offer (or null) with its new column names in the new language. The replies and the plot text of the next version follow it, and the language a typed message implies never replaces it (see Session language). Versions made before keep their text. 422 for a value outside the pattern, 409 run_active while a turn or a render is in flight
POST /sessions/{sid}/dataset/convert {data_base64, sheet?}: a dropped binary data file as base64, at most 5 MB (5,242,880 bytes) raw; sheet a sheet (or .npz array) name from a previous answer's sheets {text, format, kind, rows, columns, sheet, sheets, sheet_count, warnings, dropped}: the file read on the server, in the renderer's sandbox, into the text the paste box takes, and nothing stored. Values keep the precision they came with: every fraction digit of a timestamp and its offset in its own zone (UTC, counted as utc_timestamps, only where the zone cannot be read), decimals and 64-bit integers exactly, float32 in its shortest text; dropped names the columns left out (durations, intervals, binary, map, union, nested; at most 20). kind is the format found from the first bytes (xlsx, xlsm, xlsb, xls, ods, parquet, feather, arrow, npy, npz); text is CSV, or JSON records when a Parquet or Arrow table has list columns (format json), which the dataset route expands into one row per value; a spreadsheet comes as its raw cell grid, so a title above the header stays a note. sheet is the sheet read (the first visible one with a cell unless one was asked for), sheets at most ten visible ones with cells (an empty sheet is not offered) and sheet_count all of them; warnings counts formulas_without_value (empty cells, xlsx and xlsm only), hidden_sheets, dropped_columns, list_columns, utc_timestamps and renamed_columns (a repeated column name given a _2 suffix). Sheet names come whole, so a name from sheets can be sent back as is; a name over 255 characters, with a control or bidi character, or with a path separator is left out of the list. Send text to POST /sessions/{sid}/dataset with the file name and sheet as source. 413 too_long above 5 MB, 422 invalid for a body that is not this shape or no base64, 422 file_unsupported, file_encrypted (remove the password), file_pickle, file_hdf5 (never read), file_corrupt (also a zip member that inflates to another size than it declares, or a Parquet footer whose row groups do not add up), file_archive_limits (a zip declaring more than 100 MB, 250 times its size or 2,000 entries), file_too_many_rows (20,000, or 20,200 raw rows of a sheet, measured from the span of its cells before the sheet is loaded), file_too_many_columns (50), file_formulas_only (a sheet whose cells are all formulas without saved values: open it in Excel and save it once), file_too_large (more than 1 MB of text), file_empty, file_sheet_not_found, file_array_shape (no 2-D or structured array), file_object_array (a pickled NumPy array), file_timeout (10 seconds), file_too_complex (the sandbox's memory or disk limit, or a reader that died without an answer) and file_unreadable, each with measured and limit where a cap was broken; 403 session_closed, 409 run_active (another conversion in flight in this session), 429 rate_limited past 5 conversions in a minute and 429 daily_limit past 30 in a UTC day or past the daily token budget, 429 locked_out, 503 capacity (no render slot within 20 seconds) and 503 convert_unavailable (the renderer cannot convert files)
POST /sessions/{sid}/dataset/confirm none Step 1 of two: you confirm that the stored dataset and its bindings fit; {confirmed: true, rows, columns}. The plot pipeline runs only on confirmed data (create_plot before it answers plot{status: "not_ready", reason: "data_unconfirmed"}); a new paste, a transcription's .use(), a binding change, a change of the data by the assistant or an undo drops the confirmation, a library switch keeps it. 422 no_dataset before a parse, 409 run_active while a turn or a render is in flight
POST /sessions/{sid}/dataset/undo none Undo the assistant's last change to the data (see Data changes): the version before it is the current dataset again, {preview, profile, bindings, warnings, context_lines, roles, suggestion, version} like the dataset route (the wide-table offer comes back with a wide version), unconfirmed. The service keeps the last five versions. 422 nothing_to_undo when the paste was never changed or the first data came from a formula, 409 run_active while a turn or a render is in flight
POST /sessions/{sid}/dataset/to_long none Make a wide table long for the plot's category and value roles (the page's .to_long(), see Data changes): the service recomputes its suggestion and applies that pivot_longer without a model call, as the next version with the default bindings for the new columns, answered like the undo route plus changes, unconfirmed. 422 no_suggestion when the table is not wide for this plot, 409 run_active while a turn or a render is in flight
POST /sessions/{sid}/messages {text} (at most 2,000 characters), {"action": "create_plot"} or {"action": "summarize_data"} An SSE stream in protocol anyplot/1; 413 too_long above the limit, 409 run_active while this session has a turn, a theme render or a paste in flight (your other sessions, such as a second browser window, never block it); a full run queue ends the turn's stream with error {"code": "capacity"}, and a plot run or data snippet while you already have three such runs queued or running in other sessions with error {"code": "run_limit"} (see SSE protocol). summarize_data asks the assistant for the step-1 summary of the data (rows, columns with types and units, missing values, percentages, the proposed bindings, one question when something is ambiguous) without the scope judge, like create_plot; when the stored dataset kept notes pasted with the table (context_lines, judged when they were pasted), the turn carries them to the assistant fenced as <user_notes>, so it can act on a request in them, such as "make it a line chart"
POST /sessions/{sid}/cancel None 204; a turn that still waits leaves the run queue
POST /sessions/{sid}/outcome {action, version?, reaction?}: action is copied_code, copied_image, downloaded_png, downloaded_code, downloaded_data, saved_session or feedback; version a JSON integer from 0 to 999 (0 for the session); reaction only with feedback, one of the four reactions; nothing else 204: the page's content-free outcome beacon, forwarded to the agents service, which writes the outcome telemetry line (Agent usage telemetry); 429 rate_limited / daily_limit past 30 a minute or 2,000 a day per user. The page ignores every error
POST /sessions/{sid}/versions/{version}/render {"theme": "light"} or {"theme": "dark"}; version is 0 to 999, where 0 is the latest version {status, reason?, artifacts} once the render is done (see Theme toggle)
GET /sessions/{sid}/artifacts/{name}?v= name is one of plot-light.png, plot-dark.png, plot.py, data.csv The file, with Cache-Control: private, no-store and X-Content-Type-Options: nosniff; any other name is 404, and so is the PNG of a theme the version has not rendered. A PNG carries the footer strip (see Footer strip)
GET /sessions/{sid}/bundle?include_data=&version= include_data is true or false (the default); version is 0 to 999, where 0 or none means every version (the BFF then asks the agents service without one, since there version=0 is the chat's feedback case) The session as one JSON file to save (the chat page's .save()), with Content-Disposition: attachment; filename="anyplot-session-<first 8 characters of the session id>.json", Cache-Control: private, no-store and X-Content-Type-Options: nosniff. 404 not_found for a version the session does not have; a bundle over 32 MiB can't be saved (see Session bundle)
POST /sessions/{sid}/feedback {reaction, message?, include_data, result_version, replaces?}: reaction is thumbs_up, thumbs_down, bug or idea; message has at most 500 characters after the strip; include_data is a JSON boolean and result_version a JSON integer from 0 to 999, neither coerced from another type, where 0 is the chat itself (feedback on a session that made no plot, since 2026-10-12); replaces is the case id of the earlier submission for the same version, when this one is an edit of it 201 {case_id, stored}: the version is stored as a feedback case in the private case bucket and indexed as a row of the agent_feedback table, and stored lists its file names (see Feedback case); a chat case has no image and no plot.py, only data.csv when asked for and manifest.json. 404 not_found for a version the session does not have, 422 no_data for a chat case that asks for data.csv before the session has data, 502 storage when the bucket refused the case, 502 row_missing with the stored case_id when the files are stored but the row insert failed, 503 before the bundle fetch when the API has no database configured, otherwise the errors of the bundle route
GET /cases?reaction=&status=&spec=&library=&model_version=&tag=&include_superseded=&before=&limit= Every filter is optional: reaction is one of the four reactions; status is new, in_progress, done, wont_solve, or open for the first two; spec is a spec id, library a library id, model_version an exact value and tag one tag; include_superseded=true lists superseded cases too; before is the last case id of the page before; limit is 1 to 200, 50 by default {items, has_more}: the cases' agent_feedback rows, newest first, each with every column, timestamps in UTC ISO 8601 with Z; has_more says that a further page exists (see Feedback triage)
GET /cases/{id} A case id such as 20261012T143005Z-1a2b3c4d The row plus what the case's manifest.json adds: transcript, code (the case's plot.py), diff (a unified diff of the catalogue original against that code), catalogue_library_version, snapshot_library_version, residual_defects, changes, result_reason, config and bindings, and siblings, the ids of the other cases of the same session hash and version. expired: true when the lifecycle rule has deleted the manifest: then transcript, code, diff, snapshot_library_version, result_reason and config are null, the lists residual_defects, changes and bindings are empty, and the row and siblings remain. 404 not_found for an id without a row, 502 storage when the bucket could not be read
GET /cases/{id}/files/{name} name is one of plot-light.png, plot-dark.png, plot.py, data.csv, manifest.json The file as stored, with its content type, Cache-Control: private, no-store, Content-Disposition: inline, X-Content-Type-Options: nosniff and X-Request-Id. 404 not_found for an id without a row, a file the case does not hold, or one the lifecycle rule deleted; 502 storage for any other bucket error
PATCH /cases/{id} {status?, tags?, promoted?, expected?}, at least one: status is new, in_progress, done or wont_solve; tags replaces the whole list with at most 10 unique tags that match ^[a-z0-9-]{1,40}$; promoted is a JSON boolean; expected is accepted or rejected The whole row, as the list shows it, with the tags sorted. promoted: true sets promoted_at once, and when neither the body nor the row names an expected outcome it takes the reaction's (thumbs_up accepted, thumbs_down rejected); promoted: false clears promoted_at. 422 no_plot when you promote a chat case (result_version 0, no plot to replay), 422 no_data when you promote a case stored without data.csv, 422 expected_required when you promote a bug or idea without an expected outcome, 404 not_found for an id without a row
DELETE /sessions/{sid} None 204

Validation: spec_id matches ^[a-z0-9-]{1,100}$; library is one of the 15 supported library ids; locale is a language tag of at most 16 characters such as de or de-CH, the browser's, recorded with a feedback case and never used as the session language; language matches ^[A-Za-z][A-Za-z -]{0,39}$; the session id matches ^[A-Za-z0-9_-]{1,128}$; a binding role matches ^[A-Za-z_][A-Za-z0-9_]{0,31}$ (the agents contract, so roles such as temperature_K or X1 pass) and its column has at most 64 characters. An unknown field in a body is a 422, and so is a body with a lone surrogate escape such as "\ud800", which UTF-8 cannot carry (422 invalid_text).

Personal data is allowed. Your dataset, your messages and the plot may carry names, postal and e-mail addresses, phone numbers, web addresses and amounts, for example from your own tax return or contact list, in tick labels, titles and annotations, and the assistant may name a single value in its reply. The guardrails refuse misuse of the assistant by intent, such as plot text that advertises, calls to action, or addresses other people, and never refuse the data itself. Links are the one limit, and only in what the model writes: a bare web address such as jana-novak.de passes, while a link with a scheme such as https:// or with www. is stripped from a reply and refused in a change request, and a mailto: link is stripped from a reply. A dataset cell with a link plots as it is.

The agents service keeps a session's data, code and images in memory for the session's lifetime and writes no content to its logs. anyplot stores none of it unless you submit quick feedback (see Feedback case). That feedback case always holds the conversation, the code, the images and the dataset profile with its column names and sample rows, so it can show your data even without the data box; the full data file is in it only when you tick the box. Admins can read a submitted case on /debug through the case routes, the conversation, the images and the data.csv included, until the bucket's lifecycle rule deletes the files after 180 days. The case's row in the agent_feedback table stays after that: it holds the reaction, your message, the hashed user and session ids and the run's metadata, but no conversation, code, images or data. The chat page's .save() downloads something else, the whole session as one bundle with every version and no case manifest, to your computer (see Session bundle); anyplot keeps no copy of it.

POST /sessions and POST /sessions/{sid}/library add the user id and a catalogue snapshot before they forward the body, because the agents service has no database access: {spec_id, title, description, data_roles, notes, code, library_version}, with # noqa comments stripped from the code.

Version numbers: {version} in the theme toggle route and v on the artifact route are the agents service's version numbers. A session's versions are numbered from 1 in the order the service stores them, and it stores exactly the turns whose plot event has the status ok or needs_attention. That plot event carries the stored number as version, so a client addresses a version by the server's number instead of counting the events it received; a failed or not_ready event has no version. 0, or no v, means the latest version.

Roles and bindings: the dataset response lists the spec's data roles as roles, each {name, kinds, required, variadic, description, non_negative}, where kinds names what a role accepts (numeric, categorical, text, boolean, datetime; empty means any column), description is the spec's own text, at most 200 characters, and non_negative marks a role that needs values of 0 or more, such as a radius, a size, a standard deviation, or the value of a pie, treemap or sankey. The default bindings never give such a role a column with a negative value. A single role binds under its own name. A variadic family such as y binds its members y1, y2, and so on, never its bare name. PUT /sessions/{sid}/bindings replaces the whole set and answers {bindings, complete, missing_roles, warnings}: the stored bindings, whether every required role has a column, the required roles that still have none, a family by its name, and one line per non_negative role bound to a column with a negative value or left open because every fitting column has one (a warning, never a refusal). A refused set answers {"detail": "invalid", "ref": "<request id>", "errors": [...]} with at most 20 of the binding check's lines, such as role 'y' takes numbered members such as 'y1'.

Data changes

In step 1 you can ask the assistant in words to change the data: read a column as numbers or dates, drop a total row, sum or average per month or category, rename a column, sort, turn columns into rows or rows into columns, add a category column derived from other columns, or add a number column computed from number columns (a ratio, a difference, a row sum, a share of a group's total, or, in the order of a year or date column, a percent change from the previous row, a running total or an index with the first row at 100). The assistant never writes data rows: it chooses from a fixed set of operations (rename, drop_columns, drop_rows, set_type, aggregate, sort, pivot_longer, pivot_wider, classify, derive, at most five per call), the agents service applies them to the stored data and stores the result as the next version, numbered from the paste's 1. The turn's done event then carries dataset, the new version as the dataset route would answer it plus changes, one line per operation with its counts (for example dropped 1 row where Tier = 'Gesamt'), and data_confirmed: false: you check the preview and confirm the new version like a paste. A column the assistant set to text stays text in every later version. POST /sessions/{sid}/dataset/undo restores the version before the last change; the service keeps the five versions before the current one, so after more than five changes the history ends above version 1, and an undo with nothing left answers 422 nothing_to_undo. A new paste starts over at version 1 and drops the history.

A table that holds the same quantity side by side, such as Sensor_A,Sensor_B,Sensor_C, on a plot that wants one category column and one value column (a box, violin or strip plot) carries suggestion in every dataset answer and done.dataset: {kind: "to_long", columns, names_to, values_to, keys, rows}, the pivot_longer that makes it long, with the plot's role names as the new columns and date columns kept as keys. It is null for a table with a text column, a plot that takes two numeric roles or a series family, and long data. POST /sessions/{sid}/dataset/to_long (no body, the page's .to_long()) has the service recompute and apply that one operation without a model call: the next version, with the default bindings for the new columns, answered like an undo plus changes; 422 no_suggestion when the table is not wide for this plot.

You can also ask for a mathematical function or curve instead of your data, such as sin(x) from -pi to pi, a parabola or a parametric circle. The assistant's generate_data tool then chooses a spec, never rows: one or two variables, each a range of evenly spaced points (200 by default, at most 5,000; two variables make a grid of at most 10,000 rows), and one to ten series, each an expression over the variables with numbers, pi, e, tau, + - * / ** % //, parentheses and a fixed set of functions (sin, cos, exp, log, sqrt, atan2, min, max and others). The agents service computes the table and stores it like a change: as the next version when the session has data, which you can undo, or as version 1 when it has none. The turn's done event carries it with a change line such as generated 200 rows: x from -3.1416 to 3.1416, y = sin(x), plus one line per series with values the formula does not define (they stay empty). The generated table starts from the default bindings for its columns. A formula pasted into the data field answers 422 formula instead, because the free-text reader is a model and would write the rows itself; only text within the chat's 2,000-character cap is refused this way, because the page's offer sends it to the chat as a turn. Text that holds its own values is data, not a formula: with three numbers or more besides a range, such as Umsatz von 2020 bis 2024: 10 12 15 18, the free-text reader reads it as a table.

You can also paste or type code that generates your data, in Python, JavaScript, R or as a formula from a textbook. The assistant translates it into Python and calls its run_data_code tool. The agents service validates the Python (only numpy, pandas, random, math, statistics and the scipy computing modules; no files, network, plotting or values written out from memory), runs it in the renderer's sandbox without network access, and stores the table the code assigns to df like a change: the next version, or version 1 when the session has no data. The turn's done event carries it with the change line computed from your code: <rows> rows, <columns> columns and the Python that ran as dataset.code; the data panel shows it under # code that made this data. Code pasted into the data field answers 422 code instead.

Session language

Every session has one language for the assistant's replies and for the text in the plot: the title, the axis titles, the legend entries and the annotations. It starts as English (en, source default), also when your browser is set to another language. When you paste data before typing anything, the language of the notes around your table, or else of its column names, sets it (source pasted, shown as lang: de · from data); column names such as temperature_2m_max or English notes leave the current language unchanged. Your first typed message that the assistant answers sets it to the language you write in (source detected), also after a paste: a refused message and the create_plot and summarize_data actions set nothing, and later messages do not change it again; a very short English message, such as a formula, leaves the current language in place, and the next message is read for its language again. A paste never changes a language that a typed message or your choice set. To set it yourself, pick a language from the lang: chip in the chat header, type any language name in Latin letters into its field, or ask in the chat ("labels in English"). Both make the same change (source chosen), which detection never overrides; when you already have a plot, a request in the chat also makes the next version in the new language. The code, its comments, column names and labels from your data are not translated. A plot you already have keeps its text; the next version uses the new language. After each turn, the done event carries the language as {language, source}, and so does the answer to a paste.

Theme toggle

A chat turn renders the plot in one theme: light, unless you ask for a dark plot. POST /sessions/{sid}/versions/{version}/render renders another theme of a finished version from its stored code and data. It calls no model and does not wait in the run queue, but it shares the one render slot with running turns, which get a freed slot first, so it can wait for a render in progress. The response arrives when the render is done:

status Meaning reason
ok The theme rendered on the exact canvas None
needs_attention The canvas missed, so the PNG was padded onto it, never cropped canvas_padded
failed The render failed the host gates (render), or the renderer could not run (error); no PNG was stored render or error

artifacts lists the version's files after the call, for example ["plot-light.png", "plot-dark.png", "plot.py", "data.csv"]. Asking again for a theme the version already has answers from its record without a new render. A render failure is recorded too: the same code and data fail the same way, so asking again renders it once more and then answers from the record. An error is not recorded, so you can try again.

The route answers 409 run_active while the session has a queued or running turn, another theme render or a paste in flight (a turn in the session also gets 409 run_active while the theme renders; your other sessions are not blocked), 404 not_found for an unknown version or one whose render was swept, and 503 capacity when no render slot came free within 120 seconds or the agents service's render store is full.

Footer strip

The PNGs you download through the artifact route carry a strip below the plot: "made with any.plot()" on the left and "anyplot.ai/" on the right, in the theme's page colour. The strip adds 64 pixels to a 3200-pixel-wide plot, so a 3200x1800 render arrives as 3200x1864 and a 2400x2400 render as 2400x2448; size an image box from the PNG itself, not from a fixed 16:9. The plot above the strip is the render exactly as the gates and the reviewer saw it, and the exported plot.py reproduces the plot without the strip. The agents service switches the strip off with AGENT_WATERMARK=false; the layout is in Agent network design.

Session bundle

GET /sessions/{sid}/bundle answers the feedback case that the agents service assembles for the session: the conversation (your messages and the assistant's replies, never tool calls or thoughts), the catalogue snapshot's spec id and library version, every version's working, run and exported code with its adapt plan, plot result, gate outcome per theme and the PNG of every rendered theme as base64 (with the footer strip), the dataset profile, the bindings, and a config stamp (provider, model, judge model, location, ADK and service versions, prompt hashes); each version's data.csv only with include_data=true. The top-level keys are session, snapshot, transcript, versions, profile, bindings and config, plus refusals when the agents service runs with AGENT_KEEP_REFUSALS. session names the spec, the library, the browser's locale and the session language (plot_language, plot_language_source), and each version carries the language its text was written in.

A bundle over 32 MiB can't be saved: that is Cloud Run's limit on an HTTP/1 response that is not streamed, and the agents service answers the bundle in one piece. On Cloud Run, the agents service's side refuses such a bundle before the BFF reads a byte, so the route answers 500 upstream. The BFF reads the whole bundle before it answers and refuses one over 32 MiB itself with 502 too_large, which you see only where nothing sits in front of the agents service, such as a local run. A typical session stays far below the limit, because a version's PNGs take about 0.1 to 1 MB of base64.

The BFF compresses the bundle with gzip in a worker thread when the browser accepts gzip, so the API's compression middleware doesn't hold the event loop for a large file.

Feedback case

POST /sessions/{sid}/feedback is the result card's quick feedback, and with result_version 0 the chat's own, for a session that made no plot. In phase 1 only admins can send it, like every route here. The BFF fetches the bundle of the one version you rate (GET /v1/sessions/{sid}/bundle?version=<result_version>; for version 0 the conversation without any version, and the session's current data as the top-level data_csv; with data.csv only when include_data is true; the same fetch and the same 32 MiB cap as .save()), splits it into files in a worker thread, and uploads them to the private bucket AGENT_CASES_BUCKET under cases/<case_id>/:

File Content type Contents
plot-light.png, plot-dark.png image/png The version's PNG of each theme it rendered, with the footer strip, as you saw it; a theme it never rendered has none
plot.py text/x-python; charset=utf-8 The exported code
data.csv text/csv; charset=utf-8 The version's data, only with include_data
manifest.json application/json The case record, and the whole bundle without the PNGs and the data, which are the files above

The case id is the UTC time in ISO 8601 basic format and eight random hex digits, such as 20261012T143005Z-1a2b3c4d, so case ids sort by time. The manifest holds schema (1), id, created_at, case_uri, user_hash (the HMAC user id the agents service sees), session_hash (the first 16 hex digits of the session id's SHA-256), spec_id, library_id (the version's library), language (the session's locale), reaction, message, include_data, status (new), result_version, replaces (as the page sent it, or null), model, model_version, judge_model, prompt_hash (one hash over the config stamp's prompt hashes), pipeline_status, attempts, tokens, cost_estimate, files and bundle. Every field but bundle is a column of the case's row in the agent_feedback table, language as locale and schema as manifest_schema: core.agent_cases.row_from_manifest turns the manifest into that row, for the route and for the backfill of agents/evals/sync_cases.py alike (see Feedback triage). A field the bundle does not carry is null; today that is model_version, tokens and cost_estimate. A bundle that lacks the version's data while include_data is set is refused as 502 upstream, so no manifest claims data the case lacks.

The manifest is uploaded last, and every upload refuses to replace an existing object. A case directory without a manifest is an aborted upload, never a case: when an upload fails, the route answers 502 storage with a request id and uploads nothing more. A retried upload that meets an object under its own name counts as stored only when that object's MD5 matches the bytes it carries (its own first attempt stored them and lost the response); any other content is a conflict and 502 storage.

After the manifest, the route inserts the case's row into agent_feedback. When that insert fails (the database is unreachable, or a constraint refuses the row), the files are already stored: the route answers 502 row_missing with the request id and the stored case_id, so the page can name that case in its next edit, and sync_cases.py --backfill can index it later (see Sync promoted cases from the bucket). An API without a configured database answers 503 before it fetches the bundle, so it never stores a case it cannot index.

Sending feedback for the same version again stores a new case, and the page sends the earlier case's id as replaces. The new row supersedes the earlier one only when that row exists with the same session hash and result version and is not superseded yet; the client sends replaces, so the check keeps an edit from hiding another session's case. A submission without replaces supersedes nothing, and the case detail lists the other cases of the same version as siblings, so duplicates stay visible. The API deletes nothing, so a replaced case keeps its files, its data.csv too, until the bucket's lifecycle rule deletes them after 180 days; that rule is the retention for cases and aborted uploads alike. The API's runtime identity needs permission to create objects in the bucket, to read them (the case routes) and to read their metadata, which the check of a retried upload uses.

Feedback triage

The case routes (GET /cases, GET /cases/{id}, GET /cases/{id}/files/{name} and PATCH /cases/{id}) serve the "agent cases" section of /debug (app/src/sections/debug/). They never call the agents service. The agent_feedback row is the source of truth for a case's triage state: its status, tags, expected outcome, promotion and supersession. The bucket holds the files until its lifecycle rule deletes them, and the row keeps the class statistics after that. The table's columns are in Database schema.

  • List. The section opens on the open queue (status=open, which is new and in_progress) with superseded cases hidden, and filters by reaction, status, spec, library, model version and tag. model_version is null on every case until the agents service stamps it.
  • Detail. A case shows the PNG of each theme it holds, the code with a unified diff against the catalogue original, the transcript, the defect lines, the config stamp and your message. The diff runs against the catalogue at triage time, which a regeneration may have changed since the session: compare snapshot_library_version, the version the session adapted, with catalogue_library_version. The export's attribution header is left out of the diff.
  • Status and tags. The status takes the four values of the site feedback's triage; done and wont_solve retire a case. Tags name failure classes, such as legend-overlap.
  • Promotion. Promoting sets promoted_at and the outcome the regression harness expects (expected), and needs the case's data.csv, because the harness cannot run a case without it. The section shows the rule as guidance, and nothing enforces it: promote a case when your reaction contradicts the pipeline outcome, when it shows a class no fixture covers, or when it is a bug or idea with a reproducer; retire it once a fixture or a test covers it. On the dev machine, agents/evals/sync_cases.py pulls the promoted, active cases into agents/evals/.cases/ and removes the retired ones (see Sync promoted cases from the bucket).
  • Supersession. A superseded case keeps its status and tags and can still be changed; a new case starts as new without tags. When the backfill indexes a case after the edit that names it in replaces, the case goes in already superseded by that edit, in the same commit as its insert, so the chain is the same whichever of the two was indexed first.

The feedback route and the case routes need the migration that creates the table (alembic/versions/b4e7a9c2d6f1_add_agent_feedback_table.py). sync-postgres.yml runs it on the merge, in parallel with the API deploy. Until it has run, the feedback route stores the case and answers 502 row_missing, and the case routes answer 500.

Usage highlights

GET /debug/agent-usage?days=7 (also days=30; any other value is 400) is the admin-gated, read-only source of the "agent usage" card on /debug (api/routers/debug.py, api/agent_usage.py). It never calls the agents service: it runs one query over the BigQuery telemetry views of AGENT_TELEMETRY_DATASET and answers aggregates of live sessions only:

{
  "status": "ok",
  "days": 7,
  "sessions": 12,
  "plots_shipped": 30,
  "kept_rate": 0.4167,
  "kept_sessions": 5,
  "shipped_sessions": 12,
  "repair_rate": 0.3125,
  "repaired_runs": 5,
  "runs": 16,
  "top_specs": [{"key": "scatter-basic", "count": 5}],
  "top_formats": [{"key": "csv", "count": 9}],
  "top_refusals": [{"key": "unparseable", "count": 3}],
  "generated_at": "2026-10-16T12:00:00Z"
}

kept_sessions of shipped_sessions and repaired_runs of runs are the counts behind the two rates. A session is kept when the user copied or downloaded a result after its last run and sent no thumbs_down or bug feedback; a saved session alone is no keep. status is not_configured while the variable is unset and unavailable when the query fails; both come with zeros, null rates and empty lists. See Agent usage telemetry.

SSE protocol anyplot/1

Event Data
ready {v, run_id}
status {step: "queued", position, waiting} mid-turn, while the turn's plot run or data snippet waits in the run queue: position 1 runs next, and waiting counts every queued run, this one included
status {step, attempt} for a pipeline step: adapting, checking, rendering, reviewing, or repairing; repairing adds outcome and findings, how the round before it ended (see rounds below), and defects, the rubric ids the repair works on (VQ-01, VQ-05)
message {text}: the assistant's reply as plain text, at most 1,200 characters; a longer reply arrives cut to 1,200 characters when the turn ran the plot pipeline, and as the out_of_scope refusal otherwise
plot {status, reason, attempts, artifacts, changes, residual_defects, version, rounds}; version only on ok and needs_attention; rounds lists each attempt as {attempt, outcome, findings, step, shipped, defects} (step is the pipeline step the round ended in, shipped marks the round whose render the result shipped, defects are the rubric ids the round passed on to the repair), where outcome is ok (the review passed), review (the review named findings), measured (measured findings went to the repair), render_failed, check_failed, answer_unusable, unreviewed or error
refusal {code, text}; code is out_of_scope, needs_data (the request needs data the service would have to fetch, also code that reads its data from a URL or a file), other_library (this plot in another library or language: the fixed answer points to the plot's catalogue page and, by default, does not count toward closing the session; with AGENT_OTHER_LIBRARY_COUNTS=true it does), unsupported_content, budget, locked_out (paused until the next UTC day after three messages or pastes classed as attacks), rate_limited (more than 10 messages in a minute), daily_limit (more than 200 messages in a UTC day) or session_closed (the session's third out_of_scope or attack refusal, and every turn after it: reload the page to start a new session; the refusal before it ends with a fixed warning sentence), and text is the fixed reply for it in the language the scope judge detects in your message, or in the session language for a refusal sent before the judge runs (budget, locked_out, unsupported_content, rate_limited, daily_limit, and session_closed after the closing turn), which is English until your pasted data, your first message or your choice sets another (see Session language): English, German, French, Spanish, Italian, Portuguese, Dutch, Polish, Chinese, Vietnamese or Russian, and English for any other language
error {code, ref}; code is capacity (the run queue was full when the turn's plot run or data snippet asked for a place, the run waited the queue's maximum of 600 seconds, or the BFF's turn cap ran out while it waited), run_limit (you already have three plot runs or data snippets queued or running in other sessions, AGENT_USER_MAX_RUNS), deadline, guard_unavailable, upstream, or internal; ref is the request id
done {llm_calls, tokens, bindings?, data_confirmed?, dataset?, language?, suggestion?}: after a run, the session's bindings and whether the data is confirmed, so the page syncs its dropdowns and its step after a turn in which the root's set_bindings changed them; dataset only after a turn in which the assistant changed the data or computed it from a formula (see Data changes): {preview, profile, bindings, warnings, context_lines, roles, changes, version}, the dataset route's shape (roles at most 50; context_lines, the pasted notes, carried over to every version made from the paste) plus the turn's change lines, for the page's preview table; version is 1 when a formula or the user's code made the session's first data; code, only on a version computed from the user's code, holds the Python that ran; language after every run: {language, source}, the session language and how it was set (default, pasted, detected or chosen), so the page syncs its chip; suggestion after every run on a session with data: the .to_long() offer, or null, with its new column names in the session language, so the data panel's card names what .to_long() applies

The BFF re-frames the upstream stream instead of forwarding it. It assembles each complete event and drops events of any other type, events without a type, data that is not a JSON object, and every field the table does not list, so error_details and stack traces never reach the browser. An event over 64 KiB is dropped whole, except done, which may carry a dataset version and is relayed up to 512 KiB (the largest dataset the service accepts fits: the event carries the 20-row preview and the profile, never the data itself, so its size does not grow with the 1 MB paste cap or the 2 MB cap of data the assistant computes). An error code outside the list becomes internal. The BFF stops reading after done.

A turn starts at once. Only the work that runs in the sandbox waits in the agents service's run queue, which runs one such run at a time and starts at most one a minute: a plot run (the pipeline, after its check that your data is confirmed) or a snippet that generates data. A question, an answer about your data or code, a refusal, and a data change that needs no sandbox never wait. A run that can start at once sends no queued status. Otherwise, mid-turn, the stream sends a queued status at once, on every change of the position or the queue length, and every 15 seconds while nothing changes, and then the run's own events. A question streams ready, message, done; a plot streams ready, the queued statuses if it has to wait, the pipeline steps, plot, message, done. A turn whose run finds the queue full ends with error {"code": "capacity"} and done.

Each browser window opens a session of its own, and your sessions work in parallel: a turn, a paste or a data change in one window never waits for another window, and only their sandbox runs share the queue. You can have at most three such runs queued or running at the same time across your sessions (AGENT_USER_MAX_RUNS); a turn whose run would be the fourth ends with error {"code": "run_limit"} and done, before the queue's length is checked. Within one session it stays one thing at a time (409 run_active).

Before the scope check, and without any model call, the stream sends ready, a refusal and done at once when you are paused for the day after three messages or pastes the checks classed as attacks (locked_out; the count lives in the agents service's memory and resets at midnight UTC or when the service restarts), over the daily token budget (budget), past 10 messages in a minute (rate_limited) or 200 in a UTC day (daily_limit), counting typed messages and the page's actions alike, or when the session is closed (session_closed). The scope check closes a session at its third out_of_scope refusal; a needs_data refusal never counts, and by default neither does an other_library answer (a request for this plot in another library or language, which points you to the plot's catalogue page; it counts when the service sets AGENT_OTHER_LIBRARY_COUNTS=true). The refusal before the closing one ends with a warning sentence ("One more request outside this scope will close this chat."). A closed session answers every turn with session_closed, and its data panel takes no paste (403 session_closed); reload the page to start a new session.

While the stream is idle, a : ping comment arrives every 15 seconds. Every stream ends with done: when the agents service is unreachable, cuts the stream, runs past AGENT_REQUEST_TIMEOUT_S, or ends without done, the BFF sends error {"code": "upstream"} and then done {}. Time in the run queue does not count toward AGENT_REQUEST_TIMEOUT_S: every queued status starts the budget again with 15 seconds on top, because the run can start up to one queued status before its first event, and the first event after the wait starts it again. No turn runs past AGENT_TURN_MAX_S (1,190 seconds), which stays below anyplot-api's own request timeout of 1,200 seconds: a turn that is still queued when its run could no longer finish inside that cap ends with error {"code": "capacity"} and done {}, and leaves the queue before it spends a token. At the defaults, the agents service's full 600-second queue maximum plus the 540-second run fit inside the cap, so the BFF never cuts a wait short. Errors that happen before the stream starts, such as 413 too_long or an upstream 409 run_active, arrive as HTTP statuses instead.

Agent error responses

The agent routes answer errors as {"detail": "<code>", "ref": "<request id>"}; the kill switch, the CSRF guard, and the invalid_text check leave out ref, and the admin gate and FastAPI's own 422 keep the API-wide shapes. An upstream 4xx or 5xx keeps its status. Its code is one the agents service documents (not_eligible, run_active, too_long, unparseable, formula, code, data_refused, session_expired, guard_unavailable and capacity (both 503, worth a retry), no_dataset, nothing_to_undo, rate_limited, daily_limit, locked_out, session_closed, convert_unavailable and the file conversion's file_* refusals) or a generic one for the status (bad_request, not_found, conflict, too_long, invalid, rate_limited, rejected, upstream); the upstream body is never echoed, except the binding check's errors lines of a refused binding set (see the routes above), each cut to one line of at most 300 characters, a conversion refusal's measured and limit, whole numbers only, as {"detail": "file_too_many_rows", "ref": "<request id>", "measured": 48213, "limit": 20000}, and, since 2026-10-16, the cause of a refused paste (422 unparseable or 413 too_long from the agents service): one line of at most 300 characters that names what the data fails on, such as {"detail": "unparseable", "ref": "<request id>", "cause": "the header has 3 fields, but line 3 has 4; line 4 has 2; a value that holds a comma needs quotes around it"}. The page shows the cause in place of the code's own text (a whole sentence, cut at a word with … past 300 characters, control characters removed). A 422 file_corrupt also passes damage, the broken part of the file from a closed set (zip, workbook, sheet, parquet, arrow, npy_header, npy_data), which the page words; any other value is dropped. Every agent route refuses a body whose declared Content-Length is over about 6.7 MB (the conversion's base64 and its JSON) with 413 too_long before reading it, and the conversion answers 411 length_required to a chunked body. Two cases answer 502 instead:

  • upstream_auth: an upstream 401, or a 403 without a documented code. Cloud Run IAM refused the BFF's own ID token, which is not the admin's session failing.
  • upstream: the agents service is unreachable, an ID token could not be fetched, or a success response is not JSON.

A session bundle over 32 MiB answers 500 upstream on Cloud Run and 502 too_large where the BFF reads it itself (see Session bundle). The feedback route answers 502 storage when the case bucket refused an upload, 502 upstream when the bundle is not the shape the agents service documents, and 502 row_missing when the case is stored but its table row is not; that body also carries the stored case id, as {"detail": "row_missing", "ref": "<request id>", "case_id": "<case id>"} (see Feedback case). The case routes answer 404 not_found for a case id without a row and 502 storage when the bucket could not be read; PATCH /cases/{id} answers 422 no_data and 422 expected_required for a promotion it refuses (see Feedback triage).

Configuration

Variable Default Purpose
AGENT_ENABLED false The kill switch
AGENT_SERVICE_URL Unset Base URL of anyplot-agents, without /v1; also the ID-token audience
AGENT_USER_ID_KEY Unset HMAC key for the user id; from Secret Manager in production
AGENT_REQUEST_TIMEOUT_S 550 Upstream timeout, and the cap on one chat stream outside the run queue; above the agents service's 540-second deadline
AGENT_TURN_MAX_S 1190 Hard cap on one chat turn, queue wait included; keep it below anyplot-api's Cloud Run --timeout (1,200 seconds in api/cloudbuild.yaml)
AGENT_TELEMETRY_DATASET Unset <project>.<dataset> of the usage telemetry's BigQuery views (anyplot.agent_telemetry), which GET /debug/agent-usage reads; unset, that route answers status: not_configured (Agent usage telemetry)
AGENT_CASES_BUCKET anyplot-agent-cases The private EU bucket the feedback cases go to and the case routes read from (see Feedback case); never the public image bucket. The default is the production bucket, so a local API with Application Default Credentials stores its cases there too; set a bucket of your own for local runs

In production, AGENT_ENABLED, AGENT_SERVICE_URL and AGENT_TELEMETRY_DATASET come from the _AGENT_ENABLED, _AGENT_SERVICE_URL and _AGENT_TELEMETRY_DATASET substitutions in api/cloudbuild.yaml. Change them there: the deploy rewrites all three variables on every build.


Error responses

Standard error format

{
  "status": 404,
  "message": "Spec 'unknown' not found",
  "path": "/specs/unknown"
}

Errors that pass through the API's exception handlers (for example 400, 404 and 503) use this shape. Two responses use a detail body instead: request validation errors (next section) and the 403 that the origin gate returns to a caller that bypasses https://api.anyplot.ai. The agent chat routes use their own detail shape; see Agent error responses.

Request validation errors

A request whose body or parameters fail schema validation returns status 422 with FastAPI's standard detail list instead, one entry per problem:

{
  "detail": [
    {
      "type": "string_type",
      "loc": ["body", "message"],
      "msg": "Input should be a valid string",
      "input": 123
    }
  ]
}

The body is always ASCII: any non-ASCII character in the echoed input is JSON-escaped, so text that cannot be encoded as UTF-8, such as a lone surrogate (the JSON string "\ud800"), still gets a 422 rather than a 500. POST /feedback rejects such text in any field this way.

HTTP status codes

Status Description
200 Success
400 Bad request (invalid parameters)
404 Resource not found
422 Request body or parameters failed schema validation
502 External service error (GCS)
503 Database not available

Caching

In-memory cache

API uses in-memory caching with TTL:

  • Stats: 5 min
  • Specs list: 2 min
  • Individual specs: 2 min
  • Filter results: 30 sec

HTTP cache headers

Cache-Control: public, max-age=120, stale-while-revalidate=600

Applied to:

  • /libraries - 5 min
  • /stats - 5 min
  • /specs - 2 min
  • /specs/{spec_id} - 2 min
  • /plots/filter - 30 sec

Origin gate

The API runs on Cloud Run with ingress=all, so it answers on two addresses: https://api.anyplot.ai, which Cloudflare proxies, and the raw *.run.app URL, which it does not. Everything the edge enforces — the bot challenge, the WAF, the cache that makes the max-age=300 reads free — is one URL away from being bypassed.

A Cloudflare Transform Rule stamps X-Origin-Secret onto every request it proxies for api.anyplot.ai, and api/origin_gate.py refuses anything without it with 403. It is not authentication: it says "you came through the front door", nothing about who you are — require_admin still decides what a caller may do on /debug/*.

Unset means off. The gate is dormant unless ORIGIN_SECRET is set on the service, which is what makes local development, the test suite and the rollback work: remove the variable from the service, promote the resulting revision, and the gate is gone.

Arming and rolling back are the same procedure with one flag changed. Several things make it more than two commands, and each of them has bitten a comparable rollout somewhere. The whole block runs in a fail-fast subshell: half of these commands feed the next one, so continuing after a failed lookup would mutate the service from an empty variable and still print a plausible-looking health line at the end.

(
set -euo pipefail
SERVICE=anyplot-api
LOC="--project=anyplot --region=europe-west4"

# 0. Do not race the deploy pipeline. A build that ALREADY deployed its
#    candidate promotes it at the end — and that revision was cloned from the
#    pre-arm template, so the promote silently undoes the arm (its own smoke
#    accepts `off`, by design). A build that starts AFTER this block inherits
#    the binding, because the deploy is additive (`--update-secrets`). So the
#    dangerous window is exactly "a build already in flight".
gcloud builds list --project=anyplot --region=europe-west4 --ongoing --format="value(id)" | grep -q . && {
  echo "a Cloud Build is in flight; wait for it to finish (or fail) before arming."
  exit 1
}

# 1. Build the new revision from the image that is SERVING, not from whatever
#    is latest. `services update` clones the service's latest template, and the
#    deploy pipeline deliberately leaves each build's candidate there — smoked
#    and unpromoted on a good build, and still there on a bad one, because a
#    failed smoke skips the promote and nothing cleans it up. Pinning the image
#    means the arm/disarm revision serves what is serving now, whatever state
#    the pipeline is in; naming the revision alone would not have.
read -r SERVING LATEST <<<"$(gcloud run services describe "$SERVICE" $LOC --format=json \
  | python3 -c "import json,sys; d=json.load(sys.stdin); \
      t=[x for x in d['status']['traffic'] if x.get('percent')==100]; \
      print(t[0]['revisionName'], d['status']['latestReadyRevisionName'])")"
IMAGE=$(gcloud run revisions describe "$SERVING" $LOC --format="value(spec.containers[0].image)")
test -n "$SERVING" && test -n "$IMAGE" || { echo "could not resolve the serving revision or its image"; exit 1; }

# A mismatch is a WARNING, never a stop: it is normal right after a deploy, and
# it is permanent after a failed smoke — a hard refusal here would make the
# emergency rollback unavailable exactly when it is needed. With the image
# pinned, the remaining risk is only that the latest template carries a config
# change nobody promoted; check the revision afterwards if this fires.
test "$SERVING" = "$LATEST" || echo "note: latest ($LATEST) is not serving ($SERVING) — image pinned to the serving one"

# 2. Pin the secret to a NUMBER, never `:latest`. Cloud Run resolves a
#    secret-backed variable when each instance starts, so with `:latest` a new
#    secret version reaches new instances while older ones keep the old value —
#    and since the edge stamps exactly one value, the difference shows up as
#    intermittent 403s inside a single revision.
VERSION=$(gcloud secrets versions list ORIGIN_SECRET --project=anyplot \
  --filter="state=ENABLED" --sort-by=~createTime --limit=1 --format="value(name)")
test -n "$VERSION" || { echo "no ENABLED version of ORIGIN_SECRET"; exit 1; }

# 3. Update, then promote BY NAME. The service pins traffic to a named
#    revision, so the update alone serves nothing; and `--to-latest` here would
#    hand traffic to whatever the pipeline last built.
SUFFIX="arm-$(date -u +%Y%m%d%H%M)"
gcloud run services update "$SERVICE" $LOC --image="$IMAGE" \
  --update-secrets="ORIGIN_SECRET=ORIGIN_SECRET:$VERSION" --revision-suffix="$SUFFIX"
gcloud run services update-traffic "$SERVICE" $LOC --to-revisions="$SERVICE-$SUFFIX=100"

# 4. Confirm the result. Not optional: step 0 only narrows the race, and this
#    is what catches a build that promoted over the arm anyway — the verdict
#    would read `off` on a path that carries the header. If it does, that
#    promote reverted the arm; re-run the whole block.
curl -s "https://api.anyplot.ai/health"
gcloud run services describe "$SERVICE" $LOC --format="value(status.traffic)"
)

Rolling back is its own block, not the one above with a flag swapped. It has to run in the worst state the service can be in — which includes the secret having been disabled or deleted during the incident, so it must not look the secret up at all. Nothing here depends on anything but the currently serving revision:

(
set -euo pipefail
SERVICE=anyplot-api
LOC="--project=anyplot --region=europe-west4"

# No in-flight check and no secret lookup: when the gate is the outage, waiting
# for a build is the wrong trade, and step 2 above would abort here on a
# disabled version — leaving the gate armed at the moment it must come off.
SERVING=$(gcloud run services describe "$SERVICE" $LOC --format=json \
  | python3 -c "import json,sys; d=json.load(sys.stdin); \
      print(next(x['revisionName'] for x in d['status']['traffic'] if x.get('percent')==100))")
IMAGE=$(gcloud run revisions describe "$SERVING" $LOC --format="value(spec.containers[0].image)")
test -n "$IMAGE" || { echo "could not resolve the serving image"; exit 1; }

SUFFIX="disarm-$(date -u +%Y%m%d%H%M)"
gcloud run services update "$SERVICE" $LOC --image="$IMAGE" \
  --remove-secrets=ORIGIN_SECRET --revision-suffix="$SUFFIX"
gcloud run services update-traffic "$SERVICE" $LOC --to-revisions="$SERVICE-$SUFFIX=100"

curl -s "https://api.anyplot.ai/health"   # expect "off" or "off-seen"
)

Removing the Worker's binding is not a rollback — while the service is armed that takes the apex route down rather than freeing it. Roll back here first.

Rotating the secret means changing every side that must agree, and the gate accepts exactly one value — so there is no overlap window. Roll back first, rotate the Secret Manager version, the Transform Rule, the Worker binding and the ORIGIN_SECRET repository secret in GitHub Actions settings, then arm again on the new version number. The gate is off in between, which is the documented safe state; /health shows off-seen throughout, and ok when the new value is live on both sides.

The repository secret is the copy that is easiest to forget, because nothing about it lives in the Google Cloud console: sync-postgres.yml sends it as X-Origin-Secret on the cache flush, which goes to the direct run.app URL and therefore never passes the edge. Skip it in a rotation and the sync's last step starts failing with Cache invalidation was refused by the origin gate (HTTP 403) — loudly, by design, but a day after the rotation rather than during it.

Exempt paths — exact matches, no prefixes, and only this one:

Path Why
/health the deploy smoke probes the candidate revision on its run.app tag URL, which never passes the edge

/debug/cache/invalidate was the second until sync-postgres.yml learned to send X-Origin-Secret itself, out of the ORIGIN_SECRET repository secret. It still posts to the direct run.app URL — Cloudflare's bot challenge answers an unauthenticated curl POST against api.anyplot.ai with a 403 HTML page — but it now arrives carrying the header the edge would have stamped, so it needs no hole in the gate. Its own CACHE_INVALIDATE_TOKEN (constant-time compared, 503 when unconfigured) is the second lock behind the first. If the repository secret goes missing, that step fails with a message naming it rather than letting the flush go quietly stale.

OPTIONS is exempt too — a browser cannot attach a custom header to a CORS preflight, so a gate that refused one would break every cross-origin call instead of protecting anything.

/seo-proxy/… is not exempt. The site's nginx fetches the prerendered pages over https://api.anyplot.ai, so that path carries the header — while an exemption would leave the API's most expensive reads open on the direct URL: a cache miss or an unknown id queries the repositories, and a crawler user agent schedules an outbound Plausible event per request. Validate the crawler path end to end while the gate is still off (below), not with an exemption:

curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1)' https://anyplot.ai/scatter-basic | head -5

The second door, and where it is. This gate protects the API service. The app service (anyplot-app) also stands with ingress=all, and its nginx relays a crawler user agent through @seo_proxy to https://api.anyplot.ai, where the edge stamps the header legitimately — so a caller who sent a crawler user agent to the app's raw *.run.app URL used to reach the prerendered render and its DB queries, and this gate could not tell: the request it saw really did come through the edge. That was never a hole in this one. It is a second door on a second service, and it has a gate of its own now — app/origin-gate.conf.template, the same secret and the same five verdicts, reported on the app's /_health as X-Origin-Gate. Its rollout, the hostnames it covers and the callers that reach that origin without the edge are in infra/cloudflare/README.md.

Observing it. GET /health reports origin_gate for the request it was asked with, never the value:

Value Meaning
off not armed, no header arrived
off-seen not armed, the header arrived — the state to be in before arming
ok armed, header matches
missing armed, no header — this path would now be dead
mismatch armed, wrong value (a half-applied rotation)

That is what makes the rollout measurable: put the Transform Rule live while the gate is still off, ask every route into the service (api.anyplot.ai, the apex anyplot.ai/api/* Worker, the site's nginx, the raw run.app), and only arm it once every path that must keep working answers off-seen.

The apex Worker stamps the header itself rather than getting it from the rule, because a Worker subrequest to a host in the same zone bypasses that zone's Transform Rules. Its source and the measuring procedure live in infra/cloudflare/.


CORS configuration

Allowed Origins:

  • https://anyplot.ai
  • http://localhost:* (development)

Allowed Methods: All

Exposed headers: Mcp-Session-Id (MCP sessions) and X-Request-Id (the agent chat's reference id).

The origin gate sits directly inside CORSMiddleware, so a 403 from it still carries the CORS headers a browser needs to read it as a 403 rather than as an opaque network error.


GZip compression

Responses > 500 bytes are compressed with GZip.

Example: /plots/filter response: 301KB → ~40KB compressed.


OpenAPI documentation

Interactive API documentation available at:

  • Swagger UI: https://api.anyplot.ai/docs
  • ReDoc: https://api.anyplot.ai/redoc
  • OpenAPI JSON: https://api.anyplot.ai/openapi.json

For database schema, see database.md