@doubleoh/sdk
v0.4.1
Published
Your agents learn from every fix. Stop failing the same way twice.
Maintainers
Readme
@doubleoh/sdk
Your agents learn from every fix. Stop failing the same way twice.
Observability tells you your agent broke. This makes sure it never breaks the same way again.
When your agent gets stuck on a real website — a login wall, a cookie banner, a redesigned page — one of your people fixes it once in a live browser. That fix is recorded and compiled into a skill. The next time any agent in your fleet hits the same wall, it follows the skill instead of asking a human.
npm install @doubleoh/sdkThe loop, in code
Three calls, because the product is three moments.
import { DoubleOh } from "@doubleoh/sdk";
const doubleoh = new DoubleOh({ apiKey: process.env.DOUBLEOH_API_KEY! });
// 1. Before your agent tries something it has failed at before, ask what has been learned.
const skills = await doubleoh.skillsFor("check out on the supplier portal");
if (skills.length > 0) {
// Put it in the agent's context. This is the whole point: a past human's fix, applied now.
systemPrompt += `\n\nA colleague has done this before:\n${skills[0].instructions}`;
}
// 2. Your agent runs. When it gets stuck, ask for a human.
const fix = await doubleoh.requestFix({
url: page.url(),
task: "Check out on the supplier portal",
});
await slack.send(`An agent needs a hand: ${fix.fixUrl}`);
// 3. Your teammate opens the link, drives the browser, clicks "I fixed it".
// Their actions become a skill. Step 1 returns it from now on.You decide what "stuck" means — a timeout, a retry count, a model that says it cannot proceed. Only you know your agent.
What your teammate sees
The fix link opens a page with the live browser already under their control and the task written at the top. They finish it by hand, press I fixed it, and are told what their fix became. No account, no install, no DoubleOh knowledge. The link expires after a few hours and stops working the moment it is used.
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.
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 });client.guard(fn, spec) returns the same function, guarded: every call opens a receipt (the rules are checked there), waits for a person when one must decide (waitForPersonSeconds, default 0), runs fn only when allowed, and confirms with evidence (ok: false when it throws). It throws GuardRefused and returns GuardPending or GuardReplay instead of running fn; each carries .text for the model. client.guardTools(tools, specs) does it for every execute or run in an array or a name-keyed record, and hands the model the text; a spec of false marks a read-only tool. The default idempotency key is a hash of the tool and its arguments (case and whitespace in strings ignored), so the same call after it was done is not done again; pass idempotencyKey when the same arguments may rightly repeat. onPending(pending, ...args) hands you a waiting call's arguments: after await client.waitForFix(pending.wallId), call the guarded function again with them and it runs once.
Vercel AI SDK
import { generateText, tool, stepCountIs } from "ai";
import { z } from "zod";
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 }),
payee: (a) => a.to,
idempotencyKey: (a) => `pay-${a.invoice}` },
send_email: { action: "Email {to}: {subject}" },
});
const { text } = await generateText({ model, tools, stopWhen: stepCountIs(8), prompt });payee names who the money goes to: the same amount to the same payee within 24 hours waits for a
person, and two payees are never mistaken for one (without it, the payee is guessed from the action's
words).
Test without production
// Unit tests: no key, no network. decide() plays your rules; mock.answer() plays the person.
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");DOUBLEOH_MODE=dry runs any agent against your real rules with nothing done: each guarded call
returns a GuardDryRun ("Dry run, not done: ... would wait for a person (Transfers over $500 need a
person)") and the function never runs. DOUBLEOH_MODE=mock is the same as mode: "mock".
API
new DoubleOh({ apiKey, baseUrl?, fetch? })
apiKey starts with oo_live_. baseUrl points at your own deployment if you self-host.
skillsFor(task: string): Promise<LearnedSkill[]>
What has been learned that helps with this task. Each skill has name, description, and
instructions — put the instructions in your agent's context.
requestFix({ url, task }): Promise<Intervention>
Ask a human to finish it. Returns { id, fixUrl, prepared }. Send fixUrl wherever your team
watches; prepared says whether we managed to pre-open the page for them.
fixUrl is null when the same wall is already waiting. Your retry loop can call this every
attempt without spamming your team — asking twice about the same page and task returns the first
intervention, with duplicateOf set. Guard on it:
const fix = await doubleoh.requestFix({ url, task });
if (fix.fixUrl) await slack.send(`An agent needs a hand: ${fix.fixUrl}`);The URL must be a public http(s) page; private and loopback addresses are refused.
deflected: true means nobody was paged — you already know the answer. When a learned skill
with a working record matches the task, requestFix returns it instead of opening an intervention:
no container, no Slack ping, no waiting on a human. Retry with the skill in your agent's context;
if it still fails, call requestFix again — the same wall deflects only once, so the second ask
reaches a human:
const fix = await doubleoh.requestFix({ url, task });
if (fix.deflected && fix.skill) {
prompt += fix.skill.instructions; // retry with what a colleague already taught the fleet
} else if (fix.fixUrl) {
await slack.send(`An agent needs a hand: ${fix.fixUrl}`);
}On a duplicate, blockedRuns says how many runs are stuck on this wall — surface it wherever your
team triages, because the wall blocking fourteen runs deserves a human before the wall blocking one.
intervention(id): Promise<InterventionStatus>
How one fix went. status is open, resolved, abandoned, or expired; skillName is what it
produced, once it has.
reportSkill(name, worked): Promise<void>
Say whether a skill worked. This is the call that makes the rest trustworthy — without it a skill compiled from one recording is served forever with nothing checking it. One boolean, so wire it into whatever already tells you a run succeeded:
const [skill] = await doubleoh.skillsFor(task);
if (skill) {
// Raw counts, not a rate: 5/5 is worth following, 1/1 is a guess that worked once.
const trusted = skill.timesWorked >= 2 && skill.timesFailed === 0;
if (trusted) prompt += skill.instructions;
}
// …run the agent…
if (skill) await doubleoh.reportSkill(skill.name, itWorked);A skill followed three times that never works retires itself.
retireSkill(name, reason): Promise<void>
Stop serving a learned skill that turned out to be wrong. Your agents stop being told about it immediately. Wire it to whatever signal tells you a run went badly — a bad compile should cost one failure, not every future one.
interventions(): Promise<InterventionStatus[]>
Recent fixes across your fleet, newest first.
Notes
- No dependencies. It is
fetchand types. A reliability tool that drags a build tree into your project is a reliability problem. - Errors throw
DoubleOhErrorcarrying the server's message, the HTTPstatus, a stablecodeandnext, the move to make. An unreachable service iscode: "unreachable", status 0. - What is recorded. The fix session keeps screenshots and the shape of each action (a click, a key). Typed text is never recorded — a fix session is exactly where a password gets typed, so the keystrokes stay off this path entirely.
