Your AI app answers a question about your data. The answer sounds right, the number looks plausible, and nothing errors. A week later someone in finance notices it was wrong.

The fix most teams reach for is a better prompt. The fix that actually protects users is simpler: when the system can't back an answer, it should say so, and your app should show that clearly instead of a number.

This post shows how to wire that into an app, in Python and Node, using FactQL. The pattern applies to any AI data layer that can decline.

What a decline looks like

FactQL runs the query before it answers. When it can back the answer, you get the explanation, the query that ran, the rows and the lineage:

{ "result": "success", "sql": "SELECT region, SUM(revenue) AS total_revenue FROM orders GROUP BY region ORDER BY total_revenue DESC", "data": { "columns": ["region", "total_revenue"], "rows": [["North", 43148.0], ["South", 41223.0]] }, "explanation": "North leads with 43,148, followed by South (41,223).", "trace_id": "c5f50cff-..." } 

When it can't, it declines instead of guessing. Note this is still HTTP 200:

{ "result": "error", "decline_reason": "no_safe_sql", "explanation": "I couldn't find data that answers this. Try naming the table or column you mean.", "trace_id": "c5f50cff-..." } 

That status code matters. If your code only checks resp.ok, a decline sails through and your UI renders an empty or half-filled answer. You have to check result.

The four rules

  1. Check result before you show any number.
  2. On a decline, show the explanation. It's written for the end user and tells them what to change.
  3. Don't retry the same question. It gets the same answer.
  4. Retry only on HTTP 429, 500 and 503, after Retry-After.

Python

import os, sys, time import requests RETRYABLE = {429, 500, 503} def ask(question: str, attempts: int = 3) -> dict: for attempt in range(attempts): resp = requests.post( "https://api.factql.ai/query", headers={"X-API-Key": os.environ["FACTQL_API_KEY"]}, json={"q": question}, timeout=120, ) if resp.status_code in RETRYABLE and attempt < attempts - 1: time.sleep(int(resp.headers.get("Retry-After", 2 ** (attempt + 1)))) continue resp.raise_for_status() return resp.json() def render(answer: dict) -> str: if answer.get("result") == "success": return answer["explanation"] print(f"[log] declined: {answer.get('decline_reason')} trace={answer.get('trace_id')}", file=sys.stderr) return f"I can't verify that from your data. {answer.get('explanation', '')}".strip() print(render(ask("What was our ad spend last quarter?"))) 

Ask about something your data doesn't contain (ad spend on a sales-only database is the easiest) and you'll see the decline path.

Node

const RETRYABLE = new Set([429, 500, 503]); async function ask(question, attempts = 3) { for (let i = 0; i < attempts; i++) { const resp = await fetch("https://api.factql.ai/query", { method: "POST", headers: { "X-API-Key": process.env.FACTQL_API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ q: question }), }); if (RETRYABLE.has(resp.status) && i < attempts - 1) { const wait = Number(resp.headers.get("Retry-After")) || 2 ** (i + 1); await new Promise((r) => setTimeout(r, wait * 1000)); continue; } if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return resp.json(); } } function render(answer) { if (answer.result === "success") return answer.explanation; console.error(`[log] declined: ${answer.decline_reason} trace=${answer.trace_id}`); return `I can't verify that from your data. ${answer.explanation ?? ""}`.trim(); } 

If an agent is calling it

When FactQL is a tool for an LLM agent, the decline has to survive the agent too. Two things help:

  • Return the decline to the agent with an unmistakable prefix, like DECLINED:.
  • Tell the agent in its system prompt: only state numbers that come back from the tool; if it returns DECLINED, say you can't verify that and pass on the explanation; never estimate.

Without that instruction, a helpful model will often "fill the gap" with an estimate, which puts you right back where you started.

Logging declines is free product research

Every decline_reason you log is a question your users asked that your data couldn't answer. Grouped weekly, that list tells you which tables, columns or definitions to add next.

Full code

All of this, plus a LangChain / LangGraph tool, OpenAI-compatible function calling, a Next.js route and an embedded chat, is in factql/factql-examples (MIT).

We build FactQL, so we're biased: it has a free tier with no card. The pattern works with any AI data layer that can say "I can't verify that".