Ask
Ask questions about cases. Datagoat answers from what happened to cases like them.
You send a record (past cases with a yes/no outcome), questions, and cases. You get back, for each case, a chance, the columns that moved it, and a signed Verdict. If the record cannot support an answer, Datagoat says so rather than guess.
A record is usually a table with one row per case. It can also be an event log, a time series, a panel of periods, a sensor stream or a log of agent runs. See Shapes.
- Deterministic. The same record and the same question give the same answer, every time.
- Glass box. Every case lists up to four columns that moved its chance, and which way.
- Refuses rather than guesses. No pattern that holds on held-out rows means no answer, and no charge.
- Signed. Anyone can check that an answer was not altered.
Try it
pip install datagoat
datagoat signup # a free test key for the sample datasets
datagoat sample # asks about sample:saas_churn and verifies the answer
from datagoat import Client, yesno, score
dg = Client() # DATAGOAT_API_KEY, or the key `datagoat signup` saved
out = dg.ask(
{"churn": yesno("churned", outcome_is_desirable=False),
"risk": score("churned", outcome_is_desirable=False)},
dataset_id="sample:saas_churn", entity_column="customer_id", subject_kind="org",
cases={"ids": ["cust_0001"]},
)
The answer, trimmed:
{
"status": "done",
"answers": {
"churn": {
"type": "yesno", "state": "answered", "cache": "miss",
"quality": {"realised_lift": 4.48, "top_decile_lift": 4.44, "validation_scheme": "holdout"},
"cases": [{
"entity_id": "cust_0001",
"p": 0.7005,
"reasons": [
{"feature_label": "logins_last_30d", "value": 11, "likelihood_direction": "higher", "strength": "strong"},
{"feature_label": "tenure_months", "value": 21, "likelihood_direction": "higher", "strength": "strong"},
{"feature_label": "support_tickets", "value": 12, "likelihood_direction": "higher", "strength": "moderate"},
{"feature_label": "monthly_charges", "value": 171.02, "likelihood_direction": "higher", "strength": "moderate"}
]
}],
"verdicts": [{"verdict": {"…": "…"}, "signature": {"…": "…"}}]
},
"risk": {"type": "score", "state": "answered", "cases": [{"entity_id": "cust_0001", "p": 0.7005, "level": "likely", "…": "…"}]}
},
"fits_run": 1,
"billable_decisions": 2
}
Customer cust_0001 has a 70% chance of churning, which reads as likely. Few logins, a
short tenure, 12 support tickets and high charges all push it up.
Both questions ask about churned with the same outcome_is_desirable, so they share one fit
(fits_run: 1). Ask again and nothing refits: cache reads hit and fits_run reads 0. A
question on the same outcome with a different outcome_is_desirable (or none) is a separate fit.
The same call over HTTP:
curl https://api.datagoat.io/v1/ask \
-H "Authorization: Bearer $DATAGOAT_API_KEY" -H "Content-Type: application/json" \
-d '{"data": {"dataset_id": "sample:saas_churn"},
"entity_column": "customer_id", "subject_kind": "org",
"questions": {"churn": {"type": "yesno", "outcome_column": "churned", "outcome_is_desirable": false}},
"cases": {"ids": ["cust_0001"]}}'
In Claude, ChatGPT, Cursor or any MCP host, add https://api.datagoat.io/mcp and ask in words:
"Using dg_ask on sample:saas_churn, how likely is cust_0001 to churn, and why?"
The four question types
| Type | Answers | Example |
|---|---|---|
yesno |
the chance the outcome happens | Will this customer churn? |
score |
that chance as a level | How risky is this loan: unlikely, possible, likely, very likely? |
choice |
the best option | Which contract keeps this customer? |
rank |
cases in order of the chance | Which 50 accounts should sales call first? |
Every type rests on one fit: a model of one yes/no outcome in your record.
yesno
The chance, from 0 to 1, that the outcome happens for each case.
yesno("churned", outcome_is_desirable=False)
score
The chance, cut into named levels. By default: unlikely below 0.25, then possible, likely,
and very_likely from 0.75.
score("defaulted", outcome_is_desirable=False)
score("defaulted", levels=["low", "medium", "high"], cuts=[0.2, 0.6]) # your own cut points
Name the levels lowest first. Give one fewer cut than levels, in ascending order.
choice
The best option for each case. Try each value of a column you control:
choice(option_column="contract", options=["month-to-month", "one_year", "two_year"],
outcome_column="churned", outcome_is_desirable=False)
{"entity_id": "acct_0001", "choice": "one_year", "most_likely": "month-to-month", "least_likely": "one_year",
"p": {"month-to-month": 0.701, "one_year": 0.091, "two_year": 0.198}}
choice needs outcome_is_desirable, and it is the only type that does. The best option is the
likeliest to give an outcome you want and the least likely to give one you avoid. Here churn is
to be avoided, so the pick is one_year. Every answer also names most_likely and
least_likely, whichever you asked for.
If the record shows the column makes no difference to the outcome, the answer is refused with
option_not_in_pattern. Datagoat will not present a tie-break as a finding.
To choose between outcomes instead, name one per option:
choice(option_outcomes={"email": "replied_to_email", "call": "answered_call"}, outcome_is_desirable=True)
Each option's chance is a separate likelihood; the chances need not sum to 1.
rank
Cases in order of the chance, highest first. Omit cases to rank the whole record.
dg.ask({"queue": rank("churned", top_k=3, outcome_is_desirable=False)},
dataset_id="sample:saas_churn", entity_column="customer_id", subject_kind="org")
{"ranked_total": 800, "ranked": [
{"position": 1, "entity_id": "cust_0563", "p": 0.776, "reasons": ["…"]},
{"position": 2, "entity_id": "cust_0088", "p": 0.774, "reasons": ["…"]},
{"position": 3, "entity_id": "cust_0050", "p": 0.752, "reasons": ["…"]}]}
Your data
Send the record one of four ways:
data |
When |
|---|---|
{"rows": [...]} or {"csv": "..."} |
up to 10,000 rows, inline |
{"dataset_id": "ds_..."} |
a table stored once with dg_add_dataset |
{"fetch_url": "https://..."} |
a public file Datagoat downloads |
{"dataset_id": "sample:..."} |
the free samples |
Inline data is used for the call and then deleted. Its fit is cached by the record's content, so
the same table sent again does not refit. For a larger table, or one you ask about repeatedly,
store it with dg_add_dataset (dg.upload_rows(rows) sends it in pieces; dg.upload_file(path)
uses a presigned upload), then ask by dataset_id. A stored dataset is deleted 24 hours after it
was last used, or at once with dg_delete_dataset. See Your data for everything
Datagoat keeps and for how long.
Data that is not one row per case takes a shape and a time_column:
from datagoat import Client, yesno, events
dg.ask({"quiet": yesno("lapsed_90d", outcome_is_desirable=False)},
dataset_id="sample:customer_events", entity_column="customer_id", subject_kind="org",
time_column="date", shape=events(label={"lapsed": True}, event_column="event", horizon_days=90),
cases={"ids": ["cust_0001"]})
Datagoat works out which value of the outcome column means yes when the column has two values:
0/1, true/false, yes/no. Otherwise pass positive_values.
Cases
{"ids": ["cust_0001"]}answers about rows already in the record. With several rows per id, the latest wins; nametime_columnto say which is latest.{"rows": [{...}]}answers about new cases. Each row needs the entity column and every column the model uses.
Send up to 10,000 cases per call.
Read state first
| State | Meaning | What to do |
|---|---|---|
answered |
The record supports an answer. | Use it. |
refused |
The record holds no pattern that holds on held-out rows. | Do not retry; the same call returns the same answer. Add columns or change the question. |
not_yet |
Too few labeled rows to check the model. needs.labeled_rows says how many more. |
Add rows, then ask again. |
A refusal is an answer. It tells you the record cannot support a claim, and it costs nothing.
A first ask on a large record can take a minute or two. The response is then
{"status": "pending", "task_id": "tk_..."}. The SDKs wait for you; over HTTP, call dg_poll
with the task_id. Never send the ask again while it is pending: that would fit twice.
Reasons
Each case lists up to four columns, strongest first. Each names the column, the case's value,
which way it moved the chance (higher or lower, compared with the average case in the
record) and how strongly (strong or moderate).
Reasons come straight from the model's own terms. They are exact, not estimated, and the
signature covers them. They describe association, not cause: association_not_causal is always
true.
likelihood_direction says which way the chance moved, not whether that is good news. You say
that with outcome_is_desirable. Omit it only when you do not know; nothing fills it in for you.
Verify
dg.verify_all(out) # True when every Verdict in the answer is valid
Verification is free and needs no key. The public keys are at
https://api.datagoat.io/.well-known/jwks.json. Never act on a Verdict that is not valid; an
expired one is re-asked, never reused.
Close the loop
After you act, report what really happened. Outcomes are evidence; they never change a model or an answer.
dg.report_outcomes(model_ref, [{"entity_id": "cust_0001", "outcome": 1, "observed_at": "2026-10-01", "event_id": "crm-881"}])
When your table changes, ask again on the new version with refit_of set to the old
model_ref, then call dg.drift(new_model_ref): keep, refit or abandon.
Cost
| Price | |
|---|---|
| An answered case | $0.00002 ($20 per million) |
| A fit | 1,000 free each month, then $0.01 |
Refusals, not_yet, verification, samples, storage |
free |
A fit runs once per record and outcome. Later questions on the same bytes reuse it.
When to use something else
Datagoat learns from outcomes you have recorded. It does not read free text, count, or do arithmetic. To judge what a document says, use a language model. To predict a number rather than a yes/no, use a regression tool.
Errors
Errors are RFC 9457 problems with code, remedy and, when an argument is at fault, field.
| Code | Field | Fix |
|---|---|---|
unknown_case_id |
cases.ids |
Send ids that appear in the entity column, or send the rows. |
positive_values_required |
questions.<name>.positive_values |
Say which values of the outcome column mean yes. |
row_not_scoreable |
cases |
Send every column the model uses, with the same types. |
test_key_samples_only |
A test key works on the samples only. Create a live key at https://datagoat.io/keys. | |
scope_required |
Reporting outcomes needs a key with "Can report outcomes". | |
shape_invalid, shape_empty |
shape |
See Shapes. |
unknown_dataset |
data.dataset_id |
The dataset was deleted (24 hours after last use) or belongs to another workspace. Store it again. |