Deciders
A decider is a named, versioned question set. Evaluated against an input, it produces a decision: one answer per question, each confined to the answer space the question declares, recorded append-only.
Overview
Getting a machine-readable judgment out of an agent otherwise means configuring an output_schema and remembering, at every call site, which schema and which prompt produced the answer. A decider makes the judgment a resource: the questions are stored and versioned, the caller supplies only the input, and every decision names the version it was answered under.
- The questions come from the decider, never from the request, so no call site can widen what is judged. A one-off judgment sends its own questions instead; see Inline questions.
- Every answer is confined to its question's answer space: SOAT compiles the questions to the schema the answer must satisfy and refuses anything outside it.
- A decision is append-only: its
answersare written once, when it settles, and never rewritten. - Two backends answer: a tool-less agent, which SOAT prompts, or a tool, which receives the questions and answers them — a classifier, a calibrated model or any endpoint of your own.
- The shapes are OpenAI's Decisions API: questions, input and answers are those of its request and response, so a tool that forwards to
https://api.openai.com/v1/decisions, withmodelas a preset parameter, answers a decider unchanged.
Related Tutorials
- Route Work with a Decider — a tool-backed decider applies rules to a customer's actor tags, and an orchestration branches on its answer
- Grade an Eval with a Decider — a decider backed by TypeSafe Jev reviews a live reply, then grades every item of an eval
- Answer a Decider with OpenAI's Decisions API — an
httptool forwards a decider's questions to OpenAI
See the Permissions Reference for the IAM action strings for this module.
Data Model
Decider
| Field | Type | Description |
|---|---|---|
id | string | Public identifier (e.g. dcd_…) |
project_id | string | ID of the owning project |
name | string | Human-readable name, unique per project |
description | string | Optional |
agent_id | string | The tool-less agent that answers, or null |
tool_id | string | The tool that answers, or null |
version | integer | The question set's version; see Versions |
questions | array | 1 to 20 questions, in the order the model is shown them |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
Decision
| Field | Type | Description |
|---|---|---|
id | string | Public identifier (e.g. dec_…) |
project_id | string | ID of the owning project |
decider_id | string | The decider asked; kept after the decider is deleted. Null for an inline decision |
decider_version | integer | The question-set version the decision was answered under. Null for an inline decision |
questions | array | The questions an inline decision was answered under. Null for a decider's decision |
status | string | queued | running | completed | failed |
answers | array | One answer per question, in question order. Null until the decision completes |
answers_by_name | object | The same answers keyed by question name. Null until the decision completes |
error | object | { code, message, meta? } on a failed decision (recorded failure) |
generation_id | string | The generation that answered; its receipt carries what the decision cost. Null for a tool |
metadata | object | Caller-owned annotations, written once with the decision |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
The input is not stored. Put the id of what was judged in metadata ({ "ticket_id": "ZD-48213" }) to find a decision again.
answers_by_name lets a JSON Logic path read an answer by name — answers_by_name.route.choice — rather than by position, answers.0.choice.
Questions
The question and answer shapes are those of OpenAI's Decisions API. questions is an array of 1 to 20 entries, shown to the model in that order. Every question has a type, a name and instructions:
type | Judges | Extra field |
|---|---|---|
predicate | Whether a condition holds | — |
choice | Which option applies | choices: 2 to 20 { value, description }; each value unique within the question |
score | Where it sits on a scale | levels: 2 to 20 { label, description }, ordered; a level is named by its zero-based index |
A name is unique within the set, starts with a letter or underscore and holds only letters, digits and underscores (at most 64). Every value, label and description is a non-empty string, and no field outside the table is accepted. A violation is 400 VALIDATION_FAILED, naming the path.
Answers
answers holds one entry per question, in question order, named after it:
type | Answer |
|---|---|
predicate | { "type": "predicate", "name", "probability" } — the probability, 0 to 1, the condition holds |
choice | { "type": "choice", "name", "choice", "probabilities"?, "confidence"? } — choice is a value |
score | { "type": "score", "name", "score", "probabilities"?, "confidence"? } — score is in 0 to levels − 1 |
probabilities is a distribution over the answer space: [{ "value": "billing", "probability": 0.8 }] for a choice, [{ "value": 1, "label": "Workaround exists", "probability": 0.7 }] for a score, value being the level index and label the decider's. probabilities and confidence come only from a tool backend; SOAT carries them as supplied and does not vouch for their meaning. An agent answers a predicate true or false, recorded as probability 1 or 0, and a score with a level index.
- CLI
- SDK
- curl
soat create-decider \
--project-id proj_… \
--name support-triage \
--agent-id agent_… \
--questions '[
{
"type": "choice",
"name": "route",
"instructions": "Which team should own this ticket?",
"choices": [
{ "value": "billing", "description": "Charges, refunds and invoices." },
{ "value": "technical", "description": "Errors and outages." }
]
},
{
"type": "score",
"name": "severity",
"instructions": "How urgent is this ticket?",
"levels": [
{ "label": "Cosmetic", "description": "Appearance only." },
{ "label": "Workaround exists", "description": "A task fails, but another way works." },
{ "label": "Blocked", "description": "No workaround." }
]
},
{
"type": "predicate",
"name": "needs_human",
"instructions": "Must a person read this before any automated reply?"
}
]'
const { data, error } = await soat.deciders.createDecider({
body: {
project_id: 'proj_…',
name: 'support-triage',
agent_id: 'agent_…',
questions: [
{
type: 'choice',
name: 'route',
instructions: 'Which team should own this ticket?',
choices: [
{ value: 'billing', description: 'Charges, refunds and invoices.' },
{ value: 'technical', description: 'Errors and outages.' },
],
},
{
type: 'score',
name: 'severity',
instructions: 'How urgent is this ticket?',
levels: [
{ label: 'Cosmetic', description: 'Appearance only.' },
{ label: 'Workaround exists', description: 'A task fails, but another way works.' },
{ label: 'Blocked', description: 'No workaround.' },
],
},
{
type: 'predicate',
name: 'needs_human',
instructions: 'Must a person read this before any automated reply?',
},
],
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/deciders \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"project_id": "proj_…",
"name": "support-triage",
"agent_id": "agent_…",
"questions": [
{
"type": "choice",
"name": "route",
"instructions": "Which team should own this ticket?",
"choices": [
{ "value": "billing", "description": "Charges, refunds and invoices." },
{ "value": "technical", "description": "Errors and outages." }
]
},
{
"type": "score",
"name": "severity",
"instructions": "How urgent is this ticket?",
"levels": [
{ "label": "Cosmetic", "description": "Appearance only." },
{ "label": "Workaround exists", "description": "A task fails, but another way works." },
{ "label": "Blocked", "description": "No workaround." }
]
},
{
"type": "predicate",
"name": "needs_human",
"instructions": "Must a person read this before any automated reply?"
}
]
}'
Key Concepts
Backends
A decider names exactly one of agent_id and tool_id. Naming one on PATCH /api/v1/deciders/{decider_id} replaces the other; naming both, on a create or an update, is 400. The backend is not part of the question set, so changing it leaves version alone.
The agent runs tool-less
A decider's agent may carry no tool surface: no binding left active by active_tool_ids, and no knowledge_config.write_memory_store_id. A tool call is the only thing that parks a generation, and a decision has no route to resume one; nor may evaluating an input take a side effect the decision cannot record. Knowledge retrieval is inline, not a tool, and stays available.
The rule is checked when a decider is written and again when a decision is requested, since an agent is editable after a decider names it. Either refusal is 400 DECIDER_AGENT_NOT_TOOL_LESS. A decider also keeps its agent from being deleted: DELETE /api/v1/agents/{agent_id} answers 409 AGENT_HAS_DEPENDENTS with meta.decider_count, even with force=true.
The agent stays thin: a model, a provider and, optionally, domain instructions. SOAT supplies the rest per decision, so one agent can back many deciders.
What the agent receives
For each decision SOAT builds two things from the questions:
- The output schema: an object with one property per question name, all required — a boolean for a
predicate, one of thevalues for achoice, an integer from 0 to levels − 1 for ascore. It replaces the agent's ownoutput_schemafor that generation. - The frame: the input message, holding the questions with their instructions, choices and levels, then the input. The agent's
instructionsremain the system prompt.
For the decider above and the input I was charged twice., the frame reads:
Answer every question below about the input that follows. Choose each answer only from the ones its question offers.
## Questions
### route (choice)
Which team should own this ticket?
Answer with one of these values:
- billing: Charges, refunds and invoices.
- technical: Errors and outages.
### severity (score)
How urgent is this ticket?
Answer with the number of the level that fits:
- 0: Cosmetic. Appearance only.
- 1: Workaround exists. A task fails, but another way works.
- 2: Blocked. No workaround.
### needs_human (predicate)
Must a person read this before any automated reply?
Answer true if the condition holds, false otherwise.
## Input
<input>
I was charged twice.
</input>
The frame is kept in the generation's transcript, so every decision's generation shows exactly what the model was given. Its wording belongs to SOAT, not to the decider: decider_version tracks the questions, so a release that rewords the frame can move answers on an unchanged decider.
Input
input takes the forms of OpenAI's Decisions API:
| Form | Example | The agent is shown |
|---|---|---|
| A string | "I was charged twice." | The string as written |
| User messages | [{ "role": "user", "content": [{ "type": "input_text", "text": "Is this damaged?" }, { "type": "input_image", "image_url": "data:image/png;base64,…" }] }] | The text of every message, joined; each image is attached after the frame |
| Any other JSON | { "customer_id": "act_…", "amount": 400 } | Indented JSON |
An array whose every entry carries role is read as messages and held to that shape: role is user, content is a string or a list of input_text and input_image parts, and an image is a base64 data URL. Anything else in it is 400 VALIDATION_FAILED. A request without input is 400 too. A tool receives input exactly as sent.
The tool backend
A decider can point at a tool instead of an agent. SOAT calls it as it calls any tool — with its egress rules, preset parameters, output mapping and guardrails — and reads its answer against the questions.
Only http and pipeline tools can back a decider. A client tool has no caller to hand the call to, and an mcp or builtin tool needs an action a decider does not carry: wrap it in a pipeline step, which names one. A tool whose preset_parameters pin input or questions is refused as well, since presets are merged over what the decider sends. Each is 400 DECIDER_TOOL_NOT_CALLABLE, checked when the decider is written and again when a decision is requested. A decider keeps its tool from being deleted: DELETE /api/v1/tools/{tool_id} answers 409 TOOL_HAS_DEPENDENTS with meta.decider_count.
The tool runs as whoever requested the decision. A builtin pipeline step calls the API with the requester's credential, so it reads and writes only what the requester may: a step whose call the requester may not make fails the decision with PIPELINE_STEP_FAILED.
The tool receives the request body of OpenAI's Decisions API without model: the input as sent and the questions as stored, with no frame.
{
"input": "I was charged twice.",
"questions": [
{ "type": "choice", "name": "route", "instructions": "…", "choices": [{ "value": "billing", "description": "…" }, { "value": "technical", "description": "…" }] }
]
}
It answers, after its own output mapping, with that API's response:
{
"answers": [
{
"type": "choice",
"name": "route",
"choice": "billing",
"probabilities": [{ "value": "billing", "probability": 0.8 }, { "value": "technical", "probability": 0.2 }],
"confidence": 0.8
},
{ "name": "severity", "score": 1.3 },
{ "name": "needs_human", "probability": 0.1 }
]
}
- The answer is a JSON object, or text that parses to one. Keys beside
answersare ignored. answersanswers every question exactly once, in any order; the decision stores them in question order. Anamethat names no question is refused.typeis optional and must match the question's.- A
predicatecarriesprobability(0 to 1). Achoicecarrieschoice, one of its values. Ascorecarriesscore, a number from 0 to levels − 1: a tool may weigh levels into a value between two. - A
choiceorscoremay carryprobabilities, each entry avalue(a choice value, or a level index) and aprobability, no value twice, andconfidence(0 to 1). A score entry'slabelis accepted and replaced by the decider's. - Any other field is refused.
An answer outside that contract settles the decision failed with DECISION_ANSWER_INVALID, naming the answer and the field. A failed call settles it with the tool's own code, such as TOOL_HTTP_ERROR.
Answering with OpenAI's Decisions API
Since the tool receives the request body of OpenAI's Decisions API and answers in its response, an http tool that forwards to https://api.openai.com/v1/decisions, with model as a preset parameter, answers a decider unchanged (tutorial):
{
"name": "openai-decisions",
"type": "http",
"execute": {
"url": "https://api.openai.com/v1/decisions",
"method": "POST",
"headers": { "Authorization": "Bearer {{secret:sec_…}}" }
},
"preset_parameters": { "model": "gpt-6-luna" }
}
Bridging an engine with a pipeline
A judgment engine that speaks almost this shape — questions in, answers out, but its own spelling for a field — is bridged with a pipeline tool rather than code: a step's input rewrites the request, and the pipeline's output rewrites the answer. For an engine that takes the input as text and answers each question in a results map keyed by name, with a predicate's probability as p_true:
{
"name": "engine-triage",
"type": "pipeline",
"pipeline": {
"steps": [{
"id": "call",
"tool_id": "tool_…",
"input": {
"text": { "var": "input.input" },
"questions": { "var": "input.questions" }
}
}],
"output": {
"answers": [
{
"name": "route",
"choice": { "var": "steps.call.results.route.choice" },
"probabilities": { "var": "steps.call.results.route.probabilities" }
},
{
"name": "needs_human",
"probability": { "var": "steps.call.results.needs_human.p_true" }
}
]
}
}
}
The output names each answer field, which is what leaves the engine's own extras behind. It also names each question: a question added to the decider later fails its decisions on the missing answer until the pipeline names it too.
Versions
version starts at 1 and increments on every write that changes questions; each version's question set is archived. A rename, a new description, a new backend or a rewrite of the questions the decider already holds leaves it alone. Because every decision names its decider_version, rewording a level never reinterprets an earlier answer: read the questions a decision was answered under with GET /api/v1/deciders/{decider_id}/versions/{version}.
PATCH /api/v1/deciders/{decider_id} takes expected_version (or If-Match) and answers 409 VERSION_CONFLICT on a stale one. POST /api/v1/deciders/{decider_id}/versions/{version}/restore writes an archived question set back as a new version.
Requesting a decision
POST /api/v1/deciders/{decider_id}/decisions takes input, optional metadata and wait. The decision is a run: with wait omitted it is 201 in status: queued, and with wait: true it is 201 settled. Poll GET /api/v1/decisions/{decision_id} or subscribe to the events below; see Synchronous & Asynchronous Execution for the contract, including how a builtin tool or MCP call waits.
- CLI
- SDK
- curl
soat create-decision \
--decider-id dcd_… \
--input 'I was charged twice for the same order.' \
--metadata '{ "ticket_id": "ZD-48213" }' \
--wait true
const { data, error } = await soat.deciders.createDecision({
path: { decider_id: 'dcd_…' },
body: {
input: 'I was charged twice for the same order.',
metadata: { ticket_id: 'ZD-48213' },
wait: true,
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/deciders/dcd_…/decisions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"input": "I was charged twice for the same order.",
"metadata": { "ticket_id": "ZD-48213" },
"wait": true
}'
A settled agent decision on the decider above reads:
{
"id": "dec_…",
"decider_id": "dcd_…",
"decider_version": 1,
"questions": null,
"status": "completed",
"answers": [
{ "type": "choice", "name": "route", "choice": "billing" },
{ "type": "score", "name": "severity", "score": 1 },
{ "type": "predicate", "name": "needs_human", "probability": 0 }
],
"answers_by_name": {
"route": { "type": "choice", "name": "route", "choice": "billing" },
"severity": { "type": "score", "name": "severity", "score": 1 },
"needs_human": { "type": "predicate", "name": "needs_human", "probability": 0 }
},
"metadata": { "ticket_id": "ZD-48213" }
}
Everything that can refuse the request runs before the decision is written, so a refusal is never a polled failure: invalid input or questions, the agent's tool surface or a tool that cannot answer (400), a paused project (409 PROJECT_PAUSED) and, for an agent, quota admission (429 QUOTA_EXCEEDED).
A decision that settles failed carries the reason in error.code — for example OUTPUT_SCHEMA_VALIDATION_FAILED when the model answered outside the answer space, or DECISION_ANSWER_INVALID when a tool did. Failure is terminal: request a new decision.
GET /api/v1/decisions lists a project's decisions, filtered by decider_id and status. Deleting a decider leaves its decisions in place.
Inline questions
A judgment asked once, or composed by the caller, needs no decider: POST /api/v1/decisions takes the questions with the request, the input, exactly one of agent_id and tool_id, and optional metadata and wait. It is the request of OpenAI's Decisions API with the backend in place of model.
The backend is held to the rules of a decider's (tool-less agent, callable tool), the questions to those of Questions, and admission, wait and the tool contract are those of Requesting a decision. Naming both backends or neither is 400 VALIDATION_FAILED; an agent or tool outside the project is 400 AGENT_NOT_FOUND or 400 TOOL_NOT_FOUND.
No version names the questions, so the decision stores them in questions, with decider_id and decider_version null. Filter a listing to decider_id to leave inline decisions out.
- CLI
- SDK
- curl
soat create-inline-decision \
--project-id proj_… \
--agent-id agent_… \
--questions '[
{
"type": "predicate",
"name": "visible_damage",
"instructions": "Does the product have visible damage?"
}
]' \
--input '[
{
"role": "user",
"content": [
{ "type": "input_text", "text": "Photo of the returned item." },
{ "type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo…" }
]
}
]' \
--metadata '{ "return_id": "RMA-1042" }' \
--wait true
const { data, error } = await soat.deciders.createInlineDecision({
body: {
project_id: 'proj_…',
agent_id: 'agent_…',
questions: [
{
type: 'predicate',
name: 'visible_damage',
instructions: 'Does the product have visible damage?',
},
],
input: [
{
role: 'user',
content: [
{ type: 'input_text', text: 'Photo of the returned item.' },
{ type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgo…' },
],
},
],
metadata: { return_id: 'RMA-1042' },
wait: true,
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/decisions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"project_id": "proj_…",
"agent_id": "agent_…",
"questions": [
{
"type": "predicate",
"name": "visible_damage",
"instructions": "Does the product have visible damage?"
}
],
"input": [
{
"role": "user",
"content": [
{ "type": "input_text", "text": "Photo of the returned item." },
{ "type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo…" }
]
}
],
"metadata": { "return_id": "RMA-1042" },
"wait": true
}'
The IAM action is deciders:CreateInlineDecision, on the decision resource type.
Interrupted decisions
A decision is evaluated by the process that accepted it. One still unsettled when its lease expires — the process stopped — is settled failed with DECISION_INTERRUPTED by a sweep. The input was never stored, so it cannot be re-run: request a new decision.
Metering
A decision's generation is metered like any other, with source: decider, so decision spend is separable in usage rollups; an inline decision is metered the same way. A tool-backed decision is a tool_execution event with the same source; it has no generation, so it admits no generation quota. Decision rows count toward the project's record_gb_day.
Grading an eval
A decider scorer grades each item of an eval run with a decision, pinned to the decider version the run started under, so the questions production asks are the ones the eval measures.
Events
| Event | When |
|---|---|
decisions.completed | A decision settled with its answers |
decisions.failed | A decision settled without answers; the payload carries error |
Both carry the decision, inline or not, with answers and answers_by_name. See Webhooks.
Formations
A decider formation resource (DeciderResourceProperties) takes name, description, questions (the array of Questions) and exactly one of agent_id and tool_id, each of which may be a { "ref": "ResourceName" } to an agent or tool resource in the same template. The questions are validated as on POST /api/v1/deciders, and a template update that changes them archives a new version. Tearing a stack down deletes a decider before the agent or tool it names; a decider outside the stack still keeps that agent or tool, and the teardown is refused before anything is deleted.
{
"resources": {
"Judge": {
"type": "agent",
"properties": { "ai_provider_id": "aip_…", "name": "triage-judge" }
},
"Triage": {
"type": "decider",
"properties": {
"name": "support-triage",
"agent_id": { "ref": "Judge" },
"questions": [
{
"type": "predicate",
"name": "needs_human",
"instructions": "Must a person read this before any automated reply?"
}
]
}
}
}
}
Configuration
| Variable | Default | Description |
|---|---|---|
DECISIONS_SCHEDULER_INTERVAL_MS | 60000 | How often the interrupted-decision sweep runs |
Errors
| Code | Status | When |
|---|---|---|
DECIDER_AGENT_NOT_TOOL_LESS | 400 | The agent has a tool surface, on a decider write or a decision request |
DECIDER_TOOL_NOT_CALLABLE | 400 | The tool is not http or pipeline, or pins input or questions |
AGENT_NOT_FOUND | 400 | agent_id names no agent in the project |
TOOL_NOT_FOUND | 400 | tool_id names no tool in the project |
VALIDATION_FAILED | 400 | Invalid questions or input, a write naming both backends, or one naming neither |
NAME_CONFLICT | 409 | Another decider in the project has the name |
VERSION_CONFLICT | 409 | expected_version is stale |
PROJECT_PAUSED | 409 | The project is paused |
QUOTA_EXCEEDED | 429 | A generation quota is exhausted |
DECISION_INTERRUPTED | — | Recorded on a decision whose evaluation stopped before it settled |
DECISION_ANSWER_INVALID | — | Recorded on a decision whose tool answered outside the contract |