Skip to content

Tutorial: the golden path

Available · pip install agent-adapter fastapi uvicorn · about 20 minutes

A small support-desk service wired the way we recommend for production: a confirmed refund, approvals that survive a restart, a daily budget per user, guardrails, memory across chats, cited help answers, a crash-safe flow, recorded runs, and evals that gate CI. A scripted function stands in for the model, so it all runs offline.

File Does
examples/golden_path/tools.py the tools: orders, a confirm=True refund, help search, memory, tickets
examples/golden_path/agent.py SupportDesk: one executor, its stores and the flow runner; the chat agent
examples/golden_path/app.py the FastAPI routes: chat, approvals, tickets, budget
examples/golden_path/requesters.py who asked for each parked call (four eyes)
examples/golden_path/cases.jsonl the eval cases CI runs
tests/docs/test_golden_path.py, test_golden_path_app.py run all of it, over HTTP too
flowchart LR
  C["client<br/>Bearer token"] --> A["app.py<br/>FastAPI"]
  A -- "user id from the token" --> D["SupportDesk.ask"]
  D --> L["run_tool_loop<br/>scripted model"]
  L --> E["Executor"]
  E --> G["PIIGuard<br/>block cards to the model and tools,<br/>mask PII going out"]
  G --> B["BudgetHook<br/>per user per day"]
  B --> P{"confirm?"}
  P -- "refund" --> Q[("approvals.db<br/>DeferredApproval")]
  P -- "no" --> T["tools: orders, search_help,<br/>remember / recall"]
  Q -- "reviewer POST /approvals" --> T
  A -- "POST /tickets" --> F["escalation flow<br/>checkpoints.db"]
  E --> R[("runs/<br/>viewer recorder")]

1. Tools

Plain functions in one registry. The refund is confirm=True, so it never runs without a one-use approval. The order tools check that the order belongs to ctx.user_id, which the host sets and the model never chooses: another user's order looks like no order. retriever_tool returns each passage with its link; memory_tools scope every record to the same user.

def build_registry(memory: SQLiteMemory) -> Registry:
    """Every tool of the desk; the model sees `registry.subset(names=CHAT_TOOLS)`."""
    registry = Registry()

    @registry.tool
    def order_status(order_id: str, ctx: ToolContext) -> str:
        """Where an order is: packed, shipped or unknown."""
        order = own_order(order_id, ctx)
        return order[1] if order else "unknown"

    @registry.tool(confirm=True)  # runs only after a reviewer's one-use approval
    def refund_order(order_id: str, ctx: ToolContext) -> str:
        """Refund an order in full."""
        order = own_order(order_id, ctx)
        if order is None:
            raise ToolUsageError(f"no order {order_id}")
        return f"refunded {order[2]:.2f} EUR on order {order_id}"

    retriever = EmbeddingRetriever(
        CallableEmbedder(embed, provider="local", model="words-v1"), HELP
    )
    registry.add(retriever_tool(retriever, name="search_help", k=2))
    registry.add_many(memory_tools(memory))  # remember, recall, forget: scoped to ctx.user_id

    @registry.tool(idempotent=True)  # the same order and summary give the same ticket
    def open_ticket(order_id: str, summary: str, ctx: ToolContext) -> str:
        """Open a support ticket for a person to follow up."""
        if own_order(order_id, ctx) is None:
            raise ToolUsageError(f"no order {order_id}")
        digest = hashlib.sha256(f"{order_id}:{summary}".encode()).hexdigest()[:8]
        return f"T-{digest}"

    @registry.tool
    def notify_team(ticket_id: str) -> str:
        """Page the support team about a ticket (must not run twice)."""
        return page_team(ticket_id)

    return registry


def own_order(order_id: str, ctx: ToolContext) -> tuple[str, str, float] | None:
    """The order if the signed-in user owns it; another user's order looks like no order."""
    order = ORDERS.get(order_id)
    return order if order is not None and ctx.user_id and order[0] == ctx.user_id else None


