API guide

SeaCat answers typed questions about a piece of text or JSON. You send a state and up to 64 questions: pick a category, rate on a scale, or answer yes or no. You get back each answer's probabilities, computed in one forward pass of Qwen/Qwen3.5-35B-A3B. Nothing is generated, so every answer is one of your options.

The API is HTTPS and JSON at https://seacat.dev. There are no official SDKs yet. Every endpoint is also in the interactive reference, and the OpenAPI schema can generate a client.

  1. Quickstart
  2. Core concepts
  3. Request
  4. Response
  5. Examples: curl, Python, TypeScript
  6. Using the probabilities
  7. Writing good questions and states
  8. Limits
  9. Pricing and token counting
  10. Errors
  11. Queued requests, delayed results and retries

Quickstart

  1. Sign in with your email and create a key on the dashboard. Keys start with tz_ and are shown once. Requests are paid from prepaid credits, which you add on the dashboard.
  2. Put the key in your environment: export API_KEY=tz_...
  3. Ask a question:
curl https://seacat.dev/v1/decide \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "state": "Buy 10,000 followers for $5!!! Link in my profile.",
    "questions": {
      "is_spam": {
        "type": "yes_no",
        "text": "Is this comment spam?"
      }
    }
  }'

The response:

{
  "model": "Qwen/Qwen3.5-35B-A3B",
  "answers": {
    "is_spam": {
      "type": "yes_no",
      "answer": "yes",
      "probabilities": {
        "yes": 0.9993,
        "no": 0.0007
      },
      "certainty": 0.9917
    }
  },
  "usage": {
    "input_tokens": 82,
    "cost_usd": 0.000017
  }
}

Your request may be queued while GPU capacity comes online, which can take a minute or two. Then you get a redirect to a URL that returns the answer when it's ready. See queued requests.

Core concepts

State

The state is what your questions are about: a message, a document, a transcript or a record. A string is used as is. An object or array is serialized as JSON with 2-space indentation. Every question in a request shares the state, so it is processed once and billed once, however many questions you ask.

Questions

questions maps names you choose to questions, and the answers come back under the same names, in the same order. Each question is answered on its own: the model sees the state and that question, never the other questions or their answers.

TypeUse it tooptions
categoryPick one option2 to 26 options: {"option": "description", ...} or ["option", ...]
scaleRate on ordered levels2 to 26 levels, lowest first: ["level", ...]
yes_noTell whether a statement is trueNone. Leave it out.

Every answer has the same shape, whatever the type: the most likely option as answer, each option's probabilities, and a certainty. For yes_no the options are "yes" and "no". A scale answer also has a mean.

How answers are computed

Each question becomes a prompt: the state, then the question, then its options labelled A, B, C and so on (or "Answer Yes or No."). The model reads the prompt, and SeaCat takes its probabilities for the next token over those labels only, normalized to sum to 1. There is no sampling and no reasoning step: an answer is the model's immediate judgment. So:

Certainty

certainty is 1 - entropy / ln(n) over an answer's n probabilities. It is 1 when one option has all the probability and 0 when the probability is split evenly. It summarizes how peaked the distribution is; it is not a separate estimate of accuracy. Because it depends on n, compare it only between questions with the same number of options.

Request

POSThttps://seacat.dev/v1/decide

Send Authorization: Bearer <key> and Content-Type: application/json. The scheme name Bearer is case-insensitive.

Body

FieldTypeDescription
statestring, object or arrayRequired. The text or JSON the questions are about.
questionsobjectRequired. 1 to 64 questions, keyed by names you choose.
modelstringOptional. Leave it out, or pass "latest" or "Qwen/Qwen3.5-35B-A3B". Any other value returns 400.

Question

