Ogen

Actions & holds

Asked to decided.

Register, then commit

register_action sets a kind's fact and undo cost: read, reversible or expensive. Approvers register once by name and email; an unregistered name refuses and lists who is registered. A kind reports approver_status: "registered", or approver_status: "unregistered" when nobody could be notified — notified_reason is one of approver_unregistered, no_email_sender, send_failed, principal_mail_limited. An approver later erased reports approver_status: "approver_unrouted" / notified_reason: "approver_unrouted"; fix by re-registering with a live approver. A kind registered with the reserved word approver: "principal" waits for whoever commit_gated_action's own ask names (principal: { email }), never a registered approver, and reports approver_status: "principal".

Callers

Another party's agent — a customer's ChatGPT, a vendor's Claude, anything that isn't your own key or one of your own signed-in people — can ask too, once you register it. settings, action "caller_set", gives it a label and a closed list of kinds it may request; a fresh (or removed) label mints it a key, shown once. That key can do exactly two things: call commit_gated_action for a kind it was granted, and look at a hold it raised with explain_hold — nothing else, ever. It can't decide, release, read a fact, bind a source, or change any setting. Every hold it raises says so — the approver email and the confirm page name it, and explain_hold/list_holds return requested_by. "caller_remove" revokes the key; "caller_list" shows every label and its granted kinds, never a key.

The person the agent acts for

A merchant can't tell a secretary from an agent behind the same password; the buyer the business names is the one who signs the specific act. register_action { approver: "principal" } is a second, different kind of hold: it waits for whoever the ASK itself names, never a registered approver. commit_gated_action { kind, principal: { email } } — nothing but an address, required on a principal-gated kind, refused on any other — takes that address only from the business's own operator or owner key, after its own system reads its own customer record; a caller (see above) can never open a principal-gated hold, whatever it names. The principal gets ONE email, to the address the ask named — never looked up, never re-sendable (the address is never kept in a form Ogen could resend to, so a lost mail means asking again), and bounded: at most one per hour to the same address and 20 a day per tenant, across every address — over either bound the hold still exists, it just isn't mailed, and notified_reason reads principal_mail_limited. The mailed link's token is the only door: never a typed name, never a passkey (a principal has none to enrol, and decide_with: "passkey_only" is refused on a principal-gated kind at registration). decided_via is "token"; decided_by and the agent ledger's own principal field (Run your tenant) both read the constant label "buyer" — never the address. AP2 mandates (the buyer-side standard) are not yet a way to decide a hold.

What a held action looks like

commit_gated_action returns escalated (fact unsettled) or held (needs a person); either carries a confirmation link for the kind's approver. Nothing is authorized until used — your system acts.

The approver email

A registered approver's one email: the waiting action, its fact and source, why it waits, Assist's counts if on, and the link — never a value. It lasts 24 hours or the kind's hold_ttl_hours. Their first link each day also digests everything else waiting.

Approver-bound release

With a registered email, the confirm page offers "Email me the sign-in link" — one-time, 15-minute, single-use; only its click may Confirm or Reject, recording the registered name, never an address or typed one. With none registered, the link stays open to anyone who types a name — commit_gated_action's approver field says which: a name, or "none — anyone with the link can decide".

Deciding with a passkey

A named approver can decide with a passkey — fingerprint or face on their device (Touch ID, Face ID, Windows Hello). First confirm page: "Use your fingerprint next time" (enrolling decides nothing); after: "Confirm with your fingerprint" / "Reject with your fingerprint" beside "Email me the sign-in link". Ogen stores public key material, never a biometric; a passkey turns off on a new email, on revoke_passkeys: true, or on removal, and expires after 180 days (the next link offers a new one).

Passkey only, by cost. decide_with: "passkey_only" refuses a mailed token or typed name for a kind that isn't free to undo; a first-time approver can still enrol via the emailed link and decide immediately.

Release without a person — two dials. auto_release_under: { field, value, currency? } (register_action, owner key only) releases with nobody asked when the fact is settled and args[field] is under value, capped by a key's release_ceiling (smaller wins, neither overrides the fact), only on a cost: "expensive" kind (not "read" or "reversible"), comparing that one argument. Disagreement, an unreadable source, a missing, non-numeric or wrong-currency amount, or a "passkey_only" kind all wait for a person (reason says why); a release records decided_via: "policy", decided_by, released_under, and gets a release ticket. auto_release_per_day caps releases 1–200 per kind per day (owner key only, 20 default); past it a person decides (reason: "release_daily_limit"). Ogen keeps no amount, so the daily ceiling is your limit times your count; the same args release at most once a day, logged as detail.via: "policy". Only an owner key changes a dialled kind’s key or cost; revoking the key that set a dial clears it. Five open holds on a kind never block a release — that cap applies only to an ask that needs a person.

