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.

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

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.