FieldTypeDescription
typestringRequired. "category", "scale" or "yes_no".
textstringRequired, 1 to 4,000 characters. The question. The model sees it as Question: <text>.
optionsobject or arraycategory: 2 to 26 unique options, as {"option": "description"} or ["option", ...]. scale: 2 to 26 unique levels as ["level", ...], lowest first. yes_no: leave it out.

Fields not listed here are rejected with 422, so a misspelled field fails loudly instead of being ignored.

A request with all three types:

{
  "state": "Hi, I run operations at a 40-person logistics company. We are moving dispatch off spreadsheets and need something live before our peak season in November. Budget is approved for this quarter. Could someone walk me through pricing for 25 seats?",
  "questions": {
    "stage": {
      "type": "category",
      "text": "How far along is this lead in buying?",
      "options": {
        "researching": "Early research, no timeline or budget yet",
        "evaluating": "Comparing options, with a rough timeline",
        "ready": "Budget approved and a firm deadline"
      }
    },
    "fit": {
      "type": "scale",
      "text": "How well does the company in this message match our target customer: logistics or retail companies with 20 to 500 employees?",
      "options": [
        "Poor match",
        "Partial match",
        "Strong match"
      ]
    },
    "wants_pricing": {
      "type": "yes_no",
      "text": "Does the message ask about prices or plans?"
    }
  }
}

Response

FieldTypeDescription
modelstringThe model that answered.
answersobjectOne answer per question, under the question's name, in request order.
usage.input_tokensintegerBilled input tokens. See pricing.
usage.cost_usdnumberWhat the request cost, in US dollars.

Answer

FieldTypeDescription
typestringThe question's type.
answerstringThe most likely option: a category's option name, a scale's level, or "yes" or "no".
probabilitiesobjectEach option's probability, keyed the same way, in request order.
certaintynumberFrom 0 (split evenly) to 1 (all on one option). See certainty.
meannumberScale only. The expected level: each level's position (0 for the lowest) weighted by its probability, so a number from 0 to n-1.

Probabilities are rounded to 4 decimal places, so they may not add up to exactly 1, and very small ones show as 0.

The response to the request above. The probabilities are illustrative:

{
  "model": "Qwen/Qwen3.5-35B-A3B",
  "answers": {
    "stage": {
      "type": "category",
      "answer": "ready",
      "probabilities": {
        "researching": 0.0063,
        "evaluating": 0.1102,
        "ready": 0.8835
      },
      "certainty": 0.6501
    },
    "fit": {
      "type": "scale",
      "answer": "Strong match",
      "probabilities": {
        "Poor match": 0.0041,
        "Partial match": 0.0514,
        "Strong match": 0.9445
      },
      "certainty": 0.7915,
      "mean": 1.9404
    },
    "wants_pricing": {
      "type": "yes_no",
      "answer": "yes",
      "probabilities": {
        "yes": 0.9981,
        "no": 0.0019
      },
      "certainty": 0.9801
    }
  },
  "usage": {
    "input_tokens": 251,
    "cost_usd": 0.000051
  }
}

List models

GEThttps://seacat.dev/v1/models

No key needed. Returns the model this server runs and its price per million input tokens:

{
  "models": [
    {
      "name": "Qwen/Qwen3.5-35B-A3B",
      "price_per_mtok_usd": 0.2
    }
  ]
}

Examples: curl, Python, TypeScript

The request above, with the timeout and retries described in queued requests. Call the API from your backend: it sends no CORS headers, and a key in a browser can be read by anyone.

curl https://seacat.dev/v1/decide \
  --max-time 180 --retry 2 --location \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "state": "Hi, I run operations at a 40-person logistics company. We are moving dispatch off spreadsheets and need something live before our peak season in November. Budget is approved for this quarter. Could someone walk me through pricing for 25 seats?",
    "questions": {
      "stage": {
        "type": "category",
        "text": "How far along is this lead in buying?",
        "options": {
          "researching": "Early research, no timeline or budget yet",
          "evaluating": "Comparing options, with a rough timeline",
          "ready": "Budget approved and a firm deadline"
        }
      },
      "fit": {
        "type": "scale",
        "text": "How well does the company in this message match our target customer: logistics or retail companies with 20 to 500 employees?",
        "options": [
          "Poor match",
          "Partial match",
          "Strong match"
        ]
      },
      "wants_pricing": {
        "type": "yes_no",
        "text": "Does the message ask about prices or plans?"
      }
    }
  }'

