Text requests
Declare typed model calls on an agent machine and invoke them from a state, parsing structured or streamed output.
Alpha:
@statelyai/agent2.0 is in alpha. APIs can change between releases; pin an exact version. Feedback: github.com/statelyai/agent.
Overview
A text request is a typed model call your machine can invoke by name. You declare it once, with its own input and output schemas, a model reference, and a prompt built from that input. The machine decides when to make the call; the host executes it.
Declare requests in setupAgent
Pass a requests map to setupAgent. Each entry becomes an invokable actor under the same name.
import { z } from "zod";
import { setupAgent } from "@statelyai/agent";
import { defineModels } from "@statelyai/agent/ai-sdk";
import { openai } from "@ai-sdk/openai";
const models = defineModels({ quick: openai("gpt-5.4-mini") });
const answerSchema = z.object({ answer: z.string() });
const agentSetup = setupAgent({
models,
context: z.object({ prompt: z.string(), answer: z.string().nullable() }),
input: z.object({ prompt: z.string() }),
output: answerSchema,
requests: {
answerQuestion: {
schemas: { input: z.object({ prompt: z.string() }), output: answerSchema },
model: "quick",
system: "Answer the question directly.",
prompt: ({ input }) => input.prompt,
},
},
});- Each schema field accepts any Standard Schema validator.
- Each request-shaping field (
system,prompt,messages,temperature,maxOutputTokens, and the rest) is either a static value or a({ input }) => valuefunction.
Model references and typed aliases
Prefer a models registry (the canonical form): it narrows model to the map's keys, making a typo a compile error and sharing one alias map between authoring and the host adapter. A bare model string is the escape hatch (any string, passed straight through to your host to resolve) for a machine that must not name concrete models (see Which authoring form when).
import { openai } from "@ai-sdk/openai";
import { defineModels } from "@statelyai/agent/ai-sdk";
const models = defineModels({
quick: openai("gpt-5.4-mini"),
careful: openai("gpt-5.4"),
});
const agentSetup = setupAgent({
models,
context: z.object({ prompt: z.string(), answer: z.string().nullable() }),
input: z.object({ prompt: z.string() }),
output: answerSchema,
requests: {
answerQuestion: {
schemas: { input: z.object({ prompt: z.string() }), output: answerSchema },
model: "quick", // typed as "quick" | "careful"
prompt: ({ input }) => input.prompt,
},
},
});Invoke a request from a state
Invoke by name with src, pass input, read the typed result in onDone:
const machine = agentSetup.createMachine({
context: ({ input }) => ({ prompt: input.prompt, answer: null }),
initial: "answering",
states: {
answering: {
invoke: {
id: "answer",
src: "answerQuestion",
input: ({ context }) => ({ prompt: context.prompt }),
onDone: ({ output }) => ({
target: "done",
context: { answer: output.answer },
}),
},
},
done: {
type: "final",
output: ({ context }) => ({ answer: context.answer ?? "" }),
},
},
});Inside onDone, output is already validated against the request's own output schema and typed from it ({ answer: string } here), so you read output.answer directly; no parsing step is ever needed in the machine.
Narrowing an unknown output outside the machine
parseOutput(schema, output) validates a value against a schema and returns it parsed, throwing on mismatch. It is an escape hatch for host code that holds a raw, still-untyped output: e.g. a value read back from a persisted snapshot, or an inline agent.generateText result whose static type is unknown. Inside a request's onDone it is never needed.
import { parseOutput } from "@statelyai/agent";
// Host code with an untyped value from elsewhere:
const answer = parseOutput(answerSchema, rawOutput); // typed as { answer: string }Structured output vs plain text
Output is structured when the output schema describes an object, an array, or a top-level union of them (z.union/z.discriminatedUnion), and plain text otherwise: output: z.object({ ... }) returns a validated object, output: z.string() returns the model's text.
Every structured request is sent to the provider wrapped in the structured-output envelope: a root object { result: <your schema> } that hosts unwrap before validation. This makes a bare union or array root portable: it is nested under result for you, so providers that reject a union/array root still accept it. You always declare and receive the bare schema; the envelope is invisible to the machine.
export const triageTicket = createTextLogic({
schemas: {
input: z.object({ ticket: z.string() }),
output: z.object({
sentiment: z.enum(["positive", "neutral", "negative"]),
category: z.enum(["billing", "technical", "other"]),
reply: z.string(),
}),
},
model: "ticketTriage",
system: "Triage the support ticket: sentiment, category, and a short reply.",
prompt: ({ input }) => input.ticket,
});The mode is derived from the schema for you. Host adapters read it (via the exported getAgentOutputMode/isStructuredOutputSchema) to decide whether to request structured output from the provider; you rarely call these directly. See examples/triage/index.ts.
Reasoning
Set reasoning: true on a structured request to add an optional string reasoning field to the envelope, listed before result so property order nudges the model to reason before committing to an answer:
export const triageTicket = createTextLogic({
schemas: { input: z.object({ ticket: z.string() }), output: triageSchema },
model: "ticketTriage",
reasoning: true, // opt in
prompt: ({ input }) => input.ticket,
});The reasoning never enters machine context or output; it is surfaced only on the raw executor result (result.reasoning on createAiSdkExecutors' generateText), on runAgent's onResult(request, { raw }), and as a reasoning field on the request.end onTrace event. It is ignored for text-mode requests (no output schema, or output: z.string()).
Streaming requests
A request streams when its mode is 'stream'; without mode it is single-shot ('generate'). A streaming request resolves to the final text, with intermediate chunks delivered to runAgent's onChunk.
export const tellJoke = createTextLogic({
mode: "stream",
schemas: {
input: z.object({ topic: z.string() }),
output: z.string(),
},
model: "jokeWriter",
system: "You tell short, punchy jokes.",
prompt: ({ input }) => `Tell a joke about ${input.topic}.`,
});const result = await runAgent(machine, {
input: { topic: "state machines" },
executors: { generateText, streamText },
onChunk: (chunk) => process.stdout.write(chunk),
});onChunk fires per chunk alongside the request that produced it, so parallel streams stay distinguishable. It is purely observational. A mode: 'stream' request needs a streamText executor; without one, runAgent fails at bind time. See examples/joke/index.ts.
Tools and multi-step loops
A text request can carry tools: a map of tool name to a tool. Tools are whatever your SDK produces: the tools type is a minimal structural contract (description?, inputSchema?, outputSchema?, execute?, plus any extra fields), so an AI SDK tool({...}), an MCP-style descriptor, or a plain object all drop in as-is. Extra fields (providerOptions, toModelOutput, …) pass through untouched.
Bring your SDK's tool: the SDK owns the input typing, so execute's argument is typed with no cast:
import { tool } from 'ai';
tools: {
getWeather: tool({
description: 'Look up the current weather for a city.',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => fetchWeather(city), // city: string
}),
}For a host with no SDK, the minimal shape is just a plain object (or a bare execute function). Core reads description/inputSchema and runs execute; nothing SDK-specific is required:
tools: {
getWeather: {
description: 'Look up the current weather for a city.',
inputSchema: z.object({ city: z.string() }),
execute: async (input) => fetchWeather((input as { city: string }).city),
},
}To let one request run a bounded tool-call loop, set metadata.maxSteps. The shipped AI SDK adapter forwards it as stopWhen: stepCountIs(maxSteps); a request with no maxSteps stays single-step.
export const research = createTextLogic({
schemas: { input: z.object({ question: z.string() }), output: z.string() },
model: "careful",
prompt: ({ input }) => input.question,
tools: { getWeather },
metadata: { maxSteps: 5 },
});Note:
metadatais host-owned per-call data, passed through untouched by core. A host that does not understand a key ignores it, so requests stay portable across hosts.
Reusable request logic with createTextLogic
Inline requests: (above) is the default. createTextLogic is the escape hatch when a request should be standalone (exported, tested on its own, or shared across machines) and registered under actorSources. A requests entry is exactly what setupAgent builds internally from createTextLogic, so the two are interchangeable (see Which authoring form when).
import { createTextLogic, setupAgent } from "@statelyai/agent";
export const draftEmail = createTextLogic({
schemas: {
input: z.object({ prompt: z.string(), messages: messagesSchema }),
output: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
},
model: "emailDrafter",
system: "Draft a polished email from the request.",
messages: ({ input }) => [...input.messages, userMessage(input.prompt)],
});
const agentSetup = setupAgent({
models,
context,
input,
output,
actorSources: { draftEmail },
});Because draftEmail is a value, a test can import it and drive it with a fake executor, no machine required. examples/email-drafter/index.ts shows structured, streaming, and message-based createTextLogic requests across a multi-state workflow.