Menu
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.
| Recipe | What it decides | Questions | Mode |
|---|---|---|---|
| Support ticket triage | Which team owns a ticket, its priority, and whether to page someone now | 4 | fast |
| Agent tool routing | An agent's next tool, whether to ask the user, and whether a person approves first | 3 | consistent |
| Content moderation | Publish, hide or send to review, and which policy was broken | 2 | fast |
| Invoice approval | Pay or hold, with a hard stop on changed bank details | 3 | consistent |
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:
- Sends
model,mode,stateandquestionstoPOST /v1/decisions, with your key as a bearer token. - Waits up to 120 seconds for a response, and tries again if none arrives. After an idle period the first request takes about 60–80 seconds while Clara starts. Consistent requests can get
503 model_warming_upwith aRetry-Afterfor about 50 seconds more, and the helper waits that long before trying again. - Retries
429 rate_limit_exceededand503 model_warming_upafter the number of seconds in theRetry-Afterheader, and other502and503errors with a short backoff. Failed requests are never charged, so retries cost nothing. - Raises every other error with its
codeandrequest_id.402 insufficient_credit,429 project_spend_limitand the400,413and422validation errors need a fix, not a retry. See Errors.
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:
| Type | Field | What 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"}. |
"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."
}))
{
"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
- Teams. Replace the
teamoptions with your own queues, from 2 to 255 of them. Describe what each queue handles in a short phrase. - Unclear routing. The code sends a ticket to a
triagequeue whenconfidenceis below 0.5. Confidence is scaled for the number of options, from 0 when every team is equally likely to 1 when one team has all the probability, so the cutoff stays comparable as you add teams. - Severity. Rename or add levels, lowest first, and change which indexes you sum. Score scales are the least reliable question type: when a yes/no question captures what you act on, such as “Is the customer blocked from working?”, prefer it.
- Paging. Tune the paging threshold in the question itself. The code reads
decision, so it does not change. - More tags. Add yes/no questions for anything else you tag, such as a refund request. In
fastmode the ticket is billed once and each extra question adds only its own tokens. A request can carry up to 32 questions on Tier 1 and 64 from Tier 2.
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"))
{
"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.
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
- Tools. Use your agent's tool names as option ids, up to 255, and describe in one line when each is the right next step. Keep
ask_userandfinishso the agent always has a way to stop. - Arguments. Clara picks the tool and never writes free text. Your agent, or your code, fills in the arguments once the tool is chosen.
- The trace. Put the user's request first and each tool call with its result on its own numbered line. Records over 16K tokens are shortened in the middle, so trim long traces yourself and keep the request and the latest steps.
- Approval rules. Rewrite the
needs_humaninstructions to describe your own rule, such as refunds over a set amount. Tools that must always be approved belong in code: checktoolagainst a list before you call it. - Unsure choices. The code also asks for approval when the chosen tool's probability is below 0.6. Raise it to stop the agent more often, lower it to let it run.
- Cost at volume. If you call Clara on every step and never change the question set,
fastmode costs less. Validate the tool choice in that mode before you switch.
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."
}))
{
"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
- Policies. Replace the options with your own policy categories, each with a one-line description. Always keep
none: without it every comment is forced into some violation and the probabilities stop meaning anything. - Overlapping policies. A choice question returns one set of probabilities that sums to 1, which fits “which policy, if any”. If one comment can break several policies and you need each flag, ask one yes/no question per policy instead.
- Context.
statecan be any JSON object, so add what your moderators look at, such as the parent comment or the author's past removals. Clara answers from the record alone. - Severity. Use the expected
scoreto order the review queue, not to remove comments automatically. Score scales are the least reliable question type. - Language. Clara reads English only. Send comments in other languages to review instead of acting on their answers.
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" }
}))
{
"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.
Adapt the invoice recipe
- Documents. Put each document under its own key in
stateand include the vendor record you want compared. Clara can only compare what is in the record. - Arithmetic and tolerances. Do exact calculations, such as price variance against your tolerance, in code and add the result to the record, for example
"price_variance": "3.3%, tolerance 2%". Use Clara for the questions that need reading. - More hard rules. Add a yes/no question with a threshold below 0.2, such as 0.1, for each rule that must hold before payment, such as a missing purchase order, and check them all before the payment rule.
- Audit trail. Saanora stores only request metadata, such as time, project, key, model, mode, status, tokens, cost and request id, never the record or the questions. Store the response
idand the answers with the invoice, as the code does, so you can show why it was paid or held. - Mode.
consistentmode reads the invoice again with each question, so its answers hold steadier when you reword a rule later. For payments that stability is worth the extra tokens.
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:
- Label a few hundred real records with the answer you want for each question. Include the hard cases.
- Send them through the exact request you will run in production: the same questions, wording, options and mode.
- 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.
- Pick the threshold whose mistakes match their cost. A wrong page wakes someone up; a missed page leaves a customer waiting.
- 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: 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.