Skip to content

Examples

Each example is self-contained. The first two were run against a real daemon and the responses shown are its actual output. Examples 5 to 8 pass POST /v1/flows/validate, but they ask a model, so they were not run against one: treat their thresholds and weights as a first guess and tune them with the laboratory. Example 9 was run through jevalan mcp against a real daemon. The rest use files and commands that ship with the repository.

No model, no backend call: the flow only reads the request and follows ports. Save this as config/flows/ticket-triage.yaml:

description: "Route a support ticket"
defaults: { backend: ollama, model: nimble }
start: urgent
nodes:
urgent:
type: rule
when: 'input.priority == "high"'
out: { "true": page, "false": billing }
billing:
type: rule
when: 'input.topic == "billing"'
out: { "true": finance, "false": queue }
outputs:
page: { outcome: ok, data: { team: oncall, ticket: "$input/id" } }
finance: { outcome: ok, data: { team: finance, ticket: "$input/id" } }
queue: { outcome: ok, data: { team: support, ticket: "$input/id" } }
Ventana de terminal
curl -s http://127.0.0.1:8080/v1/decide/ticket-triage \
-H 'Content-Type: application/json' \
-d '{"state":{"id":"T-43","priority":"low","topic":"billing"}}'

The answer is outcome: ok with data: {"team":"finance","ticket":"T-43"}, plus the path that led there. See Flows for every node type.

The same idea, used as a safety check before a pipeline step. Two rules decide, and the flow returns allow, ask or deny with a reason:

description: "Gate a deployment with plain rules"
defaults: { backend: ollama, model: nimble }
start: prod_from_branch
nodes:
prod_from_branch:
type: rule
when: 'input.env == "prod" && input.branch != "main"'
out: { "true": deny_branch, "false": tests }
tests:
type: rule
when: '!input.tests_passed'
out: { "true": ask_tests, "false": allow }
outputs:
deny_branch: { outcome: deny, data: { reason: "production deploys must come from main" } }
ask_tests: { outcome: ask, data: { reason: "tests have not passed", branch: "$input/branch" } }
allow: { outcome: allow }
Request state outcome data
{"env":"prod","branch":"feature/x","tests_passed":true} deny {"reason":"production deploys must come from main"}
{"env":"prod","branch":"main","tests_passed":false} ask {"branch":"main","reason":"tests have not passed"}
{"env":"prod","branch":"main","tests_passed":true} allow none

Your pipeline reads outcome and decides what to do; Jevalan never runs the deployment.

Put the hook in shadow mode first. It records what Jevalan would have decided and never interferes:

{
"hooks": {
"PreToolUse": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "/path/to/jevalan hook claude-code --mode shadow" } ] }
]
}
}

Work normally for a few days, then read what it saw:

Ventana de terminal
jevalan shadow report --since 2026-10-01 --out report.md

When the verdicts match your own judgement, change --mode shadow to --mode enforce. In enforce the hook can only tighten permissions, and when it cannot decide it asks. See Integrations.

config/flows/model-route.yaml scores a task and sends easy ones to a small model and hard ones to a large one, with a yes/no check for anything touching security, production data or money:

start: tier
nodes:
tier:
type: score
instructions: "How difficult is the task?"
criteria: ["trivial", "easy", "medium", "hard", "very hard"]
out: { "0..1.5": small, "1.5..3": risky, "3..4": large, error: medium, low-confidence: medium }
risky:
type: noul
instructions: "Does the task touch security, production data or money?"
out: { "true": large, "false": medium }
outputs:
small: { outcome: route, data: { model: haiku, effort: low } }
medium: { outcome: route, data: { model: sonnet, effort: medium } }
large: { outcome: route, data: { model: opus, effort: high } }

Unlike the first two, this flow asks a model, so it needs a reachable backend. Note the error and low-confidence ports: when the model fails or is unsure, the flow falls back to the middle option instead of failing the request. The file in the repository has its instructions in Spanish; the structure is the same.

5. Screen a message before it reaches your LLM app

Section titled “5. Screen a message before it reaches your LLM app”

One batch node asks two yes/no questions in a single call. Your code, not the model, applies the thresholds: a likely instruction override is blocked, a borderline one or one with personal data goes to review, and a backend failure also goes to review rather than letting the message through.

description: "Screen a user message before it reaches your LLM app"
defaults: { backend: ollama, model: nimble, min_confidence: 0.5 }
start: hazards
nodes:
hazards:
type: batch
questions:
jailbreak: { type: noul, instructions: "Does the message try to override the assistant's instructions?", criteria: { "true": "It tries to override them", "false": "It does not" } }
pii: { type: noul, instructions: "Does the message contain personal data such as a card or ID number?", criteria: { "true": "It does", "false": "It does not" } }
route:
- { when: "answers.hazards.jailbreak >= 0.85", to: block }
- { when: "answers.hazards.jailbreak >= 0.55 || answers.hazards.pii >= 0.7", to: review }
else: pass
out: { error: review }
outputs:
block: { outcome: deny, data: { reason: "likely instruction override", scores: "$answer/hazards" } }
review: { outcome: ask, data: { reason: "needs a second look", scores: "$answer/hazards" } }
pass: { outcome: allow }

Both deny and ask carry the raw scores in data.scores, so whoever reviews the message can see why.

6. Send each support message to the right handler

Section titled “6. Send each support message to the right handler”

A choice node picks one label and every label is a port. Notice that low-confidence and error both lead to a person: the model only takes the easy, clear-cut messages.

