1. Expanso + Jev
  2. Example 05 of 10

An agent guardrail: judgment on every tool call, rules on every outcome.

Every tool call is judged for intent match and risk before it runs — exfiltration and privilege escalation block with an alert, the gray zone holds for approval.

Where the record goes

Every stage on the left is Expanso, and it is deterministic. The record crosses to Jev once, for the one question a rule cannot answer, and comes straight back. Line numbers link to the YAML below.

SourcePOST /tool-calls
Expanso nodeone pipeline, 5 steps, deterministic
  1. Receive
  2. Shape
  3. Ask Jev
  4. Gate
  5. Route
Jev judgment
  • intent_match
  • risk
  • block
  • hold
  • allow
One record through this pipeline. Which decision each pass lands in cycles in order here; in the pipeline the gate picks it from Jev's answers.
  1. An HTTP server input accepts tool calls on POST /tool-calls.

  2. Parses the POST body if it arrived as a string, then stamps received_at. It applies the same fixed rules every time, with no model involved; the timestamp is the one value that differs.

  3. Expanso packs the whole record and the typed questions into one request. Jev answers. If the call fails, a catch substitutes empty answers marked jev-unavailable, and the pipeline keeps going.

    • intent_matchnoul
    • riskchoice
  4. Fixed thresholds over Jev’s answers choose block, hold, or allow.

  5. A switch output writes to one file per decision.

What Jev is asked

Jev, judgment

The pipeline sends the record with 2 typed questions. Jev answers each one with a value the pipeline can compare against a number.

  • intent_matchnoul

    Does this tool call match the user’s stated intent?

  • riskchoice

    What is the risk class of this tool call?

    safe · irreversible · data_exfiltration · privilege_escalation · other

What Expanso does with the answers

Expanso, deterministic

Fixed thresholds, checked in order. The first rule that matches sets the route. These are the expressions in the pipeline, not a summary of them.

  1. $intent < 0.3 || ($risk != "safe" && $risk_conf >= 0.7)block · Off-intent, or confidently risky.
  2. $risk != "safe" || $intent < 0.7hold · The gray zone: a person approves it.
  3. elseallow · Safe and on-intent.

If Jev is unreachable: block

With no answers, intent match defaults to 0, which is under 0.3, so the call is blocked. The example fails closed.

The pipeline

This is the example's own pipeline file, unmodified. Violet marks the lines Expanso runs on its own. Orange marks the handoff, and the darker orange band is the HTTP call to Jev itself.

05-agent-guardrail.yaml
Expanso, deterministicJev, judgment
name: jev-agent-guardrail
type: pipeline
description: Agent guardrail with Jev — a 0.4s safety check on every AI agent tool call. Calls matching user intent pass through; risky or off-intent calls are blocked or held for approval.
namespace: production
labels:
  category: data-security
  pattern: ai-decision
  model: jev

config:
  input:
    http_server:
      address: "0.0.0.0:8080"
      path: /tool-calls
      allowed_verbs: ["POST"]

  pipeline:
    processors:
      - mapping: |
          # http_server already parses JSON bodies; only parse raw strings
          root = if this.type() == "string" { this.parse_json() } else { this }

      - mapping: |
          root = this
          root.received_at = now()

      # ── Ask Jev: does this tool call match intent, and how risky is it? ──
      - mutation: |
          meta jev_start = timestamp_unix_milli()

      - branch:
          request_map: |
            root = {
              "state": this.string(),
              "model": "jev-latest",
              "questions": {
                "intent_match": {
                  "type": "noul",
                  "instructions": "Does this tool call match the user's stated intent? Answer no if it goes beyond, contradicts, or is unrelated to what the user asked for."
                },
                "risk": {
                  "type": "choice",
                  "instructions": "What is the risk class of this tool call?",
                  "criteria": {
                    "safe": "Read-only or trivially reversible, no sensitive data",
                    "irreversible": "Deletes, sends, publishes, or spends — cannot be undone",
                    "data_exfiltration": "Moves sensitive data outside the trust boundary",
                    "privilege_escalation": "Grants access, changes permissions, or elevates rights",
                    "other": "Risky in a way not covered above"
                  }
                }
              }
            }
          processors:
            - http:
                url: "${JEV_API_URL:https://api.typesafe.ai/v1/systemone}"
                verb: POST
                headers:
                  Content-Type: application/json
                  Authorization: "Bearer ${TYPESAFE_API_KEY}"
                timeout: 2s
                retries: 1
            - catch:
              # Fail closed: if Jev is unreachable, hold for approval
              - mapping: |
                  root = {"answers": {}, "model": "jev-unavailable"}
          result_map: |
            root.jev = {
              "answers": this.answers,
              "model": this.model.or("jev-latest"),
              "ms": timestamp_unix_milli() - metadata("jev_start")
            }

      # ── Confidence-gated cascade. Fail closed on uncertainty. ──
      - mapping: |
          root = this
          let intent = this.jev.answers.intent_match.noul.or(0)
          let risk = this.jev.answers.risk.choice.or("other")
          let risk_conf = this.jev.answers.risk.confidence.or(0)

          root.jev_decision = if $intent < 0.3 || ($risk != "safe" && $risk_conf >= 0.7) {
            "block"
          } else if $risk != "safe" || $intent < 0.7 {
            "hold"
          } else {
            "allow"
          }

  output:
    broker:
      pattern: fan_out
      outputs:
        - stdout:
            codec: lines
        - switch:
            cases:
              # Production: swap for http_client -> agent control plane (deny) + alerting
              - check: this.jev_decision == "block"
                output:
                  file:
                    path: ./data/jev-agent-guardrail/blocked-${! now().ts_format("2006-01-02") }.jsonl
                    codec: lines
              # Production: swap for http_client -> human approval queue
              - check: this.jev_decision == "hold"
                output:
                  file:
                    path: ./data/jev-agent-guardrail/held-${! now().ts_format("2006-01-02") }.jsonl
                    codec: lines
              - check: "true"
                output:
                  file:
                    path: ./data/jev-agent-guardrail/allowed-${! now().ts_format("2006-01-02") }.jsonl
                    codec: lines

113 lines. Copy and Download both give you the file byte for byte.

What you need

  • Expanso Edge installed, to validate and run the pipeline.
  • A Jev endpoint. The pipeline posts to JEV_API_URL, and falls back to https://api.typesafe.ai/v1/systemone when that variable is unset.
  • A key for that endpoint in TYPESAFE_API_KEY. The pipeline sends it as a bearer token and has no default for it.

What it proves

  • Input. POST /tool-calls on port 8080.
  • Output. Local files under ./data/jev-agent-guardrail/. Comments in the YAML mark where an agent control plane and an approval queue replace the files in production.
  • Scope. This example ships as a pipeline file and sample records. Its README marks the live runtime (event generator and dashboard) as coming next, so what is published here is the pipeline itself.
  • Revision. The file shown is the example as of commit 517c38f of its repository, which is still being developed.

Sample records

The first two of the records that ship with this example. Download all of them.

{"agent_id":"agent-01","user_intent":"Summarize the Q3 sales report for me","tool":"read_file","args":{"path":"/data/q3-sales-report.pdf"}}
{"agent_id":"agent-01","user_intent":"Summarize the Q3 sales report for me","tool":"send_email","args":{"to":"[email protected]","subject":"Q3 numbers","body":"..."}}

Questions about this example.

receive, shape, gate, route. Each of those stages applies the same fixed rules every time, with no model involved. Expanso also sets the thresholds that turn Jev's answers into a route.

Run the deterministic half on your own nodes.

Expanso Edge runs these pipelines where the data is created. The first five nodes are free.