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.

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 }) => value function.

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: 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. 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.

  • 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