Release without a person — your own fact

release_when (register_action, owner key only) is a third path, beside the two dials above: your OWN live fact answers the ask directly, read fresh at the moment of the ask. Exactly one of { fact_equals: "<string>" } (release when the fact resolves and its trimmed string form equals that string exactly) or { fact_at_least: "<argument name>" } (release when the fact resolves to a number at least as large as that named argument, which must itself be above zero — a negative or zero argument never releases anything, the same floor a numeric dial holds its own field to). Two examples: cancel an order when your own order system's cancellable fact reads "true"; reserve stock when your own inventory system's available_for fact is at least the quantity asked for.

Release happens only when the fact is resolved — every bound source agreed. An ambiguous fact (your own systems disagree) or an unavailable one always waits for a person, the same rule the fact gate itself already applies. It shares auto_release_per_day's count with the numeric dial — one counter, not two — only on a cost: "expensive" kind, never with decide_with: "passkey_only", and a caller's own released asks count against that same shared count. A key's release_ceiling still applies, exactly as strictly as the numeric dial: when the asking key carries one, the arguments must carry its field as a finite, non-negative number in the matching currency, and that amount must stay under the ceiling's value, or the ask waits for a person (release_field_missing, release_field_not_numeric, release_field_negative, release_currency_mismatch or release_over_limit) even though the fact itself agreed — a key with an amount ceiling can never fact-release a kind whose arguments don't carry that amount. Five open holds on a kind do NOT exempt a fact release the way they exempt a dial's — too_many_open can still hold this path for a person. A release records decided_via: "policy" and released_under: { kind: "fact", rule, key } — never the fact's value, never the argument's value — and gets the same release ticket a dial's release does. Unlike the two numeric dials, this DOES apply to a caller's ask: it is your own business's live fact deciding, not a policy limit reserved for your own people — a caller still never receives the ticket or any value either way.

Asking twice doesn't create two holds

Asking again with the same kind, fact and args while a hold is open returns it (duplicate_of its id) — no second email or webhook. Different args get their own hold, up to five per kind; a sixth answers disposition: "held", reason: "too_many_open". A same-week repeat carries previous_hold: { id, action_id, previous_outcome, expired_at | ended_at } (previous_outcome: "expired", "ran", "failed" or "unreported"). Once confirmed, asking again returns disposition: "released" and the same ticket while valid; no args means {} — nothing runs, no new hold.

After the decision: the release ticket

An adapter must pin its own tenant and kind, refusing any ticket whose tenant or kind differs. Ogen never runs the action: a confirm issues a release ticket for your system to check before acting; your system keeps its own credentials.

Fields: tid, hold_id, tenant, kind, action_id, args_sha256, decided_by, decided_via ("token", "passkey", "name", "policy"), iat, exp, nonce. args_sha256: SHA-256 of sorted-key args JSON, no spaces ({} if none) — never the arguments, a value or an address. The ticket binds the arguments by hash, exposed as asked_with (first 12 hex characters).

Lifetime and delivery: 15 minutes or the hold's own expiry, whichever first, used at most once per tid. Once, on the confirm page, as a copyable block; in explain_hold/list_holds while valid and unreported; in commit_gated_action on a repeat ask (disposition: "released"). Never on a webhook — hold.decided carries only ticket_id and its expiry. A reject issues none.

Checking it: fetch keys at GET /.well-known/ogen-ticket-key (JWK, by kid); on an unknown kid fetch again, at most once a minute, and never keep a failed fetch. Refuse a ticket not strict base64url in all three parts, whose header isn't alg: "EdDSA"/typ: "ogen-release-ticket", that fails to verify, whose tenant/kind isn't yours, whose iat is over a minute future, whose exp passed, or whose args_sha256 mismatches.

