Skip to main content
The long form of the SDK, split over six pages in the order a post-training run happens: this overview, the five calls, what to run, the platform, parameters and output, and package layout and development. The short version, with the loop and where each method comes from, is the README. One package, two importable modules:
  • whileai: the platform client. OTLP trace ingest and trace-dataset listing against the token gate.
  • whileai.simulations: post-training data for an agent. Give it the agent’s traces, or its tools and system prompt; it simulates the situations, the people, and the world, plays the agent through multi-turn tool-calling conversations, and returns rows for your grader.
Have an agent and want a pass rate with an interval? Start at Evals: offline, seconds, and coverage_gap names what your tests miss.
Renamed. This SDK was zeroproof (ZeroProof is now While). pip install zeroproof still installs whileai, and import zeroproof (or the older zeroproof_simulations) resolves to the same modules with a deprecation warning. ZEROPROOF_* environment variables and a saved ~/.zeroproof/credentials.json are still read. The package is whileai, the import is whileai.simulations, keys start with zp_. zp, wai and whileai run the same CLI, so zp login and whileai login do the same thing. A machine with the old package still picks up ~/.zeroproof/credentials.json; set WHILEAI_HOME to a fresh directory to isolate a new account from it.Releases of whileai before 0.3 were an unrelated encrypted agent-to-agent messaging client. Pin whileai<0.3 if you still depend on it.
Two ways in, one engine. Give it the agent’s tools and system prompt and it samples situations across everything that agent can be asked. Give it graded traces as well (traces=, plain row dicts, see Close the loop) and it aims the budget at the situations that fail in production, so new rows land where the agent is weak. Every row is a full conversation: user turns, agent turns, tool calls, tool results, scheduled faults. Rows come back ungraded; your grader decides what good means. The default mode, explore, draws one unique situation per row. How it thinks: Simulations.

How a row gets made

