Skip to content

A restartable support agent with external approval

This guide ships with the Cayu package, so the installed version is always available offline with cayu guide order-support. Release source.

This installed application investigates a damaged order, asks which item was broken, resumes the conversation in another process, and holds a replacement call for an externally issued approval. It uses the same public contracts as cayu guide durable-service-tools and cayu guide durable-operations; read those companions for identity declarations and ambiguous-outcome reconciliation.

The canonical executable source ships as cayu.examples.order_support. There is no checkout dependency or second copy to keep in sync. Inspect it with:

Terminal window
python -c 'import inspect, cayu.examples.order_support as example; print(inspect.getsource(example))'
python -m cayu.examples.order_support --help

investigate -> propose -> wait for approval -> execute -> verify

In this example, creating a bounded replacement proposal does not require representative approval. After clarifying the damaged item, create the proposal and request execution of that exact proposal; Cayu holds the execution tool call until verified approval arrives. Approval gates execution, not proposal creation. Customer prose is not an approval receipt, and resuming a conversation does not approve a protected tool call. Other applications may choose different proposal authorization rules.

The application authenticates the external receipt and checks business authority. Cayu owns the pending tool approval and execution evidence; the downstream service owns its idempotency and effect contract. These responsibilities are separate even when the demo stores live in the same state directory.

With Cayu installed, run these commands from an empty working directory. Every command starts and exits a separate OS process. No network or provider credentials are needed. Use the same interpreter throughout.

Terminal window
python -m cayu.examples.order_support init demo-state demo-operator
python -m cayu.examples.order_support start demo-state
python -m cayu.examples.order_support clarify demo-state --item mug
python -m cayu.examples.order_support review demo-state review.json
python -m cayu.examples.order_support issue demo-state demo-operator review.json receipt.json approve
python -m cayu.examples.order_support deliver demo-state receipt.json
python -m cayu.examples.order_support verify demo-state
python -m cayu.examples.order_support deliver demo-state receipt.json

start reads the order, tracking, and policy, then asks whether the mug or plate was damaged. clarify resumes the same session, persists one immutable proposal, and exits with a native pending approval. No replacement exists yet. review exports the proposal and the persisted native approval/round/call identities. issue runs the separate representative fixture and signs its decision; it does not execute a replacement. deliver verifies that receipt and constructs a ToolApprovalRequest. Cayu executes the protected tool and the agent makes a separate verification read. verify performs another read in a new conversation turn. Delivering the identical receipt again reuses the completed resolution and creates no additional replacement. Commands print Runtime events, not only a canned success message.

The initial run uses max_steps=18. Later ResumeRequests omit that setting and inherit the verified persisted model-loop configuration; they do not need to repeat it. Explicit overrides still undergo execution-profile admission. Older sessions without matching stored configuration retain the existing default behavior.

The independently persisted databases are demo-state/sessions.sqlite (Cayu) and demo-state/service.sqlite (orders, proposals, issued receipts, effects). The representative’s private signing key is under demo-operator, created with owner-only permissions and never passed to a model or tool. Possession of that file represents representative authority only in this local teaching fixture. Production needs authenticated operators, protected signing infrastructure, real business authorization, key rotation/revocation, and appropriate expiry. Do not deploy this local fixture as an authentication service.

To repeat from fresh state, remove only these generated fixture files/directories:

Terminal window
rm -rf demo-state demo-operator review.json receipt.json

Run init, start, clarify, and review again, then issue deny instead of approve. Deliver and verify it: the replacement list stays empty. Re-delivery of that denial also has no effect.

Terminal window
python -m cayu.examples.order_support issue demo-state demo-operator review.json receipt.json deny
python -m cayu.examples.order_support deliver demo-state receipt.json
python -m cayu.examples.order_support verify demo-state

With a pending approval, a receipt delivered with --session another-session is rejected. Editing the receipt’s proposal, decision, native IDs, or receipt ID invalidates its signature. An unregistered receipt fails verification, even with a valid signature. A legitimately issued receipt for different native IDs cannot resolve the pending action. None of these paths calls the protected service tool. Changing --version 2 on delivery demonstrates profile rejection; the example never auto-adopts changes or disables checks.

Read receipt_to_native_request in the canonical source for the complete adapter. It verifies the pinned public-key signature, issued-receipt digest, conversation, proposal contents/hash, bounded action, and approver authority. It then compares the native identities with the pending action before constructing the typed request with actor provenance. A repeated delivery still goes through Cayu’s completed-resolution contract; the adapter never calls the replacement service. ResolutionActorSource.REQUEST accurately describes this trusted SDK adapter’s identity assertion; it does not claim server HTTP authentication.

Pending-action arguments are quarantined. The application therefore stores one bounded immutable proposal per conversation. Its tool policy checks the exact proposal ID against the protected call before requiring approval. The representative reviews that service record, not inferred redacted arguments. External receipt IDs, service action IDs, and Cayu approval/round/call IDs are separate namespaces and are never substituted for each other.

Owner Records and responsibilities
Cayu Conversation, pending approval, tool-round evidence, admission, supported resume/recovery, completed-resolution replay
Application/service Operator authentication and business authorization, immutable proposal, receipt signature/binding verification, stable downstream action key and receipt, reconciliation

