Example

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).

Full source and test .

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.