edit on github↗

Example: Support ticket triage

Use case: Customer support automation. Incoming tickets are classified, simple issues are resolved automatically, and anything involving money or account changes requires a human to approve before the agent acts.


Full code

from agentspan.agents import Agent, tool, start
from pydantic import BaseModel
from enum import Enum

# ── Data types ────────────────────────────────────────────────────────────────

class TicketCategory(str, Enum):
    BILLING    = "billing"
    TECHNICAL  = "technical"
    ACCOUNT    = "account"
    GENERAL    = "general"

class Resolution(BaseModel):
    category: TicketCategory
    action_taken: str
    response_to_customer: str
    requires_followup: bool

# ── Tools ────────────────────────────────────────────────────────────────────

@tool
def lookup_customer(email: str) -> dict:
    """Fetch customer record: plan, billing status, open tickets, account age."""
    return db.customers.find_one({"email": email})

@tool
def lookup_ticket_history(customer_id: str) -> list[dict]:
    """Fetch the last 10 support tickets for this customer."""
    return db.tickets.find({"customer_id": customer_id}).sort("created_at", -1).limit(10)

@tool
def send_reply(customer_id: str, message: str) -> dict:
    """Send a reply to the customer and mark the ticket as resolved."""
    return support_api.send(customer_id=customer_id, message=message, status="resolved")

@tool(approval_required=True)
def issue_refund(customer_id: str, amount_usd: float, reason: str) -> dict:
    """Issue a refund to the customer. Requires human approval."""
    return billing_api.refund(customer_id=customer_id, amount=amount_usd, reason=reason)

@tool(approval_required=True)
def suspend_account(customer_id: str, reason: str) -> dict:
    """Suspend a customer account. Requires human approval."""
    return accounts_api.suspend(customer_id=customer_id, reason=reason)

@tool(approval_required=True)
def apply_credit(customer_id: str, amount_usd: float, note: str) -> dict:
    """Apply account credit. Requires human approval for amounts over $50."""
    return billing_api.credit(customer_id=customer_id, amount=amount_usd, note=note)

# ── Agent ─────────────────────────────────────────────────────────────────────

support_agent = Agent(
    name="support_agent",
    model="anthropic/claude-sonnet-4-6",
    output_type=Resolution,
    tools=[
        lookup_customer,
        lookup_ticket_history,
        send_reply,
        issue_refund,
        suspend_account,
        apply_credit,
    ],
    instructions="""You are a support agent for a SaaS product.

When a ticket arrives:
1. Look up the customer's account and ticket history
2. Diagnose the issue based on context
3. For general / technical questions: resolve directly with send_reply
4. For billing actions (refunds, credits): use the appropriate tool — these will pause
   for human review before executing
5. Return a Resolution with what happened

Always be clear and empathetic in your response_to_customer.
Never invent facts about the customer's account.""",
)

# ── Handle a ticket ───────────────────────────────────────────────────────────

def handle_ticket(ticket_id: str, customer_email: str, message: str):
    prompt = f"""
Ticket ID: {ticket_id}
Customer email: {customer_email}
Message: {message}
"""
    handle = start(support_agent, prompt)

    for event in handle.stream():
        if event.type == "waiting":
            # Route to your approval queue — Slack, internal tool, email, etc.
            notify_approval_queue(
                ticket_id=ticket_id,
                workflow_id=handle.execution_id,
                tool=event.tool_name,
                args=event.args,
                preview=f"Agent wants to {event.tool_name} for {customer_email}",
            )
            # handle.approve() or handle.reject() will be called by the reviewer
            break

    return handle.execution_id

# ── Reviewer approves or rejects ──────────────────────────────────────────────

def reviewer_approve(workflow_id: str, runtime: AgentRuntime):
    from agentspan.agents import AgentHandle
    # runtime must have serve(agent, blocking=False) called before this
    handle = AgentHandle(execution_id=workflow_id, runtime=runtime)
    handle.approve()

def reviewer_reject(workflow_id: str, runtime: AgentRuntime, reason: str):
    from agentspan.agents import AgentHandle
    handle = AgentHandle(execution_id=workflow_id, runtime=runtime)
    handle.reject(reason)
    # Agent receives the rejection and can try an alternative action

What this demonstrates

Human-in-the-loop with approval_required=True — tools decorated this way pause the agent when called. The agent’s state is held on the server indefinitely — no timeout, no polling loop needed on your side. The reviewer can approve or reject hours later.

Reconnecting to a paused agentAgentHandle(execution_id=...) lets any process (a webhook, a background job, a CLI command) resume a paused execution. The execution ID is stable and stored.

Different tools with different risk levelslookup_customer and send_reply run immediately. issue_refund, suspend_account, and apply_credit all require human review. This is expressed in the tool definition, not in orchestration logic.


Variations

Approve from the CLI

agentspan agent respond exec-a1b2c3 --approve
agentspan agent respond exec-a1b2c3 --reject "Amount too large, escalate to finance"

Auto-approve small refunds

Override the approval gate based on args:

def reviewer_route(workflow_id: str, tool: str, args: dict, runtime: AgentRuntime):
    if tool == "issue_refund" and args["amount_usd"] <= 25:
        AgentHandle(execution_id=workflow_id, runtime=runtime).approve()
    else:
        notify_approval_queue(workflow_id, tool, args)

Query ticket history

Open http://localhost:6767 to browse execution history. You can filter by agent name, status, and date range in the UI.


Testing

from agentspan.agents.testing import mock_run, MockEvent, expect

# Test the happy path: general question, resolved without approval
result = mock_run(
    support_agent,
    "Ticket: billing@acme.com — How do I export my data?",
    events=[
        MockEvent.tool_call("lookup_customer", {"email": "billing@acme.com"}),
        MockEvent.tool_result("lookup_customer", {"id": "cust_123", "plan": "pro"}),
        MockEvent.tool_call("send_reply", {"customer_id": "cust_123", "message": "..."}),
        MockEvent.tool_result("send_reply", {"status": "sent"}),
        MockEvent.done('{"category": "general", "action_taken": "Sent export instructions", ...}'),
    ]
)

expect(result).completed().used_tool("send_reply")

# Test the refund path: confirm approval tool is called and agent waits
result = mock_run(
    support_agent,
    "Ticket: billing@acme.com — I was charged twice this month, please refund",
    events=[
        MockEvent.tool_call("lookup_customer", {"email": "billing@acme.com"}),
        MockEvent.tool_result("lookup_customer", {"id": "cust_123", "plan": "pro"}),
        MockEvent.tool_call("issue_refund", {"customer_id": "cust_123", "amount_usd": 99.0, "reason": "duplicate charge"}),
        MockEvent.waiting("Waiting for human approval on issue_refund"),
        MockEvent.done('{"category": "billing", "action_taken": "Refund pending approval", ...}'),
    ]
)

expect(result).completed().used_tool("issue_refund")