Skip to content

Latest commit

 

History

History
1984 lines (1652 loc) · 135 KB

api.md

File metadata and controls

1984 lines (1652 loc) · 135 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": 15
  }
]

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",
      "language": "python",
      "preview_url_light": "https://storage.googleapis.com/anyplot-images/plots/scatter-basic/python/matplotlib/plot-light.png",
      "preview_url_dark": "https://storage.googleapis.com/anyplot-images/plots/scatter-basic/python/matplotlib/plot-dark.png",
      "preview_html_light": null,
      "preview_html_dark": null,
      "preview_url": "https://storage.googleapis.com/anyplot-images/plots/scatter-basic/python/matplotlib/plot-light.png",
      "preview_html": null,
      "quality_score": 91.0,
      "code": null,
      "generated_at": "2025-12-10T20:55:10",
      "updated": "2026-06-25T01:34:04",
      "generated_by": "claude-sonnet",
      "python_version": null,
      "language_version": "3.13.14",
      "library_version": "3.11.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",
      "impl_tags": {"styling": ["alpha-blending"], "patterns": ["data-generation"]}
    }
  ]
}
  • code is always null here; the page loads it on demand from GET /specs/{spec_id}/{library}/code.
  • preview_url and preview_html are legacy single-theme fields that resolve to the light variant.
  • language_version is the runtime version of the implementation's language (Python, R, Julia, or Node.js). python_version is the older field it replaces, and language_version falls back to it for rows without one.

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",
      "language": "python",
      "framework": "none",
      "version": "3.11.2",
      "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 (live values on 2026-11-02):

{
  "specs": 325,
  "plots": 4863,
  "libraries": 15,
  "languages": 4,
  "lines_of_code": 896029
}
Field Meaning
specs Specifications with at least one implementation
plots Implementations across all libraries
libraries Libraries in the libraries table
languages Distinct languages of those libraries
lines_of_code Lines of code summed over all implementations

Without a database, the endpoint answers 0 for specs, plots, and lines_of_code, and the library and language counts from core/constants.py.


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 /insights/visitors

Purpose: Unique visitors per day for the visitors strip on the stats page

Queries the Plausible Stats API for today and the 27 days before it, oldest first, zero-filled:

{
  "points": [
    {"date": "2026-10-13", "visitors": 41},
    {"date": "2026-10-14", "visitors": 0}
  ]
}

points is empty when PLAUSIBLE_API_KEY is unset or Plausible fails, so the page shows its placeholder instead of an all-zero strip. Cached with stale-while-revalidate (1h refresh); an empty answer is cached too.

GET /insights/machine-readers

Purpose: Verified crawler and AI assistant reads per day for the machine readers tile on the stats page

Reads the machine-read telemetry in BigQuery (api/machine_reads_query.py) for today and the 27 UTC days before it, oldest first, zero-filled. A point counts live, served reads (2xx or 304) of pages, code, specs, and MCP that Cloudflare verified, by audience. Each point's mcp, and so totals.mcp, counts the served MCP requests of every client, verified or not; only the three audience fields are verified-only. totals.unverified, a total only, counts the reads of a named vendor in the same audiences that Cloudflare didn't verify. Counts only: no IP address, user agent, path, or query string.

{
  "status": "ok",
  "days": 28,
  "points": [
    {"date": "2026-11-09", "for_person": 2, "index_training": 16, "search": 3, "mcp": 4}
  ],
  "totals": {"for_person": 2, "index_training": 16, "search": 3, "mcp": 4, "unverified": 26},
  "generated_at": "2026-11-09T13:50:00Z"
}

Always 200. status is not_configured while MACHINE_READS_DATASET is unset or not a <project>.<dataset> name, and unavailable when BigQuery doesn't answer; both come with empty points and zero totals, and neither is cached, so the next request tries again. An ok answer is cached with stale-while-revalidate (1h refresh); a failed refresh keeps the last answer. See Machine readers.

GET /debug/machine-readers

Purpose: Every machine read of the last 7 or 30 days for the machine readers section on /debug

