Skip to content

Answers in a shape

A turn given a schema answers with that pydantic model instead of with text. Reach for it whenever a flow has to decide something before it acts.

Try it

Declare the answer as a pydantic model:

python
from pydantic import BaseModel, Field


class Review(BaseModel):
    """What one round's review comes to."""

    model_config = {"extra": "forbid"}

    done: bool = Field(description="True only if there is nothing left to do or to fix.")
    notes: str = Field(description="What to say to the agent, passed on word for word.")

The model is the question. Its fields, their types, which are required, and the line each was declared with are what the backend is given, so nothing has to be repeated in the prompt.

Ask for it:

python
review = agents.reviewer(REVIEW, schema=Review)   # a Review, not a str
if review.done:
    return
working(review.notes, suppress=True)

review comes back as a Review, not a string, so you read review.done as a bool instead of searching the agent's prose.

Why a loop wants this

A flow that has to decide something reads a field instead of looking for a word at the end of a paragraph. Is this finished? Does this plan belong to this repository?

python
review = agents.reviewer(REVIEW_PROMPT + task, suppress=True, schema=Review)
if review is not None and review.done:
    return

That is what official/rlar ends on, and what humanize1 asks its analyst and its reviewer before it starts anything.

Here is a whole flow built on it:

python
# .humanize/flows/reviewed/__init__.py
"""Build under review, and stop when the reviewer says there is nothing left."""

from typing import NamedTuple

from pydantic import BaseModel, Field

from hmz.agents import AgentBase
from hmz.flows import flow

REVIEW = """Read the repository and the current diff.
Decide whether there is anything left to do or to fix."""


class Review(BaseModel):
    """What one round's review comes to."""

    model_config = {"extra": "forbid"}

    done: bool = Field(description="True only if there is nothing left to do or to fix.")
    notes: str = Field(description="What to say to the agent, passed on word for word.")


class Agents(NamedTuple):
    actor: AgentBase
    reviewer: AgentBase


@flow
def run(agents: Agents, task: str) -> None:
    working = agents.actor.new()
    working(task, suppress=True)
    for _ in range(12):
        review = agents.reviewer(REVIEW, suppress=True, schema=Review)
        if review is None:
            continue
        if review.done:
            print("the reviewer says it is finished")
            return
        working(review.notes, suppress=True)
    print("twelve rounds and it is still not done")

Run it with:

sh
hmz exec -f reviewed -a claude/claude-opus-5:max -a codex/gpt-5.6-sol:high "$(cat TASK.md)"

The flow asks the reviewer for a Review up to twelve times and stops as soon as review.done is true.

How each backend is held to it

A backend is held to the shape in one of two ways:

Claude Code--json-schema; it validates the answer itself
Antigravity, Grok Build, Qwen Code--json-schema on the run
Codexthe turn's outputSchema
anything else — dsh, kimi, pi, opencode, mimoasked in the prompt, and what it says is read back

SessionBase.shapes records which of the two a backend is. Either way the answer arrives as the model, or not at all.

Claude's schema is an argument of the process rather than of the turn. Asking one session for a shape it was not started with ends that process and starts one that resumes the conversation. The conversation is not restarted; only the process is. It is the same thing moving the effort does.

Failing

Pass suppress=True to get None back when the turn fails:

python
review = agent(asked, schema=Review, suppress=True)   # a Review, or None

suppress=True answers None rather than "", and covers both:

  • a turn that failed, and
  • a turn whose answer is not the shape it was asked for.

An answer that is not what was asked for is a turn that did not do what it was told. Without suppress, the second raises ValueError. Write the None branch as "take this round again". That is almost always right.

Asking a person: a questionnaire

Given a schema, the person is not shown a JSON Schema. They are asked a question per field, and the model is built out of what they typed:

python
class Settled(BaseModel):
    approach: Literal["fast", "careful"] = Field(description="Which way should this be built?")
    tests: bool = Field(description="Write tests for it?")
    rounds: int = Field(default=3, description="How many rounds may it take?")

settled = person("How should I do this?", schema=Settled, suppress=True)
In the modelWhat they are asked
description=the question itself, or the field's name where it has none
Literal[…]those words, as the answers it offers
boolyes and no
a default"or - for 3" — and a dash takes it
list[str]one line, separated by commas

Each question goes the road a coding agent's own question takes, so it is a real question in the interface, options and all. /afk or a command line answers it the way it answers any other: nobody is there. What the model refuses is put back on the field it was refused for, in the model's own words, a bounded number of times. A questionnaire nobody filled in answers with None under suppress.

This is the same thing a coding agent's AskUserQuestion is, reachable from a flow. It is more, because the flow states the shape of the whole answer once, in the model it is going to use. A flow can put the same decision to a model or to a person, in the same shape, with the same None branch.

Where it works

Everywhere a turn is run:

python
agent(prompt, schema=Review)
session(prompt, schema=Review)
await agent.aturn(prompt, schema=Review)
agent.batch(prompts, schema=Review, suppress=True)      # a list of Review | None

Writing the model

  • model_config = {"extra": "forbid"}. An answer with a field nobody asked for is an answer to a different question.
  • A description on every field. It is the only wording the model sees for that field, and it does the work the prompt would otherwise do.
  • Keep it small. A model with thirty fields is a form, and a turn that fills in a form is a turn that did not do the work. Two or three fields is usually the whole of a decision.
  • Booleans for decisions, strings for what to pass on. done steers the loop; notes becomes the next prompt word for word.

See also

Released under the Apache-2.0 licence.