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.
This page covers declaring text requests, invoking them from a state, and reading structured or streamed output.
Request declarations in setupAgent
A text request is a typed model call that your machine invokes 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 the call happens. The host executes it.
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 { } from "@statelyai/agent/ai-sdk";
import { openai } from "@ai-sdk/openai";
// Model IDs here are illustrative; substitute your provider's current models.
const models = {
quick: openai("gpt-5.4-mini"),
careful: openai("gpt-5.4"),
};
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.
- Both schema slots are optional. Omit
outputand the request resolves tostring. Omitinputand the invoke needs noinput. A request with neither writesschemas: {}. Theschemaskey itself stays required onrequestsentries, while a standalonecreateTextLogiccan omit it entirely. - Each request-shaping field, such as
system,prompt,messages,temperature, andmaxOutputTokens, is either a static value or a({ input }) => valuefunction. promptandmessagesare mutually exclusive: a request must resolve exactly one of them. Resolving both, or neither, throws.
Model references
model is a key into the models registry, or any bare string that the host resolves at run time. See Authoring forms.
Standalone requests
The samples later on this page use createTextLogic, which declares the same request as a standalone value instead of an entry in the requests map. The two forms take identical options. See Reusable request logic with createTextLogic.
Invoking a request from a state
Invoke the request by name with src, pass input, and read the typed result in onDone. The quickstart shows a full machine.
// inside states: { ... }
answering: {
invoke: {
id: "answer",
src: "answerQuestion",
input: ({ context }) => ({ prompt: context.prompt }),
onDone: ({ output }) => ({ target: "done", context: { answer: output.result.answer } }),
},
},In onDone, output is { result, messages }. output.result is already validated against the request's output schema and typed from it. In this example the type is { answer: string }, so you read output.result.answer directly. The machine needs no parsing step. output.messages holds the response messages the executor returned, ready to append to context; see Messages.
Note: Route on
request.name. Every lowered request carries itssetupAgent({ requests })key asname. A mock executor, or a router that picks providers per request, tells requests apart withrequest.name === 'answerQuestion'. Do not inspect thesystemorprompttext. See examples/context-compaction/index.test.ts.
Narrowing an unknown output outside the machine
The parseOutput(schema, output) helper validates a value against a schema and returns the parsed value. It throws on a mismatch. Use it in host code that holds a raw, still-untyped output, such as a value from a persisted snapshot or an inline agent.generateText result typed unknown. You never need it inside onDone.
import { parseOutput } from "@statelyai/agent";
const answer = parseOutput(answerSchema, rawOutput); // typed as { answer: string }Structured output vs plain text
Output is structured when the schema describes an object, an array, or a top-level union of them built with z.union or z.discriminatedUnion. Otherwise the output is plain text. An output: z.object({ ... }) schema returns a validated object. An output: z.string() schema returns the model's text.
Note for host implementers: Every structured request is sent to the provider as a root object
{ result: <your schema> }, built byproviderOutputSchema. The parsedresultis what the executor returns and what the machine validates. The wrapper keeps a bare union or array root portable, because providers that reject one at the root still accept it nested underresult. Machine authors declare and receive the bare schema.
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: "quick",
system: "Triage the support ticket: sentiment, category, and a short reply.",
prompt: ({ input }) => input.ticket,
});The mode is derived from the schema automatically. You never set it. See examples/triage/index.ts.
Reasoning
Set includeReasoning: true on a structured request to add an optional string reasoning field to the provider's output schema. The field is listed before result, so the property order prompts the model to reason before answering:
export const triageTicket = createTextLogic({
schemas: { input: z.object({ ticket: z.string() }), output: triageSchema },
model: "quick",
includeReasoning: true, // opt in
prompt: ({ input }) => input.ticket,
});The reasoning never enters machine context or output. It surfaces in three places: on the raw executor result as result.reasoning from the generateText executor of createAiSdkExecutors, on runAgent's onResult(request, { raw }), and as a reasoning field on the request.end onTrace event. Text-mode requests ignore the option.
includeReasoning is not the provider's reasoning-effort setting. Effort is the host's business, because what it means differs per provider: an enum for one, a thinking-token budget for another, nothing at all for a third. A machine that named an effort level would stop being portable. Set it where the executors are built, with createAiSdkExecutors({ settings }).
Streaming requests
A request streams when its mode is 'stream'. Without mode, the request is single-shot, equivalent to 'generate'. A streaming request resolves to the final text and delivers intermediate chunks to runAgent's onChunk.
export const tellJoke = createTextLogic({
mode: "stream",
schemas: { input: z.object({ topic: z.string() }), output: z.string() },
model: "quick",
system: "You tell short, punchy jokes.",
prompt: ({ input }) => `Tell a joke about ${input.topic}.`,
});
const result = await runAgent(machine, {
input: { topic: "state machines" },
executors: createAiSdkExecutors({ models }),
onChunk: (chunk) => process.stdout.write(chunk),
});onChunkfires once per chunk and receives the request that produced it, so parallel streams stay distinguishable.onChunkis observational only. It cannot change the run.- A
mode: 'stream'request needs astreamTextexecutor. Without one,runAgentfails at bind time.
See parallel-streams.
Finish reason and truncation
An executor result can report why the call stopped, as a normalized finishReason: 'stop', 'length', 'tool-calls', 'content-filter', or 'other'. createAiSdkExecutors sets it on every text, structured, and streamed result, mapping the provider's own vocabulary onto those five; the provider's raw value stays on the result's raw.
The reason reaches observability the way per-call usage does: runAgent lifts it onto the request.end trace event, next to usage.
'length' means the output token limit cut the call off. What that costs depends on the request:
- A text request returns the text it did produce, with
finishReason: 'length'. Nothing throws. The machine decides whether a half-finished draft is worth keeping. - A structured request has nothing usable: a JSON object that never closed does not parse, and one that stopped mid-thought is not an answer.
createAiSdkExecutorsthrows anAgentTruncatedError.
AgentTruncatedError extends AgentError with the code 'truncated', so an invoke's onError branches on the code without an instanceof check across bundles:
answering: {
invoke: {
id: "answer",
src: "answerQuestion",
input: ({ context }) => ({ prompt: context.prompt }),
onDone: ({ output }) => ({ target: "done", context: { answer: output.result.answer } }),
onError: [
{ guard: ({ event }) => event.error.code === "truncated", target: "askingForLess" },
{ target: "failed" },
],
},
},The error carries requestName, the requestId when the host knows it, and partialOutput when the model produced something before it ran out. Core never throws it: truncation is a host observation, and only an adapter knows a call ran out of tokens. See Hosts.
Tools and multi-step loops
A text request can carry tools, a map of tool name to tool. The tool type is a minimal structural contract, so tools from any SDK work. See Tools for the contract, how to attach tools, and how the host runs the tool loop.
To let one request run a bounded tool-call loop, set the typed maxSteps field on the request. 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 },
maxSteps: 5,
});Note:
metadatais host-owned per-call data. Core passes it through untouched. A host that does not understand a key ignores it, so requests stay portable across hosts.
Reusable request logic with createTextLogic
The inline requests map shown above is the default form. Use createTextLogic when a request should be standalone, meaning exported, tested on its own, or shared across machines, and registered under actors. setupAgent builds each requests entry from createTextLogic internally, so the two forms are interchangeable. See Authoring forms.
import { createTextLogic, setupAgent, type AgentMessage } from "@statelyai/agent";
export const draftEmail = createTextLogic({
schemas: {
input: z.object({
prompt: z.string(),
messages: z.custom<AgentMessage[]>((value) => Array.isArray(value)),
}),
output: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
},
model: "careful",
system: "Draft a polished email from the request.",
messages: ({ input }) => [...input.messages, userMessage(input.prompt)],
});
const agentSetup = setupAgent({ models, context, input, output, actors: { draftEmail } });Because draftEmail is a value, a test can import it and drive it with a fake executor without a machine. examples/email-drafter/agent-logic.ts shows structured, streaming, and message-based createTextLogic requests across a multi-state workflow.
Related
- Read more about Tools, including defining tools, attaching them to a request, and how the host runs the tool loop.
- Read more about Hosts, including the executors that run text requests and how model aliases reach a provider.
- Read more about Messages, the
messagesfield a request can send instead of a bareprompt. - Read more about Decisions, the other request kind, which chooses a legal machine event instead of producing text.