def page_team(ticket_id: str) -> str:
    """Stands in for your pager or chat-ops client."""
    return f"team notified about {ticket_id}"

2. One desk per process

Build everything once per process, in a factory, never at import: the executor holds the guards, the budget (SQLite, so every worker on the host shares the spend), the approval store, the ledger and the recorder. The model only sees chat_tools; the ticket tools are the flow's.

class SupportDesk:
    """Everything one process needs, built once: tools, stores, the executor and the flow runner."""

    def __init__(self, folder: Path, *, runs: Path, daily_usd: float = 0.50) -> None:
        folder.mkdir(parents=True, exist_ok=True)
        self.memory = SQLiteMemory(folder / "memory.db", retention_days=90)
        self.registry = build_registry(self.memory)
        self.chat_tools = self.registry.subset(names=CHAT_TOOLS)
        self.budget = BudgetHook(
            daily_usd, scope="user", per="day", store=SQLiteBudgetStore(folder / "budgets.db")
        )
        self.executor = Executor(
            self.registry,
            hooks=[
                # Block cards and secrets in what the user sends the model and in tool arguments.
                PIIGuard(kinds=["secret", "card"], on=("llm_request", "args")),
                PIIGuard(on=("result", "llm_response"), action="redact"),  # mask on the way out
                self.budget,  # per user per UTC day, shared by every worker on this file
            ],
            approval=DeferredApproval.sqlite(folder / "approvals.db"),  # survives a restart
            ledger=Ledger(prices=PRICES),
        )
        self.requesters = Requesters(folder / "requesters.db")
        self.recorder = record(self.executor, folder=runs)  # agent-adapter ui
        store = SQLiteCheckpointStore(folder / "checkpoints.db")
        self.runner = FlowRunner(
            self.executor, checkpoints=store, checkpoint_key=checkpoint_key(folder)
        )
        self.model = CallableModel(scripted_model, provider="local", model="desk-v1")

    def context(self, user: str, chat_id: str, run_id: str | None = None) -> ToolContext:
        """A call context; the host sets the user, never the model."""
        return self.executor.context(run_id=run_id, thread_id=chat_id, user_id=user)

    async def ask(self, user: str, chat_id: str, text: str, run_id: str | None = None) -> Reply:
        """One turn of the agent loop for `user` in chat `chat_id`.

        Raises:
            BudgetExceeded: the user's daily budget is spent.
        """
        ctx = self.context(user, chat_id, run_id)
        messages = [Message(role="system", content=SYSTEM), Message(role="user", content=text)]
        result = await run_tool_loop(
            self.model, self.chat_tools, messages, executor=self.executor, ctx=ctx
        )
        if result.finish == "pending":
            waiting = next(r for r in result.tool_results if r.status == "pending")
            approval_id = str(waiting.error.details["approval_id"]) if waiting.error else ""
            text = f"A reviewer must approve {waiting.name} first."
            await self.requesters.add(approval_id, user, ctx.run_id)
            return Reply(text=text, run_id=ctx.run_id, approval_id=approval_id)
        return Reply(text=result.final.text or "", run_id=ctx.run_id)

    async def decide(self, approval_id: str, approved: bool, reviewer: str) -> str:
        """Apply a reviewer's decision; an approved call runs once, here, as its requester.

        Raises:
            ApprovalNotFound: unknown id.
            Forbidden: the reviewer asked for the call (four eyes).
            ApprovalAlreadyDecided: the decision was used already.
        """
        asked = await self.requesters.get(approval_id)
        if asked is None:
            raise ApprovalNotFound(approval_id)
        requester, run_id = asked
        if reviewer == requester:
            raise Forbidden("a reviewer cannot decide their own request")
        ctx = self.executor.context(run_id=run_id, user_id=requester)  # tools check ownership
        try:
            result = await self.executor.resume(approval_id, approved, ctx, decided_by=reviewer)
        finally:
            self.executor.end_run(run_id)
        why = result.error.kind if result.error else result.status
        return str(result.value) if result.ok else f"not run ({why})"

    async def escalate(self, user: str, order_id: str, summary: str) -> Ticket:
        """Run the escalation flow, checkpointed after every step."""
        ctx = self.executor.context(user_id=user)
        result = await self.runner.run(ESCALATION, {"order_id": order_id, "summary": summary}, ctx)
        return result.state

    async def recover(self, *, rerun_in_doubt: bool = False) -> dict[str, str]:
        """Finish escalations a crashed worker left behind (call on startup)."""
        found = await self.runner.recover_all(ESCALATION, rerun_in_doubt=rerun_in_doubt)
        outcome: dict[str, str] = {}
        for run_id, result in found.items():
            if isinstance(result, AdapterError):
                logger.warning("escalation %s not recovered: %s", run_id, result.kind)
                outcome[run_id] = result.kind
            else:
                outcome[run_id] = result.status
        return outcome

    def close(self) -> None:
        """Flush and detach the run recorder."""
        self.recorder.stop()

