-## The refund that happened twice
-
-An agent refunds €500. The call reaches the provider and commits. The reply is lost on the way
-back, so the agent sees an error — and does what every retrying client does. The customer is
-refunded twice, and nothing in the stack noticed.
-
-The bug is not the retry. It is that the agent had no way to tell *this failed* from *I do not
-know what happened*. Retry libraries, agent frameworks and tool loops collapse those two into
-one, and a write that may already have committed is retried as though it certainly had not.
-
-CTRLRun does not collapse them. A lost reply is `AMBIGUOUS`, never `FAILED`, and a retry against
-an `AMBIGUOUS` effect is refused — until a human, or a `reconcile` hook, says what happened.
+## The refund nobody approved
+
+A ticket asks for a €500 refund. The agent calls the refund tool with €5,000 — one extra zero.
+The tool is in its list, the arguments are well-formed, and the model is completely confident.
+Nothing above the call disagrees, because nothing above the call is a check: a tool being
+callable is not permission to call it with those arguments.
+
+CTRLRun is that check. It reads the arguments about to leave your process and answers what may
+happen to them. Under the demo's policy €500 is autonomous and €5,000 is not, so the extra zero
+meets a human instead of the provider. Nor does an approval travel: the demo's second scenario
+approves €2,000, executes €5,000 under that approval, and it authorises nothing, because it was
+bound to the action the human actually read.
+
+That is the half people expect. The other half is the same agent making a *correct* €500 refund
+that commits at the provider while the reply is lost coming back. The agent sees an error and
+retries, because retry libraries, agent frameworks and tool loops collapse *this failed* into *I
+do not know what happened*. CTRLRun keeps them apart: a lost reply is `AMBIGUOUS`, never
+`FAILED`, and a retry against an `AMBIGUOUS` effect is refused until a human, or a `reconcile`
+hook, says what happened.
```bash
pip install ctrlrun && ctrlrun demo
diff --git a/docs/docs.mdx b/docs/docs.mdx
index 817cdc2..c545e99 100644
--- a/docs/docs.mdx
+++ b/docs/docs.mdx
@@ -3,13 +3,13 @@ title: "The execution safety layer for AI agents"
sidebarTitle: "Overview"
description: "The last check before an AI agent does something it can't undo. Autonomy belongs to the action, not the agent."
mode: "wide"
-"og:title": "CTRLRun — the execution safety layer for AI agents"
-"twitter:title": "CTRLRun — the execution safety layer for AI agents"
+"og:title": "CTRLRun: the execution safety layer for AI agents"
+"twitter:title": "CTRLRun: the execution safety layer for AI agents"
canonical: "https://ctrlrun.dev/docs"
---
CTRLRun is a Python library that sits between an agent's decision to act and the call that acts.
-A consequential action happens at most once, exactly as approved, and leaves a receipt — and when
+A consequential action happens at most once, exactly as approved, and leaves a receipt, and when
the outcome is unknown, CTRLRun says so instead of guessing.
```bash
diff --git a/docs/index.mdx b/docs/index.mdx
index 1b4769e..a7a31d2 100644
--- a/docs/index.mdx
+++ b/docs/index.mdx
@@ -3,67 +3,102 @@ title: "Execution safety for AI agents"
sidebarTitle: "CTRLRun"
description: "Execution safety for AI agents: prevent duplicate effects, bind approval to the exact action, and stop blind retries when the outcome is uncertain."
mode: "custom"
-"og:title": "CTRLRun — Execution safety for AI agents"
-"twitter:title": "CTRLRun — Execution safety for AI agents"
+"og:title": "CTRLRun: execution safety for AI agents"
+"twitter:title": "CTRLRun: execution safety for AI agents"
canonical: "https://ctrlrun.dev/"
"og:url": "https://ctrlrun.dev/"
---
-import { ScenarioExplorer } from "/snippets/scenario-explorer.jsx";
+import { DeckNav } from "/snippets/deck-nav.jsx";
-
-
+
+
+
+
CONTROL THE ACTION. KEEP THE AUTONOMY.
Execution safety for AI agents.
-
Let agents act. Keep control of what happens next.
-
CTRLRun sits at the execution boundary before an AI agent changes a real system. It prevents duplicate effects, binds approval to the exact action, and stops blind retries when the outcome is uncertain.
+
Control what AI agents can do in your systems, what needs human approval, and what must never happen.
Your model chooses the action. CTRLRun decides whether it may execute. Pick one of 48 domains, then pick the moment it goes wrong: an approval the agent edited, a retry of something that already happened, a reply that never came back. No login. No API key. No setup.
A library inside your process. Not a service in front of it.
+
+
Models will be wrong sometimes. CTRLRun decides whether being wrong can reach your systems.
+
+
+ 01 / SET LIMITS
Your rules define what AI may reach
+
Action rulesAllow, require approval or block, before an action runs.
Agent permissionsEach agent's limits. Delegating work cannot widen them.
Data-use rulesControl actions by the data labels your team supplies.
+
+
+ 02 / CHECK ACTIONS
A wrong request need not become a costly action
+
Exact approvalsApprove the precise action. Changed details need a new one.
Duplicate preventionBlock repeats, even when different agents try.
Outcome checksHold retries until the outcome is confirmed.
+
+
+ 03 / KNOW WHAT HAPPENED
Trust backed by a record of what AI did
+
Action historyTrace requests, decisions and results, including refusals.
Control testingPreview what rules would block before enforcing them.
+
+
+
What it is not. It does not read prompts, choose models, rank tools, or judge whether the agent's reasoning was any good. It is not an LLM, and it does not call one. It checks the action, not the thinking.
A library inside your process. Not a service in front of it.
-
Your agent decides what to do. Somewhere after that, your code calls something that changes the real world: charges a card, deletes a user, sends the email. CTRLRun goes in that one gap, and only that gap. It never sees the prompt, the reasoning or the conversation.
-
-
01 / YOU NAME THE ACTION
Wrap the function that causes the effect
You give it a name and an effect key: the identity of the business action, like refund:txn_4821. Two attempts at the same business action carry the same key, whether they come from a retry, a second worker, or a process that restarted.
-
02 / IT CHECKS BEFORE THE CALL
Four questions, every time
Is this agent entitled to act at all? Does policy allow these exact arguments, require a person, or deny them? If a human approved something, was it this exact action? Could this action already have happened? Anything unclear stops the call.
-
03 / IT RESERVES FIRST
The claim is written before the effect
The effect is reserved in a database before your function runs, not after it returns. That ordering is the whole trick: if the process dies mid-call, the reservation is still there, so the retry meets a claim instead of an empty table.
-
04 / IT RECORDS WHAT HAPPENED
Including “we do not know”
Committed, failed, or unknown. A timeout is not a failure, it is an unknown outcome, and CTRLRun holds it that way until something confirms it. A receipt records the action, the decision behind it, the approval and the result.
+
+
+
+
FOR THE PEOPLE BUILDING IT
Start at the function that takes action.
Wrap the call that causes the real-world effect. Policy decides whether it can run, needs approval, or must stop. Execution state survives restarts, so an uncertain outcome still blocks a blind retry.
Python, agent frameworks, or an MCP gateway: the execution boundary stays the same, and it protects workers, webhooks and scheduled jobs too. Not only agents →
-
What it is not. It does not read prompts, choose models, rank tools, or judge whether the agent's reasoning was any good. It is not an LLM, and it does not call one. It checks the action, not the thinking.
Wrap the call that causes the real-world effect. Policy decides whether it can run, needs approval, or must stop. CTRLRun protects the effect and records what happened.
Python, agent frameworks, or an MCP gateway. The execution boundary stays the same.
Execution state survives restarts, so an uncertain outcome still blocks a blind retry. How recovery works →
Illustrative wrapper. The quickstart adds your policy, identity, and approval flow.
allow → execute
approve → wait for exact approval
deny → do not execute
unknown outcome → do not retry blindly
-
Not only agents. The same execution boundary protects workers, webhooks, scheduled jobs, and other systems that can retry a consequential action. Learn why →
-
+
+
FOR AGENTS MOVING INTO PRODUCTION
Know which actions are safe to automate.
Leave with a concrete execution-control map: what can run automatically, what needs a person, and what must stop until the outcome is known.
Architecture reviews · Integration sprints · Production support CTRLRun OSS remains Apache-2.0 and free.
+
-
-
FOR AGENTS MOVING INTO PRODUCTION
Know which actions are safe to automate.
Get a focused review of your agent's actions, permissions, approval paths, duplicate-execution risks, and retry behavior. Leave with a concrete execution-control map: what can run automatically, what needs a person, and what must stop until the outcome is known.
diff --git a/docs/snippets/architecture-review.jsx b/docs/snippets/architecture-review.jsx
index 2a2e73b..971e516 100644
--- a/docs/snippets/architecture-review.jsx
+++ b/docs/snippets/architecture-review.jsx
@@ -26,12 +26,12 @@ export const ArchitectureReview = () => {
if (['High', 'Needs review', 'Lower indicated risk', 'Planning'].includes(level)) {
const count = Math.max(0, Math.min(5, Number(params.get('patterns')) || 0));
const unknowns = Math.max(0, Math.min(5, Number(params.get('unknowns')) || 0));
- setRisk(level + ' — ' + count + ' patterns, ' + unknowns + ' unknowns (self-reported)');
+ setRisk(level + ': ' + count + ' patterns, ' + unknowns + ' unknowns (self-reported)');
}
}, []);
useEffect(() => { if (prepared && reviewRef.current) reviewRef.current.focus(); }, [prepared]);
const brief = ['Architecture review request', '', 'Company: ' + company, 'Reply email: ' + emailAddress, domain && 'Domain: ' + domain, 'Agent purpose: ' + purpose, 'Actions it can execute: ' + actions, 'Production status: ' + status, 'Primary concerns: ' + (concerns.join(', ') || 'Discuss during review'), risk && 'Execution risk check: ' + risk].filter(line => line !== false).join('\n');
- const email = 'mailto:contact@arpanghoshal.com?subject=' + encodeURIComponent('CTRLRun architecture review — ' + company) + '&body=' + encodeURIComponent(brief);
+ const email = 'mailto:contact@arpanghoshal.com?subject=' + encodeURIComponent('CTRLRun architecture review: ' + company) + '&body=' + encodeURIComponent(brief);
const sendReview = async () => {
if (sendingRef.current || sent) return;
sendingRef.current = true; setSending(true); setError('');
diff --git a/docs/snippets/deck-nav.jsx b/docs/snippets/deck-nav.jsx
new file mode 100644
index 0000000..abf5b80
--- /dev/null
+++ b/docs/snippets/deck-nav.jsx
@@ -0,0 +1,121 @@
+/* Controls for the homepage deck. The slides themselves are plain markup in
+ index.mdx, so the page still reads and indexes with no JavaScript: without
+ this component it is a scroll-snap deck, and with it the arrows, the dots
+ and the keyboard work. */
+export const DeckNav = ({ labels = [] }) => {
+ const [active, setActive] = useState(0);
+ const [count, setCount] = useState(labels.length);
+ const deckRef = useRef(null);
+ const slidesRef = useRef([]);
+ const activeRef = useRef(0);
+
+ const goTo = (index) => {
+ const deck = deckRef.current;
+ const slides = slidesRef.current;
+ if (!deck || !slides.length) return;
+ const target = slides[Math.max(0, Math.min(slides.length - 1, index))];
+ if (!target) return;
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ deck.scrollTo({ top: target.offsetTop, behavior: reduce ? 'instant' : 'smooth' });
+ };
+
+ useEffect(() => {
+ const deck = document.querySelector('.cr-deck');
+ if (!deck) return undefined;
+ deckRef.current = deck;
+ slidesRef.current = Array.from(deck.querySelectorAll('.cr-slide'));
+ setCount(slidesRef.current.length);
+
+ // The deck sits under whatever chrome the docs theme renders above it, and
+ // every slide is exactly one viewport. Where a composition is taller than
+ // the room it has, scale it down rather than let it scroll or clip.
+ const fit = () => {
+ const navbar = document.getElementById('navbar');
+ const top = navbar ? navbar.getBoundingClientRect().height : 0;
+ deck.style.setProperty('--cr-chrome', Math.round(top) + 'px');
+ // One scale for the whole deck, not one per slide: a slide scaled on its
+ // own would sit at a different left edge from its neighbours, and the
+ // misalignment reads as a mistake when you arrow between them.
+ let scale = 1;
+ slidesRef.current.forEach((slide) => {
+ const inner = slide.querySelector('.cr-slide-inner');
+ if (!inner) return;
+ const style = getComputedStyle(slide);
+ const roomY = slide.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
+ const roomX = slide.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
+ // offsetHeight is layout height, so it does not compound with the scale
+ // already applied on the previous pass.
+ scale = Math.min(scale, roomY / Math.max(1, inner.offsetHeight), roomX / Math.max(1, inner.offsetWidth));
+ });
+ deck.style.setProperty('--cr-slide-scale', String(Math.max(0.5, scale)));
+ };
+
+ let frame = 0;
+ const update = () => {
+ const slides = slidesRef.current;
+ if (!slides.length) return;
+ let current = 0;
+ slides.forEach((slide, i) => {
+ const near = Math.abs(slide.offsetTop - deck.scrollTop);
+ if (near < Math.abs(slides[current].offsetTop - deck.scrollTop)) current = i;
+ });
+ activeRef.current = current;
+ setActive(current);
+ };
+ const schedule = () => {
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => { fit(); update(); });
+ };
+
+ fit();
+ update();
+ deck.addEventListener('scroll', schedule, { passive: true });
+ window.addEventListener('resize', schedule);
+ return () => {
+ cancelAnimationFrame(frame);
+ deck.removeEventListener('scroll', schedule);
+ window.removeEventListener('resize', schedule);
+ };
+ }, []);
+
+ useEffect(() => {
+ const onKey = (event) => {
+ // Leave typing, search and control activation alone.
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
+ if (event.target instanceof Element && event.target.closest('input, textarea, select, [contenteditable]')) return;
+ if (event.key === ' ' && event.target instanceof Element && event.target.closest('button, a')) return;
+ const steps = { ArrowDown: 1, PageDown: 1, ' ': 1, ArrowUp: -1, PageUp: -1 };
+ if (event.key in steps) {
+ event.preventDefault();
+ if (!event.repeat) goTo(activeRef.current + steps[event.key]);
+ } else if (event.key === 'Home') {
+ event.preventDefault();
+ goTo(0);
+ } else if (event.key === 'End') {
+ event.preventDefault();
+ goTo(slidesRef.current.length - 1);
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, []);
+
+ const names = count === labels.length ? labels : Array.from({ length: count }, (_, i) => 'Slide ' + (i + 1));
+
+ // Dots only. They are the deck's whole visible chrome, so unlike a decorative
+ // indicator they carry real labels and take focus.
+ return (
+
+ );
+};
diff --git a/docs/snippets/scenario-explorer.jsx b/docs/snippets/scenario-explorer.jsx
index 41691d0..07bdf9a 100644
--- a/docs/snippets/scenario-explorer.jsx
+++ b/docs/snippets/scenario-explorer.jsx
@@ -81,7 +81,7 @@ export const ScenarioExplorer = ({ standalone = false }) => {
tone: 'green',
reason: 'This action is inside the autonomy you gave the agent, so no person is asked.',
llm: 'This is within my limits, so I will call the tool.',
- kernel: 'Agreed — and the effect is reserved first, so a second worker proposing the same thing now waits.',
+ kernel: 'Agreed, and the effect is reserved first, so a second worker proposing the same thing now waits.',
rule: money ? 'Example rule: this agent may act autonomously up to $1,000.' : 'Example rule: this agent may perform this action on the selected target.',
sees: 'decision=allow → your function runs',
doc: { href: '/docs/concepts/decisions', label: 'Decisions' },
@@ -186,7 +186,7 @@ export const ScenarioExplorer = ({ standalone = false }) => {
reason: 'The real system confirmed the action. The outcome and the decision behind it are written to a receipt.',
llm: 'Done. I will report success.',
kernel: 'Committed. Any later attempt at the same business action now has something to be refused against.',
- rule: 'A receipt records the action, the decision and the outcome — including who approved it.',
+ rule: 'A receipt records the action, the decision and the outcome, including who approved it.',
sees: 'outcome=committed · receipt rcp_4c2a…',
doc: { href: '/docs/concepts/receipts-and-evidence', label: 'Receipts and evidence' },
marks: ['pass', 'pass', viaApproval ? 'pass' : 'skip', 'pass', 'pass'],
@@ -308,7 +308,7 @@ export const ScenarioExplorer = ({ standalone = false }) => {
{!standalone && Open on its own page ↗}
-
Nothing here executes: this is an illustration of the decisions, with example rules that are not industry defaults. To watch the real library refuse a real call, run the released wheel in your browser — Python and CTRLRun load into the tab and every refusal there is the library's own.{physical ? ' CTRLRun governs the software authorization and execution workflow; physical safety controls remain separate.' : ''}{domain === 'Healthcare Operations' ? ' Administrative workflows only; no diagnosis or clinical decisions.' : ''}
+
Nothing here executes: this is an illustration of the decisions, with example rules that are not industry defaults. To watch the real library refuse a real call, run the released wheel in your browser: Python and CTRLRun load into the tab and every refusal there is the library's own.{physical ? ' CTRLRun governs the software authorization and execution workflow; physical safety controls remain separate.' : ''}{domain === 'Healthcare Operations' ? ' Administrative workflows only; no diagnosis or clinical decisions.' : ''}
{reviewQuestion}
Review where execution controls belong in your architecture.
Your model chooses the action. CTRLRun decides whether it may execute.
-
Pick your domain — there are 48 — then pick the moment it goes wrong: a lost reply, an approval the agent edited, a retry of something that already happened. You get the decision, the check that produced it, and the line your own code would see.
+
Pick one of 48 domains, then pick the moment it goes wrong: an action the agent was never allowed to take, an approval the agent edited, a retry of something that already happened, a reply that never came back. You get the decision, the check that produced it, and the line your own code would see.
No login. No API key. No setup. Nothing here executes. Every scenario has its own link, so you can send someone straight to the one that matters.
@@ -37,7 +37,7 @@ import { ScenarioExplorer } from "/snippets/scenario-explorer.jsx";
Not sure where the controls belong in your architecture? A focused review maps which of your agent's actions can run automatically, which need a person, and which must stop until the outcome is known.
diff --git a/tests/test_readme_assets.py b/tests/test_readme_assets.py
index 6ec3ac1..2c5c4b2 100644
--- a/tests/test_readme_assets.py
+++ b/tests/test_readme_assets.py
@@ -170,4 +170,4 @@ def test_the_header_carries_the_fixed_copy_and_the_five_badges():
assert marker not in head, "the capability matrix is not the first screen"
assert marker in text, "the capability matrix was moved, not dropped"
assert head.count("\n|---|") == 0, "no table above the first H2"
- assert text.split("\n## ", 2)[1].startswith("The refund that happened twice")
+ assert text.split("\n## ", 2)[1].startswith("The refund nobody approved")