# Route with Rules (/docs/guide/route-with-rules)



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:

```json title="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](/docs/guide/send-an-action). The first Rule whose condition matches wins:

```json title="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:

| 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:

```bash
npx siftline label recipe.json records.jsonl --rules rules.json > decisions.jsonl
```

```json title="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](/docs/guide/send-an-action).
