Durable side effects for AI agent tool calls.
Your agent sends an email, opens a PR, issues a refund — then the process crashes, the LLM retries, or two workers race. Did the effect happen? Should it run again?
The most dangerous window in any agent system is the one between "the tool call left the building" and "the result was recorded". Crash inside it and you face the question no retry policy can answer: did it happen?
OpenOnce wraps a tool call in a crash-safe lifecycle so that:
- Duplicates replay instead of re-executing. Same intent, same receipt — including failures (a 400 stays a 400).
- Ambiguous outcomes are never blindly retried. A timeout mid-charge
parks the effect as
UNKNOWN; a reconciler resolves it against the external world, or a human does. - Every effect leaves an auditable receipt trail. An append-only journal of every state transition, approval, and probe.
It is not a workflow engine. No orchestration, no task queues, no server — a library and a SQLite/Postgres table, embedded in whatever agent framework you already use.
Exactly-once side effects against external systems are physically impossible from a local process. OpenOnce gives you the strongest thing that exists:
at-least-once execution + idempotency + reconciliation
Planned → PolicyChecked → (ApprovalGranted) → Started
→ ReceiptRecorded → Committed
crash between Started and ReceiptRecorded?
→ Unknown → probe the provider → Commit / Re-arm / HumanReview
pip install openonce # stdlib-only core, SQLite included
pip install openonce[postgres] # production storeThe minimal form is one decorator and one with — no names, no config:
import openonce
oo = openonce.OpenOnce("openonce.db") # or ":memory:" — zero infrastructure
@oo.effect # tool name = function name
def send_email(to: str, subject: str) -> str:
...
with oo.scope(): # auto run id: in-run dedup
send_email("a@b.c", "hi")
send_email("a@b.c", "hi") # LLM retry → replayed, not re-sent
protected = oo.effect(existing_tool) # or wrap an existing callable directlyDial in precision as you need it:
@oo.effect(idempotency_fields=["owner", "repo", "head"])
def create_pr(owner: str, repo: str, head: str, title: str, body: str) -> dict:
... # your real tool code
with oo.scope("run-2026-07-02-a"): # stable id: crash-resume dedup too
create_pr(owner="acme", repo="api", head="fix-login", title="Fix login", body="...")
# The LLM retries with a reworded body — same intent fingerprint, replayed:
create_pr(owner="acme", repo="api", head="fix-login", title="Fix login", body="reworded")idempotency_fields is the intent fingerprint: only those fields enter
the key, so LLM noise (prose, timestamps) doesn't defeat dedup. Everything
else about key derivation is strict — RFC 8785 canonicalization, floats
rejected (use integer minor units), and derived keys require a run scope so
two runs that legitimately want the same call aren't silently collapsed.
Async handlers work identically, and the event loop is never blocked:
@oo.effect(tool="email.send", idempotency_fields=["to", "subject"])
async def send(to: str, subject: str, body: str) -> str:
...| class | meaning | behavior |
|---|---|---|
| any exception | definitively failed (business error) | cached & replayed for the same key |
RetryableEffectError, connect-phase network errors |
definitively did not happen | re-executed up to max_attempts |
UnknownOutcomeError, read-phase timeouts & disconnects |
may have happened | parked UNKNOWN, reconciled, never blind-retried |
Classification understands real-world libraries: requests.ReadTimeout,
httpx.TimeoutException, and friends are recognized by class name across the
exception's MRO — a read timeout parks as UNKNOWN, a connect timeout
(nothing was sent) retries automatically. Fully configurable, and explicit
raise always wins.
oo = openonce.OpenOnce("openonce.db",
policy=openonce.require_approval_for(["stripe.*"]))
try:
refund(charge="ch_1", amount_cents=500)
except openonce.ApprovalPending as p:
notify_human(p.effect_id)
# later, after oo.approve(effect_id):
refund(charge="ch_1", amount_cents=500) # same call, same key → executes onceNo separate resume path: the agent just retries the call, hits the same idempotency key, and proceeds.
@oo.effect(tool="stripe.charge")
def charge(amount_cents: int) -> dict:
ctx = openonce.current_effect()
# Pass ctx.provider_key as Stripe's Idempotency-Key header: the one hard
# defense against duplicates once a request has left the process.
return stripe.PaymentIntent.create(..., idempotency_key=ctx.provider_key)If charge times out after the request was sent, the effect parks in
UNKNOWN. A Reconciler probes the provider:
rec = openonce.Reconciler(oo.store)
rec.register("stripe.charge", StripeProber.from_api_key(STRIPE_KEY))
rec.run_once() # HAPPENED → commit with receipt; NOT_HAPPENED → re-arm;
# inconclusive / no prober → human review. Never a blind retry.When no explicit grace_seconds is passed, the reconciler uses the provider
capability matrix's default grace: Stripe and Email wait through their lag
windows; GitHub PR lookup can reconcile immediately. Pass grace_seconds= or
grace_overrides= when you need an explicit operator override.
If one tool name maps to more than one provider behavior, pin the capability when registering the prober:
rec.register("email.send", gmail_prober, capability="email_provider_api")Teams can also load their own provider capability file and use it at runtime:
extra = openonce.load_capabilities_file("openonce-providers.json")
rec = openonce.Reconciler(oo.store, extra_capabilities=extra)
rec.register("slack.post_message", slack_prober, capability="slack_metadata")Or run it as a daemon straight from the CLI:
$ openonce --db openonce.db reconcile \
--probers myapp.probers:PROBERS \
--capability-file openonce-providers.json \
--provider-capability slack.post_message=slack_metadata \
--require-provider-capability \
--conformance-file openonce-conformance.json \
--require-conformance \
--watch--capability-file and --provider-capability TOOL=CAPABILITY use the same
reviewable provider knowledge as the Python API, so the daemon enforces the
same miss semantics that passed CI. --require-provider-capability is the
production fail-closed mode: HAPPENED and NOT_HAPPENED probe results
cannot resolve an unknown tool unless the tool has matching reviewed provider
capability metadata.
--conformance-file and --require-conformance extend that fail-closed mode
to prober behavior: the daemon will not commit or re-arm from a definitive
probe result unless the matching capability's current fingerprint and standard
scenarios have passing evidence.
Runtime reconciliation also requires HAPPENED probes to return a non-empty
receipt. Without external evidence, the effect stays in human review instead
of being committed without an audit trail.
Probing "did this effect actually happen?" is provider-specific knowledge. The bundled probers are reference implementations of the three tiers:
| tier | example | probe basis | a miss means |
|---|---|---|---|
| 1 — native idempotency keys | Stripe | metadata search (+24h provider-key dedup backstop) | inconclusive inside the index lag window, then not-happened |
| 2 — natural business key | GitHub PR (owner, repo, head) |
authoritative primary-store read | genuinely not-happened |
| 2/3 — sender-controlled key | Email (deterministic Message-ID) |
sent-store search | not-happened only if the sent store is authoritative; bare SMTP escalates to a human, forever |
That matrix is also executable product surface:
$ openonce providers
provider capability matrix
name tool tier auto_rearm grace_s prober
stripe stripe.* tier_1_native_idempotency_key yes 120 StripeProber
github_pr github.create_pr tier_2_natural_business_key yes 0 GitHubPullRequestProber
email_provider_api email.send tier_2_sender_controlled_authoritative yes 120 EmailMessageIdProber(authoritative=True)
email_smtp email.send tier_3_non_authoritative no 120 EmailMessageIdProber(authoritative=False)auto_rearm=no is the important bit: a miss is not evidence. OpenOnce keeps
the effect in the human queue instead of turning uncertainty into a duplicate
external action. The reconciler enforces this too: if a prober returns
NOT_HAPPENED for a known non-authoritative tool, OpenOnce escalates to
HUMAN_REVIEW instead of re-arming.
For a specific tool, ask for the exact handler contract:
$ openonce providers stripe.charge --requirements
requirements:
handler:
- pass current_effect().provider_key as Stripe Idempotency-Key
- stamp effect_metadata(current_effect()) on the created object
receipt fields: stripe_idAsk for the conformance plan when you are writing or reviewing a prober:
$ openonce providers stripe.charge --conformance-plan
conformance:
- happened: expect happened + receipt
receipt fields: stripe_id
- young_miss: expect inconclusive
- mature_miss: expect not_happened
- ambiguous: expect inconclusiveThe same contract is available in tests:
from openonce import ConformanceScenario, ProbeOutcome, ProbeResult, assert_conformance
assert_conformance(
stripe_capability,
{
ConformanceScenario.HAPPENED: ProbeResult(
ProbeOutcome.HAPPENED, receipt={"stripe_id": "pi_1"}
),
ConformanceScenario.YOUNG_MISS: ProbeResult(ProbeOutcome.INCONCLUSIVE),
ConformanceScenario.MATURE_MISS: ProbeResult(ProbeOutcome.NOT_HAPPENED),
ConformanceScenario.AMBIGUOUS: ProbeResult(ProbeOutcome.INCONCLUSIVE),
},
)CI can validate the same evidence from JSON:
{
"schema_version": 1,
"capabilities": {
"github_pr": {
"capability_fingerprint": "sha256:...",
"source_args": {"owner": "acme", "repo": "api", "head": "fix-login"},
"scenarios": {
"happened": {
"outcome": "happened",
"receipt": {"number": 42, "head": "fix-login"},
"detail": "fixture returned exactly one PR for acme:fix-login"
},
"mature_miss": {
"outcome": "not_happened",
"detail": "fixture returned no PRs for acme:fix-login"
},
"ambiguous": {
"outcome": "inconclusive",
"detail": "fixture returned duplicate PRs for acme:fix-login"
}
}
}
}
}$ openonce providers github.create_pr \
--conformance-file openonce-conformance.json \
--require-conformance
conformance: passedThe fingerprint is emitted by openonce providers github.create_pr --json.
If capability semantics change, stale evidence fails until regenerated.
To avoid hand-copying scenario names and fingerprints, generate a fill-in
openonce-conformance.json template from the current capability contract:
$ openonce providers github.create_pr --conformance-file-template > openonce-conformance.jsonThe template includes TODO source_args, outcome: null, receipt: null,
and TODO detail fields; it is a scaffold, not passing evidence, until the
full fixture key, observed prober outputs, and fixture details are filled in.
You can audit the tools declared in a Python project instead of listing them one by one:
$ openonce providers --scan src \
--require-known \
--require-auto-rearm \
--require-handler-contract \
--require-receipt-contract \
--conformance-file openonce-conformance.json \
--require-conformance
scan: passedThe scanner recognizes literal tool="..." values and simple module-level
constants on @oo.effect(...), @effect_tool(...), and
@effect_function_tool(...). Dynamic tool expressions are reported as
unauditable when a gate is enabled. --require-handler-contract also checks
structured provider contracts that can be proven statically; for example,
github.create_pr must have owner, repo, and head in both the handler
signature and idempotency_fields, and its successful probe receipt must carry
the PR number and verified head.
--require-receipt-contract checks the handler side of that same receipt
contract. It is intentionally conservative: every return path must be a
literal receipt dict, a dict(...) constructor, or an unmutated local variable
assigned once to such a dict, containing the capability's
required_receipt_fields. Dynamic SDK returns and mutated receipt dicts fail
the static proof until wrapped into an explicit receipt dict.
Capabilities can also require receipt fields to come from specific handler
args. The built-in github_pr contract requires the returned receipt head
to be sourced from the handler's head arg, so a hard-coded or unrelated PR
head cannot satisfy the static proof, conformance evidence, or runtime
reconciliation commit.
Those source args must also be declared in required_idempotency_fields, so
the proof ties the dedupe key, handler input, and external receipt together.
By default, OpenOnce keeps handler success behavior backward-compatible: whatever the handler returns is recorded and replayed. If you want the hot path to fail closed against the provider matrix too, opt in at construction time:
oo = openonce.OpenOnce(
"openonce.db",
enforce_provider_receipts=True,
require_provider_capability_for_receipts=True,
provider_capabilities={"github.create_pr": "github_pr"},
)With enforcement enabled, a successful handler return is recorded as the
receipt, checked against the matching capability's required_receipt_fields
and required_receipt_source_fields, then committed only if the contract
passes. A mismatch moves the effect to HUMAN_REVIEW after
RECEIPT_RECORDED, preserving the returned value for audit while blocking
automatic commit and duplicate replay.
Before the handler runs, matching capabilities also require every
required_args field to be present in the call and every
required_idempotency_fields entry to be part of the derived key material
using idempotency_fields. For those natural-key contracts, an opaque explicit
idempotency_key is rejected because OpenOnce cannot verify it contains the
provider key fields. This prevents a tool such as github.create_pr from
replaying a receipt for one head branch when a later call changes head.
require_provider_capability_for_receipts=True makes unknown tools fail closed
before the handler runs instead of silently skipping receipt-contract
validation.
After reviewing the provider directly, commit external evidence instead of re-running the effect:
oo.resolve_happened(
effect_id,
receipt={"stripe_id": "pi_1"},
by="eric",
reason="matched in Stripe dashboard",
)approve() means "allow the next identical call to execute." resolve_happened()
means "it already happened; commit this receipt and replay it to duplicates."
If OpenOnce(enforce_provider_receipts=True) is enabled, manual receipts are
checked against the same provider receipt contract before commit. Pass
require_provider_capability=True, or set
require_provider_capability_for_receipts=True on OpenOnce, when unknown
tools should fail closed too.
If the human review proves the effect did not happen, re-arm explicitly:
oo.resolve_not_happened(
effect_id,
by="eric",
reason="not found in provider dashboard",
)approve() is only for pending pre-execution approvals. Use
resolve_not_happened() for UNKNOWN/HUMAN_REVIEW effects so retries require
an explicit "did not happen" decision. reason is required because this path
does not have a receipt; the ledger needs the operator's evidence trail.
Pass require_auto_rearm=True and require_provider_capability=True when the
manual retry decision must obey the provider matrix's auto-rearm policy.
require_auto_rearm=True fails closed for unknown tools; the provider matrix
must explicitly say that a miss is safe to re-arm.
Proof gates such as --require-conformance, --require-reviewed, and
--require-handler-contract also fail unknown tools. No matching capability
means there is no reviewed provider contract to prove.
The scanner can generate the conformance evidence scaffold for exactly the capabilities used by the codebase:
$ openonce providers --scan src --conformance-file-template > openonce-conformance.jsonFor unknown tools, generate a conservative backlog stub:
$ openonce providers --scan src --suggest-capabilities --jsonThe suggested entries default to tier_3_non_authoritative and
can_auto_rearm_on_miss=false. Treat them as review work: fill in the probe
basis, risk, handler contract, and conformance evidence before trusting a miss.
Use --require-reviewed in release gates so TODO stubs, auto-rearm entries
without probers, and prober entries without required receipt fields cannot pass
as finished provider knowledge:
$ openonce providers --capability-file openonce-providers.json \
--validate-only \
--require-reviewed
readiness: passedOr consume the same safety knowledge from CI, UIs, or agent harnesses:
$ openonce providers email.send --jsonOutput excerpt:
{
"schema_version": 1,
"tool": "email.send",
"capabilities": [
{
"name": "email_smtp",
"can_auto_rearm_on_miss": false,
"miss_semantics": "inconclusive forever: sent copy can fail after delivery"
}
]
}For CI, turn the matrix into a hard gate:
$ openonce providers stripe.charge --require-known --require-auto-rearm
policy: passed
$ openonce providers email.send --require-auto-rearm --json
{
"policy": {
"passed": false,
"required": ["auto_rearm_on_miss"],
"checks": {
"auto_rearm_on_miss": {
"passed": false,
"reason": "'email.send' is not safe to auto-rearm on a miss; blocking capabilities: email_smtp"
}
}
}
}--require-known fails unknown tools. --require-auto-rearm fails unless every
matching built-in capability can safely convert a post-grace miss into a retry.
That conservative rule is intentional: email.send is blocked because the same
tool name covers both provider-API sends and bare SMTP. Disambiguate the
capability when your integration is more specific:
$ openonce providers email.send --capability email_provider_api --require-auto-rearm
policy: passedRepo-specific provider knowledge lives in the same schema. Example
openonce-providers.json:
{
"schema_version": 1,
"capabilities": [
{
"name": "slack_metadata",
"tool_pattern": "slack.post_message",
"tier": "tier_2_sender_controlled_authoritative",
"key_strategy": "message metadata event_payload.openonce_effect_id",
"probe_basis": "conversations.history search by metadata",
"miss_semantics": "not-happened after Slack history propagation",
"can_auto_rearm_on_miss": true,
"default_grace_seconds": 30,
"prober": "SlackMetadataProber",
"handler_requirements": [
"stamp openonce effect_id into Slack message metadata",
"grant the prober history scope for the target channel"
],
"risk": "medium: depends on Slack retention and history permissions",
"required_receipt_fields": ["ts"]
}
]
}Use that file as a reviewable CI gate:
$ openonce providers --capability-file openonce-providers.json --validate-only
provider capability matrix valid: 5 capability(ies), 1 custom from openonce-providers.json
$ openonce providers slack.post_message \
--capability-file openonce-providers.json \
--require-known \
--require-auto-rearm
policy: passedIf an UNKNOWN effect has no registered prober, the reconciler writes the
matching capability guidance into the human-review note. Operators see the
missing prober, the miss semantics, handler requirements, required
idempotency fields, required receipt fields, and receipt source contracts in
the same place they approve, deny, or investigate the effect.
GitHubPullRequestProber also verifies returned PR head metadata before it
records HAPPENED. If GitHub or a wrapper returns unrelated PRs, or multiple
PRs match the same natural key, the result is INCONCLUSIVE and stays in human
review instead of trusting a suspicious read.
Which SDK, which spelling — every row has a runnable example under
examples/:
| Your stack | What you use | Example |
|---|---|---|
| LangGraph | integrations.langgraph.effect_tool — thread-scoped dedup, approval via interrupt() |
examples/langgraph_langchain.py |
LangChain plain @tool |
no adapter — stack @tool on @oo.effect |
same file |
| OpenAI Agents SDK | integrations.openai_agents.effect_function_tool — JSON signals the model acts on |
examples/openai_agents_sdk.py |
| OpenAI SDK (bare tool loop) | no adapter — @oo.effect on each handler; tool_call.id narrows the scope |
examples/openai_sdk.py |
Claude SDK (anthropic manual loop) |
no adapter — @oo.effect on each handler; pass ctx.provider_key to providers |
examples/anthropic_sdk.py |
The pattern behind the table: in a bare SDK loop you execute the tool
calls, so the core decorator is the whole integration. Dedicated adapters
exist only where the framework owns two touchpoints — where the dedup scope
comes from (thread id, run context) and what surface approvals use
(interrupt(), structured tool outputs). All examples run offline in CI.
LangGraph — one decorator makes a handler both a durable effect and a
LangGraph tool. Scope binds to the thread_id; ApprovalPending maps onto
interrupt(), and because OpenOnce approvals are re-entrant, LangGraph's
node-replay-on-resume is exactly the right thing:
from openonce.integrations.langgraph import effect_tool
@effect_tool(oo, tool="stripe.refund", idempotency_fields=["charge"])
def refund(charge: str) -> str:
"""Refund a Stripe charge."""
...
graph.invoke(Command(resume={"approved": True, "by": "eric"}), config)OpenAI Agents SDK — control-flow becomes structured JSON tool outputs the
model reads and acts on: approval_required teaches it to inform the user and
call again with the same arguments after approval; outcome_unknown instructs
do NOT retry. Optional dedup="call" scopes dedup to a single model
decision via the tool_call_id:
from openonce.integrations.openai_agents import OpenOnceRunContext, effect_function_tool
@effect_function_tool(oo, tool="stripe.refund", idempotency_fields=["charge"])
def refund(charge: str) -> str:
"""Refund a Stripe charge."""
...
result = Runner.run_sync(agent, "refund ch_1",
context=OpenOnceRunContext(openonce_scope="conv-123"))$ openonce --db openonce.db review
* eff_76cd… requires_approval stripe.refund attempt 0/3 2026-07-02 12:01:07Z
1 effect(s) need a human. Use approve for pending approvals, resolve-happened to commit evidence, resolve-not-happened to retry, or deny pending approvals.
$ openonce --db openonce.db resolve-happened eff_fd61… \
--require-receipt-contract \
--receipt-json '{"stripe_id":"pi_1"}' \
--by eric \
--reason "matched in Stripe dashboard"
resolved eff_fd61… as happened — committed with manual receipt
$ openonce --db openonce.db resolve-not-happened eff_ab12… \
--by eric \
--require-auto-rearm \
--reason "not found in provider dashboard"
resolved eff_ab12… as not happened — re-armed for retry
$ openonce --db openonce.db show eff_fd61…
journal:
2026-07-02 12:01:07Z planned -> approved
2026-07-02 12:01:07Z approved -> started
2026-07-02 12:01:09Z started -> unknown {"error": "TimeoutError(...)"}
2026-07-02 12:03:21Z unknown -> receipt_recorded {"probe": "happened", ...}
2026-07-02 12:03:22Z receipt_recorded -> committed--require-receipt-contract fails if the receipt does not satisfy a matching
provider capability. Use --capability-file and
--provider-capability TOOL=CAPABILITY here for the same custom or ambiguous
providers you use with reconcile.
resolve-not-happened --require-auto-rearm uses that same provider knowledge
to block manual re-arm for non-authoritative tools.
--db accepts a SQLite path or a Postgres DSN.
Lineage: Stripe's idempotency keys, AWS Builders' Library (client request IDs, parameter-mismatch rejection), brandur's rocket-rides-atomic (atomic phases + completer), Temporal's Activity semantics (at-least-once + "make it idempotent") — scaled down to a library.
The key divergence from Temporal: Temporal replays code, OpenOnce replays data. An agent's "workflow" is LLM inference and cannot be deterministically re-run, so OpenOnce persists at the effect level and never imposes determinism constraints on your code.
Alpha. The semantics (dedup, replay, approval, UNKNOWN/reconcile, first-writer-wins under concurrency) are covered by a test suite that runs identically against the in-memory, SQLite, and Postgres stores, plus integration tests against the real LangGraph and OpenAI Agents SDK runtimes.