Build plugins

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.

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

flows/support-triage.yaml
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:

oz0-plugin.yaml
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

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)
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.
Copyright © 2026