Decisions with the SDK
A decision sends JSON state to a model and asks one or more typed questions.
Each question ID becomes a key in the response's answers object.
Create a decision
from modaic import Modaic
modaic = Modaic()
result = modaic.decisions.create(
state={
"ticket": "I was charged twice for order 4832. Please refund one charge."
},
model="acme/support-priority",
questions={
"needs_refund": {
"type": "noul",
"instructions": "Should this customer receive a refund?",
"criteria": {
"true": "A duplicate or invalid charge should be refunded.",
"false": "The charge is valid or more information is required.",
},
},
"priority": {
"type": "choice",
"instructions": "Choose the support priority.",
"criteria": {
"low": "No financial or time-sensitive impact.",
"normal": "Routine customer issue.",
"high": "Financial impact or an urgent blocker.",
},
},
},
idempotency_key="ticket-4832-v1",
)
print(result.answers["needs_refund"].noul)
print(result.answers["priority"].choice)
print(result.usage.input_tokens)
Use AsyncModaic and await modaic.decisions.create(...) for asynchronous
Python. The arguments and response models are otherwise identical.
The acme/support-priority example uses a saved model, not
ZDR direct inference. For approved ZDR
base-model calls, an idempotencyKey or idempotency_key is ignored and each
call is a new operation.
Run a model directly
Models returned by models.create, models.get, or models.update also expose
model.decisions.create. It accepts the same decision options except model,
which is supplied automatically.
model = modaic.models.get(workspace="acme", model="support-priority")
result = model.decisions.create(
state={"ticket": "I was charged twice. Please refund one charge."},
)
With AsyncModaic, await both the model lookup and model.decisions.create(...).
The model uses its originating client, so keep that client open while running
decisions. This shorthand does not pin a revision; pass revision when needed.
Model serialization still contains only response data.
Question types
| Question type | Use it for | Main result field |
|---|---|---|
noul | Boolean likelihood | answer.noul |
choice | One option from a finite set | answer.choice |
score | A position on an ordered rubric | answer.score |
Repository models load their saved questions automatically. Questions sent in
a request override saved questions with matching IDs. Set capture to false
to skip recording a repository-model call, or pass an example_id /
exampleId to group it under a caller-assigned UUID.
Typed responses
Both SDKs can validate a decision into a type you declare.
Python
Pass a Pydantic model as response_model; it changes local parsing only and
is never sent to the API.
Extending DecisionResponse is the shorter path. It already declares model,
answers, and usage, and it lifts the answers you name to the top level:
from modaic import DecisionResponse, Modaic, NoulAnswer, ScoreAnswer
class ListingReview(DecisionResponse):
photo_matches: NoulAnswer
condition: ScoreAnswer
result = modaic.decisions.create(
model="mo-fast",
state={"listing": "Vintage leather jacket, size M"},
questions={
"photo_matches": {"type": "noul", "instructions": "Does the photo match?"},
"condition": {"type": "score", "criteria": ["Worn", "Good", "Like new"]},
},
response_model=ListingReview,
)
print(result.photo_matches.noul, result.condition.score)
Field names must match question IDs. Use Field(alias="question-id") for IDs
that are not Python identifiers, and avoid reusing response metadata names
such as model or usage.
response_model also accepts a plain Pydantic BaseModel, for callers who
want to own the whole schema. Declare the parts you care about, nested
answers included:
from pydantic import BaseModel
from modaic import NoulAnswer
class ListingAnswers(BaseModel):
photo_matches: NoulAnswer
class ListingEnvelope(BaseModel):
answers: ListingAnswers
result = modaic.decisions.create(
model="mo-fast",
state={"listing": "Vintage leather jacket, size M"},
questions={
"photo_matches": {"type": "noul", "instructions": "Does the photo match?"}
},
response_model=ListingEnvelope,
)
print(result.answers.photo_matches.noul)
Pydantic drops response fields the model does not declare, so this validates
answers.photo_matches and ignores the rest. Nothing is added to a plain
model: request_id and the typed nouls / choices / scores views exist
only on DecisionResponse subclasses. BaseModel itself is rejected, since
Pydantic cannot validate into it.
Either way, a missing or mistyped answer that your model declares raises
ModaicConnectionError.
TypeScript
The TypeScript SDK takes a schema instead. answers is a record of a union,
so reading one answer normally means a key lookup and a narrowing check; a
schema returns the exact shape:
import { z } from "zod";
const ListingReview = z.object({
answers: z.object({
photo_matches: z.object({ type: z.literal("noul"), noul: z.number() }),
condition: z.object({ type: z.literal("score"), score: z.number() }),
}),
});
const result = await modaic.decisions.create({
model: "mo-fast",
state: { listing: "Vintage leather jacket, size M" },
questions: {
photo_matches: { type: "noul", instructions: "Does the photo match?" },
condition: { type: "score", criteria: ["Worn", "Good", "Like new"] },
},
schema: ListingReview,
});
console.log(result.answers.photo_matches.noul);
The schema validates the camelCase response the SDK returns, so usage is
{ inputTokens, outputTokens }. Like response_model, schema affects local
parsing only and is never sent to the API, and it works on
model.decisions.create too.
Zod is not a dependency of the SDK. schema accepts any object with a
parse(value: unknown) => T method, so Valibot, ArkType, and hand-written
validators work the same way; the exported ResponseSchema<T> type describes
the contract. A response that fails validation raises ModaicConnectionError
with the validator's own error kept as cause.
Pin a revision
result = modaic.decisions.create(
state={"ticket": "The app crashes when I open settings."},
model="acme/support-priority",
revision="main",
)
print(result.checkpoint, result.revision, result.example_id)
See the Decision API reference for the complete wire contract.
Send images
Put OpenAI-compatible image_url blocks anywhere in state. Use an HTTPS image
URL or a data:image/...;base64, URL. Replace the example URL with a publicly
accessible image. No separate image upload or top-level images parameter is
needed. mo and mo-fast accept up to four PNG, JPEG, GIF, or WebP images per
decision, up to 20 MB each.
from modaic import Modaic
with Modaic() as modaic:
result = modaic.decisions.create(
model="mo",
state={
"listing": [
{"type": "text", "text": "Is this jacket visibly damaged?"},
{"type": "image_url", "image_url": {"url": "https://your-cdn.example/jacket.jpg"}},
],
},
questions={
"damaged": {"type": "noul", "instructions": "Is the jacket visibly damaged?"},
},
)
print(result.answers["damaged"])
Enable reasoning
With Python SDK 0.53.0+ or TypeScript SDK 0.10.0+, for mo-fast, set
reasoning_effort (Python) or reasoningEffort (TypeScript) to low,
medium, high, or xhigh. The first three cap reasoning at 512, 1,024,
or 2,048 generated tokens per question; xhigh has no explicit cap.
Omit the option to disable reasoning. Other models reject reasoning.
from modaic import Modaic
with Modaic() as modaic:
result = modaic.decisions.create(
model="mo-fast",
state={"message": "I was charged twice for this order."},
questions={
"needs_refund": {
"type": "noul",
"instructions": "Should this charge be reviewed for a refund?",
},
},
reasoning_effort="low",
)
print(result.answers["needs_refund"])
print(result.usage.reasoning_tokens)
print(result.thinking["needs_refund"].finish_reason if result.thinking else None)
reasoningTokens / reasoning_tokens is included in the output-token total;
do not add it again when computing usage. thinking provides per-question
diagnostics, not raw reasoning text. See the Decision API reference
for image validation and the HTTP wire format.