This avoids an application conversation log, an application pending-tool state machine, and a direct post-approval replacement handler. It intentionally retains business proposals, representative receipts, and downstream idempotency records. review.json is a transport view, not another authority for pending Runtime state. An application-owned approval workflow remains reasonable when business approvals span multiple systems or precede a Runtime session; use native tool approval when holding this exact pending tool call is the useful boundary.

Set OPENAI_API_KEY and SUPPORT_MODEL in the environment, using a model available to your account. Do not put provider secrets in files or command arguments. Start with fresh state and add --live to every start/clarify/review/deliver/verify command (init and issue are provider-independent). Never switch an existing scripted session to live mode: that changes its execution profile.

For example, after setting those two environment variables:

Terminal window
python -m cayu.examples.order_support init live-state live-operator
python -m cayu.examples.order_support start live-state --live
python -m cayu.examples.order_support clarify live-state --item mug --live
python -m cayu.examples.order_support review live-state live-review.json --live
python -m cayu.examples.order_support issue live-state live-operator live-review.json live-receipt.json approve
python -m cayu.examples.order_support deliver live-state live-receipt.json --live
python -m cayu.examples.order_support verify live-state --live

The live model chooses its investigative calls and clarification using the same tools, policy, stores, proposal and approval adapter. It runs no authoring CLI. The deterministic script proves Runtime/service boundaries; it does not establish live-model judgment or behavior. Live mode is optional and is validated separately.

Many support agents end each turn through one of a few tools, for example ask_customer for a clarification or propose_resolution for a recorded proposal. Live models occasionally stop before calling one, sometimes with an empty step (model.completed with step_classification.type == "invalid"). Cayu completes that turn normally unless a loop policy says otherwise, and your application then has neither a question nor a proposal to return.

Pass RequireFinalTool on every RunRequest and ResumeRequest for that agent:

from cayu import RequireFinalTool, ToolCompletionPolicy
names = ("ask_customer", "propose_resolution")
final_tool = RequireFinalTool(names)
completion = ToolCompletionPolicy(tool_names=names)
RunRequest(..., loop_policies=(final_tool,), tool_completion=completion)
ResumeRequest(..., loop_policies=(final_tool,), tool_completion=completion)

If the model stops before one of those tools has returned, the policy appends a reminder and the model continues, up to two times. After that, the session is interrupted with a reason instead of completing. A tool result counts only when is_error is false, so return ToolResult(..., is_error=True) for failures such as rejected evidence or an unavailable service. The example’s propose_replacement does this when the conversation already has a different proposal. The policy’s identity comes from its configuration, so use the same tool names in every process.

tool_completion skips the provider request after a successful final tool when the host displays the question or proposal from that tool’s result. Both tools must be registered with ToolEffect.NONE or ToolEffect.IDEMPOTENT; an EXTERNAL execution tool is ineligible. The SDK and HTTP run/resume requests accept the same {"tool_names": [...]} setting. Unknown, blank, duplicate and ineligible names fail before provider dispatch.

A single successful call completes with session.completed reason host_rendered_tool. run_to_completion(...).tool_completion contains its published call, effect and result, including the tool-round identity. Render that result in the application. Cayu keeps the assistant call and tool result in the conversation and does not generate an extra answer. A failed, denied, blocked or malformed call does not trigger this completion. A round containing sibling calls executes through the ordinary loop, preserving every call.

RequireFinalTool supplies reminders when the model stops too early; ToolCompletionPolicy completes immediately when the designated tool succeeds. Without tool_completion, the existing post-tool model step still runs. Pass both settings consistently on fresh turns. They belong to the execution profile, so changing or omitting an established policy requires the existing profile adoption flow. Approval resolution and crash recovery retain the admitted policy. tool_completion cannot be combined with structured_output, which owns a separate validated output contract.

  • Duplicate session: run creates, resume(ResumeRequest(...)) continues an ordinary conversation. The CLI’s clarify and verify use resume. Inspect a running or pending session before choosing approval/input resolution or recovery.
  • Process died during a tool: ordinary resume automatically recovers an executor proved dead on the same host, or one whose execution lease has expired. SessionExecutionInProgress means the executor may still be running; retry after it finishes or its lease expires (60 seconds by default). For an older session without a recorded owner, first establish that its executor stopped, then call app.recover_incomplete_session(IncompleteSessionRecoveryRequest(...)) and inspect the result before resuming. The recover command demonstrates explicit recovery. Unknown external effects still interrupt for receipt-based reconciliation; automatic recovery never blindly repeats the proposal write.
  • Execution-profile mismatch: keep the same provider/model and declared versions across processes. Process-local opaque identities need stable declarations from the first run; adding one does not repair a persisted opaque baseline. Digests may support only class-level diagnosis. See cayu guide durable-service-tools.
  • No pending proposal: the live model may still be asking a question, or the approval may already be resolved. Inspect events; never fabricate native IDs.
  • Signature/binding error: obtain the receipt for this exact reviewed proposal and conversation from the trusted representative. Do not rewrite signed fields.

The fixture supports process exits at completed conversation and pending-approval boundaries. It does not prove arbitrary crash recovery, multi-host coordination, power-loss durability, or generic exactly-once external effects. The local service uses an atomic SQLite insert with a stable unique action key. A real remote service must durably deduplicate that key and reconcile an ambiguous external outcome before retrying. Never blindly replay an unknown effect: follow the existing protocol in cayu guide durable-operations and cayu guide tool-effects.