Guides

Four complete recipes for common decisions: routing support tickets, choosing an agent's next tool, moderating comments and approving invoices. Each one sends a record and a few typed questions in one request, then turns the answers into an action with a few lines of your own code.

The pattern is always the same. Clara answers narrow questions about the record, and your code owns the policy. Thresholds live in your codebase, where you can test, review and change them without touching a prompt.

RecipeWhat it decidesQuestionsMode
Support ticket triageWhich team owns a ticket, its priority, and whether to page someone now4fast
Agent tool routingAn agent's next tool, whether to ask the user, and whether a person approves first3consistent
Content moderationPublish, hide or send to review, and which policy was broken2fast
Invoice approvalPay or hold, with a hard stop on changed bank details3consistent

Before you start

You need an API key in the SAANORA_API_KEY environment variable and some credit. The Quickstart covers both in a few minutes.

Every recipe imports the same small helper. Save it as saanora.py or saanora.ts in the same folder as the recipe files.

# saanora.py: the helper every recipe on this page imports (pip install requests)
import os
import time

import requests

API_URL = "https://api.saanora.com"


class SaanoraError(Exception):
    def __init__(self, status, error, request_id):
        super().__init__(f"{status} {error['code']}: {error['message']} (request {request_id})")
        self.status = status
        self.code = error["code"]
        self.param = error.get("param")
        self.request_id = request_id


def decide(state, questions, mode="fast", model="clara-1", retries=5):
    """Ask questions about one record. Returns the decision object, or raises SaanoraError."""
    for attempt in range(retries + 1):
        try:
            r = requests.post(
                f"{API_URL}/v1/decisions",
                headers={"Authorization": f"Bearer {os.environ['SAANORA_API_KEY']}"},
                json={"model": model, "mode": mode, "state": state, "questions": questions},
                timeout=120,  # generous: after an idle period the first request waits for capacity to start
            )
        except requests.Timeout:  # Clara can take about a minute to start: try again
            if attempt == retries:
                raise
            continue
        if r.ok:
            return r.json()
        body = r.json()
        # A rate limit clears within its 60-second window; 502 and 503 are temporary. Failed requests are free.
        retryable = body["error"]["code"] == "rate_limit_exceeded" or r.status_code in (502, 503)
        if not retryable or attempt == retries:
            raise SaanoraError(r.status_code, body["error"], body.get("request_id"))
        time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))

The helper does four things:

Reading the answers

Answers come back in answers, keyed by the question ids you chose. Each answer is always one of your options, probabilities always sum to 1, and Clara never writes free text, so there is nothing to parse. These are the fields the recipes branch on:

TypeFieldWhat it holds
noul["noul"]The probability that the answer is yes, from 0 to 1.
noul["decision"]Only when the question sets threshold: true when noul >= threshold. The answer also echoes threshold.
choice["choice"]The most likely option id.
choice["probabilities"][option]The probability of each option, keyed by option id.
choice["confidence"](max p - 1/n) / (1 - 1/n) for n options: 1 when one option has all the probability, 0 when every option is equally likely.
score["probabilities"]["2"]The probability of each level, keyed by its index as a string. "0" is the first (lowest) level you listed.
score["score"]The expected level index: the sum of each index times its probability. 2.31 sits between levels 2 and 3, nearer 2.
score["confidence"]The probability of the most likely level.
score["legend"]Each level index mapped to the level you sent, such as {"0": "none", "1": "low"}.
Put yes/no cutoffs in the question. Add "threshold", a number from 0 to 1, to a noul question and its answer gains "decision", a boolean. The cutoff then sits next to the question it belongs to, your code branches on true or false, and the probability is still there to log.

Support ticket triage

Use this when every inbound ticket needs an owner and a priority, and a few need someone woken up. One request answers four questions about the ticket: whether a scheduled export is failing, which team owns it, how severe the impact is, and whether to page on-call now.

The ticket goes in state as a JSON object. The request uses the default fast mode, which reads the ticket once for all four questions.

# triage.py: python triage.py
from saanora import decide

QUESTIONS = {
  "failing": { "type": "noul", "instructions": "Is a scheduled export currently failing?", "threshold": 0.5 },
  "team": {
    "type": "choice",
    "instructions": "Which team should own this ticket?",
    "criteria": {
      "engineering": "Broken product functionality or errors",
      "billing": "Invoices, charges, refunds and plan changes",
      "success": "How-to questions and account guidance"
    }
  },
  "severity": {
    "type": "score",
    "instructions": "How severe is the business impact for the customer?",
    "criteria": ["none", "low", "high", "critical"]
  },
  "page_oncall": {
    "type": "noul",
    "instructions": "Should someone be paged now rather than wait for business hours?",
    "threshold": 0.85
  }
}


