VANSA / API REFERENCE
Documentation
Vansa is a System One decision model for your software. Send context and questions to get a choice (choice), a score on your scale (score), or the probability of yes (noul), without generated text.
Try the playground Install the agent skill Markdown reference OpenAPI schema
Quickstart
Set VANSA_KEY to an API key from the console, then run this request on your server. To try Vansa without a personal key, use the public playground.
curl https://api.vansa.org/v1/systemone \
--max-time 100 \
-H "Authorization: Bearer $VANSA_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "vansa-3.5",
"state": "Please refund my duplicate charge.",
"questions": {
"intent": {
"type": "choice",
"instructions": "What does the customer need?",
"criteria": ["refund", "technical_support", "other"]
}
}
}'
# pip install requests
import os
import requests
response = requests.post(
"https://api.vansa.org/v1/systemone",
headers={"Authorization": f"Bearer {os.environ['VANSA_KEY']}"},
json={
"model": "vansa-3.5",
"state": "Please refund my duplicate charge.",
"questions": {
"intent": {
"type": "choice",
"instructions": "What does the customer need?",
"criteria": ["refund", "technical_support", "other"],
}
},
},
timeout=100,
)
response.raise_for_status()
print(response.json()["answers"]["intent"])
// Node.js 18+. Run on your server.
const response = await fetch("https://api.vansa.org/v1/systemone", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.VANSA_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "vansa-3.5",
state: "Please refund my duplicate charge.",
questions: {
intent: {
type: "choice",
instructions: "What does the customer need?",
criteria: ["refund", "technical_support", "other"]
}
}
}),
signal: AbortSignal.timeout(100000)
});
if (!response.ok) throw new Error(await response.text());
const { answers } = await response.json();
console.log(answers.intent);
Read the selected option from answers.intent.choice and each option’s probability from answers.intent.probabilities. Question IDs are returned unchanged.
Multi-question request and response
This support-ticket request combines all three question types in one call.
curl https://api.vansa.org/v1/systemone \
-H "Authorization: Bearer $VANSA_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "vansa-3.5",
"state": {
"subject": "Duplicate charge on invoice #4411",
"body": "Hi, we were billed twice for March. Please refund the duplicate today or we will cancel our plan."
},
"questions": {
"department": {"type": "choice", "instructions": "Which department should handle this request?",
"criteria": {"billing": "invoices, payments, refunds", "technical": "bugs, outages, system errors",
"sales": "pricing, new contracts", "other": "everything else"}},
"urgency": {"type": "score", "instructions": "How urgent is this request?",
"criteria": ["not urgent", "soon", "critical deadline or blocking issue"]},
"churn_risk": {"type": "noul", "instructions": "Does the customer threaten to cancel or leave?"},
"refund_requested": {"type": "noul", "instructions": "Does the customer explicitly ask for a refund?"}
}
}'Recorded response
{
"id": "req_bfe3ef4ff5f38256",
"object": "systemone.result",
"model": "vansa-3",
"created": 1790309369,
"answers": {
"department": {
"type": "choice",
"choice": "billing",
"probabilities": {"billing": 0.9922, "technical": 0.0052, "sales": 0.0009, "other": 0.0017},
"confidence": 0.9624
},
"urgency": {
"type": "score",
"score": 1.703,
"legend": {"0": "not urgent", "1": "soon", "2": "critical deadline or blocking issue"},
"probabilities": {"0": 0.0578, "1": 0.1815, "2": 0.7607},
"confidence": 0.3788
},
"churn_risk": {"type": "noul", "noul": 0.8568, "probabilities": {"false": 0.1432, "true": 0.8568}, "confidence": 0.8568},
"refund_requested": {"type": "noul", "noul": 0.8794, "probabilities": {"false": 0.1206, "true": 0.8794}, "confidence": 0.8794}
},
"usage": {"input_tokens": 357, "output_tokens": 0, "state_tokens": 40, "truncated": false},
"latency_ms": 711.5
}
This is recorded Vansa-3 output for the request above. Try the current model in the playground.
Authentication
POST /v1/systemone and GET /v1/me require a bearer key:
Authorization: Bearer vsk_your_key_here
To get a key, create an account in the console with your email address, Google or GitHub, then create API keys there. Keep them on your server; do not embed them in browser or mobile code. The playground uses a separate shared demo key for visitors.
Use GET /v1/me to check your key and remaining quota. GET /v1/models and GET /health are public.
You can also sign in to the usage console with an API key to see requests, input tokens, response times and estimated cost in USD and EUR. The console shows usage for that key only. See pricing and the usage calculator for the current rate. Usage estimates are not invoices.
Token balance
- Shared balance. API keys created in the console draw input tokens from their account’s token balance, which all of the account’s keys share. Monthly plan tokens are used first, then promotional tokens such as the welcome bonus, then prepaid credit. New verified accounts can receive a one-time welcome bonus of 10,000,000 tokens. One per person; a bonus can be held for review, for example for duplicate, disposable or automated sign-ups.
- What is charged. A successful request uses its
usage.input_tokens. Output tokens are free, and a failed request is not charged. A request is charged once the model has answered it, even if your client closed the connection before the response arrived, including while the request was still waiting in the queue. - Reservation. Before answering, the API holds 16,384 tokens for each prompt the request needs: one per question, more for a question with over 16 options or levels. The unused part is released when the request finishes. Near an empty balance, a request can therefore be declined with
402even if its actual usage would have fit. - Where to check it. Your balance, plan, prepaid credit and invoices are in the console.
GET /v1/mereports the key’s request limits and usage, not the token balance. Rate limits apply separately from the token balance. - Existing personal keys. Keys issued by the Vansa team before accounts were available keep working as before: they are not billed and keep their existing limits.
Request & response
Send JSON with Content-Type: application/json. Every question is evaluated against the same state. Request time grows with state length and question count.
Request body
| Field | Type | Description |
|---|---|---|
model | string · optional | Defaults to vansa-3.5. Aliases vansa, vansa-latest, vansa-1, vansa-2, vansa-3 and vansa-3.0 through vansa-3.4 all resolve to the current model. Model names are case-insensitive. |
state | string · object · array | Required, non-empty input. Use named JSON fields to give context; arrays can represent conversation turns. The full prompt must fit within 16,384 tokens per question. |
questions | object | Required map of 1–32 question IDs to definitions. IDs are non-empty strings of at most 64 characters and return unchanged in answers. |
assets | object · optional | Up to 4 PNG or JPEG images, keyed by a reference that you place in state. See Images. |
Question definition
| Field | Type | Description |
|---|---|---|
type | string | Required: choice, score or noul. |
instructions | string | Required, non-empty question. Maximum 4,000 characters. Name the state fields the question refers to. |
criteria | object · array | choice: 2–255 named options as an object of descriptions, or an array of names.score: 2–32 non-empty level descriptions, lowest first.noul: optional non-empty object with true and/or false descriptions. |
Response body
| Field | Description |
|---|---|
id | Request ID; also returned in X-Request-Id. Include it when reporting a problem. |
object | Always systemone.result. |
model | The model that answered, including when an alias was requested. |
created | Unix timestamp in seconds. |
answers | Map of question IDs to typed answers. |
usage.input_tokens | Prompt tokens read: the state (including its images) and questions, with shared content counted once. |
usage.output_tokens | Always 0; the model does not generate text. |
usage.state_tokens | Text tokens in the state alone; images are not included. Present when the API tokenizer is available. |
usage.truncated | false for Vansa-3.5. Over-limit prompts receive 400 rather than being cut. Present when the API tokenizer is available; retained for compatibility. |
usage.images | Requests with images only: the number of images read. |
usage.image_tokens | Requests with images only: the tokens the images took, included in usage.input_tokens. |
latency_ms | Server time in milliseconds, including time waiting for the model. |
Images
A request can include up to 4 PNG or JPEG images in a top-level assets map, the same assets map as TypeSafe’s Jev (base64 images only). Each key is a reference that you also write, as text, in state: the image is placed at that point of the state and the reference stays there as its label, so a question can name it (“Does image 1 show …?”).
- Values.
{"image 1": {"type": "image", "format": "jpg", "data_base64": "…"}}.typeis optional and must beimage;formatispng,jpgorjpegand must match the file;data_base64is the file as plain base64 on one line, without adata:prefix. URLs are not fetched and multipart uploads are not accepted. - References. 1–64 letters, digits, spaces,
.,_or-, starting with a letter or digit, such asimage 1. No reference may contain another, sophotoandphoto 2cannot be used together. - Exactly once in the state. Each reference must appear exactly once in the string values of
state, never in a key. It is matched as plain text, so choose references that occur nowhere else in your state.instructionsandcriteriamay mention a reference as plain text; question IDs must not contain it. - Files. At most 4,194,304 bytes per image and 6,291,456 bytes for all images of a request (decoded); each side 32–2,048 pixels. Animated PNGs and CMYK JPEGs are refused. Send upright images: EXIF rotation is not applied.
- Tokens and billing. Image tokens are input tokens: they are included in
usage.input_tokensand charged at the same rate as text. On our test images, one image took about 280–312 tokens, plus a few tokens around it. An image is counted once per request, but it is part of every question’s 16,384-token prompt and counts toward the per-request work limit.usage.imagesandusage.image_tokensappear on requests with images only. - Access and size. Every API key can send images, except the playground’s shared demo access. Requests with images count toward your key’s own request limits; there is no separate limit for images. A request with images may be up to 8.5 MB, sent with a
Content-Lengthheader; its text without the images must stay within 512 KB. - Reserved text. In a request with images,
stateand the questions must not contain control sequences (special-token text in angle brackets).
Request and response with an image
A support ticket with a photo of the delivered box. parcel.jpg stands for any JPEG file; the data is fictional.
# pip install requests
import base64
import os
import requests
with open("parcel.jpg", "rb") as f:
photo = base64.b64encode(f.read()).decode("ascii") # plain base64, no data: prefix
response = requests.post(
"https://api.vansa.org/v1/systemone",
headers={"Authorization": f"Bearer {os.environ['VANSA_KEY']}"},
json={
"model": "vansa-3.5",
"state": {
"ticket": "The parcel arrived today. The customer asks for a replacement.",
"photo": "image 1",
},
"assets": {"image 1": {"type": "image", "format": "jpg", "data_base64": photo}},
"questions": {
"box_damaged": {"type": "noul", "instructions": "Does image 1 show visible damage to the box?"},
"next_step": {
"type": "choice",
"instructions": "What should support do with the request in `ticket`?",
"criteria": {"send_replacement": "the box in image 1 is visibly damaged",
"ask_for_details": "image 1 does not show the problem clearly",
"other": None},
},
},
},
timeout=100,
)
response.raise_for_status()
print(response.json()["usage"])Response shape
{
"id": "req_…",
"object": "systemone.result",
"model": "vansa-3.5",
"created": 1792569600,
"answers": {
"box_damaged": {"type": "noul", "noul": 0.9, "probabilities": {"false": 0.1, "true": 0.9}, "confidence": 0.9},
"next_step": {
"type": "choice",
"choice": "send_replacement",
"probabilities": {"send_replacement": 0.8, "ask_for_details": 0.15, "other": 0.05},
"confidence": 0.44
}
},
"usage": {"input_tokens": 561, "output_tokens": 0, "state_tokens": 25, "truncated": false, "images": 1, "image_tokens": 294},
"latency_ms": …
}
The values are illustrative, not recorded output. usage.images and usage.image_tokens follow truncated on requests with images only.
Other endpoints
| Endpoint | Auth | Response |
|---|---|---|
GET /v1/models | Public | Model IDs, aliases and context size. Retired IDs include routed_to for the current model. |
GET /v1/me | Bearer key | Key name, per-minute and daily limits, usage and remaining quota. |
GET /health | Public | 200 / status: ok when ready; 503 with loading, upstream_unreachable or error otherwise. |
Key and quota response
{
"key": {"id": 3, "name": "Tomas – email triage", "prefix": "vsk_9Xk2mQ1p", "created_at": "2026-09-21T10:00:00Z", "expires_at": null},
"limits": {"requests_per_minute": null, "requests_per_day": null},
"usage": {"today_requests": 17, "today_remaining": null, "quota_resets_at": "2026-09-22T00:00:00Z",
"total_requests": 231, "total_input_tokens": 88410},
"model": "vansa-3.5"
}Request quotas are set individually for each API key. null means unlimited; a number is the quota assigned to that key.
Question types
All answer types return probabilities and confidence. The following examples illustrate their shapes.
choice — one option
Choose from 2–255 named options. The answer’s choice is one of your keys.
// definition
"intent": {
"type": "choice",
"instructions": "What does the customer want in `message`?",
"criteria": {
"refund": "money back or a charge reversed",
"technical_help": "a bug, outage or integration problem",
"cancellation": "wants to cancel or downgrade",
"other": null
}
}
// answer
"intent": {
"type": "choice",
"choice": "refund",
"probabilities": {
"refund": 0.91, "technical_help": 0.03,
"cancellation": 0.04, "other": 0.02
},
"confidence": 0.71
}
Option descriptions may be strings, null, or other JSON values. With null, the model sees only the name. An array of unique, non-empty names also works. More than 16 options requires multiple passes; see limits.
score — an ordered scale
Define 2–32 levels, lowest first. score is the expected level on a zero-based scale: Σ(level × probability).
// definition: levels from lowest to highest
"frustration": {
"type": "score",
"instructions": "How frustrated does the customer sound in `message`?",
"criteria": [
"calm and neutral",
"concerned but civil",
"clearly annoyed",
"very angry or using strong language"
]
}
// answer: score is the expected level (0 .. N-1)
"frustration": {
"type": "score",
"score": 2.31,
"legend": {"0": "calm and neutral", "1": "concerned but civil",
"2": "clearly annoyed", "3": "very angry or using strong language"},
"probabilities": {"0": 0.02, "1": 0.12, "2": 0.39, "3": 0.47},
"confidence": 0.24
}
Use the expected score to sort or threshold. For the most likely discrete level, choose the highest-probability entry; rounding score can produce a different level. legend maps zero-based indices to your descriptions.
noul — probability of yes
noul is a number from 0 to 1. The optional criteria describe what true and false mean.
// definition (criteria optional)
"is_phishing": {
"type": "noul",
"instructions": "Is this email a phishing or scam attempt?",
"criteria": {
"true": "phishing, scam, or fraud",
"false": "a legitimate email"
}
}
// answer: noul = probability the statement holds
"is_phishing": {
"type": "noul",
"noul": 0.08,
"probabilities": {"false": 0.92, "true": 0.08},
"confidence": 0.92
}
Use a threshold of 0.5 for a yes/no decision, or a higher threshold when false positives cost more.
choice and score, it is 1 − H(p) / log(N): 0 means a uniform distribution, 1 means certainty. For noul, it is max(p, 1 − p), from 0.5 to 1.Limits & data
Request quotas are set individually for each API key and may be unlimited. GET /v1/me returns your key's quotas; null means unlimited.
| Request constraint | Maximum | Behavior |
|---|---|---|
| Questions per request | 32 | Each question is evaluated separately. |
| Options / score levels | 255 / 32 | Keep option descriptions short. See multi-pass behavior below. |
| Context per question | 16,384 tokens | Includes state, question, options and roughly 100 fixed instruction tokens. Over-limit requests receive 400, naming the question. No truncation. |
| Work per request | 49,152 tokens | The model reads the state once for all questions, then each question's own part and, for a question with more than 16 options, its final pass (counted at its largest possible size, at most 16,384 tokens), so this count can be larger than the billed usage.input_tokens. For example, one or two 255-option questions fit a state of up to about 15,900 tokens. Larger requests receive 400; the message gives both numbers. |
| Request body | 512 KB · 8.5 MB with images | Larger bodies receive 413. A request with images needs a Content-Length header, and its text without the images must stay within 512 KB. |
| Images | 4 per request | PNG or JPEG, at most 4,194,304 bytes each and 6,291,456 bytes in total (decoded), each side 32–2,048 pixels. Requests with images count toward the key’s own request limits; there is no separate image limit. See Images. |
Latency: allow at least 100 seconds. More questions, longer states and more than 16 options take longer; bursts may queue. Split large batches into several requests. With more than 16 choice options, groups of up to 16 are evaluated first, then the group winners enter a final pass.
Access: no SLA. Create an account and API keys in the console; keys created there use your account’s token balance. The playground has shared demo access. Keys may be rate-limited or rotated.
Data handling
The public API's routine usage database does not store full inputs or answers. Usage records contain request time, key identifier, model, question count, token count, latency, status and client IP for quotas and support. Images are processed in memory to answer the request and are not stored; usage records contain no image data. Error records and diagnostic logs may contain fragments of submitted content, including question IDs or model names.
Playground drafts are saved separately in your browser. In the current deployment, requests pass through Cloudflare and are answered by our model, self-hosted in Lithuania on servers we operate; inputs are not sent to a third-party AI inference provider.
Only send data you are comfortable sharing with this API. See the Privacy Policy for storage, retention and browser data details.
Errors
Errors return a non-2xx HTTP status and an error object. type and message are always present; code and param are included when available.
{
"error": {
"type": "invalid_request_error",
"message": "`state` must not be empty.",
"param": "state"
}
}
| Status | type / code | Meaning |
|---|---|---|
| 400 | invalid_request_error | Malformed JSON, missing fields, bad question definition, a question whose prompt is over 16,384 tokens, or a request that needs the model to read more than 49,152 tokens in total. param, when present, names the field; for a request over the token limit the message names the question. |
| 400 | invalid_request_error / images_not_supported | Images are not available for this API key.: this key cannot send images (for example the playground’s shared demo access). Images are not available right now.: the model currently serving cannot read images; try again later. Images are not available on this model. (from the model server, without a code) means the same. The request is not processed and not charged. |
| 400 | invalid_request_error / invalid_assets | `assets` must be an object mapping image references to images. Also when images are sent as images or image instead of assets. |
| 400 | invalid_request_error / too_many_images | Too many images: <n> (the maximum is 4 per request). |
| 400 | invalid_request_error / invalid_image_reference | Image reference '<ref>' must be 1-64 letters, digits, spaces, '.', '_' or '-'., Image reference '<ref>' is part of another reference ('<other>'); use references that do not contain each other. or Image references '<ref>' and '<other>' overlap in state. |
| 400 | invalid_request_error / invalid_image | Image '<ref>': data_base64 must be plain base64 without a data: prefix., Image '<ref>': format must be png, jpg or jpeg., Image '<ref>' is not a valid PNG or JPEG file of that format., Image '<ref>' is an animated PNG; send one still image., Image '<ref>' is a CMYK JPEG; send an RGB or grayscale image., Image '<ref>': type must be image., Image '<ref>': unknown field '<name>' (use type, format, data_base64). or Image '<ref>': only data_base64 images are supported (no multipart parts). |
| 400 | invalid_request_error / image_too_large | Image '<ref>' is <n> bytes; the maximum is 4194304 per image and 6291456 per request. |
| 400 | invalid_request_error / image_size | Image '<ref>' is <w> x <h> pixels; each side must be 32-2048 pixels and the image at most 4194304 pixels. |
| 400 | invalid_request_error / image_unreadable | Image '<ref>' could not be read. The file cannot be decoded. The model server can return the same message without a code. |
| 400 | invalid_request_error / image_reference_count | Image '<ref>' must appear exactly once in state (found <n>). |
| 400 | invalid_request_error / image_reference_place | Image references may only appear in state (found '<ref>' in questions.<id>). or Image references may only appear in state string values (found '<ref>' in a key of <path>). |
| 400 | invalid_request_error / reserved_text | state or questions contain a reserved control sequence; remove it. A request with images contains special-token text in angle brackets. |
| 401 | authentication_error | Missing, invalid, revoked or expired API key. |
| 402 | billing_error / insufficient_tokens | Console key only: the account’s token balance cannot cover this request’s reservation. Add prepaid credit, choose a plan or wait for your plan to renew. |
| 402 | billing_error / billing_hold | Console key only: the billing account is on hold. Contact support. |
| 403 | billing_error / sandbox_key | A sandbox (test-mode) key was sent to the production API. Sandbox keys cannot run production requests. |
| 404 | not_found_error / model_not_found | Unknown endpoint or model name. |
| 413 | invalid_request_error / request_too_large | Body over 512 KB. A request with images may be up to 8.5 MB (The request body is <n> bytes; the maximum is 8.5 MB for requests with images and 512 KB otherwise.), and its text without the images must stay within 512 KB (The request without its images is <n> bytes; the maximum is 512 KB.). |
| 429 | rate_limit_error / requests_per_minute | Per-minute limit hit. Wait Retry-After seconds. |
| 429 | rate_limit_error / daily_quota | Daily quota used up; resets at 00:00 UTC. |
| 503 | server_error / model_loading, server_busy | Model starting up, too many requests queued, or too many large image uploads at once. Retry after Retry-After seconds. |
| 502 | server_error | The model server is unreachable or restarting, did not answer in time, or failed on this request. If it did not read a request’s images: The model server did not read the images; the request was not charged. Retry in a moment. Retry with backoff. |
| 500 | server_error | Something broke on our side. Send us the request id. |
Retry 429, 502 and 503 with backoff; do not retry 400, 401, 402, 403, 404 or 413. API responses carry X-Request-Id; an unexpected 500 gives the id in its error message instead.
Cloudflare 403 / 1010
A 403 with the plain-text body "error code: 1010" does not come from the API but from Cloudflare's browser integrity check: it rejects the default Python-urllib User-Agent. Send any User-Agent header (for example my-app/1.0); requests, curl and fetch already do.
Additional reference
Model behavior, question-writing guidance and presets.
What's new in Vansa-3.5
Released 20 October 2026. Vansa-3.5 is the current model and the default for every model name. It uses a 16,384-token context per question.
- Images (21 October 2026): a request can include up to 4 PNG or JPEG images in an optional
assetsmap; image tokens count as input tokens. See Images. - Compatibility: requests without images use the same request and response format as before.
vansa-3.4and every earlier model name keep working and are answered by Vansa-3.5; responses say"model": "vansa-3.5". Existing TypeSafe Jev clients can switch by changing the base URL, key and model. - Measured: On three of our internal test sets, with the same items for both models, Vansa-3.5 answered more decisions correctly than Vansa-3.4 (accuracy averaged over each set's task groups): 78.3 % vs 63.2 % on our hard decision set; 92.9 % vs 89.1 % on never-seen tasks (questions written for our own tests and never published); 72.6 % vs 69.8 % on held-out public classification tasks. Validate the results on your own inputs.
- Instructions inside the state: put the rules your application relies on in
instructionsorcriteria, not inside the state. - Email threads: the email preset's questions and state builder are unchanged. Validate the answers on your own threads.
- Token counts: Vansa-3.5 counts tokens with a different tokenizer than Vansa-3.4, so
usage.input_tokensfor the same request changes with the language and content of the state. In our tests: +4 % for English email text, -1 % for German, +18 % for URLs and log lines and +24 % for Chinese text. A state that just fitted 16,384 tokens per question may now receive400. - Work per request: one request may need the model to read at most 49,152 tokens (see the limits); a larger request receives
400. Use client timeouts of at least 100 seconds and split large batches into several requests.
Use cases
Use Vansa for classification, ordered scoring and yes/no decisions: ticket routing, product feedback, intent detection, content checks, urgency or sentiment scoring, LLM input checks, and selecting an agent’s next action.
Inference is deterministic for the same request, with no generation temperature to set. Answers stay within your defined options. Accuracy varies by task; evaluate on your own inputs.
Writing questions
- Name state fields. Send a JSON object and refer to fields in instructions, such as “Which team should handle the request in
body?” - Describe ambiguous options. Include
otherornone of the abovewhen the categories are not exhaustive. - Ask only relevant questions. Batching saves round trips, but each extra question still adds time.
- Keep relevant context. Remove signatures, footers and unnecessary quoted history. The prompt limit is 16,384 tokens.
- Use English. Use English for the documented examples. Validate other languages on representative inputs before relying on the results.
Email qualification preset
26 fixed questions for guest-post and link-building outreach, plus 26 extras. Use the published question wording and state builder; the questions are asked from the buyer’s perspective.
| File | What it is |
|---|---|
/presets/email-qualification.json | The preset: questions (send as is), extra_questions, the state format, an example state and usage rules. |
/presets/email_state.py | Reference state builder, Python standard library only: cleans text, computes counts, keeps up to eight messages and trims the state to an estimated 1,250-token budget. The built-in token estimate is approximate and can exceed the target; provide a compatible token counter when exact sizing is required. |
/llms.txt | The same guidance for AI coding agents, in plain text. |
State format and preprocessing
{
"site": "example-garden-blog.com",
"subject": "Guest post on example-garden-blog.com",
"roles": "us = our outreach team buying a sponsored guest post; them = the website owner or editor",
"counts": {
"ours": 2,
"theirs": 1,
"auto_replies": 0,
"bounces": 0,
"days_since_last": 112,
"last_from": "us"
},
"messages": [
{
"n": 1,
"from": "us",
"date": "2026-06-02",
"text": "Hi, we would like to publish a sponsored guest post on your site for one of our clients. Do you accept guest posts, and what is your price for one article with one do-follow link?"
},
{
"n": 2,
"from": "them",
"date": "2026-06-03",
"text": "Hello, our price is 150 USD per post, do-follow and permanent. Payment in advance by PayPal."
},
{
"n": 3,
"from": "us",
"date": "2026-06-04",
"text": "Thanks, we will check with our client and get back to you."
}
]
}
usis always the outreach side (the buyer of the post),themthe website. Keeprolesexactly as shown.countsare computed by your code from the mail headers and today's date:theirsexcludes auto-replies and bounces,auto_repliescounts every message with an auto-reply signal (bounces that carry one included),days_since_lastis whole days since the last message,last_fromis who sent it. The time-based outcomes (still open or ghosted after 14 days) depend on them.- Messages: oldest first,
dateasYYYY-MM-DD,textis the new text only (quoted replies,>lines, mail footers and anything after a line of only 2 or 3 dashes or a "Sent from my iPhone/iPad/Samsung/Android" line removed), at most its first 150 words, shortened to the first 120 plus up to 3 later sentences (35 words at most) that carry an amount or a yes/no answer, with " ..." where text was cut (fewer words when a long thread must be trimmed), plus"kind": "auto_reply"or"bounce"when it applies. - Long threads keep the first message, their first real reply and the last 4, with
{"n": "...", "omitted": 7}in place of the rest; the whole state stays under about 1,250 tokens.email_state.pydoes all of this. - Send the state as a JSON object with the keys in this order, not as a string: the model reads it serialised exactly like that.
Request and example answers
# pip install requests; email_state.py from https://docs.vansa.org/presets/email_state.py
import os
import requests
from email_state import build_state
VANSA_KEY = os.environ["VANSA_KEY"] # your API key (vsk_...)
preset = requests.get("https://docs.vansa.org/presets/email-qualification.json", timeout=30).json()
state = build_state(
site="example-garden-blog.com",
subject="Guest post on example-garden-blog.com",
emails=[ # every email with this site; "us" = your outreach team
{"from": "us", "date": "2026-06-02T09:00:00+00:00", "text": "Hi, we would like to publish a sponsored guest post ..."},
{"from": "them", "date": "2026-06-03T10:00:00+00:00", "text": "Hello, our price is 150 USD per post, do-follow and permanent. ..."},
{"from": "us", "date": "2026-06-04T09:00:00+00:00", "text": "Thanks, we will check with our client and get back to you."},
],
)
r = requests.post(
"https://api.vansa.org/v1/systemone",
headers={"Authorization": f"Bearer {VANSA_KEY}"},
json={"model": preset["model"], "state": state, "questions": preset["questions"]},
timeout=100,
)
r.raise_for_status()
answers = r.json()["answers"]
print(answers["outcome"]["choice"], answers["mistake_gave_up_too_early"]["noul"])
Recorded Vansa-3 answers for the example thread above (the site quoted, we promised to come back and never did):
| Question | Answer | Probability |
|---|---|---|
reply | answered | 1.00 |
outcome | ghosted_by_us | 1.00 |
negotiation | price_named_no_decision | 1.00 |
fail_reason | we_dropped | 1.00 |
they_named_price | true | 1.00 |
rounds | 0 (0, we never sent a number) | 1.00 |
prepayment_demanded | yes | 0.99 |
our_handling | ok | 0.98 |
mistake_gave_up_too_early | true | 0.93 |
Preset questions
Exact wording and option descriptions are in the preset file; send them unchanged.
| Id | Type | Answers | What it tells you |
|---|---|---|---|
reply | choice | no_reply · auto_reply · bounce · answered | did the site answer at all, automatically, or bounce |
outcome | choice | published · agreed_not_published · declined_by_them · declined_by_us · ghosted_by_them · ghosted_by_us · still_open · no_response · unclear | where the deal ended: published, agreed, declined, ghosted (either side), still open |
negotiation | choice | no_price_talk · price_named_no_decision · accepted_first_price · negotiated_success · negotiated_fail · firm_price · they_accepted_ours · countered_no_decision | how the price talk went |
fail_reason | choice | not_applicable · none_yet · price_too_high · niche_rejected · no_dofollow · sponsored_tag_only · not_permanent · off_script_terms · no_response · wrong_contact · we_dropped · payment_problem · other | the main reason it was not published |
they_named_price | noul | true / false | did they quote a per-post price |
price_for_igaming | choice | not_mentioned · same_price · different_price | a separate price for betting / casino content |
rounds | score | 0: 0, we never sent a number · 1: 1 number · 2: 2 numbers · 3: 3 or more numbers | how many different amounts we proposed |
dofollow | choice | yes · no · unknown | do-follow links confirmed by them |
permanent | choice | yes · no · unknown | the post stays up for good |
sponsored_tag_required | choice | yes · no · unknown | a sponsored / partnership label is required |
prepayment_demanded | choice | yes · no · unknown | payment before publishing |
article_by_them_only | choice | yes · no · unknown | they insist on writing the article |
links_allowed | choice | not_said · one · two · three_or_more | links allowed in one post |
our_handling | choice | good · ok · mistake | how well our side handled the thread |
mistake_answered_twice | noul | true / false | our mistake: answered twice |
mistake_ignored_their_question | noul | true / false | our mistake: ignored their question |
mistake_wrong_price | noul | true / false | our mistake: wrong price |
mistake_too_pushy | noul | true / false | our mistake: too pushy |
mistake_gave_up_too_early | noul | true / false | our mistake: gave up too early |
mistake_accepted_too_fast | noul | true / false | our mistake: accepted too fast |
mistake_wrong_language | noul | true / false | our mistake: wrong language |
mistake_revealed_client_early | noul | true / false | our mistake: revealed client early |
mistake_promised_payment | noul | true / false | our mistake: promised payment |
mistake_sent_wrong_details | noul | true / false | our mistake: sent wrong details |
mistake_slow_to_answer | noul | true / false | our mistake: slow to answer |
mistake_other | noul | true / false | our mistake: other |
Extra questions
26 additional questions, in extra_questions. Ask them in a second request with the same state (26 + 26 is over the 32-question limit). The "Use answer when" column gives the applicability conditions (extra_questions_only_when in the preset). Ignore an answer when its condition does not hold.
| Id | Type | Answers | Use answer when |
|---|---|---|---|
last_from | choice | us · them | always |
replies_bucket | score | 0: none · 1: one · 2: two or three · 3: four or more | always |
thread_age | choice | under_2_weeks · 2_to_8_weeks · over_8_weeks | always |
payment_method | choice | none · paypal · bank_transfer · card · crypto · wise · other | always |
invoice_mentioned | noul | true / false | always |
deadline_mentioned | noul | true / false | always |
word_count_bucket | choice | not_said · under_500 · 500_to_999 · 1000_or_more | only when counts.theirs > 0 |
niche_restrictions_mentioned | noul | true / false | always |
tone_of_them | choice | friendly · neutral · cold · hostile | only when counts.theirs > 0 |
tone_of_us | choice | friendly · neutral · pushy | always |
they_asked_question_last | noul | true / false | always |
we_promised_to_return | noul | true / false | always |
discount_offered | noul | true / false | always |
package_offer | noul | true / false | always |
language_mismatch | noul | true / false | always |
their_last_topic | choice | price · terms · article_requirements · scheduling · rejection · question · confirmation · payment · other | only when counts.theirs > 0 |
they_requested_something | noul | true / false | always |
we_sent_article | noul | true / false | always |
published_url_shared | noul | true / false | always |
they_asked_payment_proof | noul | true / false | always |
distinct_prices_bucket | score | 0: none · 1: one · 2: two · 3: three or more | always |
price_band | score | 0: no price named · 1: under 50 · 2: 50 to 99 · 3: 100 to 199 · 4: 200 to 499 · 5: 500 or more | only when counts.theirs > 0 |
currency | choice | none · EUR · USD · GBP · other | only when counts.theirs > 0 |
price_came_down | noul | true / false | only when they named a price and a final price was agreed |
they_lowered_price | noul | true / false | only when they named a price |
igaming_costs_more | noul | true / false | only when they named both a normal and an iGaming price |
Preset limitations
- No amounts or text. Prices, counters, the final price, a suggested next reply and free-text notes are not answered: extract numbers with code or an LLM.
they_named_price,roundsand the price extras cross-check such an extraction. - Review uncertain answers. Use
confidenceto send doubtful threads to a person. - Fresh threads. Check low-confidence answers on threads that are still moving.
- Language. Use English for the documented examples. Validate other languages on representative inputs before relying on the results.
- Perspective. The published questions use the buyer's perspective. Validate changes in perspective or roles on your own inputs.