---
title: "Example: support triage"
description: "A plugin that routes support tickets with typed decisions, a hook that holds risky refunds, and a gate that stops a worse version."
canonical_url: "https://docs.oz0.ai/build/plugins/example-support-triage"
---
# Example: support triage

> A plugin that routes support tickets with typed decisions, a hook that holds risky refunds, and a gate that stops a worse version.

For: plugin authors.

The `examples/jev-tools` plugin shows tools, agents, a flow, a hook and evals working together. Jev, the service from TypeSafe AI that answers typed questions with a confidence value, chooses a support ticket's team and assesses refund risk.

The example currently requires access to the private repository; public source downloads are coming soon. From your checkout, run `make demo-m5` to exercise the complete example.

```text
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                # jev_guard.check_refund: is this refund risky?
│   └── support_mcp.py          # jev_support.lookup_order, jev_support.refund
├── agents/                     # jev-billing, jev-technical, jev-sales
├── flows/jev-triage.yaml       # started by Zendesk's webhook
└── evals/                      # billing.yaml, triage.yaml
```

## The flow

```yaml [flows/jev-triage.yaml]
name: jev-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: jev-${{ 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 }} (confidence ${{ steps.decide.team.confidence }})?'
      timeout: 4h
  - id: route_checked
    when: has(steps.ask_human)
    agent: jev-${{ 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](https://docs.oz0.ai/build/parts/flows).

A ticket is outside text, so the flow is untrusted from its start, and `jev.decide` sends the ticket to Jev, outside the cluster: the [taint-default rule](https://docs.oz0.ai/govern/tool-policy/origins-and-sequences#the-taint-default-rule) asks a person before that call, and the card says the taint asked. A tenant that trusts its triage service with its tickets narrows the rule for that one call, with a reason, as `make demo-m5` does: `oz0 tenant tool-policy set --taint-default-when 'job.tainted && tool.outbound && !(mcp.tool.target == "jev" && mcp.tool.name == "decide")' --reason "..."`. The job stays tainted, so its other outbound calls still ask.

## 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`:

```python
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:

```text
JOB                                  AGENT        NODES              MODELS                      CALLS  INPUT  CACHED  OUTPUT  COST
hook-jev-triage-zendesk-ticket-1001  jev-triage   node-be9581dcfec6                              0      0      0       0       $0.0004
└ jev-billing-2227b26b               jev-billing  node-be9581dcfec6  anthropic/claude-haiku-4-5  3      1211   0       73      $0.0018
Reported by plugins:
  hook-jev-triage-zendesk-ticket-1001 (jev-triage): jev, 2 questions, $0.0004, by jev-tools
  jev-billing-2227b26b (jev-billing): jev, 1 question, $0.0002, by jev-tools
```

See [Secrets for plugins](https://docs.oz0.ai/admin/secrets/plugin-secrets#secrets-for-plugins) and [Report what your own services cost](https://docs.oz0.ai/build/parts/tools/model-calls#report-what-your-own-services-cost).

## The hook

Before every refund, `jev_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:

```yaml [oz0-plugin.yaml]
hooks:
  - on: pre_tool_call
    call: jev_guard.check_refund
    match: {tools: [jev_support.refund]}
```

```text
jev-billing → jev_support.refund {"amount_usd":400,"order_id":"5678","reason":"double charge"}
jev-billing ⏸ jev_support.refund waits for approval a-86f882b36825: {"hook": "jev_guard.check_refund", "reason": "Jev thinks this refund is risky (0.90)", ...}
jev-billing ▶ jev_support.refund approved by operator:admin: checked the bank statement
jev-billing ← jev_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](https://docs.oz0.ai/build/parts/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](https://docs.oz0.ai/improve/evals/quality-gates#quality-gates) runs them again at every install.

In the demo, a teammate's version 1.1.0 drops `jev_support.refund` from the billing agent's tools. Its evals get worse, and the gate stops it before the fleet gets it:

```text
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

```bash [Terminal]
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)
```

<note>

Jev is a hosted service with a waiting list, and its SDK is not public. The example therefore speaks a request of its own shape, and `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.

</note>


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