def triage(ticket):
    a = decide(ticket, QUESTIONS)["answers"]

    team = a["team"]["choice"]                  # the most likely option id
    if a["team"]["confidence"] < 0.5:           # split between teams: a person routes it
        team = "triage"

    sev = a["severity"]["probabilities"]        # keyed by level index as a string, "0" = none
    urgent = sev["2"] + sev["3"]                # P(high) + P(critical)
    tags = ["export-failure"] if a["failing"]["decision"] else []

    if a["page_oncall"]["decision"]:            # True when noul >= 0.85, the question's threshold
        return {"action": "page", "team": team, "tags": tags}
    return {"action": "queue", "team": team, "priority": "p1" if urgent > 0.6 else "p3", "tags": tags}


if __name__ == "__main__":
    print(triage({
      "from": "maya@northwind.io",
      "plan": "enterprise",
      "open_tickets": 2,
      "subject": "Export failed again, need it by 9am",
      "body": "Our nightly export has failed three nights in a row. The finance team closes the quarter tomorrow and needs the file by 9am."
    }))
answers (example)
{
  "failing": { "type": "noul", "noul": 0.97, "threshold": 0.5, "decision": true },
  "team": {
    "type": "choice", "choice": "engineering", "confidence": 0.91,
    "probabilities": { "engineering": 0.94, "billing": 0.02, "success": 0.04 }
  },
  "severity": {
    "type": "score", "score": 2.31, "confidence": 0.52,
    "probabilities": { "0": 0.01, "1": 0.07, "2": 0.52, "3": 0.40 },
    "legend": { "0": "none", "1": "low", "2": "high", "3": "critical" }
  },
  "page_oncall": { "type": "noul", "noul": 0.82, "threshold": 0.85, "decision": false }
}

With these answers the code queues the ticket for engineering as p1 and tags it export-failure. High or critical severity has a combined probability of 0.92 (0.52 + 0.40). The probability that someone should be paged, 0.82, is below the question's 0.85 threshold, so decision is false and nobody is woken up.

Score answers are keyed by level index, so "2" and "3" are high and critical. Summing the levels that should trigger an action gives a steadier signal than reading one level, or the expected score, on its own.

Adapt the triage recipe

Agent tool routing

Use this when an agent has a fixed set of tools and you want each step chosen, and stopped when it should be, by code you can audit. Send the user's request and the tool calls so far. Clara picks the next tool and says whether a detail is missing and whether a person should approve the step. Clara scores 93.55% on AgentDecisionBench tool routing.

The trace goes in state as a string, one step per line.

# agent_router.py: python agent_router.py
from saanora import decide

QUESTIONS = {
  "next_tool": {
    "type": "choice",
    "instructions": "Which tool should the agent call next?",
    "criteria": {
      "get_order": "Look up one order's details and status",
      "cancel_order": "Cancel an order that has not shipped",
      "issue_refund": "Refund a payment to the original payment method",
      "ask_user": "Ask the user for a missing detail or a confirmation",
      "finish": "The user's request is complete"
    }
  },
  "missing_detail": {
    "type": "noul",
    "instructions": "Is a detail needed to complete the user's request missing from the conversation?",
    "threshold": 0.5
  },
  "needs_human": {
    "type": "noul",
    "instructions": "Would the agent's next step move money or be hard to undo, so a person should approve it first?",
    "threshold": 0.5
  }
}


def next_step(trace):
    a = decide(trace, QUESTIONS, mode="consistent")["answers"]

    if a["missing_detail"]["decision"]:          # check first: on its own, Clara tends to pick a lookup tool
        return {"action": "ask_user"}

    tool = a["next_tool"]["choice"]              # the most likely tool
    p = a["next_tool"]["probabilities"][tool]
    if tool in ("ask_user", "finish"):
        return {"action": tool}
    if a["needs_human"]["decision"] or p < 0.6:  # risky or unsure: a person approves first
        return {"action": "request_approval", "tool": tool, "p": p}
    return {"action": "call", "tool": tool}      # your agent fills in the arguments


if __name__ == "__main__":
    print(next_step("User: cancel order A-1043 and refund it to my card\n[0] find_customer({email}) -> C-2207, gold\n[1] list_orders(C-2207) -> A-1043 shipped, A-1051 pending\n[2] get_order(A-1043) -> shipped 2026-10-18, paid by card, 184.00 USD"))
