Pause for external approval#

Correlate an external reply with a waiting tool. The simulated UI answers immediately; duplicate replies are rejected. No export is actually performed.

Run it#

From the repository root:

uv run python docs/_examples/approval.py

The output includes:

Response accepted; duplicate rejected

This program uses deterministic offline models and temporary storage where needed. It requires no API key and is executed by the documentation test suite.

Connect an approval UI#

The example publishes an approval request, pauses the tool, and sends a correlated reply back through the client. In a real application, replace the simulated reply with your UI’s accept or reject action.

async def main() -> None:
    config = AgentRunConfig(session_id="approval-demo", request_id="request-1")
    client = await create_agent(
        ApprovalModel(), config=config, extensions=[ApprovalExtension(), ToolGuidelinesExtension()]
    )
    # stream() is annotated AsyncIterator but is an async generator, which aclosing needs.
    async with aclosing(client.stream("Export my notes")) as events:  # type: ignore[type-var]
        async for event in events:
            if event.type == AgentEventType.CUSTOM and event.name == "approval.requested":
                assert event.payload is not None
                print(f"Question: {event.payload['action']}?")
                # A real application sends this from a UI/API callback, not automatically.
                response = ExternalEvent(
                    name="approval.response",
                    payload={"tool_call_id": event.payload["tool_call_id"], "approved": True},
                )
                accepted = client.agent.emit_external_event(response, config=config)
                assert accepted == ["ApprovalExtension"]
                assert client.agent.emit_external_event(response, config=config) == []
                print("Response accepted; duplicate rejected")
            elif event.type == AgentEventType.RUN_COMPLETED:
                assert event.message is not None
                print(event.message.content)

Keep the session, request, and tool-call identifiers with the prompt you show the user. A reply for another request must not authorize this one. The example checks that a duplicate reply is rejected and does not perform an actual export.

Complete source#

Download approval.py.

  1"""Round-trip an external approval through the framework's pending-event base."""
  2
  3import asyncio
  4from collections.abc import AsyncIterator
  5from contextlib import aclosing
  6
  7from zett_agent.agent import (
  8    AgentRunConfig,
  9    AgentRunContext,
 10)
 11from zett_agent.client import create_agent
 12from zett_agent.events import (
 13    AgentEvent,
 14    AgentEventType,
 15)
 16from zett_agent.extensions.external import (
 17    ExternalEvent,
 18    ExternalEventExtension,
 19)
 20from zett_agent.extensions.tool_guidelines import ToolGuidelinesExtension
 21from zett_agent.messages import (
 22    AssistantMessage,
 23    ToolCall,
 24    ToolMessage,
 25)
 26from zett_agent.model import (
 27    ModelEvent,
 28    ModelRequest,
 29    ModelResponse,
 30    RetryOptions,
 31)
 32from zett_agent.tools.base import tool
 33
 34
 35class ApprovalExtension(ExternalEventExtension):
 36    def __init__(self) -> None:
 37        super().__init__(response_event_name="approval.response", correlation_field="tool_call_id")
 38
 39    async def on_tool(self, context: AgentRunContext) -> None:
 40        @tool
 41        def request_approval(action: str) -> str:
 42            """Ask the user whether a proposed action should proceed.
 43
 44            Args:
 45                action: Human-readable proposed action; this tool does not execute it.
 46
 47            Snippet:
 48                request_approval(action="Export notes")
 49
 50            Guidelines:
 51                - Ask before performing an action that requires user approval.
 52            """
 53            response = self._take_external_event(context)
 54            approved = response.payload.get("approved")
 55            if not isinstance(approved, bool):
 56                raise ValueError("approved must be a boolean")
 57            return "approved" if approved else "rejected"
 58
 59        context.register_tool(request_approval)
 60
 61    async def before_tool(self, context: AgentRunContext, call: ToolCall) -> None:
 62        if call.name != "request_approval":
 63            return
 64        async with self._wait_for_external_event(context, call.id):
 65            await context.emit(
 66                AgentEvent(
 67                    AgentEventType.CUSTOM,
 68                    session_id=context.session_id,
 69                    name="approval.requested",
 70                    payload={"tool_call_id": call.id, "action": call.arguments["action"]},
 71                )
 72            )
 73        # Leaving the async-with body waits for the routed external response.
 74
 75
 76class ApprovalModel:
 77    retry = RetryOptions(max_retries=0)
 78
 79    async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
 80        last = request.messages[-1]
 81        if isinstance(last, ToolMessage):
 82            assert last.success
 83            message = AssistantMessage(content="Approval received; no export was executed in this demo.")
 84        else:
 85            message = AssistantMessage(
 86                tool_calls=(ToolCall("approval-1", "request_approval", {"action": "Export notes"}),)
 87            )
 88        yield ModelEvent.completed(ModelResponse(message))
 89
 90
 91async def main() -> None:
 92    config = AgentRunConfig(session_id="approval-demo", request_id="request-1")
 93    client = await create_agent(
 94        ApprovalModel(), config=config, extensions=[ApprovalExtension(), ToolGuidelinesExtension()]
 95    )
 96    # stream() is annotated AsyncIterator but is an async generator, which aclosing needs.
 97    async with aclosing(client.stream("Export my notes")) as events:  # type: ignore[type-var]
 98        async for event in events:
 99            if event.type == AgentEventType.CUSTOM and event.name == "approval.requested":
100                assert event.payload is not None
101                print(f"Question: {event.payload['action']}?")
102                # A real application sends this from a UI/API callback, not automatically.
103                response = ExternalEvent(
104                    name="approval.response",
105                    payload={"tool_call_id": event.payload["tool_call_id"], "approved": True},
106                )
107                accepted = client.agent.emit_external_event(response, config=config)
108                assert accepted == ["ApprovalExtension"]
109                assert client.agent.emit_external_event(response, config=config) == []
110                print("Response accepted; duplicate rejected")
111            elif event.type == AgentEventType.RUN_COMPLETED:
112                assert event.message is not None
113                print(event.message.content)
114
115
116if __name__ == "__main__":
117    asyncio.run(main())

Next: Emit events and accept user input for response routing, or Compose built-in extensions for AskUserExtension and PlanModeExtension.