Route with Rules
An ordered list of conditions that maps Answers to an Action.
The Recipe gains a third Question first. urgency is a Score: an ordered list of levels,
each described, and the answer is the index of the level the model picks. The version goes to
2, since the Questions changed:
{
"format": 1,
"name": "support-inbox",
"version": 2,
"model": "jev-1.13.0",
"reviewThreshold": 0.7,
"questions": {
"category": {
"type": "choice",
"instructions": "Which category best describes this message?",
"criteria": {
"complaint": "The sender is unhappy with the product or service",
"question": "The sender asks how something works",
"other": "Anything else, including spam and thanks"
}
},
"wants_human": {
"type": "noul",
"instructions": "Does the sender ask to speak to a person?"
},
"urgency": {
"type": "score",
"instructions": "How soon does this need a reply?",
"criteria": ["Can wait a week", "This week", "Today"]
}
}
}
Rules are a JSON array. Each Rule has an id, one condition on one Question, and the id of the
Action it selects, or null for none. An Action is a named, configured outbound effect, such as
linear-tickets. Its Action kind, webhook or Slack incoming webhook, decides how its request is
built. Two Actions can share a kind and send to different places, so a Rule names the Action by
its id, never the kind. You define the Actions themselves in code, when you
send them. The first Rule whose condition matches wins:
[
{
"id": "escalate",
"condition": {
"question": "wants_human",
"comparator": "is",
"value": true
},
"action": "escalations"
},
{
"id": "urgent-complaint",
"condition": {
"question": "urgency",
"comparator": "atLeast",
"value": 2
},
"action": "linear-tickets"
},
{
"id": "ticket",
"condition": {
"question": "category",
"comparator": "isOneOf",
"value": ["complaint", "question"]
},
"action": "linear-tickets"
},
{
"id": "ignore",
"condition": {
"question": "category",
"comparator": "is",
"value": "other"
},
"action": null
}
]
There are four comparators, six pairings with a kind, and no more:
| Kind | Comparators | value |
|---|---|---|
| Choice | is, isOneOf | one label, or a list of labels |
| Noul | is | true or false |
| Score | is, atLeast, atMost | a level index |
No negation, no compound conditions, and confidence is not a comparator. A Rule that needs
two conditions is two Rules, and order does the rest. Reading the file above: anyone who asks
for a person goes to escalations, a Slack incoming webhook; otherwise an urgent Record and
any complaint or question go to linear-tickets, a webhook; other goes nowhere.
Pass the file to label and every Decision is routed:
npx siftline label recipe.json records.jsonl --rules rules.json > decisions.jsonl{"format":1,"id":"0192f3c2-7b1e-7c4a-9f0e-000000000001","recordId":"msg-1","recipe":{"name":"support-inbox","version":2},"model":"jev-1.13.0","judgedAt":"2026-09-22T09:00:00.000Z","trimmed":false,"answers":{"category":"complaint","wants_human":true,"urgency":2},"questions":{"category":{"confidence":1,"probabilities":{"complaint":1,"question":0,"other":0}},"wants_human":{"probability":0.99,"confidence":0.98},"urgency":{"score":1.9,"confidence":0.9,"probabilities":{"0":0.01,"1":0.09,"2":0.9}}},"confidence":0.9,"review":false,"rule":"escalate","action":"escalations","usage":{"inputTokens":400,"outputTokens":60}}
{"format":1,"id":"0192f3c2-7b1e-7c4a-9f0e-000000000002","recordId":"msg-2","recipe":{"name":"support-inbox","version":2},"model":"jev-1.13.0","judgedAt":"2026-09-22T09:00:00.000Z","trimmed":false,"answers":{"category":"question","wants_human":false,"urgency":0},"questions":{"category":{"confidence":1,"probabilities":{"complaint":0,"question":1,"other":0}},"wants_human":{"probability":0.02,"confidence":0.96},"urgency":{"score":0.2,"confidence":0.85,"probabilities":{"0":0.85,"1":0.1,"2":0.05}}},"confidence":0.85,"review":false,"rule":"ticket","action":"linear-tickets","usage":{"inputTokens":400,"outputTokens":60}}
{"format":1,"id":"0192f3c2-7b1e-7c4a-9f0e-000000000003","recordId":"msg-3","recipe":{"name":"support-inbox","version":2},"model":"jev-1.13.0","judgedAt":"2026-09-22T09:00:00.000Z","trimmed":false,"answers":{"category":"question","wants_human":false,"urgency":1},"questions":{"category":{"confidence":0.58,"probabilities":{"complaint":0.27,"question":0.72,"other":0.01}},"wants_human":{"probability":0.25,"confidence":0.5},"urgency":{"score":1.1,"confidence":0.8,"probabilities":{"0":0.1,"1":0.8,"2":0.1}}},"confidence":0.5,"review":true,"rule":null,"action":null,"usage":{"inputTokens":400,"outputTokens":60}}
rule names the Rule that matched and action the id of the Action it selected. msg-3 is Unsure
again, and both stay null for it: the review gate runs before the first Rule is read.
A Rule that no longer fits the Recipe, say one that names a label you removed, is refused when
the file is loaded, and the run exits 2 before any call is made. The CLI never sees your
Actions, so it does not check Action ids; from code, validateRules can.