Admin-gated like the other /debug/* routes; days=7 (default) or days=30, any other value is 400. It counts every live line of the window, whatever its surface, status, or verdict, and the smoke checks' synthetic lines apart:

{
  "status": "ok",
  "days": 7,
  "generated_at": "2026-11-09T13:50:00Z",
  "requests": 81,
  "verified": 50,
  "unverified": 31,
  "mcp_calls": 4,
  "synthetic": 10,
  "vendors": [
    {
      "assistant": "ahrefs", "kind": "seo", "category": "Search Engine Optimization",
      "requests": 25, "verified": 25, "unverified": 0,
      "pages": 3, "code": 0, "og": 2, "mcp": 0, "not_found": 1,
      "top_paths": [{"key": "plots_filter", "count": 3}]
    }
  ],
  "daily": [
    {"date": "2026-11-09", "for_person": 0, "index_training": 16, "search": 4, "seo": 30, "other": 0, "unverified": 31, "mcp": 4}
  ]
}

vendors holds one row per vendor and purpose, most requests first, with its five most requested routes; daily holds every day of the window, newest first. status is not_configured or unavailable as for /insights/machine-readers, with zeros and empty lists. An ok answer is kept for 10 minutes per window.

Both machine-reader routes read the dataset named by MACHINE_READS_DATASET (<project>.<dataset> of the views, anyplot.machine_reads; the queries read its linked log dataset, the name plus _logs). In production it comes from the _MACHINE_READS_DATASET substitution of the deploy-api trigger, which api/cloudbuild.yaml writes on every deploy.

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.

GET /preview/relay

Purpose: The static relay of the agent chat's interactive preview of a JavaScript plot (since 2026-10-31, api/preview_relay.py)

The result card frames it with sandbox="allow-scripts". Once its listener is in place, the relay posts the constant "anyplot-relay-ready" to its parent, a signal without content and the only message it ever sends. On that signal from the relay frame's own window, and only then, the card posts the version's plot.html (the page GET /debug/agent/sessions/{sid}/artifacts/plot.html serves) into it once, so a page that stands in at this URL (an error or challenge page of the edge) never receives it. The relay writes that page into an <iframe sandbox="allow-scripts"> through the srcdoc property. It takes the first message that comes from its parent at an allowed origin (the site's CORS_ORIGINS or http://localhost:*) and holds a string of at most 32 Mi characters, and ignores every other one. No token, no cookie, no data: it sits outside /debug/*. The response is the same for every caller:

  • Content-Security-Policy: sandbox allow-scripts; <the browser harness's policy>; frame-ancestors <the site's origins> http://localhost:*: an opaque origin, no request, no eval, and only the site may frame it
  • Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, Cache-Control: public, max-age=300

Feedback endpoints

POST /feedback

Purpose: The site's feedback widget and the 👍/👎 on a plot page (api/routers/feedback.py)

The body is {message?, reaction?, contact?, path?, spec_id?, library_id?, language?, viewport?, session_id?, website}; at least one of message (500 characters at most) and reaction (thumbs_up, thumbs_down, bug or idea) is required, and website is the honeypot. The answer is {"status": "ok"}, also for an entry the spam filters drop silently. A call needs the bot check's X-Turnstile-Token (see Bot check). An address may send 5 messages a minute; a reaction without a message is refused once the address has sent 30 entries in the last minute. Both limits answer 429.

A plot vote is a thumbs_up or thumbs_down that names spec_id and library_id. It belongs to the version of the image it was cast on: the implementation's current version began at its updated (impls.updated, the metadata's updated), and a regeneration starts a new one (core/plot_votes.py). The API drops a second vote from the same session_id on the same version silently, so a visitor can't flip a vote, and lets the same session rate each new version once. A regeneration deletes no row.

The page keeps your own vote in localStorage under anyplot_plot_votes, keyed spec/language/library@updated, so a vote on an earlier version doesn't ink the thumbs of the new image. A key from before votes were kept per version has no @: it inks nothing and blocks nothing, and the API still drops a repeat on the same version.

GET /debug/feedback/votes

Purpose: The "feedback · votes per plot" list on /debug, behind the admin gate. It replaces GET /debug/feedback/top, which grouped the rows of one reaction by page path.

Parameter Type Default Description
sort string thumbs_down thumbs_down, thumbs_up or total: the current version's count to rank by
limit int 25 Number of plots (1-100)

Returns the plots with the most votes, one entry per implementation:

[
  {
    "spec_id": "scatter-basic",
    "language": "python",
    "library_id": "matplotlib",
    "path": "/scatter-basic/python/matplotlib",
    "thumbs_up": 1,
    "thumbs_down": 4,
    "earlier_thumbs_up": 6,
    "earlier_thumbs_down": 2,
    "last_vote": "2026-11-08T12:00:00+00:00"
  }
]

thumbs_up and thumbs_down count the votes on the current version, and the earlier_* fields the votes on the versions before the last regeneration. They are counted the way FeedbackRepository.reaction_counts counts them with since and with before: on each side, one vote per visitor session, the newest. total ranks by both current counts together. A tie goes to the earlier versions' count of the same kind, then to the newer last_vote (UTC). The response holds counts only: no message, contact, or session id. Page reactions from the floating widget name no implementation and aren't in the list.

GET /debug/feedback/messages and PATCH /debug/feedback/{id} serve the messages list and its triage status on the same page.


Agent chat

Status (2026-11-06): the admin routes under /debug/agent are live, and the public lane under /agent has been open since 2026-11-02, as an experimental page: the deploy-api trigger sets _AGENT_PUBLIC_ENABLED=true. Set back to false, the lane answers 404 again while the admin routes stay open (see Public lane). Since 2026-11-06 one chat page, /agent in the app, calls both: the admin lane for a browser it detects as an admin's (the admin hint plus a successful GET /debug/agent/status), the public lane for everyone else; the routes themselves are unchanged.

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 (/agent since 2026-11-06), 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.

The BFF also sends X-Anyplot-Tier: admin on every upstream call, from the identity the admin gate verified, never from anything the browser sends. The agents service then lets you have up to five plots queued or running at once (two for a public user), and exempts you from the per-user daily caps and the per-minute message limit. Every run shares the service's five slots and its start rate of one run every 10 seconds. The global daily budget still applies. An agents service reads a missing or other value as the public tier, so the two services deploy in either order.

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.

Public lane

The /agent/* routes (api/routers/agent_public.py, since 2026-11-02) open the chat to everyone, labelled experimental. They call the same route functions as the admin lane, so each route behaves as its admin twin in Routes, with these differences:

  • Door. Every public route answers 404 {"detail": "agent chat is not enabled"} unless AGENT_PUBLIC_ENABLED is true on top of the admin lane's switch and configuration. AGENT_ENABLED stays the master switch, so closing the public door leaves the admin lane open. The deploy sets it from the trigger's _AGENT_PUBLIC_ENABLED, and its smoke test (api/deploy_smoke_public.sh) checks that GET /agent/lane answers 404 while the door is closed, and while it is open 200, a public GET /agent/status that reaches the agents service, and a POST /agent/sessions with Cloudflare's dummy token that the bot check refuses with 403 bot_check_failed. In production the door also stays shut until both the origin gate (ORIGIN_SECRET) and the bot check (TURNSTILE_SECRET_KEY, and none of Cloudflare's test secrets) are armed.
  • Identity. No login. Your page keeps a random id per browser tab in sessionStorage and sends it as X-Anyplot-Anon (22 to 64 characters of [A-Za-z0-9_-]; without it, 400 anon_required). The BFF forwards it with your network: the IPv4 address, or the /64 prefix of the IPv6 address, from Cloudflare's CF-Connecting-IP. The agents service salts both with a random per-process salt before it keys anything on them, keeps them in memory only, and logs neither, so no durable key links your usage to an address. Every request asserts X-Anyplot-Tier: public.
  • Bot check. Opening a session (POST /agent/sessions) and storing a feedback case (POST /agent/session/feedback) need a Cloudflare Turnstile token in X-Turnstile-Token (see Bot check); the other routes act on a session such a call opened and take none.
  • Session id in a header. Cloud Run's request log keeps every URL with the client address for 30 days, so the session id travels as X-Anyplot-Session instead of in the path, and the BFF's own log lines never name it.
  • Limits. The public tier's per-user limits apply to your tab id, and the per-IP limits to your network: at most 4 sandbox runs queued or running at once (AGENT_IP_MAX_RUNS; one more ends the turn with error {"code": "run_limit"}), 100 pipeline runs and 20,000,000 cost-weighted tokens a UTC day (AGENT_IP_DAILY_PIPELINE_RUNS, AGENT_IP_DAILY_TOKEN_BUDGET; past either, the turn gets the budget refusal), and twice the per-user limits on pastes, file conversions, chat turns and outcome beacons, per minute and per UTC day (the same rate_limited and daily_limit answers). These are twice the per-user limits, for shared networks. A network also holds at most 8 live sessions (AGENT_IP_MAX_SESSIONS; one more POST /agent/sessions is 429 session_limit) and 32 MiB of the dataset store (AGENT_IP_DATASET_BYTES; past it a paste is 503 capacity, as for a full store). The run queue holds at most 10 waiting public runs (AGENT_PUBLIC_QUEUE_MAX_WAITING), and one anyplot-api instance relays at most 20 public chat turns at once (AGENT_PUBLIC_MAX_STREAMS; one more is 503 capacity before it reaches the agents service), so the catalogue keeps the other half of the instance's 40 request slots. A network may store at most 20 feedback cases an hour per instance (AGENT_PUBLIC_FEEDBACK_PER_HOUR; one more is 429 rate_limited).

All paths start with /agent. A session route takes the session id as X-Anyplot-Session and has no {sid} in its path.

Route Admin twin, or what differs
GET /lane Public only: {"open": true} with Cache-Control: public, max-age=60, no identity needed and no upstream call, so the landing page and every spec page can ask whether to show the entry points. Closed, it is the router's 404
GET /status GET /status, with only the fields enabled, libraries, model, location, provider, version, waiting, in_flight, user_max_runs, tier, paused and resets_at; a field the agents service adds later stays out until it is named in PUBLIC_STATUS_FIELDS
GET /eligibility?spec=&library= GET /eligibility, with Cache-Control: public, max-age=300: the answer is the same for every caller, and the plot page asks on every visit
POST /sessions POST /sessions with {spec_id, library, locale} and X-Turnstile-Token: source is admin-only (the fan-out) and a 422 here; the token is checked after the body and before the agents service is asked
DELETE /session DELETE /sessions/{sid}
POST /session/library POST /sessions/{sid}/library
GET, DELETE /session/memory; DELETE /session/memory/{entry_id} The plot memory routes
POST /session/dataset, POST /session/dataset/convert The paste and the file conversion
PUT /session/bindings, PUT /session/language The bindings and the session language
POST /session/dataset/confirm, /undo, /to_long; GET /session/dataset/data.csv The data steps and the data view
POST /session/messages?theme= The chat turn, behind the per-instance stream cap
GET /session/turn The rest of the latest turn after a cut stream, behind the same stream cap; the turn's run id travels in Last-Event-ID, a header like the session id, never in the URL
POST /session/cancel, POST /session/outcome Stop, and the outcome beacon
POST /session/versions/{version}/render, GET /session/artifacts/{name}?v=&theme= The theme toggle and the result files
POST /session/feedback The feedback case, behind X-Turnstile-Token and then the per-network hourly cap, so a call without a token never spends the cap. The case's user_hash is a salted hash of your tab id, never the id itself

The admin lane's fan-out copy (POST /sessions/{sid}/dataset/copy), the session file of .save() (GET /sessions/{sid}/bundle; the public page has offered no .save() since 2026-11-02, and its public twin is gone), the day budget (GET /budget) and the feedback triage (/cases*) have no public twin. A test maps every admin route onto a public one or onto that list, so a new admin route fails the build until someone decides it for the public lane.

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, user_max_runs, tier, paused, resets_at} plus "enabled": true; waiting counts the turns in the agents service's run queue, in_flight the runs it is running, user_max_runs is how many sandbox runs you may have queued or running at once (AGENT_ADMIN_USER_MAX_RUNS, 5, for an admin; AGENT_USER_MAX_RUNS, 2, for a public user), tier is admin or public, and paused says the service's day budget is spent, so every turn gets service_paused until resets_at, the next 00:00 UTC. The chat page shows it before you type as the mood on the chat window's tab (resting until 01:00), and reads this route again after each run, when its tab becomes visible again and every 60 s while it is visible
GET /budget None The /debug page's live line for today: {date, since, started_at, resets_at, paused, model, tokens, budget_tokens, usd, budget_usd, users, users_at_limit, limits}. Counts and times from the agents service's memory, never a user id; see Today's spend
GET /eligibility?spec=&library= Spec id and library id The agents service's answer, passed through
POST /sessions {spec_id, library, locale, source?} (source: fanout only, sent by the admin's "plot in all libraries" for each session it opens, so the usage telemetry files the session apart from live ones) {session_id, eligibility, roles}: roles lists the spec's data roles as the dataset answer names them ({name, kinds, required, variadic, description, non_negative, computed}, at most 50), so the page can show the columns the plot expects before you paste anything; 404 not_found when the spec has no implementation for the library
POST /sessions/{sid}/library {spec_id, library, source?} (source: menu, the default, for the page's library menu, or chat for a switch you asked for in the chat; it only labels the usage telemetry line) Switches the library: the dataset, its bindings and their confirmation, the language, a request kept for the first plot, the earlier versions and the plot memory stay. {session_id, eligibility, target, carry}: target is the new {spec_id, library}; carry is {decisions, newer, target_has_plot, from_plot, offer}, where decisions counts the decisions a plot in the new library would take over (the plot memory's entries), newer the ones written after that library's own latest plot, and offer says the page should offer that plot (the carry_plot action): something carries, and the library has no plot yet or its plot misses newer decisions. Another spec than the session's is 422 invalid
GET /sessions/{sid}/memory None The plot memory (since 2026-11-02): {entries, max_entries, written}, oldest first, each {id, kind, target, text, expect, turn}; written is true once the memory has ever held a decision, from then on its entries alone (also none) are what a plot in another library takes over. kind is one of title, subtitle, axis_label, scale, limits, ticks, color, marker, line_style, reference_line, annotation, legend, theme, language, sort, layout and other; target is x, y, color, series, plot or null; text is the decision in plain words (at most 200 characters), expect the text it puts in the plot verbatim, or null. The assistant adds entries with a plot change once it has shipped; they live in the session only and go with it. See Plot memory
DELETE /sessions/{sid}/memory None Forget every decision, for a blank start: the memory after it (no entries), as the GET answers it. The plot itself does not change. 409 run_active while a turn runs
DELETE /sessions/{sid}/memory/{entry_id} entry_id such as m3 The memory after the removal, as the GET answers it. The plot itself does not change; the next plot no longer takes the decision over. 404 not_found for an id the memory does not hold, 409 run_active while a turn runs
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, can_undo, original_version, columns_changed, language} for a table, stored as the first version of new data under the session's next number (version, also original_version; can_undo is false, since an earlier paste's undo history is dropped; see Data changes for the numbering; columns_changed is true when the paste replaces data and a column a binding of that data used has no column of the same name in the new table, see Plot memory; 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. Two or more comment lines (#, % or //) that open a paste are notes too; when the table below them has no header line, its last one or two comment lines name the columns, with an entry in warnings. A header of years or months under such metadata is the header, a : cell in a number column is a missing cell, and a column of flags (each cell one to three letters, such as p or ep) with an empty header beside a named number column stays a text column named <column> flag, with an entry in warnings that counts the flags. A Markdown table's row of dashes under its header (`
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 (a role bound twice, or one column on two members of a variadic family such as y1 and y2; one column on several other roles is valid), 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, via?}: 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}$; via is header (the chip, the default) or offer (the .switch() of the thread's language offer, since 2026-11-08), forwarded only when it is offer The agents service's {language, source}, the stored form (English comes back as en, Swedish as typed) with the source now chosen from the chip, which ends every offer, or offer from an offer, 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 (the pick sets the plot's language too), and a typed message or a paste in another language only offers that language (see Session language). Versions made before keep their text. 422 for a value outside the pattern or another via, 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 message box's attachment holds, 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. A plain 1-D .npy array is one column, value; an .npz of two or more plain 1-D arrays of one length (what np.savez(path, t=t, y=y) writes) is one table, a column per array named after it, with sheet null and sheets empty, and its 0-D members are left out and named in dropped; any other .npz is read one array at a time, a 2-D or structured array before a 1-D one, which is the column of its name (value where the name could read as a value, such as 2024). An array row whose cells are all missing comes as NaN, so the paste keeps it, and an array without any value is file_empty; 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 1-D or 2-D array and no 1-D structured one), 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 your daily token budget, 429 service_paused, 429 locked_out, 503 capacity (no render slot within 20 seconds) and 503 convert_unavailable (the renderer cannot convert files, or its sandbox could not install the process filter every run requires)
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, can_undo, original_version} like the dataset route (the wide-table offer comes back with a wide version), unconfirmed. version is the restored version's own number, and can_undo says whether another undo has a version to restore. 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/copy {from_session}: the session id of the page that opened the fan-out, and nothing else The admin's "plot in all libraries": the agents service copies the confirmed current data of from_session into this fan-out session as it is stored, whatever its origin (pasted, opened, computed or changed by the assistant), with its bindings, their confirmation, the session language and the language of its plot's text, and answers like the dataset route (the fan-out session's first version, numbered 1) plus confirmed: true, complete, missing_roles, copied_from (the source's version number) and, since 2026-11-02, carry (the switch answer's shape: the copy also takes over the page session's plot memory and a carry of its latest plot for this library, so with offer the fan-out plots with carry_plot). Nothing is parsed or judged again, and the copy counts as no paste. Both sessions are looked up under your user id: another user's session is 404 session_expired. 409 has_dataset when this session already has data, 422 no_dataset or 422 unconfirmed when from_session has no confirmed data, 422 invalid for a session that is no fan-out session or another spec, 409 run_active, 429 rate_limited / daily_limit past 30 copies a minute or 150 a day for a public user (an admin has neither limit, only the store's), 503 capacity. An agents service without the route answers 404 not_found, on which the page posts the paste again
GET /sessions/{sid}/dataset/data.csv None The current data version's canonical CSV, exactly as the agents service stores it (at most 4 MiB), for the data panel's view of every row (.view_all(), since 2026-11-02): text/csv; charset=utf-8 with Cache-Control: private, no-store and X-Content-Type-Options: nosniff, gzip-compressed when your browser accepts it. Read-only: it needs no confirmation and is not blocked while a turn runs. 422 no_dataset before any data, 502 too_large above the cap. An agents service without the route answers 404 not_found
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?theme= {text} (at most 2,000 characters, and not spaces alone: 422), {"action": "create_plot"} (the plot from the catalogue plot, also .start_over(): it keeps the plot memory's decisions, and no switch's carry), {"action": "carry_plot"} (after a library switch: the plot in the new library from its catalogue plot, with the plot memory's decisions and the theme you asked for), {"action": "plot_new_data"} (since 2026-11-08, "plot the new data" after a data change or a paste: from the library's latest plot when that plot drew the same paste with the same bindings, else from the catalogue plot like create_plot, keeping the theme you asked for; every plot action draws the data and the bindings you confirmed as they are, and the assistant changes neither in that turn), {"action": "summarize_data"} or {"action": "example_data"} (the page's .example_data(): the assistant makes example data to try the plot, which the turn's done carries as the next dataset version, version 1 in a session without data); the optional query parameter theme (light or dark) is the site theme your page shows, which a plot of the turn renders in unless you asked for a theme in the chat; any other value is 422 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, and a question only when the data would be read wrong without the answer) 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"
GET /sessions/{sid}/turn Header Last-Event-ID: <run_id>.<n>, the id of the last turn event your page read An SSE stream in protocol anyplot/1: the session's latest turn from its event n + 1 on, then live until the turn ends (see Reading a turn again); 404 not_found when the session's latest turn is another one, 422 for an id of another form, 502 upstream when the agents service cannot be reached
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_page (a JavaScript plot's plot.html, since 2026-10-31), opened_preview (that page shown in the card, since 2026-10-31), opened_data_view (the data panel's view of every row, with version 0, since 2026-11-02), 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=&theme= name is one of plot-light.png, plot-dark.png, the version's code artifact (plot.py for a Python library; plot.R, plot.jl, plot.js or plot.tsx for the other languages, since 2026-10-24), plot.html for a JavaScript version (since 2026-10-31), and data.csv; theme (light or dark) is forwarded for plot.html alone 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). plot.html is the self-contained interactive page in theme (default: the version's), which the agents service builds on demand, answered as text/html; charset=utf-8 with Content-Disposition: attachment; filename="plot.html", Content-Security-Policy: sandbox and Referrer-Policy: no-referrer, set here and never forwarded; it can also answer 409 run_active, 503 capacity, 503 page_unavailable (a renderer without its page route) and 500 page_failed
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 code file, plot.py for a Python library), 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, rounds (the version's rounds, each {attempt, outcome, findings, step, shipped, defects, gates_failed, sandbox_failures}), session_versions (the versions the session held) and last_run ({status, reason, version, turn, rounds}, how the session's latest plot run ended), all three closed codes and counts, empty or null for a case sent before the agents service carried them, 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, config, session_versions and last_run are null, the lists residual_defects, changes, bindings and rounds 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, the case's code file (plot.py, plot.R, plot.jl, plot.js or plot.tsx), 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 the warnings: one line per non_negative role bound to a column with a negative value or left open because every fitting column has one, and, for a plot that draws one value per key (a bar per category, a point per x), one line when the bound key columns repeat in the rows, with the counts, such as x: column 'Length' holds 9 values in 27 rows; the plot draws one value per x, so rows with the same x need an aggregate first. A warning is 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, under the session's next number. 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 the paste, and an undo with nothing left answers 422 nothing_to_undo. A new paste is new data: it takes the session's next number and drops the history.

The session numbers every version it stores once and never gives a number twice: a paste, an opened file, a picked sheet or table, a copy and a computed table as much as a change, .to_long() or a rebuild from an earlier version. An undo makes the version it restores current again under its own number, so a paste, a change, an undo and a change are versions 1, 2, 1 and 3, and a second paste after them is version 4. The data panel's card shows this number (data v3), and the assistant names versions by it, so "v2" is the same table to you and to the assistant. Every dataset answer and done.dataset carries three fields about it:

  • version: the version's number in the session.
  • can_undo: whether POST /sessions/{sid}/dataset/undo has a version to restore. It is false after every paste and for the first data a formula or your code computed, and true after a change. Read it instead of the number: past the history cap a version above the paste's number has nothing left to undo either.
  • original_version: the number of the current data's first version, its paste, file, copy or computed table, which from_version: "original" reads.

When the bindings of a paste leave a required role open and a column reads as what the role takes without a guess (a column of years as dates, prices such as $5 as numbers, a column of ranges such as 10–25 split into its two bounds, or the row numbers on an open axis), the service makes that conversion itself on the summary turn of the paste (the summarize_data action, or the note sent with the paste), before the assistant answers. It writes a new column beside the original, binds the role to it, and stores the next version with every such conversion in it, so one undo takes them back. The turn's done event carries that version, or the assistant's next one on top of it, as above, with the conversions' change lines first in changes. A conversion that would guess, such as the day and month order, the century of a two-digit year or the decimal mark, stays the assistant's question, and no later turn makes one by itself.

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 the first version of new data 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 then sends it to the chat as your message by itself. 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 the first version of new data when the session has none. 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, at any length; within the chat's 2,000 characters the page sends it to the chat as your message, and longer code stays in the field with a line that asks you to shorten it.

To try a plot without data of your own, ask for example data, in the data field or in the chat: "Beispieldaten", "random numbers with 3 categories", "monthly sales for 4 regions". In the data field such text answers 422 data_request, and the page sends it to the chat as your message. The assistant reads the plot's data roles and writes Python that generates a small, realistic table for them, with the counts, ranges and subject you name and a fixed seed, so the same request gives the same table. It runs through run_data_code like your own code, and the change line says plainly that the table is made up: generated example data: <rows> rows, <columns> columns. You check it and confirm it as any other data. A request for real figures, such as a named company's sales, gets the fixed data refusal instead.

Session language

Every session has a language for the assistant's replies, which also sets the text of your first 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, and stays English until you pick another one (since 2026-11-08).

When you write a message, or paste data whose notes or column names are, in another language, the chat offers that language once, in one line: # German detected in your message · .switch() (or in your data). As long as you ignore the line, the replies and the plot stay English, and the same language is not offered again. .switch() sets the replies and the plot labels to that language (source offer), and the line then reads # replies and plot labels: Deutsch. When you already have a plot, the click starts no run: the next version comes in the new language. After you switch, an English message of three words or more may offer English back, once. Column names such as temperature_2m_max, a very short English message such as a formula, a refused message and the create_plot, summarize_data and example_data actions offer nothing.

You can also pick a language yourself: choose one from the language menu in the chat header, type any language name in Latin letters into its field, or ask in the chat ("auf Deutsch bitte"). A pick sets the replies and the plot (source chosen), and no language is offered after it. Ask for "labels in English" to change only the plot's text, or for "answers in French" to change only the replies. Once you have a plot, its text keeps its language (since 2026-11-01) until you pick another. 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. The fixed refusals use the language the chat replies in. After each turn, the done event carries the language of the replies as {language, source}, with offer: {language, basis} when the turn's message offered one, and so does the answer to a paste.

Plot memory

The plot memory (since 2026-11-02) holds the decisions you made about the plot, such as a horizontal line at 7, a formula as an annotation, outliers as red stars, an axis label in your words, or the x limits. The assistant adds a decision together with the change that carries it out, and the memory keeps it only once that plot has shipped. A new title, label, scale, limit or colour replaces the one it changes; a reference line, an annotation or a marker style is added beside the others. The memory holds at most 30 decisions of at most 200 characters each, 3,000 characters in all.

Every later plot of the session takes the decisions over, also in another library: the assistant carries each one out with that library's own means (a formula typeset the way the library typesets math, the nearest marker shape it has). After a library switch, the plot in the new library starts from that library's catalogue plot with your decisions (carry_plot), instead of resuming an older plot there. .start_over() (create_plot) starts again from the catalogue plot, the way out of code that went wrong, and keeps your decisions. Nothing clears the memory but you: remove one decision, or forget all of them for a blank start. Once the memory has held a decision, it alone is what a plot takes over: a decision you removed does not come back from the requests that made an earlier plot.

New data keeps the memory too (since 2026-11-08). When you paste a table that lacks a column the data before it was bound to, the paste answer says columns_changed: true, and the page shows the decisions on the new data's line with .forget_all() until the next plot; ignore the line to keep them. A plot wish you send with the new data, typed or in the pasted notes, is kept for the first plot of that data, also when the library has a plot of the data before. What a library switch carried from the plot of the data before (its theme, its texts, its requests) goes with every new data version: a paste, a change, .to_long() or an undo. A switch or the admin fan-out's copy after the data changed carries only the theme you asked for on the plot of the earlier data, never its texts or requests, besides the memory.

"Plot the new data" (plot_new_data) keeps what you tuned while it can: when the data comes from the paste your latest plot in this library drew, and the same columns stand in the same roles, the plot changes that version for the new data with a request the server writes, and keeps every other choice of its code. After a new paste, a binding change or a reshape such as .to_long(), it starts from the catalogue plot with your decisions, as .start_over() does. The plot event's new_data says which, and the page's card label names it (new data on v3, or new data, new from the catalogue).

The memory lives in the agents service's memory only and goes with the session; the usage telemetry counts its entries by kind, never their text.

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 tool calls' names and codes are in turns), 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, turns, 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. session also holds two content-free facts, in every form of the bundle: versions, the number of versions the session holds, and last_run, how its latest plot run ended (status, reason, the stored version or null, the user turn and its rounds), or null before any run. Each round of a plot result carries gates_failed, the ids of the host gates its renders failed or reported on (R1, R3, G8), and sandbox_failures, the closed class of each render that crashed (key, timeout, memory), never an error message.

turns is the record of the session's latest turns, in every form of the bundle too: records, in turn order, and dropped, how many older turns the record no longer holds (it keeps the latest 50). Each record holds closed codes only, never an argument, a result, your words, an id or a time:

  • turn, action, kind, refusal and error: the turn's number and how it ended, as the usage telemetry's chat_turn line records them. Turn N is the N-th user line of transcript. A turn refused before the scope judge (session_closed, budget_halt or message_limit in its guards) adds no line, so it has the number of the line that the next turn adds.
  • tools: every tool call of the turn in order, refused calls included, each as {tool, verdict, outcome, code}. verdict is allowed or the guard's refusal code, outcome what the call did (ok, changed, shipped, error, unfinished for a call that had not answered when the turn ended, and so on), and code the code of its answer (invalid_operations, incomplete_bindings), or null. At most 32 per turn; tools_dropped counts the rest.
  • guards: the verdicts of the turn's guards in order, each as {guard, verdict}, such as {"guard": "output_guard", "verdict": "reply_cut"}. The scope judge's attack reads out_of_scope. The latest 24 per turn; guards_dropped counts the earlier ones.
  • prep_ops: the operations of the turn's data changes (derive, aggregate). At most 16 per turn; prep_ops_dropped counts the rest.

The record isn't a join key: don't match it against log or telemetry lines, by sequence or by time.

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, or plot.R, plot.jl, plot.js, plot.tsx text/x-python; charset=utf-8 for plot.py; the language's type for the others (core/agent_cases.py) The exported code, under the name of the version's code artifact (plot.py for a Python library; the other names since 2026-10-24)
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. Under the pipeline it lists one line per round with the gates it failed and the class of a crash, and a line with the session's version count and how its latest run ended, so a chat case tells a plot the page did not show from a run that failed. Under turns it shows the session's turn record (turns of the detail, null for a case sent before the agents service kept one; see Session bundle) as one row per turn: the page's action or a message, how the turn ended, the guards that fired and the tool calls as chips, with a refusal, a cap or a failed render marked. Selecting a row opens that turn's whole record, and the transcript labels each of your lines with its turn number. The API closes every code to the record's vocabularies again, so a value outside them reads other. 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).
  • Triage rounds. To go through the open cases in one round, use the triage-agent-cases project skill (.claude/skills/triage-agent-cases/): it lists and copies the cases read-only, proposes a status and tags per case in a private report, and, after your OK for the round, writes them in one transaction after a snapshot of the rows. Its apply_triage.py applies the same rules as PATCH /cases/{id} (core.agent_cases.triage_updates).
  • 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.

Usage per day

GET /debug/agent-usage/daily is the admin-gated, read-only source of the per-day table on /debug. Like /debug/agent-usage, it runs one query over the BigQuery telemetry and never calls the agents service. It answers the last 14 UTC days, newest first, including days without a line:

{
  "status": "ok",
  "days": 14,
  "rows": [
    {
      "date": "2026-11-02",
      "sessions": 4,
      "plots_shipped": 3,
      "cost_usd": 0.0228,
      "fanout_cost_usd": 0.1211,
      "unpriced_calls": 0,
      "limit_hits": {
        "service_paused": 2,
        "budget": 0,
        "daily_limit": 0,
        "rate_limited": 0,
        "run_limit": 0,
        "capacity": 1
      },
      "queue_wait_p95_ms": 12400,
      "queue_waits": 3
    }
  ],
  "generated_at": "2026-11-02T14:05:30Z"
}

cost_usd is the live sessions' model cost at list price and fanout_cost_usd the same for the admin fan-out's sessions. unpriced_calls counts the steps whose model has no price row, so their cost is missing from both sums. queue_wait_p95_ms is null on a day whose runs do not carry the field, and queue_waits is the number of runs behind it. status is not_configured or unavailable as above, with no rows. See Agent usage telemetry.

Today's spend

GET /debug/agent/budget is the admin-gated source of the live line on /debug. Unlike the two routes above it reads the agents service, not BigQuery, and needs the service to answer; an upstream failure keeps the agent routes' error shape:

{
  "date": "2026-11-02",
  "since": "2026-11-02T14:05:12Z",
  "started_at": "2026-11-02T14:05:12Z",
  "resets_at": "2026-11-03T00:00:00Z",
  "paused": false,
  "model": "claude-haiku-5-5",
  "tokens": 12000000,
  "budget_tokens": 100000000,
  "usd": 1.32,
  "budget_usd": 11.0,
  "users": 3,
  "users_at_limit": 1,
  "limits": {
    "tokens": 1,
    "runs": 0,
    "data_runs": 0,
    "messages": 0,
    "pastes": 0,
    "conversions": 0,
    "locked_out": 0
  }
}

The counts live in the instance's memory: since is when today's counts began, the instance's start or 00:00 UTC, whichever came later, and a redeploy or a scale-to-zero starts them again at zero. usd and budget_usd are the weighted tokens at the main model's input list price in the service's location, null for a model without a price. users counts the users seen today, users_at_limit those at one or more of their own day limits, each once, and limits those at each limit, so a user at two limits counts under both. An admin counts only at pastes, conversions and locked_out, the limits that bind every tier.

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: "started"} once, right after the last queued status of a run that waited, when it holds its slot. A turn without pipeline steps (a summary or a question that runs data code) has no other event that ends the wait before its reply, so the page clears its queue position on it. A turn that never waited sends none
status {step, attempt, max_attempts} for a pipeline step: adapting, checking, rendering, reviewing, or repairing; max_attempts is the most rounds the run may make (AGENT_MAX_ATTEMPTS, 2 by default), so the chat's working line counts the try from the server from the second on (try 2 of 2); 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; for a longer reply the service asks the assistant once for a shorter one, and the reply arrives as that shorter one or cut to 1,200 characters, never as a refusal
plot {status, reason, attempts, artifacts, changes, residual_defects, version, memory, rounds, new_data?}; new_data only on the plot of plot_new_data (since 2026-11-08): previous when it started from the library's latest version, else why it started from the catalogue plot, other_paste, other_data (a table computed from a formula or code replaced the data), bindings_changed, kept_note (a request kept for the first plot was waiting) or no_plot; version only on ok and needs_attention; memory only with version: the plot memory the version stands on as entry ids, {kept, added}, the decisions from before the run that it kept and the ones the run added ({"kept": ["m1", "m2", "m3"], "added": ["m4"]}; two empty lists in a session without memory). A decision the run replaced is in neither list. The memory is settled with the version, before the event, so an error later in the turn leaves it as sent; rounds lists each attempt as {attempt, outcome, findings, step, shipped, defects, gates_failed, sandbox_failures} (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, gates_failed and sandbox_failures the closed ids of the gates its renders failed and of a crash, for a feedback case; the page ignores them), 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 (your own daily token budget is spent), service_paused (the service's day budget is spent, not yours: the whole chat pauses until 00:00 UTC), 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, service_paused, 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, the BFF's turn cap ran out while it waited, or the model service still refused a call for its quota or an overload after the retries), run_limit (you already have as many plot runs or data snippets queued or running in other sessions as user_max_runs of GET /status allows: five for an admin, two for a public user), deadline, guard_unavailable, no_reply (the assistant wrote no reply, also when the service asked it once more; data pasted before the turn stays unsummarized, so send the message again), upstream, or internal; ref is the request id
done {llm_calls, tokens, bindings?, data_confirmed?, dataset?, language?, suggestion?, switch?, turn?} (turn: see What a turn changed; switch, since 2026-11-02: {library, plot} after a turn in which you asked for the plot in another library and the assistant's switch_plot took it; the page then switches with source: chat and, with plot, sends carry_plot; never after a turn that ended in an error): 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, can_undo, original_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 the session's number of the version, and can_undo is false 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, chosen or offer; pasted and detected until 2026-11-08), so the page syncs its chip, with offer: {language, basis: "message"} when the turn's typed message is in another language the page has not been offered yet and the user picked none; 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 your daily token budget (budget), when the whole service has spent its day budget (service_paused, until 00:00 UTC; the page adds that time in your own time zone), 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.

What a turn changed

The done of a turn that ended without an error, a refusal or your stop carries turn, one value that names what the turn changed. A refused, failed or stopped turn carries none. The agents service reads it from its own record of the assistant's tool calls, never from the reply's text:

turn The turn
data changed the data or its bindings (prepare_data, generate_data, run_data_code, set_bindings, or the conversions of a paste's summary)
plot ran the plot pipeline: a shipped version, a failed run, or a run that stopped because the data or a binding is missing, which the page shows as a failed plot card
switch asked for the plot in another library (done.switch)
language set a language you asked for (set_language), also the one already set: the choice is stored, and language detection no longer changes it
kept kept a wish for the first plot (keep_plot_request, a plot that stopped on data_unconfirmed and kept the request, or a plot call the server turned down before the run on a paste's summary turn, keeping its request; the turn itself ends well), which the stream shows nothing of
memory removed a decision from the plot memory (forget)
answer changed nothing: it answered from the data, the profile or the code, or a change it tried stored nothing

A turn that changed several things carries the first of them in the table's order, the order the chat page reads its own stream in. For example, a turn that changed the data and kept a wish for the first plot carries data. A decision the plot memory adds with a shipped plot is part of plot; the version's memory names it.

Reading a turn again

Every event of a turn carries the SSE id <run_id>.<n>: the run_id of its ready event and the event's number in the turn, from 1 for ready. The BFF passes an id only in that form; the events it adds itself (error and done after a cut upstream stream) carry none. The agents service also writes a : ping comment after every 15 seconds without an event, so no hop between the agents service and the browser idles while a model call or a render runs.

The connection between your browser and the edge proxy can fail mid-turn, for example with an HTTP/3 error, while the agents service runs the turn to its end and stores its plot. The session keeps the events of its latest turn, so your page can read the rest:

  1. Keep the id of the last event you read.
  2. When the stream fails, or ends without done, call GET /sessions/{sid}/turn with that id in Last-Event-ID.
  3. Read the answer like the turn's own stream: the events after that id, then each new one until the turn ends with done. A read that is cut again continues from its own last id.

The read never holds or stops the run. A turn whose stream the agents service saw end early, because the BFF's connection to it closed, is cancelled as before; its read ends without done, and the BFF adds error {"code": "upstream"} and done {}. A newer turn replaces the kept one, and the session's end drops it. The chat page reads a cut turn again after 1, 2, 4 and 8 seconds, with a quiet "connection lost · reconnecting…" line below its progress, and shows the error only when every read failed.

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, service_paused, locked_out, session_closed, convert_unavailable, the file conversion's file_* refusals, and, for a JavaScript plot's plot.html, page_unavailable and page_failed) 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). On the public lane, POST /sessions and POST /session/feedback answer 403 bot_check_failed and 503 bot_check_unavailable from the bot check, with ref (see Bot check).

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

Bot check

Three calls that open or store something for an anonymous visitor need a Cloudflare Turnstile token (api/turnstile.py, since 2026-11-04):

Call Refused with
POST /agent/sessions (opening a public chat session) {"detail": "bot_check_failed", "ref": "<request id>"}
POST /agent/session/feedback (a public chat feedback case) The same, before the per-network hourly cap
POST /feedback (the site's feedback widget and the plot pages' 👍/👎) {"status": 403, "message": "bot_check_failed", "path": "/feedback"}, after the honeypot and the link filter and before any database query

The page sends the token in the X-Turnstile-Token header. A token is valid for 300 seconds and only once, so the page renders the widget (Managed mode, appearance: 'interaction-only', invisible unless Cloudflare asks the visitor to click) for each of these calls and never reuses a token. The public chat's other routes act on a session such a call opened and take no token, and the admin lane under /debug/agent has no bot check.

The API redeems the token at Cloudflare's siteverify endpoint with the secret, the token and an idempotency key. It does not send remoteip: Cloudflare documents the parameter as optional and no check it would enable, so leaving it out keeps the visitor's address off one more request without weakening the check. Nor does it compare the widget's action, which whoever renders the widget chooses; the page sets it only for Cloudflare's per-action counts. Each attempt has a 5-second timeout, and a timeout, a transport error, a 5xx or Cloudflare's internal-error gets one retry with the same idempotency key. Only an HTTP 200 with success: true passes, and in production only for a token solved on anyplot.ai or one of its subdomains. The answers:

  • 403 bot_check_failed: no token, a token over 2,048 characters, one siteverify refuses (invalid, expired or already used), or, in production, one solved on another host. The page says the check did not go through and offers to try again, which runs a fresh check.
  • 503 bot_check_unavailable: siteverify unreachable or still answering internal-error after the retry, a secret Cloudflare refuses or a request it calls malformed, one of Cloudflare's test secrets in production (one passes every token, the others refuse every visitor), or no secret in production. Nothing passes on an error, and there is no switch that lets calls through while siteverify is down: the way back is a revert.

The check is on while TURNSTILE_SECRET_KEY is set (Secret Manager, bound by --update-secrets in api/cloudbuild.yaml on every deploy). Unset, local development and the tests skip it, and production refuses the three calls and keeps the public chat's door shut (404 on GET /agent/lane), so a revision without the secret fails its deploy smoke. A production revision given one of Cloudflare's test secrets gets the same closed door. Nothing about a token or a visitor's answer is logged: only the verdicts that point at the server, so /legal's "anyplot learns only whether the check passed" holds for the logs too. The site's build takes the public site key as _VITE_TURNSTILE_SITE_KEY (app/cloudbuild.yaml), and the build refuses to start without one. For local checks outside production, Cloudflare's test keys work: the site key 1x00000000000000000000AA with the secret 1x0000000000000000000000000000000AA always passes, and the secret 2x0000000000000000000000000000000AA always fails, the answer api/deploy_smoke_public.sh expects from a production secret for Cloudflare's dummy token.


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