This is an advanced topic. Start with Agents, Tools, and Streaming. Add durable execution when an application needs to recover unfinished work or persist approval waits across process restarts.

For example, a sales agent writes a report, saves the tool result, and then its Python process crashes before delivering the answer. A replacement process can restore the run, reuse that committed result, and finish the answer. The PostgreSQL walkthrough demonstrates this with a report file and a tool invocation count that stays at one.

When to use durable execution

Need API What is retained
Run an agent in your application process Agent.run() or Agent.run_stream() Conversation state while that instance lives
Explicitly save and reload a conversation Agent.save_session() / Agent.load_session() A conversation snapshot, configuration, and supported extension state
Recover interrupted execution or a persisted approval AgentRuntime Execution boundaries, messages, tool outcomes, approvals, events, and artifact references

An ordinary agent does not need a runtime or registry. A conversation snapshot does not resume an in-flight tool call. Choose AgentRuntime when those execution boundaries matter to your workflow.

What each object does

Object Responsibility
Agent Defines the model, prompt, tools, permissions, middleware, and extensions
PostgresBackend Stores durable state in your PostgreSQL database
AgentRuntime Drives the agent, persists progress, and exposes start, resume, approval, and control operations
RunHandle Gives your application access to one submitted or resumed run's progress and result
RunResult Describes the run's output, status, usage, errors, and pending operations

For PostgreSQL, pass the configured agent directly: AgentRuntime(agent, backend=backend). No AgentRegistry or AgentRef is required for this pattern.

On a single host, SQLiteBackend is the default store. Every backend is an async context manager that initialises on entry and closes on exit. When a context manager does not fit, await SQLiteBackend.connect(path) constructs and initialises one in a single call:

from harnessx import Agent, AgentRuntime, SQLiteBackend


async def local_run(prompt: str):
    async with SQLiteBackend("runtime.sqlite3") as backend:
        async with AgentRuntime(Agent(), backend=backend) as runtime:
            return await runtime.run(prompt)

The runtime offers the same three verbs as Agent:

Verb What it does
run() Waits for the RunResult
run_stream() Yields typed events
stream_text() Yields the answer's text, and raises RunError if the run does not complete

Start a durable run

from harnessx import Agent, AgentConfig, AgentRuntime, PostgresBackend, RunResult


async def start_run(dsn: str, prompt: str) -> tuple[str, RunResult]:
    async with PostgresBackend(dsn) as backend:          # opens the pool, migrates
        agent = Agent(config=AgentConfig(system_prompt="You are helpful."))
        async with agent, AgentRuntime(agent, backend=backend) as runtime:
            handle = await runtime.submit(prompt, request_id="first-request")
            return handle.session_id, await handle.result()

Entering the runtime's context manager calls start(). submit() begins driving a run and returns a handle you can await. Store the returned session_id beside your application's own chat or workflow record: it is the only way back to this conversation after a restart.

runtime.run(prompt) combines submit and wait. run_stream() and stream_text() are the streaming forms.

Sessions and run IDs

Identifier Meaning Application use
session_id One durable conversation or workflow session Save when starting; pass to resume() after a restart
run_id One submitted request within that session Associate output and progress with a particular execution
request_id Application-supplied submission deduplication key Reuse for the same request in the same session
Event cursor A position in one run's stored event stream Reconnect after the last processed event

A session can contain multiple sequential runs. Resuming an unfinished run preserves its run ID; submitting another message creates another run. resume() takes a session ID, loads that session's latest persisted run, and resumes it if eligible. It does not select an arbitrary session or look up a user automatically.

Request deduplication is scoped to a session. It does not make an external tool effect idempotent. Your application must also retain the session ID; repeating start() creates a new session.

Resume after a restart

Create a fresh agent with matching configuration and compatible tools and extensions. Connect to the same PostgreSQL database and schema. Read the saved session ID and explicitly resume it:

import json
from pathlib import Path

from harnessx import Agent, AgentConfig, AgentRuntime, PostgresBackend, RunResult