--retry retries timeouts, 429 and 5xx responses, waiting as long as Retry-After says. --location follows the redirect a slow request gets, and keeps the Authorization header on it.

# pip install httpx
import os
import time

import httpx

client = httpx.Client(
    base_url="https://seacat.dev",
    headers={"Authorization": f"Bearer {os.environ['API_KEY']}"},
    timeout=180,  # the default is 5 seconds
    follow_redirects=True,  # a slow request is redirected to its result
)


def decide(body, attempts=3):
    """POST /v1/decide. Retries timeouts, connection errors, 429 and 5xx responses."""
    for attempt in range(1, attempts + 1):
        try:
            response = client.post("/v1/decide", json=body)
        except httpx.TransportError:
            if attempt == attempts:
                raise
            time.sleep(2**attempt)
            continue
        retry = response.status_code == 429 or response.status_code >= 500
        if not retry or attempt == attempts:
            response.raise_for_status()  # any other 4xx means the request needs fixing
            return response.json()
        time.sleep(float(response.headers.get("Retry-After", 2**attempt)))


body = {
    "state": "Hi, I run operations at a 40-person logistics company. We are moving dispatch off spreadsheets and need something live before our peak season in November. Budget is approved for this quarter. Could someone walk me through pricing for 25 seats?",
    "questions": {
        "stage": {
            "type": "category",
            "text": "How far along is this lead in buying?",
            "options": {
                "researching": "Early research, no timeline or budget yet",
                "evaluating": "Comparing options, with a rough timeline",
                "ready": "Budget approved and a firm deadline"
            }
        },
        "fit": {
            "type": "scale",
            "text": "How well does the company in this message match our target customer: logistics or retail companies with 20 to 500 employees?",
            "options": [
                "Poor match",
                "Partial match",
                "Strong match"
            ]
        },
        "wants_pricing": {
            "type": "yes_no",
            "text": "Does the message ask about prices or plans?"
        }
    }
}

result = decide(body)
for name, answer in result["answers"].items():
    print(name, answer["answer"], answer["probabilities"][answer["answer"]])
print(result["usage"])
// Node 18+ (global fetch)
interface Answer {
  type: "category" | "scale" | "yes_no";
  answer: string;
  probabilities: Record<string, number>;
  certainty: number;
  mean?: number; // scale only
}

interface DecideResponse {
  model: string;
  answers: Record<string, Answer>;
  usage: { input_tokens: number; cost_usd: number };
}

const BASE_URL = "https://seacat.dev";

// POST /v1/decide. Retries timeouts, network errors, 429 and 5xx responses.
// fetch follows the redirect a slow request gets, keeping the Authorization header.
async function decide(body: object, attempts = 3): Promise<DecideResponse> {
  for (let attempt = 1; ; attempt++) {
    let res: Response | undefined;
    try {
      res = await fetch(`${BASE_URL}/v1/decide`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(180_000), // a queued request can take a couple of minutes
      });
    } catch (err) {
      if (attempt === attempts) throw err;
    }
    if (res?.ok) return (await res.json()) as DecideResponse;
    const retry = !res || res.status === 429 || res.status >= 500;
    if (res && (!retry || attempt === attempts)) {
      throw new Error(`${res.status}: ${await res.text()}`); // any other 4xx means the request needs fixing
    }
    const wait = Number(res?.headers.get("retry-after")) || 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, 1000 * wait));
  }
}