How a row gets made: the draw, the coverage grid, the search arms, the rollout, the split A situation is drawn across the world axes (from the agent’s tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms (structured, open_ended, llm_guided, behavior_targeted, failure_mutation), and the agent plays it against a world that breaks on schedule. The row that comes out splits into Task, Rollout, Judgment, and Marker, and every training target is a projection of some of those four. The engine on one page, with references: Engine.

The pipeline

simulate() is a pipeline.
  1. Read the agent. Tools and system prompt. That is the spec of the world.
  2. Build a fake world from those tools. Objects, plausible results, and faults (timeout, deny, junk).
  3. Write users. A separate writer (same hosted model, different prompt, no agent policy) samples situations across tools, stance, history, and so on.
  4. Pick the diverse ones. Embeddings plus a bit of noise, so the batch is not 200 copies of the same prompt.
  5. Play the agent. It talks, calls tools, gets results, talks again. All of that is stored: user text, agent text, tool calls, tool results, final_text.
  6. Grade. Rows come back ungraded. Grade after with data.grade() (the hosted judge, against the spec’s rubric.md or rubric=; with neither it grades the conduct floor only and the report says so), data.grade(judge=...) (your own callable), or wai.grade(path). The legacy grade=True flag writes the deterministic conduct score at simulation time; avoid it for the rubric workflow.
Stop when the row cap or the clock hits.

How to use

Start here: no key required

This runs offline, in seconds, on nothing but the package. It is the fastest way to see a row and to check that your agent and grader are wired up before you spend a key on variety.
The bare function dict without the {"type": "function", ...} wrapper works too; both shapes are normalized. The template writer needs no model and runs in seconds, but its situations are less varied than a model writes, so it is for wiring up your agent and grader, not for a training set. For that, bring a model. If your agent raises, the rollout is dropped and the run says so: data.stopped_because == "agent_failed" when no row survived, with the count and the first error in data.search["agent_errors"] and data.search["first_agent_error"]. An agent that fails every call is called off after max(16, 2 * budget) lost rollouts, so a dead endpoint costs a handful of calls, not hundreds. data.search is the run’s own report dict. Its keys are written only when the run has something to say: the two error keys appear only if the agent raised, and search["groups"] only on a mode="rl" run with the default successive allocator. Read them with data.search.get(...).

Faults need the world

Offline, every marker is your agent’s. The template writer writes the users, not the agent, so a callable that never hedges scores zero hedging, and faults on a row only fire if your agent’s tool calls go through the world that schedules them. wai.world(TOOLS) is that world:

See the detectors fire

To see the detectors fire before you plug in your own agent, run the seeded one. It answers honestly through wai.world, and on 35% of rollouts (rate=) does one wrong thing on purpose: hedging, sycophancy, apology, boilerplate, ignore_fault (claims success through a fault), or leak (quotes the row’s privileged context). Every row says what it did in seeded ([] when it behaved), so a check that catches exactly those rows is a check that works.
leak_report says how many rows it could check. A run where nothing populated privileged has nothing to leak, and the report says so instead of passing. Every row is born with the block: hidden_state (what the world knows that the ask does not say) and reference (what the checklist expects), derived from the task’s grid cell. Exports drop it at any depth; data.trajectories keeps it for the judge.

Evals for the agent you already have

Not training anything yet? The shortest path is an eval: wrap your agent as agent(message) -> {steps, final_text}, write the policy as a judge that reads the trajectory, run the asks k times each, and read pass@1 (the share of asks the agent gets right on one try) with its interval. Offline first, then the hosted writer. The how-to is Evals; the runnable version is recipes/02-measure/eval-your-agent, which ends at a CI gate, not a push. whileai init-evals writes agent.py, judge.py, run.py, test_judge.py and a README for you, wired to the tools, system prompt and callable it finds in the project, and prints what it picked.
scored.warnings lists what would make the number hollow: no rollout called a tool, a declared tool no rollout touched, a marker that fired on no row. A 1.00 on a run like that is not a result; the note names the fix. A declared tool the world cannot answer is the quiet version of the same failure. With execute=, a tool that is in the schema but has no branch in your function fails exactly like a world fault, the agent reports the miss honestly, and a candour rubric rewards the row. Every run records calls and successes per tool in data.coverage["tools"] (n, ok, fault_n, and injected for faults the run scheduled itself) and lists the tools that never work in data.coverage["dead_tools"]. The rule is one Wilson 95% upper bound on the success rate under 0.30: 0 of 9 or 4 of 612 is dead, 0 of 3 or 2 of 5 is not. Dead tools are added to data.degraded, and the names and the one fix that applies to your world go in data.warnings and data.report(). Steps with no recorded result are not evidence and never accuse a tool.

Bring a model

Any OpenAI-compatible chat endpoint that returns tool calls works. It writes the situations and plays the agent, so both run on your key.
A model spec names the backend and the model. Four are built in:
  • ollama:<model>: a local Ollama server, no key.
  • vllm:<model>@<url>: any vLLM or OpenAI-compatible endpoint you serve.
  • openai:<model>: OPENAI_API_KEY, and OPENAI_BASE_URL for a compatible endpoint that is not OpenAI’s.
  • anthropic:<model>: the Claude Messages API on ANTHROPIC_API_KEY (WHILEAI_ANTHROPIC_API_KEY overrides it).
  • typesafe:<model>: TypeSafe’s Jev, a decision model, on TYPESAFE_API_KEY (WHILEAI_TYPESAFE_API_KEY overrides it; TYPESAFE_BASE_URL points it at a gateway). Judge only: it answers typed questions with a probability each and writes no text, so spec= takes it and agent=, simulator= and user_model= refuse it.
A spec works everywhere one is accepted: agent=, simulator= for the situation writer, user_model= for the simulated person, and spec= on wai.grade for the judge.
With Jev as the judge, the grade is two typed questions per row: did the agent do what it should (a probability), and if not, which failure class. The reward is the more probable outcome; the probability lands on the row.

A model you serve

To put a number on a model you serve (wai.serve, or your own vLLM), make it the agent, and run both arms of a before/after through the same call so the only difference is the weights:
thinking=False reaches the simulated user as well when the agent’s own model plays it (the default), and whatever a user model still emits as <think> is stripped before it becomes a user turn; the run reports those under data.search["user_think"] and says so in data.warnings. delta_report fails a before/after whose arms differ in how often they answered at all ("answered" in not_comparable: a two-proportion test at p under 0.01 and a gap over the re-run band or 10 points). That is what a reasoning base against an adapter trained on think-free targets does under one shared token budget, so set thinking= the same on both arms. Two local_model knobs the situation writer cannot guess for you:
  • result_shapes={tool_name: example_result} pins what a tool returns, so a policy branch that only exists for some results (“credits over $200 go to escalate_to_human”) is reached on purpose instead of by luck. Ids, dates and people are re-drawn per call and a number moves by up to a third of itself (900.0 lands in 600 to 1200), so pick a template value whose whole range sits on one side of the threshold and run the same pinned tasks under one shape per side.
  • fault_plans={message: {tool: {"mode": "timeout", "rate": 1.0}}} replays a known fault schedule. simulate() writes these from fault_rate=, so pass your own only to replay one.
timeout= is 300 seconds by default, enough for a served model that scaled to zero to answer its first request. When a call still times out the run says so in data.warnings with the fix.

Hosted models

With no agent=, the run uses While-hosted models. Your account key is enough: whileai login (or whileai signup --email you@example.com) and the run goes to the account endpoints, Qwen3-4B for the writer and the agent and Phi-4 for the judge, on your daily allowance. A trial key gets 25k input and 50k output tokens a day; whileai status prints your allowance and how to lift it. The endpoint refuses with 429 when the allowance is spent, and the run stops there and says so. VLLM_API_KEY, when set, wins and goes to the shared pool instead: warm and faster, shared and unmetered; ask us for one.
The situation writer also defaults to hosted Qwen, even when agent= is your own function, so simulator=False is what makes a run fully offline.

The recipe

Each scenario is a draw across the world and the human. World (from this agent’s tools and system prompt)
  • objects and tool results that match the spec
  • tool outcome: success, timeout, deny, stale, and so on
  • world state: exists, missing, already handled, unfinished, and so on
  • history: first visit, prior miss, return, and so on
  • rules the agent is supposed to follow
Human
  • intent: which tool, what they want (randomized sometimes)
  • stance: ordinary, ambiguous, adversarial, hurried, and so on
  • persona: first time, returning, in a hurry, and so on
  • tone: impatient, frustrated, polite, and so on
  • typing: standard, lowercase, typo, clipped, and so on
Ordinary asks first, then the edges. On top of that, the openers are embedded and a bit of random noise is added so the batch stays spread out, not a cluster of near-copies. The row cap and the clock go on diversity, not copies.