answers (example)
{
  "next_tool": {
    "type": "choice", "choice": "issue_refund", "confidence": 0.55,
    "probabilities": { "get_order": 0.04, "cancel_order": 0.09, "issue_refund": 0.64, "ask_user": 0.17, "finish": 0.06 }
  },
  "missing_detail": { "type": "noul", "noul": 0.12, "threshold": 0.5, "decision": false },
  "needs_human": { "type": "noul", "noul": 0.93, "threshold": 0.5, "decision": true }
}

The order has already shipped, so cancelling it is unlikely (0.09) and a refund is the most likely next step (0.64). A refund moves money, so needs_human is 0.93, above its 0.5 threshold, and the code returns request_approval for issue_refund instead of calling it.

Ask about missing details explicitly. When the right move is to ask the user, Clara chooses that on its own only 59% of the time and tends to pick a lookup tool instead. A separate yes/no question such as missing_detail, checked before you read the tool choice, is the fix.

This recipe uses consistent mode, which keeps the tool choice steadier as your tool menu changes. Consistent mode reads the record again with each question and bills the tokens it reads, roughly the trace times the number of questions, and counts them toward your tokens-per-minute limit. For a 2,000-token trace and these three questions that is about 6,000 billed tokens, or about $0.003 per step at $0.50 per 1M input tokens. See modes.

Adapt the routing recipe

Content moderation

Use this to decide whether a comment breaks a policy, which one, and how harmful it is. The clear cases are handled automatically, and the uncertain middle goes to a person with the likely reason attached.

The comment and the thread it was posted in go in state as a JSON object.

# moderate.py: python moderate.py
from saanora import decide

QUESTIONS = {
  "policy": {
    "type": "choice",
    "instructions": "Which policy, if any, does this comment break?",
    "criteria": {
      "none": "Breaks no policy",
      "spam": "Advertising, links or contact details to sell something",
      "scam": "An attempt to defraud or steal from readers",
      "harassment": "Insults or attacks on a person or group"
    }
  },
  "severity": {
    "type": "score",
    "instructions": "How harmful is this comment to readers?",
    "criteria": ["low", "medium", "high"]
  }
}


def moderate(comment):
    a = decide(comment, QUESTIONS)["answers"]

    p = a["policy"]["probabilities"]
    violation = 1 - p["none"]                                # P(breaks any policy)
    reason = max((k for k in p if k != "none"), key=p.get)   # the most likely policy broken

    if violation > 0.8:
        return {"action": "hide", "reason": reason}
    if violation > 0.5:                                      # 0 = low ... 2 = high orders the review queue
        return {"action": "review", "reason": reason, "priority": a["severity"]["score"]}
    return {"action": "publish"}


if __name__ == "__main__":
    print(moderate({
      "thread": "Best budget headphones 2026",
      "comment": "Honestly these are great for the price. If anyone wants them cheaper, DM me on telegram @deals_fast, I can get 70% off direct from the factory."
    }))
answers (example)
{
  "policy": {
    "type": "choice", "choice": "spam", "confidence": 0.76,
    "probabilities": { "none": 0.03, "spam": 0.82, "scam": 0.14, "harassment": 0.01 }
  },
  "severity": {
    "type": "score", "score": 0.95, "confidence": 0.61,
    "probabilities": { "0": 0.22, "1": 0.61, "2": 0.17 },
    "legend": { "0": "low", "1": "medium", "2": "high" }
  }
}

The comment advertises a cheaper deal and a contact handle, so spam is the most likely option (0.82) and the probability that it breaks any policy is 1 - 0.03 = 0.97. That is above 0.8, so the code hides it with spam as the reason.

The code decides whether to act on 1 - p["none"], not on choice. When the probability is split between two policies, say spam 0.45 and scam 0.45, neither option clears a threshold on its own, even though the comment almost certainly breaks one of them.

Adapt the moderation recipe

Invoice approval

Use this to three-way match an invoice against its purchase order and the goods received, and to catch the change that matters most in accounts payable: a new bank account. Clara answers the reading questions; your code applies the payment rules.

The invoice, the purchase order, the delivery and the vendor record go in state under their own keys. The bank_changed question also defines what yes and no mean through its optional criteria.

# invoice.py: python invoice.py
from saanora import decide

QUESTIONS = {
  "matches_po": {
    "type": "noul",
    "instructions": "Do the invoice's prices, quantities and total match the purchase order and the goods received?",
    "threshold": 0.9
  },
  "bank_changed": {
    "type": "noul",
    "instructions": "Is the account the invoice asks to be paid into different from the account in the vendor record?",
    "criteria": {
      "true": "The account, bank or payee differs in any way",
      "false": "The invoice names the same account as the vendor record"
    },
    "threshold": 0.1
  },
  "next": {
    "type": "choice",
    "instructions": "What should accounts payable do with this invoice?",
    "criteria": {
      "pay": "Pay on the due date",
      "hold": "Hold and ask the vendor or the buyer for clarification",
      "reject": "Reject and return to the vendor"
    }
  }
}