async def resume_run(dsn: str, session_file: Path) -> RunResult | None:
    saved = json.loads(session_file.read_text(encoding="utf-8"))
    async with PostgresBackend(dsn) as backend:
        agent = Agent(config=AgentConfig(system_prompt="You are helpful."))
        async with agent:
            runtime = AgentRuntime(agent, backend=backend)
            try:
                handle = await runtime.resume(saved["session_id"])
                if handle is None:
                    print("No run was resumed.")
                    return None
                print("Resuming run:", handle.run_id)
                return await handle.result()
            finally:
                await runtime.aclose()  # alias of stop()

Call await resume_run(dsn, Path(".agent_sessions/my-chat.json")), where that file is the {"session_id": ...} record your application saved when it started the run. If the earlier function finished normally, there is no unfinished run to drive. Use the crash demonstration below to exercise recovery.

Do not enter the runtime context manager before calling resume(). That context manager starts a new session. The outer async with agent only manages the agent's resources and is safe for this recovery pattern.

resume() returns a handle for a resumed run, or None when the session has no run or its latest run is completed/cancelled. It can raise for a missing session, incompatible definition/configuration, or an active worker lease. None does not mean the session could not be found.

On a successfully bound runtime, await runtime.status() returns a dictionary with session_id, state, and run, the latest RunResult or None for a session without a run. This lets an application read a completed result even when resume() returns no handle.

Use a RunHandle

A handle provides access to an execution. It is not the agent's answer, and it is not a running agent to serialize into your database. Store the relevant IDs instead.

Handle API What it provides
handle.session_id The session this execution belongs to
handle.run_id This execution's ID
await handle.result() Wait for a RunResult from the current execution
handle.events(after=cursor) Read stored events and follow new ones
handle.stream(after=cursor) An async-context-managed event stream with its own result() method

Both await runtime.submit(...) and a successful await runtime.resume(...) return a handle. run() returns a RunResult directly. Use the new handle returned when resuming after an approval or pause; an earlier local handle can retain its earlier execution result.

Waiting for a result does not guarantee completed. A run can stop at an approval wait, pause, cancellation, or failure. Inspect its status before treating the output as a successful answer. Infrastructure failures can also raise exceptions; normal application exception handling still applies.

Understand a RunResult

def present_result(result) -> None:
    if result.ok:
        print("Answer:", result.output)
    elif result.failed:
        print("Error:", result.error["message"])
    for pending in result.pending:
        print(pending.status, pending.execution_key, pending.call.name)

ok, failed and needs_input are the three branches most applications take. raise_for_status() returns the result or raises RunFailed, RunAwaitingInput, RunTruncated or RunCancelled, each carrying the result. A completed run is not always ok: truncated marks one whose reply was cut off at the token budget. Inspect the result covers the predicates and exceptions.

Of the statuses, running is what you see while a run is active. It is also the last thing persisted before a crash, so it does not prove a worker is alive. awaiting_input needs an approval or a reconciliation decision. A cancelled run is never resumed automatically and can still have uncertain effects worth inspecting.

Refused and limited runs

refused and limited also mark a completed run that is not ok, and raise_for_status() raises RunRefused or RunLimitReached for them. See Inspect the result.

Stream progress and reconnect

For a UI, consume the handle's events. This helper takes two async callbacks supplied by your application: send(event) renders or forwards a typed event, and save_cursor(run_id, cursor) remembers the last processed position.

from harnessx import RunHandle, RunResult


async def forward_progress(
    handle: RunHandle, send, save_cursor, *, after: str = "0"
) -> RunResult:
    async for event in handle.events(after=after):
        await send(event)
        if event.cursor:
            await save_cursor(handle.run_id, event.cursor)
    return await handle.result()

On reconnect, provide the saved cursor for that same run. With no cursor, SQL handles start from "0" and include previously stored events. A replayed tool event describes past execution; receiving it does not invoke the tool again.

Events include text_delta, text_complete, tool_call_start, tool_result, approval_required, recovery_required, and run_result. Render text deltas as provisional. Handle attempt_reset by discarding the interrupted attempt's provisional text, and use processed/final output to settle the display. See Streaming for response-middleware behavior.

Delivery and saving your UI cursor are separate operations. Reconnection can repeat an event or result summary; deduplicate stored events by event ID and render the run's final state idempotently. Some synthesized terminal summaries have no cursor, which is why the helper checks before saving one.