const body = {
  "state": "Hi, I run operations at a 40-person logistics company. We are moving dispatch off spreadsheets and need something live before our peak season in November. Budget is approved for this quarter. Could someone walk me through pricing for 25 seats?",
  "questions": {
    "stage": {
      "type": "category",
      "text": "How far along is this lead in buying?",
      "options": {
        "researching": "Early research, no timeline or budget yet",
        "evaluating": "Comparing options, with a rough timeline",
        "ready": "Budget approved and a firm deadline"
      }
    },
    "fit": {
      "type": "scale",
      "text": "How well does the company in this message match our target customer: logistics or retail companies with 20 to 500 employees?",
      "options": [
        "Poor match",
        "Partial match",
        "Strong match"
      ]
    },
    "wants_pricing": {
      "type": "yes_no",
      "text": "Does the message ask about prices or plans?"
    }
  }
};

const { answers, usage } = await decide(body);
for (const [name, a] of Object.entries(answers)) {
  console.log(name, a.answer, a.probabilities[a.answer]);
}
console.log(usage);

Using the probabilities

Because every answer comes with probabilities, you choose how sure the model must be before your code acts, and what happens when it isn't: ask a person, fall back to a rule, or leave the item for later. Every type has the same shape, so one helper covers them all. Using the answers above:

def sure_answer(answer, threshold):
    """The answer if the model gives it at least this probability, else None: abstain."""
    top = answer["answer"]
    return top if answer["probabilities"][top] >= threshold else None


answers = result["answers"]

stage = sure_answer(answers["stage"], 0.8)
if stage is None:
    queue_for_review()
else:
    set_pipeline_stage(stage)

if sure_answer(answers["wants_pricing"], 0.9) == "yes":
    send_price_sheet()

# A scale's mean is a number you can sort by or compare.
if answers["fit"]["mean"] >= 1.5:
    notify_sales()

# Or add up levels: the chance the match is at least partial.
fit = answers["fit"]["probabilities"]
at_least_partial = fit["Partial match"] + fit["Strong match"]

Pick thresholds from your own data. Label 100 to 200 real examples and run them. For a range of thresholds, measure how often the answers above the threshold are right and what share of examples they cover, then take the lowest threshold that meets your accuracy target. Check again after you reword a question, since wording moves probabilities.

Writing good questions and states

For the stage question above, the model sees these two messages, plus the formatting tokens of its chat template:

system
You are a precise decision engine. Read the state, then answer the question about it. Reply with only the requested label.

user
<state>
Hi, I run operations at a 40-person logistics company. We are moving dispatch off spreadsheets and need something live before our peak season in November. Budget is approved for this quarter. Could someone walk me through pricing for 25 seats?
</state>

Question: How far along is this lead in buying?
Options:
A. researching: Early research, no timeline or budget yet
B. evaluating: Comparing options, with a rough timeline
C. ready: Budget approved and a firm deadline
Answer with the letter only.

The answer is read from the model's probabilities for the next token being A, B or C. Keep that picture in mind:

Give each question everything it needs

The model sees the state, your question and its options, and nothing else: not your other questions, not earlier requests. Define any term it can't guess. The fit question above says what "our target customer" means instead of assuming the model knows.

Describe the options

With {"option": "description"} the model sees A. option: description, and the answer uses the option name. The model reads the names too, so make them meaningful (ready, not opt3). Don't number or letter the options yourself; they are already labelled.

Leave a way out

The probability is always split among the listed options. If a state might fit none of them, add one such as "none": "None of the above". To catch states that don't say enough, ask a separate question such as "Does the message say enough to tell?"

Ask one thing per question

"Is this lead ready to buy and in our target market?" folds two judgments into one probability. Ask two questions and combine the answers in your code.

Order scale levels from lowest to highest

The model sees them under "Levels, from lowest to highest:", and mean assumes that order. Make each level concrete enough that two people would agree on it.

Precompute anything that needs arithmetic

