Ship reliability in an afternoon.
DoubleOh is the authority layer for AI agents. When an agent gets stuck, a human fixes it once in a live browser or desktop, and the fix compiles into a reusable skill the whole fleet follows next time, so the same failure never happens twice. Integrate in four calls via the Python or TypeScript SDK, or the MCP server.
DoubleOh is the authority layer for AI agents. When an agent gets stuck, one of your own people fixes it once in a live browser, and the fix compiles into a skill your whole fleet reuses forever. You wire in four methods; the loop takes care of the rest.
On this page
Install Remote MCP (https) The loop, in code The four methods Guarded tools Test without production Every framework What's recorded, and what isn't API referenceInstall
Pick your language. The SDKs have zero dependencies, a reliability tool that drags a build tree into your project is a reliability problem.
# Python pip install doubleoh # TypeScript / Node npm install @doubleoh/sdk # MCP: any MCP-capable agent (Claude and more) gets the loop with no code npx doubleoh-mcp
Remote MCP (https)
Nothing to install: the same seven tools, served at https://api.doubleoh.ai/mcp over Streamable HTTP. For clients that take a remote server, send your key in a header:
{ "mcpServers": { "doubleoh": {
"type": "http",
"url": "https://api.doubleoh.ai/mcp",
"headers": { "Authorization": "Bearer oo_live_..." } } } }
X-DoubleOh-Key: oo_live_... works in place of Authorization. Listing the tools needs no key; every tool call does, and is held to the key's agent, rate limits and rules exactly as a /v1 call is. doubleoh-mcp guard stays local: it fronts an MCP server on your machine, so run it with npx.
The loop, in code
Three moments, because the product is three moments: ask what's known, ask for help, report how it went.
import { DoubleOh } from "@doubleoh/sdk";
const doubleoh = new DoubleOh({ apiKey: process.env.DOUBLEOH_API_KEY });
// 1. Before a task it has failed before, ask what the fleet already knows.
const skills = await doubleoh.skillsFor("check out on the supplier portal");
if (skills.length) prompt += skills[0].instructions;
// 2. Stuck? A human is paged once; a known wall is answered instantly.
const fix = await doubleoh.requestFix({ url: page.url(), task });
if (fix.deflected) prompt += fix.skill.instructions; // nobody paged
else if (fix.fixUrl) await slack.send(fix.fixUrl); // a human, once
// 3. Close the loop. Skills that stop working retire themselves.
await doubleoh.reportSkill(skills[0]?.name, worked);
You decide what "stuck" means. A timeout, a retry count, a model that says it can't proceed, only you know your agent. DoubleOh gives you the moments; the trigger is yours.
The four methods
skillsFor(task, { url })
Returns procedures learned from past human fixes, each carrying its own track record, how many times it worked, how many it didn't. Raw counts, not a rate: five-for-five is worth following; one-for-one is a guess that happened to work once.
requestFix({ url, task, agent })
Ask a human to fix a stuck agent.
agentis optional: the name your team knows this agent by ("ops-bot"), shown to the person as who asked. If the fleet already knows this wall, the learned skill comes back instantly and nobody is paged, that's a deflection. If not, a fix link is issued for a teammate to drive. Theurlmust be a public page, or a host you have allowlisted under Private targets in the portal, in which case passcomputerId(Python:computer_id) and the fix runs on your own runtime.waitForFix(id) · wait_for_fix(id)
Holds on the server up to 55 seconds per call and returns as soon as a person has finished, with the skill's name when one was compiled. A ten-minute wait is about eleven calls, not three hundred. Read
approvedfirst: true is a yes, false is a no (do not do it, do not retry).answer.value.reasonis the person's why; a screen fixed by hand staysresolvedwithanswer.value.result, what they say you need to know (a link, a code).reportSkill(name, worked)
One boolean that keeps the library honest. A skill followed three times that never works retires itself, so a bad compile costs one failure, not every future one. A skill that was served but is not for this task (another field, another tab, another intent) is not a failure: report
reportSkill(name, false, { reason: "not_applicable", task })(Python:report_skill(name, False, reason="not_applicable", task=task)). The skill is not served for that task again, its record is untouched, and your next ask reaches a person.
For the agent reading this
The caller of this API is usually a program in the middle of a task. Every answer is shaped for it.
Refusals you can act on
Every error is
{"error", "code", "next"}: the sentence a person reads, a stable code, and the next move. Codes:key_required,rate_limited(429, withretryAfterSeconds,limit, and for new wallsperandagent),bad_request,agent_mismatch(400: this key belongs to another agent),target_refused,computer_required,quota_exceeded(402, withupgradeUrlandreservedForApprovals),not_found,unavailable,authority_refused,already_settled,not_approved(409,differsnames what does not match),agent_paused(429: a person paused this agent). The SDKs expose them aserror.codeanderror.next, pluserror.differsanderror.retryAfterSeconds(Python:retry_after_seconds).One key per agent
Give every agent its own key (portal: Keys, Add an agent). The key belongs to that agent: its name is what every wall, receipt and Ledger row shows as verified (
agentName), whatever label a request sends. A write that names another agent is refused withagent_mismatch("This key belongs to agent fin-bot").GET /v1/mereturnsme.agent. Older keys without an agent keep working; their labels are shown as unverified (claimedAgent). The plan and the month's usage belong to the organisation: all its agents' keys share one plan and one count of human fixes.Rate limits and pause
Each agent may open a limited number of new walls (by default 6 a minute and 60 an hour; approvals have a budget of their own), so one looping agent cannot bury the money walls or spend the month's fixes. Over it, the answer is 429
rate_limitedwithretryAfterSeconds: wait, then ask once. A person can pause an agent from the Inbox; its new walls are 429agent_pauseduntil they resume it. Stop the task when you see it. When a free plan is down to its last tenth, plain walls are refused (402quota_exceeded,reservedForApprovals) so approvals can still reach a person.No polling
GET /v1/interventions/{id}?wait=55holds the request and returns when the status changes. Every fix response carries a readywaitUrland anexpectedSecondsplanning number.One wall, one id
wallIdis stable across retries and rephrasings of the same task on the same site. Asking twice about an open wall returns the same intervention withduplicateOf; a wall a skill already answers returnsdeflected: true. Log the id; it is what your dashboards should group by.Skills as context
Every skill comes with
systemHint: the authority boundary first, then the numbered steps, then how to report. Paste it verbatim into the agent's context. The structuredauthorityfield is there too.Quickstart for the key that is calling
GET /v1/quickstartreturns the four steps and copy-ready snippets for Python, TypeScript, curl and MCP. No key is echoed. The MCP server's tool descriptions teach the same loop, so an MCP-capable agent needs no code at all.One number to watch
GET /v1/mereportsme.agent, the organisation's plan and usage this month, andfirstDeflection: when this key's first wall was answered without a person, and how long after its first fix. That is the moment the loop closed for you.
Proof and rules
Some actions change the world: a refund, an email, an invoice. For those, ask first, keep a receipt, and only call it done when the other system says so.
checkAuthority({ tool, target, action, amountCents, currency }) · check_authority(...)
Asks whether this agent may do this. The answer is
allow,needs_personornever, with the rule that decided it as a sentence. Rules are written in the portal as plain sentences ("Refunds over $500 need a person", "Never change bank details"); every answer is recorded. Rules are matched by meaning, not only by their words: "Never change bank details" also covers "update remittance info" or anaccount_nofield. When DoubleOh is unsure which rule applies, a person decides: anevermatched only by meaning, and not surely, asks a person instead of refusing, and your own allow beats a needs-person rule only by being more specific, or when it is surely meant and the other rule matched by meaning alone (its limit still applies). A payment to the same payee for the same amount and currency as one in the last 24 hours needs a person; turn that off on the Rules page. Sendpayee(who the money goes to; the guard spec'spayee) with the receipt or check and the payee is compared as named, case and spacing aside; without it, it is guessed from the action's words with ids and numbers left out. SendcurrencywithamountCents: a limit in one currency never grants another.rules()returns them with asystemPromptready for the agent's context.receipt({ tool, action, idempotencyKey, amountCents, currency, payee, agent, approvedBy }) · receipt(...)
Open it right before the action. Use one
idempotencyKeyper real action and the same one on every retry: the second time comes backduplicate: true, and the action must not run again; afterfailed, the same key reopens the receipt (retried: true). A refused action fails with codeauthority_refused. One that needs a person pages your people and returns awaitUrlandwallUrl; when they answer, the receipt becomesapproved(do it, then confirm) orrefused, andreceipt.approvalrecords who, when and why. With no human fixes left on the plan it fails withquota_exceeded, carryingupgradeUrland the recorded receipt: do not do it. Already holding a person's yes from an answer wall? Pass its id asapprovedBy(Python:approved_by) and the receipt opens approved with no second ask. An approval is bound to what was asked: the same agent, the same tool and action, the same site, and at most the amount the person approved, once. Anything else fails withnot_approved, anddifferssays which part (agent,tool,action,target,amount,spent, ...). To ask for an amount on an answer wall, putamountCentsandcurrencyin the packet (or inpacket.args).Daily limits
A rule such as "May transfer up to $500" is a running total: a day per agent and per currency unless the rule names another window (hour, week, or per transaction). A payment that would bring the day past the limit needs a person, and the sentence says the total ("This would bring today's payments to $900, over the $500-a-day limit"). Several smaller amounts in a short time that together pass a limit also carry
authority.pattern: "split". Concurrent receipts are counted one at a time, so five parallel payments cannot all fit under one limit. A receipt that needs a person while the agent is over its wall limit or paused is 429 with the recorded receipt: do not do it.confirmReceipt(id, { evidence }) · confirm_receipt(id, evidence)
Send what the target system answered: its id, its status, a link. A receipt nobody confirms before its deadline becomes "not confirmed" and a person is asked to check. A settled receipt cannot change (
already_settled). The portal's Ledger lists every receipt and exports a CSV or a signed JSON record for an auditor.
No page to take over? requestFix({ task, mode: "answer", packet: { tool, args, error, steps } }) asks a person for an answer instead of a screen. They see what the agent tried and what came back, and answer, correct the call, or refuse. The wait returns status: "answered" with the answer to apply, and answer.approved says yes or no.
const wall = await doubleoh.requestFix({ task: "Refund order 1042, $620", mode: "answer",
packet: { tool: "stripe.refund", args: { charge }, amountCents: 62000, currency: "USD" } });
const done = await doubleoh.waitForFix(wall.id);
if (done.approved) {
const { receipt } = await doubleoh.receipt({ tool: "stripe.refund", action: "Refund order 1042",
idempotencyKey: "refund-1042", amountCents: 62000, currency: "USD", approvedBy: wall.id });
// ... do it, then confirmReceipt ...
} else {
console.log("Denied:", done.answer?.value.reason); // do not do it, do not retry
}
const check = await doubleoh.checkAuthority({ tool: "stripe.refund", amountCents: 62000, currency: "USD" });
if (check.decision === "allow") {
const { receipt } = await doubleoh.receipt({ tool: "stripe.refund", action: "Refund order 1042", idempotencyKey: "refund-1042", amountCents: 62000, currency: "USD" });
const refund = await stripe.refunds.create({ charge, amount: 62000 });
await doubleoh.confirmReceipt(receipt.id, { evidence: { providerId: refund.id, status: refund.status } });
}
Guarded tools
The model can't bypass what it never holds. Rules and receipts only bind an agent that asks first, and in a benchmark Claude Haiku 4.5 called change_bank_details directly in three runs of three. A guarded tool asks on every call, whatever the model does: the dangerous function is wrapped, and the model only ever gets the wrapped one.
Every call
opens a receipt (the rules are checked there, by meaning), runs the function only when allowed, and confirms with evidence: the result, or ok false when the function throws.
A rule says never
the function is not called. The model reads: "Refused by your organisation's rules: <the rule>. Do not try another way."
A person must decide
the function is not called yet. With waitForPersonSeconds 0 (the default) the model reads "Not done yet: waiting for a person to approve (<wallUrl>). Tell the user it is waiting; do not retry or work around it." With a budget, the call waits for the answer: yes runs it, no refuses with the person's reason, and a yes for less than the amount does not run it. To resume without a budget, keep the call's arguments from
onPending(Python:on_pending), wait for the answer withwaitForFix(pending.wallId), and call the guarded function again with the same arguments: it runs once. Do not ask the model again: it rephrases, and a rephrased call is a new action that pages a person again.The same call again
the idempotency key is a hash of the tool and its arguments, so a retry after it was done returns "Already done" and does not run it twice; after an approval, the same call goes through. The hash ignores case and whitespace in strings and reads decimal strings as numbers, so "DE89 3704" and "de893704" are one IBAN and "Invoice G-7" and "invoice G-7" one memo; the receipt keeps the arguments as they were. When the same arguments may rightly repeat (a weekly email with the same text), or the model may word one action two ways, pass your own
idempotencyKeyfrom the arguments that name the action, such as the invoice id.
TypeScript: client.guard(fn, { tool, action, target?, amount?, idempotencyKey?, evidence?, waitForPersonSeconds?, agent? }) returns the same function, guarded (it throws GuardRefused, returns GuardPending or GuardReplay, each with .text, and in dry mode GuardDryRun); onPending(pending, ...args) is called when a person must decide; client.guardTools(tools, specs) wraps every execute or run in an array or a name-keyed record and returns the text to the model. Python: client.guard(fn, tool=..., action=..., target=..., amount=..., idempotency_key=..., evidence=..., wait_for_person_seconds=0, agent=None, on_pending=None), also a decorator, and an async def stays async; it raises GuardRefused and returns GuardPending or GuardReplay (both are strings). amount returns whole minor units and a currency, (cents, "USD") in Python, { amountCents, currency } in TypeScript; a float such as 19.99 * 100 is rounded. action is a template ({tool}, {args}, {field}) or a function of the arguments.
Anthropic tool use, TypeScript
import Anthropic from "@anthropic-ai/sdk";
import { betaTool } from "@anthropic-ai/sdk/helpers/beta/json-schema";
import { DoubleOh } from "@doubleoh/sdk";
const tools = new DoubleOh().guardTools([
betaTool({ name: "change_bank_details", description: "Change a vendor's IBAN.",
inputSchema: { type: "object", properties: { vendor: { type: "string" }, iban: { type: "string" } }, required: ["vendor", "iban"] },
run: (input) => bank.changeDetails(input.vendor, input.iban) }),
], { change_bank_details: { action: "Change {vendor}'s IBAN to {iban}" } });
await new Anthropic().beta.messages.toolRunner({ model: "claude-opus-5-5", max_tokens: 16000, tools, messages });
Vercel AI SDK
import { generateText, tool, stepCountIs } from "ai";
import { z } from "zod";
import { DoubleOh } from "@doubleoh/sdk";
const tools = new DoubleOh().guardTools({
send_payment: tool({ description: "Send a payment.",
inputSchema: z.object({ to: z.string(), amount: z.number(), currency: z.string(), invoice: z.string() }),
execute: async (a) => payments.send(a) }),
send_email: tool({ description: "Send an email.",
inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
execute: async (a) => mail.send(a) }),
}, {
send_payment: { action: "Pay {to} {amount} {currency} for {invoice}",
amount: (a) => ({ amountCents: a.amount * 100, currency: a.currency }),
idempotencyKey: (a) => `pay-${a.invoice}` },
send_email: { action: "Email {to}: {subject}" },
});
const { text } = await generateText({ model, tools, stopWhen: stepCountIs(8), prompt });
Anthropic tool use, Python
from anthropic import Anthropic, beta_tool
from doubleoh import DoubleOh
doubleoh = DoubleOh()
@beta_tool
@doubleoh.guard(tool="change_bank_details", action="Change {vendor}'s IBAN to {iban}")
def change_bank_details(vendor: str, iban: str) -> str:
"""Change a vendor's IBAN."""
return bank.change_details(vendor, iban)
runner = Anthropic().beta.messages.tool_runner(model="claude-opus-5-5", max_tokens=16000,
tools=[change_bank_details], messages=messages)
LangChain, LangGraph, CrewAI, LlamaIndex
from doubleoh.langchain import guard_tool # doubleoh.crewai and doubleoh.llamaindex have the same
tools = [
guard_tool(change_bank_details, action="Change {vendor}'s IBAN to {iban}"),
guard_tool(pay_invoice, amount=lambda a: (a["amount_cents"], a["currency"])),
lookup_vendor, # read-only: left as it is
]
agent = create_react_agent(model, tools) # langgraph.prebuilt, or langchain.agents.create_agent
OpenAI Agents SDK
from agents import Agent
from doubleoh.openai_agents import guard_tool # in place of @function_tool
@guard_tool(action="Change {vendor}'s IBAN to {iban}")
def change_bank_details(vendor: str, iban: str) -> str:
"""Change a vendor's IBAN."""
return bank.change_details(vendor, iban)
agent = Agent(name="ops", tools=[change_bank_details])
Use it in place of @function_tool, not under it: the Agents SDK turns an exception into "An error occurred while running the tool. Please try again.", which tells the model to retry a refusal. guard_tool hands it the refusal's own sentence.
Any MCP server, no code
doubleoh-mcp guard starts the server it fronts, re-exposes its tools with the side-effecting ones guarded, and keeps the seven DoubleOh tools beside them. Point your MCP client at the proxy instead of the server.
{ "mcpServers": { "bank": {
"command": "npx",
"args": ["-y", "doubleoh-mcp", "guard", "--config", "guard.json", "--", "npx", "-y", "your-bank-mcp"],
"env": { "DOUBLEOH_API_KEY": "oo_live_..." } } } }
guard.json (every tool is guarded unless listed under readOnly):
{ "readOnly": ["lookup_vendor"],
"tools": { "pay": { "action": "Pay {vendor} {amount} {currency}", "amount": "amount", "currency": "currency" } },
"waitForPersonSeconds": 0 }
On start the proxy writes to stderr (your MCP client's log) which tools it guards and which it passes through, and names any readOnly entry that matches no tool. Guarded tools drop their outputSchema, since their answer may be a sentence instead of the declared shape. npx doubleoh-mcp --help lists the options.
Test without production
Two modes, set with DOUBLEOH_MODE or the mode option. Neither runs a side effect against production, and your code does not change between them.
mock: unit tests, offline
No key, no network. Guarded tools answer the way the service does (run, refused, waiting, already done), from a
decidefunction you pass, which defaults to allowing everything.client.mock.receiptslists what was asked;client.mock.answer(receiptId, true)plays the person who says yes. Anything that needs the service (fixes, skills) is refused with codeunavailable.dry: your real rules, nothing done
With a real key, a guarded call asks your rules what they would decide and returns a
GuardDryRun: "Dry run, not done: "Pay Acme 900 USD" would wait for a person (Transfers over $500 need a person)." No receipt is opened, nobody is paged, the function never runs. Use it to try a new rule against yesterday's traffic, or to run an agent end to end before it may act.
// TypeScript (bun test, vitest, node:test)
const oo = new DoubleOh({ mode: "mock", decide: (r) => (r.amountCents ?? 0) > 50_000 ? "needs_person" : "allow" });
const pay = oo.guard(sendPayment, { tool: "send_payment", action: "Pay {to}",
amount: (a) => ({ amountCents: a.cents, currency: "USD" }) });
expect(await pay({ to: "Acme", cents: 90_000 })).toBeInstanceOf(GuardPending);
oo.mock.answer(oo.mock.receipts[0].id, true);
expect(await pay({ to: "Acme", cents: 90_000 })).toBe("paid");
# Python (pytest, unittest)
client = DoubleOh(mode="mock", decide=lambda r: ("never", "Never change bank details.")
if r["tool"] == "change_bank_details" else "allow")
with pytest.raises(GuardRefused):
client.guard(change_bank_details)("Acme", "DE89...")
# Any agent, against your real rules, nothing done
DOUBLEOH_MODE=dry python agent.py
Records and compliance
In the portal, Records and compliance (under your name) sets how long your agents' records are kept and the law you keep them for: Standard, EU AI Act, EU AI Act high-risk, or financial services in the EU, US or UK. Each profile sets a floor the portal will not go under (six months for high-risk AI under Art. 26(6), five years for EU and UK financial records, six for SEC 17a-4). Screenshots and recordings have their own, shorter window; the record keeps a fingerprint of each after it is gone. A legal hold stops every deletion. Without settings, nothing is deleted.
Two exports cover any period up to 400 days: one for the EU AI Act, one for a financial audit, each as JSON or CSV. Every row is one event (a wall opened, a person paged, an answer, a rule decision, a receipt opened, confirmed or blocked as a duplicate), linked by case_id, with who acted, what they saw and what they did. The rows form a SHA-256 hash chain, and the envelope that names both ends of the chain is signed with Ed25519. A cover sheet lists the articles each field maps to, the counts, the retention in force and where the data is stored.
Check an export without trusting us: fetch the public key from GET https://api.doubleoh.ai/v1/record-key (no key needed), then run bun scripts/verify-record.ts export.json in the DoubleOh repository. It prints OK, or the first row that was changed. The Ledger's "Signed record (JSON)" carries the same signed envelope and verifies the same way.
Not legal advice. The exports name the articles they are prepared for; have counsel confirm the mapping.
Every framework, one loop
Claude & MCP clients
Run doubleoh-mcp and the loop, receipts and rules become tools with zero integration code.
LangChain · CrewAI · LlamaIndex · OpenAI Agents
One-line adapters, guarded tools included. Plus plain-function tools for AutoGen and Pydantic AI.
Vercel AI · Mastra · raw
Framework-agnostic tool definitions carry JSON Schema, wrap them in one line anywhere.
Browser agent stacks: Browser Use, Steel, Skyvern, Mastra
DoubleOh is neutral across browser backends. The loop is the same four methods whatever drives the browser; here is where they go in the stacks agents are most often built on.
Browser Use
from browser_use import Agent
from doubleoh import DoubleOh
oo = DoubleOh(api_key=KEY)
skills = oo.skills_for(task, url=start_url) # what the fleet already knows about this site
context = "\n\n".join(s.system_hint or s.instructions for s in skills)
agent = Agent(task=f"{task}\n\n{context}" if context else task, llm=llm)
result = await agent.run()
if result.is_stuck():
fix = oo.request_fix(url=result.current_url, task=task) # a person, or a known skill
if fix.deflected: result = await agent.run(extend_system_message=fix.skill.instructions)
Steel and other hosted browsers
// Steel gives you a session; DoubleOh gives the person the same page.
const session = await steel.sessions.create();
const skills = await doubleoh.skillsFor(task, { url });
// ... drive the session with your agent ...
const fix = await doubleoh.requestFix({ url: session.currentUrl, task });
// fix.deflected ? follow fix.skill.instructions inside fix.skill.authority : hand fix.fixUrl to a teammate
Skyvern
# Skyvern workflows expose a webhook on failure; point it at a small handler:
@app.post("/skyvern/failed")
def failed(run):
fix = oo.request_fix(url=run.url, task=run.navigation_goal, agent="skyvern")
return {"fix_url": fix.fix_url, "deflected": fix.deflected}
Mastra
// Mastra agents take AI SDK tools; the definitions already carry JSON Schema.
import { jsonSchema, tool } from "ai";
import { DoubleOh } from "@doubleoh/sdk";
import { doubleohTools } from "@doubleoh/sdk/tools";
export const tools = Object.fromEntries(doubleohTools(new DoubleOh()).map((t) =>
[t.name, tool({ description: t.description, inputSchema: jsonSchema(t.parameters), execute: t.execute })]));
Every skill an agent receives carries an authority field: requiresHuman lists the situations where it must stop and ask a person, never the actions it must not take while following the skill. Honour both; they are the reason a compiled procedure is safe to hand to a fleet.
What's recorded, and what isn't
A fix session keeps screenshots and the shape of each action, a click, a keypress. It does not keep typed text. A fix session is exactly where a password gets typed, so keystrokes stay off this path entirely. Fix targets are validated too: private, loopback, and cloud-metadata addresses are refused, so a fix link can never be pointed at your internal network.
API reference
The full REST contract is published as an OpenAPI 3 spec, import it into Postman, or generate a typed client in any language. Keys start with oo_live_; get one from the pricing page.