Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/skills/audit-licenses/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ Judgment flags the greps can't raise:
(largest tracked files, data-URIs by design) — all bucket 1. The
`file` tool misclassifies ~26 `specification.yaml` as "SPEC" and
flags the `.claude/commands` symlink — both allowed in the awk net.
- Added 2026-11-05: `docs/assets/readme-showcase.png`, the README's
showcase, bucket 1. `scripts/readme_showcase.py` composes it from the
catalogue's own light previews and draws its text in MonoLisa, the
same rasterized use as the OG cards and the og-samples. No font file
enters the repository. It is redrawn at every release, so a changed
hash on that path is expected.
- Former wart resolved: `plots/bar-basic/implementations/python/plot-{dark,light}.png`
were tracked plot renders violating the previews-live-in-GCS rule —
reported 2026-08-17, removed 2026-08-20 on the user's decision. They
Expand Down
16 changes: 16 additions & 0 deletions .claude/skills/write-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ Checklist for adding, renaming, or removing a doc:
`docs/reference/api.md`; workflow behavior → `docs/workflows/`;
user-facing changes → `docs/contributing.md` (the CLAUDE.md
documentation rule).
- **The README's "Engineering highlights", its showcase image and
`docs/concepts/ai-workflow.md` move with the mechanism they
describe.** Changing the rubric, the review bar, the agent
pipeline's bounds, the session idle time, the default model, the
library registry or the MCP tools means changing the sentence that
states it, in the same PR. `tests/unit/test_readme_claims.py` reads
each of those values from its source and fails until the text
follows; it also checks every relative link and anchor of both
docs and the About page's copy of the rubric. A count that only
grows (merged pull requests) is written as a floor, "more than N",
bounded by the test's `MERGED_PRS_SNAPSHOT`, which every release
updates.
The showcase image (`docs/assets/readme-showcase.png`) is redrawn
from live data by `scripts/readme_showcase.py`, at every release
(`agentic/commands/release.md` step 4) and whenever a pick or the
layout changes.
- **When a concept, path, or term changes, sweep the whole doc
surface for the old form** — don't rely on remembering which docs
cite it:
Expand Down
93 changes: 89 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,98 @@ AI pipeline writes, renders, reviews, and maintains its implementations in 15 pl
Julia, and JavaScript. On [anyplot.ai](https://anyplot.ai), you compare the libraries side by side and copy working
code.

Built by [Markus Neusinger](https://linkedin.com/in/markus-neusinger/).
## How I build anyplot

[![The ridgeline-basic spec, six of its 15 library implementations](https://api.anyplot.ai/og/ridgeline-basic.png)](https://anyplot.ai/ridgeline-basic)
I'm [Markus Neusinger](https://linkedin.com/in/markus-neusinger/), a physicist working in data and AI: since 2013 I've built data pipelines, analyses and machine learning, and the apps people use them through. Models write anyplot's code; I lead them, with that experience across data, backends, frontends and hosting.

*One spec in 15 libraries: the site's live preview card for [ridgeline-basic](https://anyplot.ai/ridgeline-basic)
shows six of them; the spec page shows all 15.*
- **I set the course.** The vision, the decisions, and the structure that keeps a codebase written by agents from turning into a mess. Seven of those decisions are [below](#engineering-highlights).
- **I know when to push back.** The models know a lot. The job is knowing which question to ask, when to make a model research a point properly, and when a confident answer is wrong. Security is a good example: I know what has to hold, private data and no way to abuse the service, and asking the right question is often enough for a model to find what still needs hardening.
- **I split, assign, test, and send back.** Work goes to Claude Code, Claude in GitHub Actions, Gemini or Copilot by what it needs and what it costs. I test what comes back and return it until it's right.

This isn't one prompt and a wait. Since November 2025, more than 850 pull requests from my account have merged, and what I correct becomes a rule the agents follow. [How anyplot is built with AI agents](docs/concepts/ai-workflow.md) tells the whole story.

[![A grid of catalogue plots, one spec in each library, with the catalogue's spec, library, and language counts](docs/assets/readme-showcase.png)](https://anyplot.ai/plots)

*One spec in each library, drawn from the live catalogue by
[`scripts/readme_showcase.py`](scripts/readme_showcase.py). [Browse all plots](https://anyplot.ai/plots).*

---

## Engineering highlights

Seven decisions I made and hold the agents to, each with its reason and the code that carries it out:

- **A review loop instead of hand-edits.** No generated plot is patched by hand. An AI reviewer scores every
render on a 24-criterion rubric, blind to any earlier score, and a repair loop fixes the defects it names,
against a bar that starts at 90 of 100 and drops 10 points per repair. A fix to a prompt or a spec fixes
every plot it governs. ([rubric](prompts/quality-criteria.md), [review workflow](.github/workflows/impl-review.yml))
- **A gate against silent regressions.** Every night the oldest implementations are regenerated, and a new
version replaces the live one only when a pairwise review of both finds a visible fix and the new score
drops by no more than a point. A newer model can't quietly make the catalogue worse.
([`regen_gate.py`](automation/scripts/regen_gate.py), [`daily-regen.yml`](.github/workflows/daily-regen.yml))
- **One agent talks, code decides.** In "Use with my data", one Google ADK agent talks to the user. The
per-library adapters and the reviewer run inside a pipeline whose loop is plain code: at most one repair
round, at most two reviews, and the spec, library, and data come from server state, so neither the model
nor an injected prompt can redirect a run. ([`pipeline.py`](agents/anyplot/pipeline.py),
[design](docs/concepts/agent-network.md))
- **Model-written code runs where it can't do harm.** No generated code runs in the agent service. A
validator (Python's `ast`, tree-sitter for R, Julia, and JavaScript) refuses disallowed imports and calls
first; a separate renderer then runs the code in Cloud Run sandboxes (gVisor) with no network egress, no
metadata server, and a read-only root filesystem, under a service account that holds no IAM role.
([renderer](agents/renderer/main.py), [validator](agents/anyplot/code/validate.py))
- **Models choose, the server acts.** The assistant never writes data rows: it picks from a closed set of
typed operations, which the server applies with pandas and reports with counts. Guardrail plugins check
every turn: a scope judge that fails closed, token and run budgets per request, user, network, and day,
and a tool allowlist per agent. ([operations](agents/anyplot/data/prepare.py),
[plugins](agents/anyplot/plugins/))
- **Private by construction.** Chats and data live only in server memory and are dropped after an hour
idle, and the session id never appears in a URL. Usage telemetry holds only enums, buckets, and counts,
keyed by hashes under a per-process random salt, and a canary test fails if any message or data value
reaches a log line. ([telemetry](agents/anyplot/telemetry.py),
[canary test](tests/unit/agents/runtime/test_canary.py))
- **Rules that turn a repeated mistake into a check.** A mistake that comes back becomes a test or a written
rule, with the incident beside it. One test keeps the assistant's prompt in step with the buttons the chat
page renders; another asserts that no agent has a code executor or a search tool. After a green deploy
left anyplot.ai blank for 45 minutes, every merge ends with a smoke test of the live site, and CI runs the
same check on the built image. Around them: a changelog fragment per pull request checked in CI, every
migration run against a throwaway PostgreSQL, CodeQL, and Dependabot.
([prompt-control test](tests/unit/agents/test_prompt_controls.py),
[site smoke test](scripts/site-smoke.mjs), [guardrails](.claude/guardrails.md))

## Architecture

The site and "Use with my data":

```mermaid
flowchart TB
browser(["Browser"]) --> cf["Cloudflare"]
cf --> app["anyplot-app<br>React SPA"]
cf --> api["anyplot-api<br>FastAPI, MCP server<br>agent BFF, Turnstile<br>per-network caps"]
browser -. "plot images" .-> gcs[("Cloud Storage")]
api --> sql[("Cloud SQL<br>PostgreSQL")]
api -- "ID token,<br>catalogue snapshot" --> chat

subgraph chat ["Use with my data"]
direction LR
agents["anyplot-agents<br>Google ADK<br>Claude Haiku 5.5<br>Vertex AI, EU"] -- "ID token" --> renderer["anyplot-renderer<br>gVisor sandboxes<br>no egress<br>zero-role account"]
end
```

The catalogue pipeline, in GitHub Actions, writing to the same storage:

```mermaid
flowchart TB
spec["spec-create"] --> gen["impl-generate"]
regen["daily-regen"] --> gen
gen --> review["impl-review<br>24-criterion rubric"]
review -- "below the bar" --> repair["impl-repair"] --> review
review -- "approved" --> merge["impl-merge"]
merge -- "promote images" --> gcs[("Cloud Storage")]
merge -- "sync" --> sql[("Cloud SQL")]
```

All of it runs live at [anyplot.ai](https://anyplot.ai).

## The catalogue in numbers

| Specifications | Implementations | Libraries | Languages |
Expand Down Expand Up @@ -221,6 +304,8 @@ For details, see [Contributing](docs/contributing.md). To run the project locall
- [Vision](docs/concepts/vision.md): the product vision
- [Workflows](docs/workflows/overview.md): the automation and its labels
- [Agent network](docs/concepts/agent-network.md): the design of "Use with my data"
- [How anyplot is built with AI agents](docs/concepts/ai-workflow.md): who does what, the path of a change,
and how corrections become rules
- [Reference](docs/reference/): API, MCP, database, repository structure, and style guide
- [Documentation index](docs/index.md): everything else

Expand Down
30 changes: 22 additions & 8 deletions agentic/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Cut a new anyplot release: finalize the changelog, bump the version, tag, and publish the GitHub
release. The release notes are the changelog section **condensed** — never copied verbatim, never
written from scratch (owner rule, 2026-08-28; the shape is in `CLAUDE.md` § "Changelog +
releases", the procedure is step 7 below).
releases", the procedure is step 8 below).

## Variables

Expand Down Expand Up @@ -47,14 +47,28 @@ version: $1 (optional — e.g. `3.1.0`; if omitted, propose one from the `[Unrel
regenerations, coverage milestones — query merged impl PRs or `impl:*:done` labels).
- The single `**Dependencies:**` bullet under `### Changed` grouping the Dependabot bumps of
the window (never one bullet per bump).
4. **Read the diff.** It touches `CHANGELOG.md`, `pyproject.toml`, `uv.lock`, `app/package.json`
and the deleted fragments — nothing else. Run `uv lock` if the lock needs more than its own
version line.
5. **Open the release PR** (`release: vX.Y.Z` title) and follow the standard PR follow-through
4. **Refresh the README showcase**, on the release branch. Run
`uv run python scripts/readme_showcase.py`: it redraws `docs/assets/readme-showcase.png` from the
live `/stats` and stops when MonoLisa or a pick's preview is missing; look at the image before
committing it. Then count the merged pull requests from the owner's account
(`gh pr list --state merged --author MarkusNeusinger --limit 5000 --json number --jq length`) and
update three places in one commit:
- `MERGED_PRS_SNAPSHOT` in `tests/unit/test_readme_claims.py`: the count and today's date.
- The floor in `README.md` and in `docs/concepts/ai-workflow.md` ("more than N pull requests from
my account have merged"): the last multiple of 50 below the count (869 merged gave "more than
850").

It stays a floor, never an exact number. The test refuses an exact count, a floor that isn't a
multiple of 50, a floor at or above the snapshot, and one more than 50 below it. Commit the image,
the snapshot and the wording on the release branch.
5. **Read the diff.** It touches `CHANGELOG.md`, `pyproject.toml`, `uv.lock`, `app/package.json`,
the deleted fragments and step 4's showcase image, pull-request snapshot and floor — nothing else. Run
`uv lock` if the lock needs more than its own version line.
6. **Open the release PR** (`release: vX.Y.Z` title) and follow the standard PR follow-through
from `CLAUDE.md`. Ask the user to merge unless explicitly authorized to merge autonomously.
6. **Tag after merge** (on the updated `main`):
7. **Tag after merge** (on the updated `main`):
`git tag -a vX.Y.Z -m "<Codename> (YYYY-MM-DD)" && git push origin vX.Y.Z`
7. **Publish the GitHub release** with the changelog section **condensed** as body — never the
8. **Publish the GitHub release** with the changelog section **condensed** as body — never the
section copied (the v3.1.0 page was 640 lines). Write the body to a temp file first (it is
multiline and contains backticks — do not inline it into `--notes`), then
`gh release create vX.Y.Z --title "vX.Y.Z — <Codename>" --notes-file <tmpfile>`. The body:
Expand All @@ -70,7 +84,7 @@ version: $1 (optional — e.g. `3.1.0`; if omitted, propose one from the `[Unrel
section are cited.
- **Last line:** `**Full Changelog:** https://github.com/MarkusNeusinger/anyplot/compare/v<last>...vX.Y.Z`.
The full text lives only in the CHANGELOG; the release page is the index into it.
8. **Verify:** `gh release view vX.Y.Z` renders correctly; the site masthead picks up the new tag
9. **Verify:** `gh release view vX.Y.Z` renders correctly; the site masthead picks up the new tag
automatically (`app/src/hooks/useLatestRelease.ts` fetches `releases/latest` with a 1 h
localStorage cache — nothing to deploy).

Expand Down
20 changes: 20 additions & 0 deletions api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,26 @@ async def seo_mcp():
"review (AI-evaluated). When a library ships a new release the pipeline re-runs; when a "
"better example pattern emerges the spec is updated and every library regenerates. "
"Generated code is never patched by hand.</p>"
"<h2>Review</h2>"
"<p>Every implementation is scored before it ships. Automated checks come first: the code "
"runs, the image isn't blank, the canvas has its size. Then an AI reviewer scores the render "
"on a 100-point rubric of 24 criteria (visual quality, design, spec compliance, data quality, "
"code quality, and library mastery), blind to any earlier score. A first review needs 90; "
"below that, AI repairs the defects the review named and the plot is reviewed again, up to "
'four times. The <a href="https://anyplot.ai/stats">stats page</a> shows the score '
"distribution per library.</p>"
"<h2>Use with my data</h2>"
"<p>Experimental, open to everyone since 2 November 2026. "
'<a href="https://anyplot.ai/plots">Pick any plot</a>, choose .use_my_data(), and paste your '
"data. An agent adapts that plot's code to your columns, renders it in a sandbox with no "
"network access, reviews the render against the catalogue's rubric, repairs it once if "
"needed, and hands back the image and the code. Your chat and data live only in server "
"memory and are deleted after an hour idle, unless you send feedback.</p>"
"<h2>MCP server</h2>"
"<p>The same catalogue, served to AI coding assistants over the Model Context Protocol. Six "
"tools search specs by tag, return a spec with its implementations, and fetch one library's "
"code, so your assistant starts from code that already rendered and passed review. Setup is "
'on the <a href="https://anyplot.ai/mcp">MCP page</a>.</p>'
"<h2>Open</h2>"
"<p>Source, specs and pipeline live at "
'<a href="https://github.com/MarkusNeusinger/anyplot">github.com/MarkusNeusinger/anyplot</a> '
Expand Down
4 changes: 2 additions & 2 deletions app/src/hooks/useAgentLaneOpen.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Whether the public lane of the agent chat is open (`GET /agent/lane`), for
* the entry points: the plot page's `.use_my_data()`, the landing page's line
* and the public chat page itself.
* the entry points: the plot page's `.use_my_data()`, the landing page's line,
* the plot of the day's `.use_my_data()` and the public chat page itself.
*
* The API answers `{"open": true}` while the owner has the lane open and 404
* while it is closed, so one switch on the server opens and closes every entry
Expand Down
14 changes: 14 additions & 0 deletions app/src/pages/AboutPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ describe('AboutPage', () => {
expect(fireEvent.click(link)).toBe(false);
});

it('links the stats, plots and mcp pages from the review, chat and mcp sections', () => {
render(<AboutPage />);

for (const [name, href] of [
['stats page', '/stats'],
['pick any plot', '/plots'],
['mcp page', '/mcp'],
]) {
const link = screen.getByRole('link', { name });
expect(link).toHaveAttribute('href', href);
expect(fireEvent.click(link)).toBe(false);
}
});

it('sends a plot request to the issue form, with no label or title prefilled', () => {
render(<AboutPage />);

Expand Down
Loading
Loading