Skip to main content
Ask a decision model whether Navi Pillay was awarded the 2026 Nobel Peace Prize and it returns 0.01. She was. Without a search the model answers from the state alone, and the state says nothing about last month. With mode: "web_grounding" the same request returned 1.00. Put 600 characters of unrelated text in front of the claim and the grounded request returned 0.07, with five office-move pages in search_results: the query is built from the start of the state, and the claim was not in it. This recipe shows how to shape the request so the search sees your claim, how to confirm it did, and how to measure what grounding changes. A decision model returns a probability, not prose. Without mode, it answers from the state you send alone. With mode: "web_grounding", the API builds a search query from the question text and the start of your state, runs a Fast Search, appends the results to the state as evidence, and the model answers from that. The response adds search_results, the evidence the model saw. Your code compares the probability to two cutoffs and marks the claim supported, contradicted, or review. The recipe runs 24 labeled claims, 8 about facts settled for years and 16 about events from September and October 2026; without grounding no true recent claim printed above 0.24, and with it 24 of 24 came back right.

Why web grounding

  • One call instead of two APIs stitched together. One POST /v1/decisions replaces a Search API request, prompt assembly, and a second request.
  • A number you can threshold. p >= 0.80 is supported, p <= 0.20 is contradicted, and the middle goes to a person. The cutoffs are constants in your code, not instructions to the model.
  • The search sees the start of your state. The query is the question text and then as much of your state as fits in 256 bytes, so where you put the claim decides what the search looks for, and search_results shows you what it found.

What you need

  • Python 3.10 or newer on macOS or Linux. The shell commands use Unix syntax.
  • A Perplexity API key. Every key can call the Decisions API. A grounded request bills input tokens as usual plus $1.00 per 1,000 searches.

Set up

Everything lives in one directory. You create the first six files from this page; the script writes out/ on every run. The script posts to https://api.perplexity.ai/v1/decisions with httpx, like the ticket triage recipe, so the exact body the gateway reads is on the page. With the Perplexity Python library (0.46.1), which has no named mode parameter or search_results field yet, pass extra_body={"mode": "web_grounding"} to client.decisions.create and read the results from response.to_dict()["search_results"].
requirements.txt
Create the directory, save requirements.txt in it, then create a virtual environment, install, and export your key.

The claims

Each line has an id, the claim, a label (true or false, checked against the linked source), a kind, and the source URL. stable claims are facts that have not changed in years. recent claims are about events from September and October 2026: Nobel Prizes, the World Series, the Ballon d’Or, a Python release, a Federal Reserve meeting. 13 are true and 11 are false. The script sends only the claim text; the label and the source stay on your side. The longest claim is 100 bytes, so with the 18-byte question every claim fits the query in full when it comes first.
claims.jsonl

Shape the request

Before the model sees anything, the gateway builds one search query per question. The query is the question’s instructions (and its criteria text, if you wrote any), then the text of your state, in that order, cut to 256 bytes of normalized UTF-8. When the state has text, the question part gets at most 128 bytes; the separator takes one more, so a question that uses all 128 leaves 127 for the state. The state part is a prefix: the first bytes of your state, as many as fit. A word the limit would cut is dropped unless at least 32 bytes of it fit, so an unspaced script keeps its start, and once something is cut nothing later fills the gap. Runs of whitespace and control characters collapse to one space, the ends are trimmed, a code point is never split, and data: URLs are skipped. The search runs with that query, the gateway appends the results to a string state after an Evidence: line, the model answers, and the response carries the same results in search_results: title, URL, snippet, and dates, or [] when nothing was added. So keep the question short, because every byte of question text is a byte the claim cannot use: “Is the claim true?” is 18 bytes, which leaves 237 for the claim. And send the claim alone, or keep it at the start, before any other context. Text in front of the claim uses up those bytes: once it is long enough, the limit cuts the claim mid-sentence and then drops it altogether, and the search runs on the text in front instead. What the search sees shows what that did here. query.py is a port of the gateway’s rule for the shape this recipe sends: a string state and one noul question with plain instructions. search_query gives you the query text, and claim_in_query tells you before sending whether the complete claim text is inside it; a claim the limit cuts in the middle counts as outside. The script runs that check on every claim, prints the count, and warns when any claim is outside. For criteria, choice and score questions, and object or array state, the helper raises instead of guessing; Web grounding has the rule for those.