The model answers in one step, without working anything out first, so it can't reliably add up, count, average or compare values spread across JSON fields. Compute totals, counts, differences and date gaps in your code and put the results in the state. Better still, if a rule is pure arithmetic, check it in code and ask the model only for the judgment. Instead of line items and a question like "Is the average hotel night over the cap?":

"line_items": [
  {"date": "2026-09-01", "category": "hotel", "usd": 298},
  {"date": "2026-09-01", "category": "meal", "usd": 41},
  {"date": "2026-09-02", "category": "hotel", "usd": 305},
  {"date": "2026-09-03", "category": "hotel", "usd": 309}
]

send the totals, and ask about what needs reading:

{
  "state": {
    "trip": {
      "purpose": "Customer workshop in Chicago",
      "nights": 3
    },
    "policy": {
      "hotel_cap_per_night_usd": 250,
      "receipt_required_over_usd": 25
    },
    "totals": {
      "hotel_per_night_usd": 304,
      "meals_usd": 186,
      "items_over_25_usd_without_receipt": 1
    },
    "employee_note": "The conference hotel was sold out, so I booked the closest one."
  },
  "questions": {
    "justified": {
      "type": "yes_no",
      "text": "Does the employee note give a sound business reason for going over the hotel cap?"
    },
    "decision": {
      "type": "category",
      "text": "What should the reviewer do with this expense report?",
      "options": {
        "approve": "Approve as submitted",
        "approve_with_reminder": "Approve, and remind the employee of the policy",
        "return": "Send it back to the employee to fix",
        "escalate": "Escalate to finance"
      }
    },
    "audit_risk": {
      "type": "scale",
      "text": "How likely is this report to need a finance audit?",
      "options": [
        "Low",
        "Medium",
        "High"
      ]
    }
  }
}

Keep the state focused

Leave out fields no question needs: they cost tokens and can distract the model. Use descriptive keys with units, like hotel_per_night_usd. Objects are sent as indented JSON, which reads well but costs tokens. If cost matters more, serialize the JSON yourself, for example compactly, and send it as a string; that can save a third of the state's tokens. Check that accuracy holds on your data before you switch.

Ask many questions per request

The state is processed and billed once per request, so ten questions about one state in one request cost far less than ten requests. Each extra question adds only its own tokens.

Test on real data

Run your questions on labelled examples, read the misses, and reword. Small changes in wording can move the probabilities a lot.

Limits

LimitValue
Input tokens per question prompt32,768
Questions per request64
Options or levels per question2 to 26
Question text1 to 4,000 characters
Requests per API key600 a minute
Requests in progress at once, per API key8

The token limit applies to each question's prompt: the system prompt, the state and that one question. The longest question decides whether a request fits. It is not a limit on the request's total, which can be higher, since each question adds its own tokens. A request over the limit fails with 400, and nothing is truncated. Shorten the state, or split it and ask about each part.

Rate limits

A request over either rate limit fails at once with 429 and a Retry-After header giving the seconds to wait. It isn't charged. The limits apply to each API key separately. Requests per minute refill steadily, so a burst can use up to a minute's worth at once. Fetching a delayed result counts as a request in progress, but not toward requests per minute. The limits are counted on each of our servers separately, so under heavy load you may occasionally get somewhat more than them before a 429. Need more? Contact us.

Pricing and token counting

$0.20 per million input tokens. There is no charge for output. You pay from prepaid credits, which you add on the dashboard.

usage.input_tokens counts, with the model's own tokenizer:

Text that every question's prompt begins with is counted once. English text runs at about 4 characters per token.

A request costs input_tokens × price per million micro-dollars, rounded up to a whole micro-dollar ($0.000001), and usage.cost_usd reports it. The request above is 251 tokens, so it costs $0.000051, and 10,000 like it cost about $0.50.