The blocking PIIGuard checks what the user sends the model (llm_request) and every tool's arguments: a card number in the chat ends the turn with guardrail-blocked (422) before any model sees it. A request cannot be rewritten, only blocked; drop llm_request if your model may see such data. The redacting guard masks emails and cards in tool results and model replies.

3. A model you can swap

scripted_model picks one tool and answers from its result. Replace CallableModel(...) with OpenAIModel, AnthropicModel or your own port; nothing else changes (Bring your own model).

def scripted_model(request: LLMRequest) -> LLMResponse:
    """Stands in for a real model: picks one tool for the question, then answers from its result."""
    last = request.messages[-1]
    if last.role == "tool":
        return _reply(Message(role="assistant", content=_answer(last.name, last.content)))
    call = ToolCall(id="call_1", **_pick_tool(last.content))
    return _reply(Message(role="assistant", tool_calls=(call,)))

4. Approvals that survive a restart

Please refund order 1043 parks the call in approvals.db and the reply carries its id. A reviewer decides with POST /approvals/{id}, on any worker that opens the same files; the call runs there, once, as the user who asked, so the ownership check holds. A second decision gets 409, a customer token 403, and a reviewer deciding their own request 403 (forbidden): requesters.db keeps who asked for each parked call.

5. A crash-safe flow

POST /tickets opens a ticket and pages the team. With a checkpoint store, every step is signed and every tool call is logged, so a worker that dies mid-flow is finished by the next one: the app's startup calls desk.recover().

class Ticket(BaseModel):
    order_id: str
    summary: str
    ticket_id: str = ""
    note: str = ""


def ticket_args(ticket: Ticket) -> dict[str, str]:
    """The arguments of `open_ticket`."""
    return {"order_id": ticket.order_id, "summary": ticket.summary}


ESCALATION = (
    Flow("escalation", state=Ticket)
    .node("open", Call("open_ticket", args=ticket_args, into="ticket_id"))
    .node("notify", Call("notify_team", args=lambda t: {"ticket_id": t.ticket_id}, into="note"))
    .edge("open", "notify")
    .end("notify")
)

open_ticket is idempotent=True, so it replays. notify_team is not: if the crash hit while it ran, recovery stops with recovery-needs-decision until a person checks the page went out and recovers with rerun_in_doubt=True (Crash-safe runs). The key that signs checkpoints comes from SUPPORT_DESK_KEY; set it from your vault and use the same one on every worker.

6. The HTTP routes