The query rule

normalize_prefix is the normalization above, applied to one part with a byte budget; the second value it returns says whether the input was truncated. Text accumulates parts up to a budget and, after a cut, pins its budget at the current length so a later part cannot fill a dropped word’s gap. search_query runs the state through a 256-byte Text, the question text through a 128-byte one when the state has text, and joins the two in a final 256-byte Text. It counts UTF-8 bytes, so a claim with accented characters fits fewer characters than one in plain ASCII. is_data_url trims the same characters Go’s TrimSpace does, which is narrower than Python’s strip. claim_in_query normalizes the claim the same way and looks for the whole of it in the query.
query.py
The tests for it are the gateway’s own cases, ported: every normalizePrefix assertion, the prefix-after-a-cut case, the half and whole budgets, the start of a long state, an unspaced Japanese state, data: URLs in either position and behind a control character, the shapes the port refuses, and where a padded claim stops being inside the query, including the padding that keeps the claim’s start and cuts its end.
test_query.py

Constants and the padding helper

QUESTION is the one question every request asks. The cutoffs and the three acceptance bounds are the policy; the model never sees them. PRICE_PER_M_INPUT and PRICE_PER_SEARCH are the published rates, used to turn usage.input_tokens and the request count into a cost per 1,000 claims. FILLER is the office-move text the padding experiments put around each claim; it is in the file so the experiments are reproducible. pad builds the experiment input: the claim on its own line after before characters of FILLER and before after more.
factcheck.py (part 1 of 4)

One request per claim

decide sends the claim as state with the one question, and adds "mode": "web_grounding" only when asked. 429 and 5xx responses are retried up to three times, waiting Retry-After seconds when the header is a whole number and 1, 2, then 4 seconds when it is absent or anything else, never more than 30 seconds. Any other error raises DecisionsError with the status and error.message from the body, and so does a 200 whose body is not the expected shape, including a probability outside [0, 1], a negative token count, or a search result without a url. The Decision it returns holds the probability from answers.is_true.noul, usage.input_tokens, the wall time for the claim with retries included, the number of POSTs it took, and the search_results URLs, [] when the key is absent. The POST count matters when grounded: a search is billed when it returns, even if the decision then fails and you retry, so every grounded attempt may be a billed search. mode accepts one value. Anything else returns 400:
factcheck.py (part 2 of 4)

Verdicts and the scorecard

verdict is the whole policy: at or above 0.80 supported, at or below 0.20 contradicted, otherwise review. Both cutoffs are inclusive. evaluate scores one mode against the labels. A verdict is correct when supported meets a true label or contradicted meets a false one, wrong when they cross, and review is counted on its own. Wrong verdicts split into missed_failures, false claims marked supported, and false_contradictions, true claims marked contradicted. The Brier score is the mean squared gap between the probability and the label, so a confident wrong answer costs more than an unsure one. flips counts claims whose verdict changed between the two modes. grounded_attempts is the number of POSTs sent with mode set, an upper bound on billed searches; mean_search_results is the evidence count per claim; and cost per 1,000 claims is the mean input tokens at the model’s rate plus one search per attempt. It rejects empty input, mismatched lengths, labels that are not booleans, and probabilities outside [0, 1], in the baseline as well as the results. accepted holds a run to three bounds at once: MAX_MISSED_FAILURES = 0, MAX_FALSE_CONTRADICTED_RATE = 0.05, and MAX_REVIEW_RATE = 0.25. Each bound exists because a trivial policy passes the other two: marking everything contradicted has no missed failures and no reviews, and reviewing everything has no wrong verdicts at all. The tests check both of those fail and that the shape of the recorded grounded run passes. Set the bounds from your own tolerance, but keep all three.
factcheck.py (part 3 of 4)

