Skip to main content
One protected refund, under one policy, with a fake Stripe that commits before its reply can go missing. Change the amount, lose the reply, approve one amount and execute another, and read what refused you.
Press Refund. The first press downloads about 10 MB of Python; after that the browser caches it.
Real Python and the released ctrlrun wheel from PyPI load into this tab. Every line in the box was produced by ctrlrun here: the page owns the controls and nothing else. Nothing is sent anywhere; the store is in memory and no socket is opened. The policy the refund runs under, in cents:

Try this, in order

  1. €500 on txn_1 — allowed. It runs, and the receipt says committed.
  2. Payment id txn_2, €2,000ApprovalRequired, with the request id a human would answer. Press Approve, change the amount to €5,000, press Refund: ApprovalMismatch. The approval was bound to the action the human saw. Put €2,000 back and it executes on that same approval.
  3. €2,000 on txn_2 once more — ApprovalMismatch (consumed): the approval was single-use.
  4. txn_3, €20,000ActionDenied. No request is created; a human is not asked about a refund the policy refuses at any size.
  5. txn_4, €500, with lose the reply ticked — the remote commits, the reply is lost, the effect is AMBIGUOUS. Untick and press Refund again: AmbiguousEffect, the blind retry is refused, and remote refund calls is still 1. That is the case this library exists for: Outcomes and AMBIGUOUS.
  6. Back to txn_1, €500DuplicateEffect. One logical effect, one reservation.
Every refusal above is the exception your own code would catch, with the reason it carries. Approval binding and Decisions are the two pages behind steps 2 to 4.

The five scenarios, end to end

Press the button and ctrlrun demo runs in this tab: the same five scenarios you would get from pip install ctrlrun && ctrlrun demo, on the same wheel.
Press the button. The first run downloads about 10 MB of Python; after that the browser caches it.
This is the output from the verification run described below, with the generated ids masked:
Scenario 1 is the one to read twice: remote refund calls: 1. The refund committed at the fake remote, the reply was lost, the agent retried, and the retry was refused. The customer was refunded once.

What is running

The version line in the output is the released version, so it can be behind main. Everything else on this site describes the current release.
The Python this page runs was verified under Node against the same Pyodide build on 2026-09-07: Pyodide 314.0.6, Python 3.14.2, SQLite 3.39.0, ctrlrun 0.5.0 from PyPI, the playground’s whole sequence and all five demo scenarios. Not a copy of it — the harness reads both programs out of try-it.js and runs those. It carried its own copy until 2026-09-06, which is how a syntax error in the page’s copy reached this page: both harnesses were green, one having stubbed the Python and the other having verified a program the page never ran. Two harnesses are committed, and you can run both:
verify-browser-demo.mjs is the one that proved the demo runs. A third check needs neither Node nor the network: tests/test_docs_travelling.py lifts both programs out of the JavaScript on every commit, compiles the demo’s, and runs the playground’s through the six steps above against the checkout’s own ctrlrun, asserting each outcome. A refusal that stopped happening fails CI rather than this page. verify-browser-wiring.mjs is the one that proves the controls on this page get wired: this site is a single-page application, and the first version of the script looked for its container before the page had rendered it, so the button did nothing.

If it does not run here

The page tells you what failed and gives you the command that always works:
A browser can fail this for reasons that have nothing to do with CTRLRun: a blocked CDN, an extension that stops WebAssembly, a very old browser, or a tab with no memory to spare. That is this page failing, not the library.

Next