Only successful requests are charged; errors are free. A request is charged once, when the model finishes it, even if your client stopped waiting or never collected a delayed result. Fetching a result again is free. A request is accepted while your balance is above zero and charged when it finishes, so the balance can end slightly below zero. After that, requests fail with 402 until you add credits. The dashboard shows your balance and recent usage.

Errors

Errors are JSON with a detail field: a message, or for 422 a list of problems.

StatusMeaningWhat to do
400A question's prompt is over the 32,768-token limit. {"detail": "State plus question is 40213 tokens; the limit is 32768."}
Or model isn't the model this server runs. {"detail": "Unknown model 'gpt-4o'. This server runs 'Qwen/Qwen3.5-35B-A3B'."}
Shorten the state or the question, or leave model out or use the name from /v1/models. Don't retry as is.
401The key is missing, wrong or revoked. {"detail": "Invalid or missing API key."}Send Authorization: Bearer <key> with a key from the dashboard.
402Out of credits. {"detail": "Out of credits. Add more at https://seacat.dev/dashboard"}Add credits on the dashboard.
404From a result URL: there's no such result for this key. The ID is wrong, the result is over 60 minutes old, or a different key made the request. {"detail": "No result with this ID for this API key. A result can be collected for 1 hour, with the key that made the request."}Fetch it with the key that made the request, within 60 minutes.
422The body isn't valid JSON or doesn't match the schema.Read detail and fix the request.
429Over one of the key's rate limits: requests per minute, {"detail": "Too many requests: each API key can make 600 requests per minute."}, or requests in progress at once, {"detail": "Too many requests at once: each API key can have 8 in progress."}Wait the number of seconds in the Retry-After header, then retry.
500An unexpected error on our side. {"detail": "Internal server error. Retry with backoff, and contact us if it keeps happening."}Retry with backoff.
503The model is unavailable: it couldn't be reached, timed out or crashed. {"detail": "The model is unavailable right now. Retry in a minute."}Wait the number of seconds in the Retry-After header (30), then retry.
502, 504The proxy in front of the server couldn't reach it, or it took too long to answer. A 504 has Retry-After.Retry with backoff.

A 422 lists every problem it found, each with loc, the path to the problem, and msg. For example, for a question {"type": "category", "text": "How far along is this lead?", "options": ["ready"]}:

{
  "detail": [
    {
      "type": "value_error",
      "loc": [
        "body",
        "questions",
        "stage"
      ],
      "msg": "Value error, category questions need 2 to 26 options",
      "input": {
        "type": "category",
        "text": "How far along is this lead?",
        "options": [
          "ready"
        ]
      },
      "ctx": {
        "error": {}
      }
    }
  ]
}

Requests fail with 422 when:

Queued requests, delayed results and retries

When no GPU capacity is free, your request is queued until it is. That can take a minute or two, and is most likely for the first requests after a quiet period. Once it's running, a request takes tens to hundreds of milliseconds, depending on the length of the state and the number of questions.

Delayed results

If the answer isn't ready within about 55 seconds, the request doesn't hang on: it gets a 303 See Other redirect to a URL on this server that returns the answer when it's ready.

HTTP/1.1 303 See Other
location: /v1/decide/result/fc-01K5EXAMPLE

{"status": "queued", "result_url": "/v1/decide/result/fc-01K5EXAMPLE", "detail": "Your request is queued. GET result_url with the same API key to collect the answer."}

A GET of that URL, with the same Authorization header, waits up to about 55 seconds more. It returns the same response the request would have: 200 with the answers, or an error. If the answer still isn't ready, it redirects to itself again. Most clients follow all of this for you, and keep the Authorization header because the redirect stays on the same host:

Only the API key that made the request can fetch its result, for up to 60 minutes. The request is charged once, when the model finishes it, whether you fetch the result once, several times or never. If your client doesn't follow redirects, GET the Location yourself with the key.

Timeouts and retries

More: the interactive reference lets you try requests with your key, and /openapi.json is the machine-readable schema.