Search Modaic documentation

Find a Modaic guide or API reference

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 typeUse it forMain result field
noulBoolean likelihoodanswer.noul
choiceOne option from a finite setanswer.choice
scoreA position on an ordered rubricanswer.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.

Was this page helpful?