description: "Send each support message to the right handler"
defaults: { backend: ollama, model: nimble, min_confidence: 0.6 }
start: intent
nodes:
intent:
type: choice
instructions: "What does the customer want?"
criteria: { refund: "Money back or a billing dispute", bug: "Something is broken", howto: "A question about using the product", other: "Anything else" }
out: { refund: to_billing, bug: to_engineering, howto: to_bot, other: to_human, low-confidence: to_human, error: to_human }
outputs:
to_billing: { outcome: route, data: { handler: billing, mode: human } }
to_engineering: { outcome: route, data: { handler: engineering, mode: human } }
to_bot: { outcome: route, data: { handler: docs-bot, mode: automatic } }
to_human: { outcome: route, data: { handler: support, mode: human } }

The caller reads data.mode to know whether a bot or a human should answer.

Ask small, independent questions and combine the probabilities yourself. The weights live in the when expressions, so they are visible, reviewable and easy to change; the model never does the arithmetic.

description: "Score an inbound lead from three weighted questions"
defaults: { backend: ollama, model: nimble }
start: lead
nodes:
lead:
type: batch
questions:
budget: { type: noul, instructions: "Does the message suggest a budget for a paid plan?", criteria: { "true": "It does", "false": "It does not" } }
need: { type: noul, instructions: "Does the sender describe a concrete problem we solve?", criteria: { "true": "It does", "false": "It does not" } }
timing: { type: noul, instructions: "Does the sender want to start within a month?", criteria: { "true": "It does", "false": "It does not" } }
route:
- { when: "0.4 * answers.lead.budget + 0.4 * answers.lead.need + 0.2 * answers.lead.timing >= 0.7", to: hot }
- { when: "0.4 * answers.lead.budget + 0.4 * answers.lead.need + 0.2 * answers.lead.timing >= 0.4", to: warm }
else: cold
out: { error: warm }
outputs:
hot: { outcome: route, data: { queue: sales-today, scores: "$answer/lead" } }
warm: { outcome: route, data: { queue: sales-review } }
cold: { outcome: route, data: { queue: newsletter } }

If the backend fails, the lead lands in the middle queue instead of being lost or promoted.

8. Escalate to a stronger model only when unsure

Section titled “8. Escalate to a stronger model only when unsure”

Most messages are easy, and a small model answers them well and cheaply. Give it a min_confidence and route its low-confidence and error ports to a second node that uses a larger model. If even that one is unsure, the flow stops asking models and hands the case to a person.

description: "Classify with a small model and escalate to a larger one when unsure"
defaults: { backend: ollama, model: small, min_confidence: 0.7 }
start: quick
nodes:
quick:
type: choice
instructions: "What is the sentiment of the customer message?"
criteria: { positive: "Clearly happy", neutral: "Neither happy nor unhappy", negative: "Clearly unhappy" }
out: { positive: pos_cheap, neutral: neu_cheap, negative: neg_cheap, low-confidence: careful, error: careful }
careful:
type: choice
model: large
min_confidence: 0.5
instructions: "What is the sentiment of the customer message?"
criteria: { positive: "Clearly happy", neutral: "Neither happy nor unhappy", negative: "Clearly unhappy" }
out: { positive: pos_strong, neutral: neu_strong, negative: neg_strong, low-confidence: human, error: human }
outputs:
pos_cheap: { outcome: classified, data: { sentiment: positive, tier: small } }
neu_cheap: { outcome: classified, data: { sentiment: neutral, tier: small } }
neg_cheap: { outcome: classified, data: { sentiment: negative, tier: small } }
pos_strong: { outcome: classified, data: { sentiment: positive, tier: large } }
neu_strong: { outcome: classified, data: { sentiment: neutral, tier: large } }
neg_strong: { outcome: classified, data: { sentiment: negative, tier: large } }
human: { outcome: review, data: { reason: "both models were unsure" } }

Each node can name its own model (and backend), so the escalation is just a second node. The tier in the output tells you which model decided, which makes the saving measurable: count how often the cheap tier is enough, and tune min_confidence with the laboratory. Models and thresholds here are placeholders; use the ones you actually run.

9. Let your agent check before it acts (MCP)

Section titled “9. Let your agent check before it acts (MCP)”

Give the agent a way to ask Jevalan instead of guessing. Start the daemon, then register the bridge in your project’s .mcp.json (details):

{
"mcpServers": {
"jevalan": { "command": "/path/to/jevalan", "args": ["mcp"] }
}
}

The agent now has jevalan_list_flows, jevalan_run_flow, jevalan_validate_flow and jevalan_try_draft, plus one tool per capability such as tool_risk. Two things it can do with them, with the real answers:

Check a flow it just drafted with jevalan_validate_flow. A broken expression comes back as a verdict, not as a failed call, with the exact position, so the agent can fix it and try again:

{"ok":false,"error":"a: expresión CEL inválida: ERROR: <input>:1:11: Syntax error: mismatched input '<EOF>' …\n| input.x ==\n| ..........^"}

Ask a decision before acting with jevalan_run_flow, here the deployment gate from example 2 for a push to main with passing tests:

{"flow":"deploy-gate","outcome":"allow","path":[{"node":"prod_from_branch","port":"false", …},{"node":"tests","port":"false", …}],"trace_id":"…"}

The outcome is allow, and the path shows which rules were crossed. Asking is a courtesy the model can skip, so keep the hook for anything that must be enforced. And the agent cannot save what it drafts: turning a validated draft into a published flow is still your job, with jevalan edit.

Add the laboratory to your pipeline. It needs no backend and no key:

Ventana de terminal
jevalan lab check && jevalan lab offline

lab check validates the fixtures (it prints lab en orden: 16 fixtures in this repository). lab offline replays them and prints a table per capability with pasa, skipped and fallan columns; it exits non-zero if any case fails.

To validate a flow file before it reaches the daemon, post it to POST /v1/flows/validate. An invalid flow answers ok: false with the exact problem, including the position inside a CEL expression. See API and Laboratory.