def review_invoice(record):
    result = decide(record, QUESTIONS, mode="consistent")
    a = result["answers"]
    audit = {"decision_id": result["id"], "answers": a}   # keep with the invoice: Saanora does not store the record

    if a["bank_changed"]["decision"]:                     # hard rule with a low bar (0.1): never pay new bank details unreviewed
        return {"action": "hold", "alert": "finance", "reason": "bank details changed", **audit}
    if a["matches_po"]["decision"] and a["next"]["probabilities"]["pay"] > 0.8:
        return {"action": "schedule_payment", **audit}
    return {"action": "hold", "reason": a["next"]["choice"], **audit}   # a likely reject also waits for a person


if __name__ == "__main__":
    print(review_invoice({
      "invoice": {
        "number": "INV-20931",
        "vendor": "Acme Supplies",
        "lines": "100 x industrial filter at 124.00 USD",
        "total": "12,400.00 USD",
        "terms": "net 30",
        "pay_to": "account ending 3000"
      },
      "purchase_order": {
        "number": "PO-7781",
        "status": "approved",
        "lines": "100 x industrial filter at 120.00 USD",
        "total": "12,000.00 USD"
      },
      "goods_received": "96 of 100 units delivered",
      "vendor_record": { "name": "Acme Supplies", "pay_to": "account ending 4931" }
    }))
answers (example)
{
  "matches_po": { "type": "noul", "noul": 0.04, "threshold": 0.9, "decision": false },
  "bank_changed": { "type": "noul", "noul": 0.98, "threshold": 0.1, "decision": true },
  "next": {
    "type": "choice", "choice": "hold", "confidence": 0.82,
    "probabilities": { "pay": 0.02, "hold": 0.88, "reject": 0.10 }
  }
}

The invoice charges 124.00 USD per unit against an approved 120.00, only 96 of 100 units arrived, and the account differs from the vendor record. bank_changed is 0.98, far above its deliberately low 0.1 threshold, so the code holds the invoice and alerts finance before it looks at anything else.

The code never rejects an invoice on its own. A likely reject comes back as a hold with reason set to reject, for a person to act on.

Keep hard rules in code. Some rules should never be probabilistic. Ask Clara the factual question (did the bank details change?), give it a threshold below 0.2, such as 0.1 (anything from 0.2 to 0.8 acts like 0.5), and apply the rule yourself before anything else runs.

Adapt the invoice recipe

Choosing thresholds

The numbers in these recipes are starting points, not recommendations for your data. Set your own on a few hundred labeled records before Clara acts unattended:

  1. Label a few hundred real records with the answer you want for each question. Include the hard cases.
  2. Send them through the exact request you will run in production: the same questions, wording, options and mode.
  3. For each question, try a range of thresholds and count how often each one would act, how often it would be right, and what it would miss.
  4. Pick the threshold whose mistakes match their cost. A wrong page wakes someone up; a missed page leaves a customer waiting.
  5. Put the number in the question's threshold (yes/no questions) or in your code.

This script runs step 3 for the triage recipe's page_oncall question:

thresholds.py
# thresholds.py: how often would each threshold act, and how often would it be right?
import json

from saanora import decide
from triage import QUESTIONS

# labeled.jsonl: one {"state": {...}, "page_oncall": true or false} per line, labeled by your team
rows = [json.loads(line) for line in open("labeled.jsonl")]
p_yes = [decide(row["state"], QUESTIONS)["answers"]["page_oncall"]["noul"] for row in rows]

for t in (0.1, 0.5, 0.85, 0.9, 0.95, 0.99):
    acted = [row["page_oncall"] for row, p in zip(rows, p_yes) if p >= t]
    missed = sum(row["page_oncall"] for row, p in zip(rows, p_yes) if p < t)
    right = sum(acted) / len(acted) if acted else float("nan")
    print(f"threshold {t:.2f}: pages on {len(acted) / len(rows):.0%} of tickets, "
          f"right {right:.0%} of the time, misses {missed} that should page")

A check like this is cheap. 300 records of about 600 billed tokens each come to 180,000 tokens, about $0.09 at $0.50 per 1M input tokens. On Tier 1 (300 requests and 300K tokens per minute) the helper waits out rate limits on its own. Run it in a separate project with a monthly spend limit so a bug in the script cannot use up your credit.

Re-run the check whenever you change a question's wording, its options or the mode. More in Best practices.