Run flows on Temporal or DBOS¶
A Flow can run as a Temporal workflow or a DBOS workflow. Each superstep runs as one
activity (Temporal) or one step (DBOS), and every tool call still goes through your
executor: approvals, hooks, budgets, cache, ledger and events all apply.
The engine keeps the run going across restarts. Your checkpoint store (Durable runs) makes each step safe to retry: a step cut short by a crash runs again, and the tool calls it had finished replay instead of running twice.
sequenceDiagram
participant C as Client
participant W as Workflow (no I/O)
participant A as Activity or step
participant R as FlowRunner + store
C->>W: start_flow(flow, state, run_id)
loop each superstep
W->>A: advance(flow)
A->>R: run, or recover the stored run
R-->>A: one superstep, checkpoint saved
A-->>W: report (running, paused, done, denied)
end
W-->>C: pending: approval id, node, tool
C->>R: decide in the approval store
C->>W: notify(approval id): a wake-up only
W->>A: resume(approval id)
A->>R: decided record? use it once, run the paused superstep
W-->>C: report (done: final state)
Install one engine: pip install "agent-adapter[temporal]" or "agent-adapter[dbos]".
Set up the flow and runner¶
Build the runner as for any durable run: a shared checkpoint store and the same
checkpoint_key on every worker. DurableFlows(runner, flows) names the flows a worker
can run.
class Ticket(BaseModel):
ticket: str
amount: float
note: str = ""
flow = (
Flow("refunds", state=Ticket)
.node("review", lambda t: {"note": f"refund {t.amount:.2f} asked"}, pause=True)
.node(
"pay", Call("refund", args=lambda t: {"ticket": t.ticket, "amount": t.amount}, into="note")
)
.edge("review", "pay")
.end("pay")
)
KEY = b"use-a-secret-from-your-vault-32b" # the same key on every worker
Temporal¶
from agent_adapter.integrations.temporal import (
DurableFlows,
flow_worker,
notify,
pending,
result,
start_flow,
)
with tempfile.TemporaryDirectory() as folder:
store = SQLiteCheckpointStore(Path(folder) / "runs.db")
approvals = MemoryApprovalStore() # shared with your review screen in production
executor = Executor(registry, approval=DeferredApproval(approvals))
runner = FlowRunner(executor, checkpoints=store, checkpoint_key=KEY)
flows = DurableFlows(runner, [flow])
async with (
await WorkflowEnvironment.start_local() as env,
flow_worker(env.client, flows, task_queue="refunds"),
):
handle = await start_flow(
env.client,
"refunds",
{"ticket": "t-17", "amount": 40.0},
run_id="refund-t-17",
task_queue="refunds",
)
waiting = await pending(handle)
while waiting is None:
await asyncio.sleep(0.1)
waiting = await pending(handle)
print("waiting for", waiting.node)
# the decision lives in the approval store; the update only wakes the run
decision = ApprovalDecision(approved=True, decided_by="desk-lead")
await approvals.decide(waiting.approval_id, decision)
await notify(handle, waiting.approval_id)
report = await result(handle)
| Name | Does |
|---|---|
flow_worker(client, flows, task_queue=...) |
a Worker with the flow workflow and its two activities; extra options go to Worker |
start_flow(client, flow, state, run_id=, task_queue=) |
starts the workflow; its id is the run id |
pending(handle) |
the approval the run waits for, or None (a query) |
notify(handle, approval_id) |
after you decided in the store: an update, refused unless the run waits for that approval; wait=False sends a signal |
result(handle) |
the final FlowStepReport |
flow_workflow(), flow_activities(flows) |
the workflow class and activities, to register on your own worker |
Options on start_flow: approval_timeout_s (a timeout denies), activity_timeout_s
(default 600) and max_attempts (0 retries forever, Temporal's default).
The workflow does no I/O, so it stays deterministic. It runs unsandboxed because
temporalio is imported lazily. For tests, WorkflowEnvironment.start_local() runs a
local server without Docker.
DBOS¶
store = SQLiteCheckpointStore(Path(folder) / "runs.db")
approvals = MemoryApprovalStore() # shared with your review screen in production
executor = Executor(registry, approval=DeferredApproval(approvals))
runner = FlowRunner(executor, checkpoints=store, checkpoint_key=KEY)
url = f"sqlite:///{(Path(folder) / 'dbos.sqlite').as_posix()}"
DBOS(config={"name": "refund_desk", "system_database_url": url})
refunds = DbosFlows(DurableFlows(runner, [flow])) # before DBOS.launch()
DBOS.launch()
handle = await refunds.start(
"refunds", {"ticket": "t-17", "amount": 40.0}, run_id="t-17"
)
waiting = await refunds.pending("t-17", timeout_s=10)
print("waiting for", waiting.node if waiting else None)
if waiting is not None:
decision = ApprovalDecision(approved=True, decided_by="desk-lead")
await approvals.decide(waiting.approval_id, decision)
await refunds.notify("t-17", waiting.approval_id) # a wake-up, not a verdict
report = await refunds.result(handle)
| Name | Does |
|---|---|
DbosFlows(flows, max_attempts=5, interval_s=1.0) |
registers the workflow and its two steps; build it once, before DBOS.launch() |
start(flow, state, run_id=) |
starts the workflow; its id is the run id, so starting it again is a no-op |
pending(run_id, timeout_s=0) |
the approval the run waits for, or None (a DBOS event) |
notify(run_id, approval_id) |
after you decided in the store: a DBOS message; one for another approval is dropped |
result(handle) |
the final FlowStepReport |
DBOS runs on SQLite (system_database_url="sqlite:///...") or Postgres. A step that hits
a passing error (a store or network blip) is retried up to max_attempts.
Approvals¶
A pause becomes a wait in the workflow. The report names the approval id, the node and
the tool, but never the tool's arguments, so engine history does not hold them. A
reviewer reads the full request with flows.pending_request(flow, run_id).
The decision lives in your approval store, never in the engine. Engine approvals need a
DeferredApproval port (the runner's for gates, the executor's for tools); without a
store the resume fails closed. Decide with store.decide(approval_id, decision), the HTTP
server or your review screen, then call notify. The notice carries no verdict.
| Rule | Effect |
|---|---|
| A notice never approves | the run re-checks the store; with no decided record (a forged or early notice) it keeps waiting |
| It wakes one approval | a notice for another id, or with any other field, is refused (update) or dropped (signal, message) |
| Used once | the runner consumes the store record when it resumes; a record already used is refused (ApprovalAlreadyDecided) |
| A timeout denies in the store | with approval_timeout_s, a still-pending record is decided as denied (decided_by="timeout"); a reviewer who decided first wins |
| A retry is safe | a resume retried after the decision was used continues the stored run |
Your store's access control decides who approves. A notice can only wake a run early
(each one restarts the timeout wait). Read pending(...) first: an update sent before the
workflow saw the pause is refused; a signal is kept for the next pause (dropped if its id differs).
Who the run acts for¶
start_flow(..., identity=RunIdentity(user_id=, thread_id=, principal=)) (and
DbosFlows.start) seals the user, thread and principal into the run. Every step runs
with them, so per-user memory and per-principal budgets and approvals apply. A step
with another identity is refused.
Failures¶
| Failure | What happens |
|---|---|
| a worker dies in a step | the engine runs the step again; finished tool calls replay |
a call may have run (not idempotent=True) |
the run stops with recovery-needs-decision; mark safe tools idempotent=True |
| a call still running on a cut-off worker | it cannot be stopped; pass idempotency_key() (from agent_adapter.exec.checkpoints) to payment and mail APIs, so a rerun is not applied twice |
| a node fails | the run is stored as failed and the workflow fails for good |
| the run id belongs to another flow | the step fails for good |
| the store or network blips | the step is retried |
Errors in engine history carry the error type and kind, never the message text.
Limits¶
- Set
activity_timeout_sabove your slowest superstep. There is no heartbeat, so a longer step is retried while the first may still run, and its call ends in doubt. - A run id is used once: starting it again returns the stored report.
- A DBOS step that runs out of retries leaves the workflow in error while the store says
running. Continue it with
DBOS.resume_workflow(run_id)once the cause is fixed. - Run guards held in worker memory (
RunLimits,once_per_run) count per worker process. A step that moves to another worker starts their counts again. - Tool loops (
run_tool_loop) are not engine workflows yet; recover them on your own worker withrecover_tool_loop.
This page's code is examples/durable_temporal.py and examples/durable_dbos.py. The
test suite runs them.