Siftline
Guide

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:

recipe.json
{
  "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:

rules.json
[
  {
    "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:

KindComparatorsvalue
Choiceis, isOneOfone label, or a list of labels
Noulistrue or false
Scoreis, atLeast, atMosta 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
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.

Next: build and send the Action a Decision selected.