Answer a Decider with OpenAI's Decisions API
A support desk triages every ticket with three questions: is it a refund request, which department owns it, how urgent is it. A decider named ticket-triage holds the questions, versioned; OpenAI's Decisions API answers them with probabilities rather than text.
The decider's questions, input and answers are the shapes of OpenAI's Decisions API, so an http tool that forwards to /v1/decisions answers the decider as it is: no pipeline, no mapping.
Prerequisites
- SOAT running locally (Quick Start); Key Concepts.
- Route Work with a Decider for deciders and tool backends.
- An OpenAI API key. Not needed against the mock endpoint the tutorial tests run with.
api.openai.cominTOOL_EGRESS_ALLOWED_HOSTSwhen your server restricts tool egress.- CLI or SDK; server at
http://localhost:5047.
export SOAT_BASE_URL=http://localhost:5047 # CLI, SDK, and curl — do NOT append /api/v1
# The default is the real endpoint; overridable so the tutorial also runs against a mock.
export OPENAI_BASE_URL="${OPENAI_BASE_URL:-https://api.openai.com/v1}"
export OPENAI_API_KEY="${OPENAI_API_KEY:-sk-your-openai-key}"
Step 1 — Log in as admin
See Users for authentication.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login!.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "openai-triage" | jq -r '.id')
echo "Project: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'openai-triage' },
});
const PROJECT_ID = project!.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"openai-triage"}' | jq -r '.id')
Step 3 — Reach the Decisions API from a tool
The key goes in a secret, and the http tool names it by reference, so no read of the tool returns it. A decider calls its tool with { input, questions }, the Decisions API request less model; model is pinned in preset_parameters, which are merged into every call.
- CLI
- SDK
- curl
SECRET_ID=$(soat create-secret --project-id "$PROJECT_ID" \
--name openai-api-key --value "$OPENAI_API_KEY" | jq -r '.id')
OPENAI_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name openai-decisions \
--type http \
--description "OpenAI Decisions API" \
--parameters '{"type":"object","properties":{"input":{},"questions":{"type":"array"}},"required":["input","questions"]}' \
--execute '{
"url": "'"$OPENAI_BASE_URL"'/decisions",
"method": "POST",
"headers": {"Authorization": "Bearer {{secret:'"$SECRET_ID"'}}"}
}' \
--preset-parameters '{"model": "gpt-6-luna"}' | jq -r '.id')
echo "OpenAI tool: $OPENAI_TOOL_ID"
const { data: secret } = await adminSoat.secrets.createSecret({
body: {
project_id: PROJECT_ID,
name: 'openai-api-key',
value: process.env.OPENAI_API_KEY!,
},
});
const { data: openaiTool } = await adminSoat.tools.createTool({
body: {
project_id: PROJECT_ID,
name: 'openai-decisions',
type: 'http',
description: 'OpenAI Decisions API',
parameters: {
type: 'object',
properties: { input: {}, questions: { type: 'array' } },
required: ['input', 'questions'],
},
execute: {
url: `${process.env.OPENAI_BASE_URL}/decisions`,
method: 'POST',
headers: { Authorization: `Bearer {{secret:${secret!.id}}}` },
},
preset_parameters: { model: 'gpt-6-luna' },
},
});
const OPENAI_TOOL_ID = openaiTool!.id;
SECRET_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/secrets" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","name":"openai-api-key","value":"'"$OPENAI_API_KEY"'"}' \
| jq -r '.id')
OPENAI_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_id": "'"$PROJECT_ID"'",
"name": "openai-decisions",
"type": "http",
"description": "OpenAI Decisions API",
"parameters": {"type":"object","properties":{"input":{},"questions":{"type":"array"}},"required":["input","questions"]},
"execute": {
"url": "'"$OPENAI_BASE_URL"'/decisions",
"method": "POST",
"headers": {"Authorization": "Bearer {{secret:'"$SECRET_ID"'}}"}
},
"preset_parameters": {"model": "gpt-6-luna"}
}' | jq -r '.id')
Step 4 — Create the decider
One question of each type the Decisions API answers. The decider stores them as version 1; every decision names the version it was answered under.
- CLI
- SDK
- curl
DECIDER_ID=$(soat create-decider \
--project-id "$PROJECT_ID" \
--name ticket-triage \
--tool-id "$OPENAI_TOOL_ID" \
--questions '[
{
"type": "predicate",
"name": "refund_request",
"instructions": "Is the customer asking for money back?"
},
{
"type": "choice",
"name": "department",
"instructions": "Which department should handle this ticket?",
"choices": [
{"value": "billing", "description": "Payments, invoices and refunds."},
{"value": "technical", "description": "Problems using the product."},
{"value": "shipping", "description": "Delivery and tracking."}
]
},
{
"type": "score",
"name": "urgency",
"instructions": "How urgent is this ticket?",
"levels": [
{"label": "Low", "description": "Can wait for the regular queue."},
{"label": "Medium", "description": "Should be handled today."},
{"label": "High", "description": "Blocks the customer right now."}
]
}
]' | jq -r '.id')
echo "Decider: $DECIDER_ID"
const QUESTIONS = [
{
type: 'predicate',
name: 'refund_request',
instructions: 'Is the customer asking for money back?',
},
{
type: 'choice',
name: 'department',
instructions: 'Which department should handle this ticket?',
choices: [
{ value: 'billing', description: 'Payments, invoices and refunds.' },
{ value: 'technical', description: 'Problems using the product.' },
{ value: 'shipping', description: 'Delivery and tracking.' },
],
},
{
type: 'score',
name: 'urgency',
instructions: 'How urgent is this ticket?',
levels: [
{ label: 'Low', description: 'Can wait for the regular queue.' },
{ label: 'Medium', description: 'Should be handled today.' },
{ label: 'High', description: 'Blocks the customer right now.' },
],
},
];
const { data: decider } = await adminSoat.deciders.createDecider({
body: {
project_id: PROJECT_ID,
name: 'ticket-triage',
tool_id: OPENAI_TOOL_ID,
questions: QUESTIONS,
},
});
const DECIDER_ID = decider!.id;
DECIDER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/deciders" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_id": "'"$PROJECT_ID"'",
"name": "ticket-triage",
"tool_id": "'"$OPENAI_TOOL_ID"'",
"questions": [
{"type": "predicate", "name": "refund_request", "instructions": "Is the customer asking for money back?"},
{"type": "choice", "name": "department", "instructions": "Which department should handle this ticket?", "choices": [
{"value": "billing", "description": "Payments, invoices and refunds."},
{"value": "technical", "description": "Problems using the product."},
{"value": "shipping", "description": "Delivery and tracking."}
]},
{"type": "score", "name": "urgency", "instructions": "How urgent is this ticket?", "levels": [
{"label": "Low", "description": "Can wait for the regular queue."},
{"label": "Medium", "description": "Should be handled today."},
{"label": "High", "description": "Blocks the customer right now."}
]}
]
}' | jq -r '.id')
Step 5 — Triage a ticket
Request a decision with POST /api/v1/deciders/{decider_id}/decisions. The ticket is the input; metadata keeps its id on the decision, since the input itself is not stored.
- CLI
- SDK
- curl
DECISION=$(soat create-decision \
--decider-id "$DECIDER_ID" \
--input "I was charged twice for order 1042. Please refund the duplicate." \
--metadata '{"ticket_id": "ZD-1042"}' \
--wait true)
echo "$DECISION" | jq '{status, decider_version, answers}'
test "$(echo "$DECISION" | jq -r '.status')" = "completed"
Expected output against the mock (OpenAI's own figures vary):
{
"status": "completed",
"decider_version": 1,
"answers": [
{ "type": "predicate", "name": "refund_request", "probability": 0.92 },
{
"type": "choice",
"name": "department",
"choice": "billing",
"probabilities": [
{ "value": "billing", "probability": 0.9 },
{ "value": "technical", "probability": 0.05 },
{ "value": "shipping", "probability": 0.05 }
],
"confidence": 0.88
},
{
"type": "score",
"name": "urgency",
"score": 1,
"probabilities": [
{ "value": 0, "label": "Low", "probability": 0.15 },
{ "value": 1, "label": "Medium", "probability": 0.7 },
{ "value": 2, "label": "High", "probability": 0.15 }
],
"confidence": 0.55
}
]
}
const { data: decision } = await adminSoat.deciders.createDecision({
path: { decider_id: DECIDER_ID },
body: {
input: 'I was charged twice for order 1042. Please refund the duplicate.',
metadata: { ticket_id: 'ZD-1042' },
wait: true,
},
});
console.log(decision!.status, decision!.answers);
curl -s -X POST "$SOAT_BASE_URL/api/v1/deciders/$DECIDER_ID/decisions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"input": "I was charged twice for order 1042. Please refund the duplicate.",
"metadata": {"ticket_id": "ZD-1042"},
"wait": true
}' | jq '{status, decider_version, answers}'
The answers are OpenAI's, validated against the decider's questions and stored in question order. probability, probabilities and confidence come from the Decisions API; a decider answered by an agent records a predicate as 1 or 0 and carries neither.
Step 6 — Read an answer by name
The caller sets the threshold on probability. answers_by_name keys the same answers by question name, the path an orchestration condition or a JSON Logic expression reads.
- CLI
- SDK
- curl
echo "$DECISION" | jq '{
refund: (.answers_by_name.refund_request.probability >= 0.8),
department: .answers_by_name.department.choice,
urgency: .answers_by_name.urgency.score
}'
Expected output against the mock:
{ "refund": true, "department": "billing", "urgency": 1 }
const byName = decision!.answers_by_name!;
console.log({
refund: byName.refund_request.probability! >= 0.8,
department: byName.department.choice,
urgency: byName.urgency.score,
});
curl -s "$SOAT_BASE_URL/api/v1/decisions?project_id=$PROJECT_ID&decider_id=$DECIDER_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '.data[0].answers_by_name | {
refund: (.refund_request.probability >= 0.8),
department: .department.choice,
urgency: .urgency.score
}'
Step 7 — Ask once, without a decider
For a one-off question, POST /api/v1/decisions takes the questions with the request, the way OpenAI's endpoint does, and names the tool in place of model. The decision carries its own questions and no decider_id.
- CLI
- SDK
- curl
INLINE=$(soat create-inline-decision \
--project-id "$PROJECT_ID" \
--tool-id "$OPENAI_TOOL_ID" \
--questions '[{"type": "predicate", "name": "abusive", "instructions": "Is the message abusive toward the agent?"}]' \
--input "Thanks for the quick help!" \
--wait true)
echo "$INLINE" | jq '{status, decider_id, answers}'
test "$(echo "$INLINE" | jq -r '.status')" = "completed"
Expected output against the mock:
{
"status": "completed",
"decider_id": null,
"answers": [{ "type": "predicate", "name": "abusive", "probability": 0.92 }]
}
const { data: inline } = await adminSoat.deciders.createInlineDecision({
body: {
project_id: PROJECT_ID,
tool_id: OPENAI_TOOL_ID,
questions: [
{
type: 'predicate',
name: 'abusive',
instructions: 'Is the message abusive toward the agent?',
},
],
input: 'Thanks for the quick help!',
wait: true,
},
});
console.log(inline!.answers);
curl -s -X POST "$SOAT_BASE_URL/api/v1/decisions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_id": "'"$PROJECT_ID"'",
"tool_id": "'"$OPENAI_TOOL_ID"'",
"questions": [{"type": "predicate", "name": "abusive", "instructions": "Is the message abusive toward the agent?"}],
"input": "Thanks for the quick help!",
"wait": true
}' | jq '{status, decider_id, answers}'
An input may also be a list of user messages with input_text and base64 input_image parts, as on OpenAI's endpoint; the tool forwards it unchanged.
What the tool path costs
- A decision answered this way is metered as a
tool_executionevent withsource: decider, not as a generation: OpenAI's token cost does not appear on SOAT's generation receipts. - An error from OpenAI, such as a
401for a bad key or a429, settles the decisionfailedwith the tool's code. Failure is terminal: request a new decision. - An answer outside the questions settles it
failedwithDECISION_ANSWER_INVALID(The tool backend).
Next Steps
- Branch an orchestration on the answer: Route Work with a Decider.
- Grade an eval with the same decider: Grade an Eval with a Decider.
- Bridge an engine that speaks a neighbouring shape: Bridging an engine with a pipeline.