text-requests.md
July 29, 2026 · View on GitHub
Alpha:
@statelyai/agent2.0 is in alpha. APIs can change between releases; pin an exact version. Feedback: github.com/statelyai/agent.
Declare requests 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";
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
Prefer a models registry (canonical): the map's keys autocomplete on model, and one alias map is shared between authoring and the host adapter. Unregistered strings still typecheck (the type keeps a string arm so hosts can resolve refs the machine does not know), so a typo surfaces as a run-time resolution error. A bare model string (any string, passed through to your host to resolve) is the alternative for a machine that must not name concrete models. See Which authoring form when.
Invoke 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.
Every structured request is sent 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 (nested under result, 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 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),
});
The onChunk callback 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 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 and it owns the input typing, so execute's argument is typed with no cast:
import { tool } from 'ai';
// inside a request
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 a plain object (or a bare execute function). Core reads description/inputSchema and runs execute:
// inside a request
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. 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/index.ts shows structured, streaming, and message-based createTextLogic requests across a multi-state workflow.