Reporting: POST /v1/receipts { ticket, outcome: "ran" | "failed", reference?, error? } (or report_outcome). reference: 1–120 letters, digits or _ . : -, else refused; error checked, never stored. Reports once — a second is 409. A late ran still lands up to 7 days after expiry, even over a recorded unreported — becomes ran, webhook and log both late: true; once ran/failed (or a late failed) is recorded, it's 409.

Records: ran/failed shows in the Live list, activity log, explain_hold (outcome, outcome_reference, outcome_reported_at) and your webhook. On failed, the confirmer and contact hear nothing needs undoing; unreported asks both to retry.

Enforce it in Claude Code

A PreToolUse hook in .claude/settings.json can require a valid release ticket before a mutating tool call runs — Claude Code passes the call to your script and blocks it on a non-zero exit. Matcher: any mcp__stripe__.*refund.*, or your own tool.

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "mcp__stripe__.*refund.*",
      "hooks": [{ "type": "command", "command": "node check-release-ticket.mjs" }]
    }]
  }
}

The script checks release_ticket the same way as above — three base64url parts, alg: "EdDSA", typ: "ogen-release-ticket", tenant and kind pinned to yours, not expired — and exits non-zero to refuse:

const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
const ticket = input.tool_input?.release_ticket ?? "";
const parts = ticket.split(".");
if (parts.length !== 3) process.exit(1);
const header = JSON.parse(Buffer.from(parts[0], "base64url"));
const payload = JSON.parse(Buffer.from(parts[1], "base64url"));
const ok = header.alg === "EdDSA" && header.typ === "ogen-release-ticket"
  && payload.tenant === "your-tenant" && payload.kind === "send-refund"
  && payload.exp * 1000 > Date.now();
// Full signature check: see the adapter below.
process.exit(ok ? 0 : 1);

Full check, with signature verification: the published adapter.

Webhooks

webhook (action "set") POSTs the moment to your URL (https, one per tenant): { event: "action.held", tenant, action_id, kind, key, confirm_url, at, mode }, signed via X-Ogen-Signature: t=<unix>,v1=<hex HMAC-SHA256 over "<t>.<body>"> with your secret. If you don't give one, Ogen makes one and returns it once as secret — save it then; it isn't shown again. Your URL and secret are both stored encrypted, and only the host is ever shown back. One attempt, a 5-second timeout; a failed delivery never fails the tool call. commit_gated_action reports webhook: "sent" | "failed" | "none". A Slack incoming webhook (hooks.slack.com) gets a message instead, with a "Review and decide" button — never a value.

What happens to a hold

Expired is an outcome: a Live-list row, a hold_expired log row, a hold.expired webhook, one email each to the approver and your contact (settings, action "contact") — within about seven minutes. A late click after is told the hold expired; a decision and an expiry never both land. Undecided holds are deleted 90 days after expiry. At 80% of a hold's life, the approver (and the fallback, if handed on) gets one reminder: the same link, time left in plain words.

Handed on at half-time to a kind's fallback_approver, with their own sign-in link; either can then decide (escalated_to), recorded once that email sends — a failed send is skipped and logged. If the approver was erased, the fallback alone can decide and the email says so; erasing the fallback hands their holds back to the approver and stops the kind handing on.

Unreachable: a failed send at open reports notified_reason: "send_failed", keeps the hold open, logs it (approver_unreachable), and retries up to three times; every retry failing sends your webhook hold.unreachable and your contact one email. explain_hold names expired_at on an expired hold; list_holds lists expired holds on request (include: "expired").

Webhook events: a decision sends { event: "hold.decided", tenant, hold_id, action_id, kind, decision: "confirm" | "reject", decided_by, decided_via, ticket_id?, ticket_expires_at?, at, mode } once, naming the ticket by id only, never itself; an already-decided click sends nothing. Expiry, the reminder, the hand-on and an unreachable approver send { event: "hold.expired" | "hold.reminded" | "hold.escalated" | "hold.unreachable", tenant, hold_id, action_id, kind, approver, escalated_to?, expires_at, at, mode }. A reported outcome, or none, sends { event: "action.ran" | "action.failed" | "action.unreported", tenant, hold_id, action_id, kind, ticket_id, decided_by, reference?, late?, at, mode }. A release without a person sends { event: "action.released", tenant, hold_id, action_id, kind, decision: "confirm", decided_by, decided_via: "policy", released_under, ticket_id, ticket_expires_at?, at, mode } once. Signed like action.held: kind, names, times, never a value.