Disconnecting an event subscriber does not cancel a durable run. Keep the worker and runtime alive for execution to continue. A browser reconnect should normally reattach to the existing run's observation stream, not ask another worker to resume an actively leased session.

Approvals and uncertain tools

A durable ASK permission persists: the run returns needs_input and result.pending lists PendingTool objects with status == "approval". Record the decision with runtime.approve(...) or runtime.decline(...), passing resume=True to continue in the same call. Several pending tools take one decision each, then a single await runtime.resume(session_id).

An uncertain pending operation is a different thing: the tool may already have had an external effect. Investigate that system, then pick exactly one resolution.

Resolution API
Record a verified result runtime.resolve_tool(pending, result=ToolResult(...))
Accept another invocation runtime.resolve_tool(pending, retry=True)
Abort the run runtime.resolve_tool(pending, abort=True)

Aborting does not undo an effect that already happened. Approvals & recovery covers execution keys and the replay policies that decide which calls can repeat on their own.

Pause, cancel, and cleanup

Controls belong to AgentRuntime, while the handle observes a particular run:

Runtime API Behavior
await runtime.pause() Stops new steps and lets active tools settle
await runtime.cancel() Requests cooperative cancellation; threads or remote effects may outlive it
await runtime.stop() or await runtime.aclose() Pauses active local work, closes the agent the runtime executes, and closes a backend the runtime created
await runtime.status() Returns session_id, state, and run, the latest RunResult or None

Leaving the runtime context manager calls stop(). Returning a handle from inside that context does not leave an independent background worker running after the context exits. Keep the runtime open for as long as its driver is needed.

The runtime closes its agent. It closes the backend only if it created that backend; close an explicitly supplied backend yourself. The outer agent contexts also cover failures before an agent is bound to the runtime. Repeated close calls are safe. Create a fresh agent after its resources have been closed.

PostgreSQL stores state and coordinates leases; it does not restart Python processes or schedule recovery workers. Your application or supervisor restarts the worker and calls resume(). A hard crash leaves a lease that must expire before another worker claims the session. The default lease lasts 30 seconds with renewal approximately every 10 seconds.

Binding an agent to a runtime

For SQLite and PostgreSQL, pass the configured Agent straight to AgentRuntime. Temporal runs the agent in a worker process, so it takes an AgentRef such as assistant@v1 and builds the agent from a factory registered in that worker's AgentRegistry.

Binding How construction works
SQLite or PostgreSQL with Agent Pass the instance to AgentRuntime
SQLite or PostgreSQL with AgentRef Supply registry=, or use the process-local default
Temporal Submit an AgentRef; configure its factory on the worker

A version label records intent. It does not prove the code still matches, so keep agent definitions under your normal deployment controls.

What survives a failure

The engine records dispatch intent before invoking a tool and a raw result before result middleware. Recovery depends on what actually committed:

Last saved state Meaning on recovery
Prepared call, before dispatch is recorded The runtime can dispatch the call
Dispatch recorded, no saved result The external outcome can be unknown; a manual tool waits for reconciliation
Raw result committed Reuse it and finish processing without invoking the tool again
Run completed Read the saved result; there is no run to resume

SQL execution-boundary updates and their events commit transactionally. Every persisted phase boundary also emits HookEvent.CHECKPOINT on the agent's hooks with session_id, run_id, status, and phase. PostgreSQL fencing rejects state writes from workers that lose their leases. It cannot stop an already-running remote request.

External effects and runtime storage are not one transaction. The SDK does not provide universal rollback or exactly-once external effects. An idempotent tool requires its external adapter/service to enforce the execution key. A safe tool must actually tolerate repetition. Result middleware may run again and should tolerate replay.

Interrupted live-model requests may be reissued, return different content, and incur additional charges. Arbitrary local files are not automatically copied into PostgreSQL. Recovery on another machine requires compatible code and shared access to the workflow's resources.

Resume semantics

Symptom What it means
resume returned None The session has no run, or its latest run already finished. Inspect status on the bound runtime
Status is still running after a crash It is the last persisted status, not proof a worker is alive
The session is busy A worker holds the lease, or a crashed worker's lease has not expired yet

Environment and connection problems are covered under Execution backends.