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.
Request declarations in setupAgent
A text request is a typed model call your machine invokes by name: declared once with its own input/output schemas, a model reference, and a prompt built from that input. The machine decides when to call; 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 { defineModels } from "@statelyai/agent/ai-sdk";
import { openai } from "@ai-sdk/openai";
// Model IDs here are illustrative; substitute your provider's current models.
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.
- Both slots are optional: omit
outputand the request resolves tostring; omitinputand the invoke needs noinput. A request with neither writesschemas: {}(the key itself stays required onrequestsentries; standalonecreateTextLogiccan omitschemasentirely). - Each request-shaping field (
system,prompt,messages,temperature,maxOutputTokens, and the rest) is a static value or a({ input }) => valuefunction.
Model references
model is a key into the models registry, or any bare string the host resolves at run time. See Authoring forms.
Invoking a request from a state
Invoke by name with src, pass input, read the typed result in onDone (full machine in the quickstart):
// inside states: { ... }
answering: {
invoke: {
id: "answer",
src: "answerQuestion",
input: ({ context }) => ({ prompt: context.prompt }),
onDone: ({ output }) => ({ target: "done", context: { answer: output.answer } }),
},
},In onDone, output is already validated against the request's output schema and typed from it ({ answer: string } here) - read output.answer directly, no parsing step in the machine.
Note: route on
request.name. Every lowered request carries itssetupAgent({ requests })key asname, so a mock executor (or a router picking providers per request) tells requests apart withrequest.name === 'answerQuestion'. Do not sniffsystem/prompttext. 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 it parsed, throwing on mismatch. Use it in host code holding a raw, still-untyped output (from a persisted snapshot, or an inline agent.generateText result typed unknown). Never needed 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 (z.union/z.discriminatedUnion), and plain text otherwise: output: z.object({ ... }) returns a validated object, output: z.string() returns the model's text.
For host implementers: the structured-output envelope. Every structured request is sent wrapped in the structured-output envelope: a root object
{ result: <your schema> }that the host must unwrap before validation. This keeps a bare union or array root portable, since providers that reject a union/array root still accept it nested underresult. Machine authors 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 automatically; you never set it. 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 answering:
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 surfaces 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. Ignored for text-mode requests.
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),
});onChunkfires per chunk alongside the request that produced it, so parallel streams stay distinguishable.onChunkis purely observational.- A
mode: 'stream'request needs astreamTextexecutor; without one,runAgentfails at bind time.
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, since the type is a minimal structural contract. See Tools for the contract, attachment, and how the host runs the tool loop.
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. Reach for createTextLogic when a request should be standalone (exported, tested on its own, or shared across machines) and registered under actors. Each requests entry is exactly what setupAgent builds internally from createTextLogic, so the two are interchangeable. See Which authoring form when.
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: "emailDrafter",
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, no machine required. examples/email-drafter/agent-logic.ts shows structured, streaming, and message-based createTextLogic requests across a multi-state workflow.
Related
- Tools: defining tools, attaching them to a request, and how the host runs the tool loop.
- Hosts: the executors that run text requests and how model aliases reach a provider.
- Messages: the
messagesfield a request can send instead of a bareprompt. - Decisions: the other request kind, choosing a legal machine event instead of producing text.