Example: support triage
This example puts every building block in one plugin, the way a team would build it. A service that answers typed questions with a confidence (Jev, from TypeSafe AI) decides which team a support ticket goes to and whether a refund is risky. The plugin is in the repository as examples/jev-tools, and make demo-m5 runs all of it.
jev-tools/
├── oz0-plugin.yaml # tools, the hook, requires.secrets: [JEV_API_KEY]
├── src/jev_tools/
│ ├── jev_mcp.py # jev.decide: typed questions in, answers with a confidence out
│ ├── hooks.py # guard.check_refund: is this refund risky?
│ └── support_mcp.py # support.lookup_order, support.refund
├── agents/ # support-billing, support-technical, support-sales
├── flows/support-triage.yaml # started by Zendesk's webhook
└── evals/ # billing.yaml, triage.yaml
The flow
name: support-triage
input_schema: schemas/ticket.json
trigger:
webhook: { secret: ZENDESK_WEBHOOK }
steps:
- id: decide
tool: jev.decide
input:
state: ${{ input.ticket }}
questions:
team: { choice: [billing, technical, sales] }
urgent: { yes_no: "The customer needs an answer within an hour" }
- id: route
when: steps.decide.team.confidence >= 0.8
agent: support-${{ steps.decide.team.choice }}
input: ${{ input.ticket }}
- id: ask_human
when: steps.decide.team.confidence < 0.8
approval:
details: 'Jev is not sure where "${{ input.ticket.subject }}" goes. Send it to ${{ steps.decide.team.choice }}?'
timeout: 4h
- id: route_checked
when: has(steps.ask_human)
agent: support-${{ steps.decide.team.choice }}
input: ${{ input.ticket }}
output: "${{ has(steps.route) ? steps.route : steps.route_checked }}"
A ticket Jev is sure about goes straight to that team's agent; one it is unsure about waits until a person approves its guess. See Flows.
The tool, its key and its cost
jev.decide reads the service's key from the environment: the plugin declares requires.secrets: [JEV_API_KEY], so every process of the plugin gets the tenant secret, and the key never goes into the repository. It returns the answers as structured content and reports what the service charged in the result's _meta:
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(answers))],
structured_content=answers,
_meta={"oz0/usage": {"service": "jev", "unit": "question", "units": len(questions), "cost_usd": cost}},
)
That cost lands on the job next to the model calls, and counts toward the job's budget:
JOB AGENT NODES MODELS CALLS INPUT CACHED OUTPUT COST
hook-support-triage-zendesk-ticket-1001 support-triage node-be9581dcfec6 0 0 0 0 $0.0004
└ support-billing-2227b26b support-billing node-be9581dcfec6 anthropic/claude-haiku-4-5 3 1211 0 73 $0.0018
Reported by plugins:
hook-support-triage-zendesk-ticket-1001 (support-triage): jev, 2 questions, $0.0004, by jev-tools
support-billing-2227b26b (support-billing): jev, 1 question, $0.0002, by jev-tools
See Secrets for plugins and Report what your own services cost.
The hook
Before every refund, guard.check_refund asks Jev whether the refund is risky. A risky one (here: over $100) is escalated, so the call waits for a person:
hooks:
- on: pre_tool_call
call: guard.check_refund
match: {tools: [support.refund]}
support-billing → support.refund {"amount_usd":400,"order_id":"5678","reason":"double charge"}
support-billing ⏸ support.refund waits for approval a-86f882b36825: {"hook": "guard.check_refund", "reason": "Jev thinks this refund is risky (0.90)", ...}
support-billing ▶ support.refund approved by operator:admin: checked the bank statement
support-billing ← support.refund Refunded $400.00 for order 5678 (double charge).
When Jev cannot be reached, the hook escalates too, so a person decides rather than nobody. See Hooks.
The evals, and a version that does worse
evals/billing.yaml wants a double charge refunded and a single charge left alone; evals/triage.yaml sends a ticket through the whole flow. plugin test runs them while the team works, and the quality gate runs them again at every install.
In the demo, a teammate's version 1.1.0 drops support.refund from the billing agent's tools. Its evals get worse, and the gate stops it before the fleet gets it:
Gate of jev-tools 1.1.0: stopped: eval billing: 1 of 2 cases passed (50%), below its threshold of 100%
The fleet keeps answering tickets with 1.0.0.
Run it
make demo-m5 # stand-ins for Jev and Claude, no keys needed
ANTHROPIC_API_KEY=sk-ant-... make demo-m5 # the agents on Claude (claude-haiku-4-5)
scripts/fakejev answers it in the demo. With access to Jev, replace ask() in jev.py with a call to TypeSafe's SDK; the same pattern works with a classifier of your own or a small LLM.