Stately
PackagesAgent

Messages

Build and store conversation history as a parts-based message model that mirrors the AI SDK without depending on it.

Alpha: @statelyai/agent 2.0 is in alpha. APIs can change between releases; pin an exact version. Feedback: github.com/statelyai/agent.

The message model

An AgentMessage is a parts-based, discriminated union representing one conversation turn. It structurally mirrors the Vercel AI SDK's ModelMessage, but core has no dependency on ai. Build messages, store them in machine context, and pass them to a text request or decision through the messages field.

type AgentMessage = SystemMessage | UserMessage | AssistantMessage | ToolMessage;

The content field is a string or an array of typed parts, depending on role:

  • system: a string.
  • user: a string, or TextPart / ImagePart / FilePart parts.
  • assistant: a string, or TextPart / FilePart / ToolCallPart / ToolResultPart parts.
  • tool: an array of ToolResultPart.

Warning: ImagePart and FilePart can hold binary data or a URL instance, which are not JSON-serializable. Use base64 strings and URL strings if the messages will be persisted. See Persisting messages.

Message builders

Build each role with its helper:

import { assistantMessage, systemMessage, userMessage } from "@statelyai/agent";

const messages = [
  systemMessage("You draft concise emails."),
  userMessage("Draft a launch email."),
  assistantMessage("Here is a first draft: ..."),
];

Both userMessage and assistantMessage also accept a parts array for multimodal content:

userMessage([
  { type: "text", text: "What is in this image?" },
  { type: "image", image: "https://example.com/photo.png" },
]);

The toolMessage(parts) helper builds a role: "tool" message from ToolResultParts; each tool result follows the assistant message whose ToolCallPart invoked it. Use it to seed runAgent({ messages }) with a prior conversation where tools ran, or to append tool results from a custom host:

const messages = [
  userMessage("What is the weather in Paris?"),
  assistantMessage([
    { type: "tool-call", toolCallId: "call_1", toolName: "getWeather", input: { city: "Paris" } },
  ]),
  toolMessage([
    {
      type: "tool-result",
      toolCallId: "call_1",
      toolName: "getWeather",
      output: { type: "json", value: { tempC: 18 } },
    },
  ]),
];

Store messages in context

Messages are plain context state. Declare the messages field with z.custom<AgentMessage[]>, and grow it over transitions:

import { setupAgent, type AgentMessage } from "@statelyai/agent";
import { z } from "zod";

const agentSetup = setupAgent({
  context: z.object({
    prompt: z.string(),
    messages: z.custom<AgentMessage[]>((value) => Array.isArray(value)),
  }),
});

z.custom keeps the exact AgentMessage[] type at author time with a shallow runtime check, which is enough when the array is built from the helpers above. Do not put messagesSchema inside a z.object: it is a Standard Schema value, not a Zod type, so Zod infers the field as unknown.

Append with appendMessages, which returns a transition result adding one or more messages. Pass a message, an array, or a function of { context, event }:

import { appendMessages, userMessage } from '@statelyai/agent';

// inside a state
on: {
  USER_REPLIED: appendMessages(({ event }) => userMessage(event.text)),
}

A request that needs history sends it through messages instead of a bare prompt. examples/email-drafter/agent-logic.ts keeps a running messages array in context and feeds it to a createTextLogic request.

Validating messages with messagesSchema

messagesSchema (root export) is a StandardSchemaV1<AgentMessage[]> that checks every message has a known role and that content is a string or an array of known parts. Use it as a standalone validator, on messages arriving from outside your process (an HTTP body, a stored transcript, a client resume payload):

import { messagesSchema, type AgentMessage } from "@statelyai/agent";

const result = await messagesSchema["~standard"].validate(await request.json());
if (result.issues) {
  throw new Error(result.issues.map((issue: { message: string }) => issue.message).join("; "));
}
const messages: AgentMessage[] = result.value;

It is also usable directly as a schema wherever a Standard Schema is accepted (a createAgentSchemas pack field, a createTextLogic input schema of its own). Just never nest it inside z.object.

Persisting messages

Warning: ImagePart and FilePart can carry binary data (Uint8Array or ArrayBuffer) or a URL instance, none of which are JSON-serializable. When persisting machine context with messages, store binary content as base64 strings and URLs as strings; the library does not convert this for you.

Everything else in a message is plain JSON, so a history built from strings, base64, and URL strings survives a snapshot round-trip cleanly. See Human in the loop for the persistence flow this applies to.

On this page