Command line

load_claims reads the JSONL and names the line of anything malformed: a missing claim, a label that is not true or false, an empty file. check runs one mode over the file, eval runs both and reports flips. --pad-before N puts N characters of FILLER in front of each claim and --pad-after N puts them after it; both may be given. --model switches to the flash model, which bills input at half the rate. Before any request goes out, the script runs claim_in_query on every claim and warns when the complete text of any claim is outside its query. The table has one probability and one verdict per mode. check prints the search_results URLs under each row, so you can see the evidence behind a verdict; eval leaves them out of the table and adds mean search results to the grounded summary line. The summary is two lines per mode and ends with acceptance met or not met. Every run writes its rows, results, and summaries to out/ as JSON.
factcheck.py (part 4 of 4)

The full file

Save this as factcheck.py. It is the four parts above in order, separated by two blank lines.
factcheck.py

Run it

Save test_factcheck.py and run the tests first. They need no key and no network: httpx.MockTransport stands in for the API. They cover the request body and mode, the error and retry paths, search_results in the Decision and the record, the scorecard and its bounds, and the claims file and command line with a fake decide.
test_factcheck.py
Then check the claims with grounding on. check prints one probability and one verdict per claim with the URLs the model saw under it, the query count, the summary for that mode, and the path of the JSON it saved. Every output on this page was recorded on 2026-11-04 with pplx-decider-latest.
Drop --grounded to get the probability without a search.

Measure it

Run eval on the same file. It sends every claim twice, without mode and with it, and lines the two up; the rest of this page reads from it.
Both modes got the eight stable claims right with probabilities at 1.00 or at 0.15 and under. The 16 recent claims split the two modes. Ungrounded, the 9 true recent claims printed between 0.01 and 0.24: 8 were marked contradicted and one, r08, went to review. The 7 false recent claims also came back low, 0.00 to 0.18, which counts as correct, but it is the same behavior: without a search, every claim about late 2026 got a low probability, true or false. That is 15 of 24 correct, but 8 of the 9 true recent claims were marked contradicted, which fails the false-contradiction bound of 5%: acceptance not met. Grounded, the same 24 requests printed 1.00 for every true claim and 0.00 for every false one. 24 of 24, no reviews, no wrong verdicts, all three bounds met, and 9 flips, all of them true recent claims moving to supported. r01 is the Navi Pillay claim from the top of the page: 0.01 to 1.00. Mean input tokens went from 99 to 1,906, because the evidence is now part of the state; individual grounded requests ranged from 1,213 to 3,143 tokens, with 5 results for every claim. Mean latency went from 0.43 s to 0.58 s in this run; the earlier check run averaged 0.50 s grounded, so the latency figures describe this session more than the mode.

What the search sees

Two runs with the same 24 claims and the same 600 characters of FILLER, which differ in where the claim sits. --pad-before 600 puts the filler first and the claim on the line after it; --pad-after 600 puts the claim first and the filler after it. Moving the claim changes two things at once: the search prefix, and the order of the state the model reads. claim_in_query says the claim is outside every query in the first run and inside every query in the second, and the script warns before the first run sends anything:
With the claim after the filler, grounded accuracy fell from 24 of 24 to 12 of 24, with 5 claims in review and 5 of the 9 true recent claims marked contradicted; every search_results list in that run holds office-move pages. Even s02, “Rust 1.0 was released in May 2015”, which both modes had at 1.00 unpadded, printed 0.15. With the claim before the filler, the grounded run got 24 of 24 and met all three bounds, the same verdicts as the unpadded run. The experiment does not separate the two effects: the first run changed both the evidence and the order of the state, and the ungrounded results moved too, 12 of 24 with the claim after the filler and 14 of 24 with it before, against 15 of 24 unpadded. s08, “PostgreSQL is licensed under the GNU General Public License”, went from 0.15 unpadded to 0.95 ungrounded with the filler in front of it, a missed failure, and 0.82 grounded. What the recorded outputs do show is that the claim’s position decided what the search returned, and the run with the claim first is the one that met the bounds. To see the wrong evidence with your own eyes, run check on one buried claim. The URLs under the row are the pages the search returned for a query that began with the office move:

Read the numbers

  • Recent against stable. Grounding changed no verdict on the 8 stable claims. If your claims are about things settled years ago, measure before you pay for the search.
  • A low ungrounded probability is not a contradiction. Ungrounded, the 9 true recent claims printed between 0.01 and 0.24 and the 7 false ones between 0.00 and 0.18. Nothing in those numbers separates “false” from “no evidence either way”. Treat contradicted from an ungrounded request as “unknown” for claims about recent events.
  • Evidence about the wrong thing looks like evidence. With the claim buried, every grounded response carried office-move URLs and a verdict that looked as definite as any other; search_results is the only place in the response that shows what the evidence was. Keep the URLs in your records, as out/ does here, and look at them when a verdict surprises you.
  • Grounding fails open, and the response says so. This recipe sends one question, so one search per request: if it fails, runs out of time (1 second per search, 3 seconds for the request), or returns nothing, the model answers from your original state and search_results is []. With several questions, the searches that succeed are used and search_results is [] only when none return. The script records the URLs per claim and prints mean search results on the grounded line, so a run where evidence went missing shows up in the summary; in the recorded runs it was 5.0 every time, 5 results for every claim.
  • Run-to-run drift. We ran eval twice, back to back. The second run held all 48 verdicts. Ungrounded, four printed probabilities changed by 0.01 at two decimals (r02 and r04 from 0.04 to 0.03, r05 and r12 from 0.10 to 0.11), seven more changed by less than a hundredth, and 13 were identical to the last digit; grounded, none changed at two decimals and 12 were identical. Mean latency was 0.30 s and 0.49 s against 0.43 s and 0.58 s. The nearest call was r11 ungrounded at 0.18, two hundredths under the contradicted cutoff; a claim that settles that close to a cutoff can print a different verdict on another day, which the ticket triage recipe saw happen.
  • Probabilities are the model’s estimate. 24 of 24 is one recorded run, not a guarantee. The verdict is only as good as the evidence the search found and the model’s reading of it. Keep review in your policy and keep the labels on your side.

Pricing

A grounded request bills input tokens at the model’s rate plus $1.00 per 1,000 searches, billed when the search returns, even with zero results and even if the decision then fails. In the recorded run, grounded requests averaged 1,906 input tokens: 1,906 × $0.02 per million = $0.000038 in tokens, plus $0.001 for the search, or $0.001038 per claim and $1.0381 per 1,000 claims. Ungrounded requests averaged 99 tokens, or $0.0020 per 1,000 claims. The search is 96% of the grounded cost: at the same input-token count, halving the token rate with the flash model (--model pplx-decider-flash-latest, $0.01 per million) saves about two cents per 1,000. No flash run was recorded for this page, so measure it on your claims before relying on that figure or its accuracy. A search that failed or timed out is not billed, and search_results does not say which searches ran, so the script prices every grounded attempt, retries included, and reports grounded attempts as an upper bound on billed searches. See Pricing.

Next steps

  • Your claims. Replace claims.jsonl. Put the claim first in the state, keep the question short, and run check on a sample: the warning and the claim text in the search query count tell you before any request whether each complete claim is in its query, and the URLs under each row tell you what the search found.
  • Several questions. Each question gets its own query, up to 100 questions and 10 searches at a time; questions with the same query share a search, and empty queries are skipped. Each search returns up to 5 results and the request keeps up to 10, or one per query when there are more than 10 queries, ordered by rank. The question part of each query is capped at 128 bytes when the state has text. Web grounding has the full rules, including criteria, choice and score questions, and object and array state.
  • Your cutoffs. Change SUPPORTED_MIN and CONTRADICTED_MAX, rerun eval, and watch all three bounds in the summary. The JSON in out/ has every probability and every URL, so you can replay cutoffs without new requests.
  • Read the docs. Web grounding covers the query, result, time, and failure rules in full; the Decisions API quickstart covers the question types.