Stately
PackagesAgent

Text requests

Declare typed model calls on an agent machine and invoke them from a state, parsing structured or streamed output.

Alpha: @statelyai/agent 2.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 output and the request resolves to string; omit input and the invoke needs no input. A request with neither writes schemas: {} (the key itself stays required on requests entries; standalone createTextLogic can omit schemas entirely).
  • Each request-shaping field (system, prompt, messages, temperature, maxOutputTokens, and the rest) is a static value or a ({ input }) => value function.

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 its setupAgent({ requests }) key as name, so a mock executor (or a router picking providers per request) tells requests apart with request.name === 'answerQuestion'. Do not sniff system/prompt text. 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 under result. 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),
});
  • onChunk fires per chunk alongside the request that produced it, so parallel streams stay distinguishable.
  • onChunk 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, 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: metadata is 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.

  • 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 messages field a request can send instead of a bare prompt.
  • Decisions: the other request kind, choosing a legal machine event instead of producing text.

On this page