def create_app(desk: SupportDesk | None = None) -> FastAPI:
    """The HTTP routes; startup builds the default desk (unless given), recovers crashed flows."""

    @asynccontextmanager
    async def lifespan(app: FastAPI) -> AsyncIterator[None]:
        support = desk if desk is not None else default_desk()
        app.state.desk = support
        await support.recover()  # in-doubt runs are logged and wait for a person
        yield
        support.close()

    app = FastAPI(title="Support desk", lifespan=lifespan)

    @app.exception_handler(AdapterError)
    async def adapter_error(request: Request, error: AdapterError) -> JSONResponse:
        detail = {"kind": error.kind, "message": str(error)}
        return JSONResponse({"detail": detail}, status_code=STATUS.get(error.kind, 400))

    @app.post("/chat")
    async def chat(body: Ask, who: Caller, support: Desk) -> dict[str, str]:
        """One agent turn; the chat id is the caller's own (prefixed with the user)."""
        user = who[0]
        reply = await support.ask(user, f"{user}:{body.chat_id}", body.text)
        return {"text": reply.text, "run_id": reply.run_id, "approval_id": reply.approval_id}

    @app.post("/approvals/{approval_id}")
    async def decide(
        approval_id: str, body: Decision, who: Caller, support: Desk
    ) -> dict[str, str]:
        """A reviewer approves or denies a parked call; it runs at most once."""
        user, role = who
        if role != "reviewer":
            raise HTTPException(403, "only reviewers decide approvals")
        return {"result": await support.decide(approval_id, body.approved, user)}

    @app.post("/tickets")
    async def escalate(body: Escalation, who: Caller, support: Desk) -> dict[str, Any]:
        """Open a ticket and page the team, as a crash-safe flow."""
        ticket = await support.escalate(who[0], body.order_id, body.summary)
        return ticket.model_dump()

    @app.get("/budget")
    async def budget(who: Caller, support: Desk) -> dict[str, Any]:
        """What the caller has spent today, and what is left."""
        status = await support.budget.status(support.context(who[0], ""))
        return {"spent_usd": status.spent_usd, "remaining_usd": status.remaining_usd}

    return app
Route Who Does
POST /chat any user one agent turn; the chat id is prefixed with the user
POST /approvals/{id} reviewers approve or deny a parked call
POST /tickets any user the escalation flow
GET /budget any user today's spend and what is left

Adapter errors become JSON problems with their kind: budget-exceeded is 429, guardrail-blocked and flow-error 422. The demo USERS tokens stand in for your IdP.

cd examples
uvicorn golden_path.app:app                    # data in .support_desk/
curl -X POST localhost:8000/chat -H "Authorization: Bearer ann-token" \
  -H "Content-Type: application/json" -d '{"chat_id": "c1", "text": "Where is order 1042?"}'
agent-adapter ui                               # recorded turns, flows, approvals, cost

7. Evals as the CI gate

create_app() builds the live desk at startup with default_desk() (data in SUPPORT_DESK_DATA, default .support_desk); chat is the same agent for the chat page, where an anonymous chat is its own user. An eval must never park a live refund, spend a user's budget or read their memory, so it runs on eval_desk(): fresh stores in a temp folder, deleted at exit, with the same guards, budget and approval. Its runs go to .agent_adapter/runs, the eval's default, so it keeps one recorder.

EVAL_USER = "ann"  # the demo customer whose orders the eval cases ask about


@functools.cache
def eval_desk() -> SupportDesk:
    """A desk on fresh temporary stores: an eval never parks a live refund or spends a budget."""
    folder = Path(tempfile.mkdtemp(prefix="support-desk-eval-"))
    desk = build_desk(folder)  # runs still go to the viewer's folder, to inspect failures
    atexit.register(_discard, desk, folder)
    return desk


async def eval_chat(message: ChatMessage, session: ChatSession) -> str:
    """The eval agent: the chat agent's turn, on the eval desk, as the demo customer."""
    reply = await eval_desk().ask(EVAL_USER, session.id, message.text, session.run_id)
    return reply.text


def __getattr__(name: str) -> Executor:
    """`eval_executor`, built on first access (for `agent-adapter eval --executor`)."""
    if name == "eval_executor":
        return eval_desk().executor
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
agent-adapter eval golden_path.agent:eval_chat golden_path/cases.jsonl \
  --executor golden_path.agent:eval_executor --grader contains --grader max-cost=0.01 \
  --junit evals.xml

It exits 1 when a case fails, so a CI step fails with it. test_golden_path_app.py runs exactly this command, and a wrong answer, to show the gate bites (Evaluate your agent).

Next