Prompty
Prompts shipped like code: versioned, evaluated, A/B tested
Prompty treats a prompt as a versioned artifact, not a string someone edits in production and then watches for complaints. Every version is graded against a golden set by an LLM judge before it can ship, then rolled out to a share of real traffic and judged by statistics. Deploying and rolling back are the same operation: moving a label.
- Type
- Personal project, 2026, designed and built end to end
- Built
- FastAPI service, Python SDK, React dashboard
- Statistics
- Wilson intervals, z-test, Welch’s t-test, paired t-test, power analysis, SRM check
- Models
- Any OpenAI-compatible endpoint, or a deterministic mock
- Tests
- 151 test functions (pytest), statistics checked by simulation
A working local prototype: single user, no authentication. Its demo data comes from a deterministic mock model, so it shows the machinery, not the quality of any model.
One completion, end to end
Applications call Prompty instead of the model. One call decides which version this user gets, renders it, calls the model and records everything an experiment will need later.
- Input or output
- LLM call
- Code
- Data store
- SDK call client.complete("support-reply", variables={"message": "My order never arrived"}, unit_id="user-42").
- Resolve target An experiment is running on support-reply, so the experiment decides the version. Without one, the “production” label would.
- Assign variant Bucket = sha256(experiment:unit) mod 10,000, walked through the variants’ weights in basis points. The assignment is stored, so user-42 keeps the same variant even if the weights change.
- Render Plain {{name}} substitution with no template logic. A missing variable is a 422, never a half-filled prompt.
- Model call The variant’s own model and settings. Rate limits and connection errors are recorded as status “error” instead of raised; a content filter becomes “refusal”.
- Completions log One row in the single completions table, priced per million tokens and tagged source = live. Eval and judge calls share the table under their own source.
- Response Text, variant, tokens, cost and latency. Later, client.track() attaches the outcome, a thumbs-up or a number, to the same variant.
This request does not pass through here. The other routes are listed below.
That is the whole request. Want something like this running on your own data?
Select any box in the diagram, or follow the request one step at a time.
Other routes through the graph
- No experiment running: the deployment label picks the version, and nothing is stored.
- No unit id: a random draw that is not persisted.
- Provider error: the logged completion comes back inside a 502, so the caller sees what was recorded.
From a new version to production
A version has to pass two different tests: offline, against answers already judged good; online, against real traffic. Either can stop it.
- Input or output
- LLM call
- Code
- Check or gate
- New version v3 of support-reply. Versions never change once created; labels are pointers to them.
- Offline eval Each golden case runs through v3, five at a time, then a judge model scores the answer from 1 to 5 on an absolute rubric, not side by side, which avoids position bias.
- Deploy gate Compared case by case with the version in production: a paired t-test over at least 5 shared cases. Only a significant drop blocks; absence of evidence never does.
- A/B experiment Traffic is split by weight in basis points, sticky per user, with the sample size planned in advance by a power analysis.
- Results Wilson intervals per variant, a pooled z-test for rates, Welch’s t-test for continuous metrics. A sample-ratio mismatch at p < 0.001 suppresses every “significant” flag; cost, latency and error-rate guardrails watch for regressions.
- Production Completing the experiment moves the production label to the winner. A guardrail regression refuses with a 409 unless forced, the one human override.
This request does not pass through here. The other routes are listed below.
That is the whole request. Want something like this running on your own data?
Select any box in the diagram, or follow the request one step at a time.
Other routes through the graph
- Deploy gate fails: the deploy is refused and the label stays where it was.
- Fewer than 5 events in a cell: the z-test isn’t trusted, and the error-rate guardrail switches to Fisher’s exact test.
Statistics chosen on purpose
An A/B platform is only as honest as its tests. Each choice here answers a specific way experiments mislead.
-
Pooled for the test, unpooled for the interval
The z-test uses the pooled standard error, as the null hypothesis implies; the interval on the difference uses the unpooled one. Each variant’s own rate gets a Wilson interval, which stays sensible near 0 and 1.
-
Paired when the cases are shared
Offline runs score the same golden cases, so comparisons are paired. In the test suite, 40 shared cases give p < 0.001 paired and p > 0.05 unpaired on the same scores.
-
Sample ratio mismatch first
If a 50/50 split delivers 45/55, assignment is broken and every number downstream is suspect. The check runs from 100 observations at p < 0.001, and silences all significance flags when it fires.
-
Guardrails beside the goal
A variant that wins on thumbs-up but costs more, runs slower or fails more often is flagged. Cost and latency need both a 10% relative regression and significance.
-
Aggregates, not rows
The statistics module has no database access. One GROUP BY feeds it, so the results endpoint costs the same at any traffic.
-
Checked by simulation
The power calculation is tested by simulation: 20,000 runs land at 0.80 ± 0.02 power, and with no true effect the false-positive rate stays at 0.05 ± 0.01.
Engineering choices
-
One table for every call
Live, eval and judge calls land in one completions table with a source column. Aggregation must filter on live traffic, and a test fails if it stops doing so.
-
Deploy and rollback are one operation
Versions are immutable and labels point at them. Rolling back is moving the label back.
-
Sticky, and stored
Assignment is a hash of experiment and user, persisted on first sight, so it survives a change of weights. Two first requests racing for one user resolve to the stored row.
-
Errors are data
Provider failures are logged like any completion, so the error-rate guardrail can see a variant that fails more often.
-
Immutable judges
A judge can’t be edited after creation, so scores from different weeks stay comparable.
-
Fail at startup, not on the bill
An unknown provider name stops the service at startup, so a typo can’t fall through to a paid endpoint.
Stack
- Backend
- Python, FastAPI, async SQLAlchemy, SQLite (WAL), Pydantic settings
- Statistics
- SciPy, NumPy
- LLM
- OpenAI-compatible SDK, LLM judge with JSON output, deterministic mock provider
- SDK
- Python client on httpx
- Frontend
- React 19, TanStack Query, React Router, Radix, Tailwind CSS 4, Vite
- Quality
- pytest, pytest-asyncio, simulation tests of the statistics
Building something similar?
I’m happy to walk through the design, the trade-offs, or what I would change next.