Get one typed answer from one model call
Declare a tool, ask the model to call it, validate the arguments: assess() gets a typed verdict from a single pi-ai call with no loop.
Most of what an application needs from a model is one question and one answer.
assess.ts is that rung — layer 1, model/provider access, the floor of the
five-rung stack: one request, no loop, no session, no terminal. A tool declaration is
a schema the model fills in, and validateToolCall() makes the answer a typed value
or an exception, all observed under scripted responses on Pi 1.0.4.
Run it
Use Pi 1.0.4 and the example repository’s test runner. The file below is the chapter’s canonical source, reproduced exactly:
import {
Type,
validateToolCall,
type Context,
type Model,
type Models,
type Static,
type Tool,
} from "@earendil-works/pi-ai";
// The result we want from the model. Everything downstream depends on this
// shape, not on the model's prose.
export const EvidenceAssessment = Type.Object({
verdict: Type.Union([Type.Literal("supported"), Type.Literal("contradicted"), Type.Literal("insufficient")]),
confidence: Type.Number({ minimum: 0, maximum: 1 }),
citations: Type.Array(Type.String(), { description: "Evidence items relied on, quoted exactly." }),
reason: Type.String({ description: "One or two sentences." }),
});
export type EvidenceAssessment = Static<typeof EvidenceAssessment>;
const submit: Tool = {
name: "submit_assessment",
description: "Submit your assessment of the claim. Call this exactly once.",
parameters: EvidenceAssessment,
};
// One model call, no loop, no session. Input in, typed value out.
export async function assess(
models: Models,
model: Model<any>,
claim: string,
evidence: string[],
): Promise<EvidenceAssessment> {
const context: Context = {
systemPrompt: "Decide whether the evidence supports the claim. Use only the evidence given.",
messages: [
{
role: "user",
content: `Claim: ${claim}\n\nEvidence:\n${evidence.map((e, i) => `${i + 1}. ${e}`).join("\n")}`,
timestamp: Date.now(),
},
],
tools: [submit],
};
const reply = await models.complete(model, context);
if (reply.stopReason === "error" || reply.stopReason === "aborted") {
throw new Error(`model call failed: ${reply.errorMessage ?? reply.stopReason}`);
}
// toolChoice is only "auto" | "none", so the model is asked to call the tool,
// not forced to. Check that it did.
const call = reply.content.find((block) => block.type === "toolCall");
if (!call) throw new Error("the model answered in prose instead of calling submit_assessment");
// Throws if the arguments do not match the schema.
return validateToolCall([submit], call) as EvidenceAssessment;
}assess() returns a typed EvidenceAssessment or throws; fauxProvider() drives
it, so no key is needed.
Expected outcome
assess.test.ts assertion |
What happens |
|---|---|
Valid submit_assessment arguments |
A typed EvidenceAssessment; one request |
| Arguments that break the schema | validateToolCall() throws; nothing is passed on |
| Prose instead of a tool call | Throws …prose instead of calling submit_assessment |
| A failed request | Throws with the provider’s message, never a silent value |
A failure is an exception with the provider’s message — model call failed: 429 rate limited in the test — not a silent value.
Mechanism and limitations
Documented: the pi-ai README and declarations (1.0.4) define the Models
collection, complete(), stopReason values including error and aborted, and
validateToolCall(); toolChoice is only "auto" | "none" — a model is asked to
call the tool, never forced to. Observed: assess.test.ts asserts all four rows
above (in-process runtime) over the faux provider.
This is the layer 1 rung only: an agent and its application sit above it. The
real-model swap (builtinModels()) is documented, not run because it needs a
credential; the faux provider proves the plumbing, not how often a real model calls
the tool (chapter 41).
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.