Actions
Actions are how the outside world talks to your flow. When a client sends a message, resets state, or triggers any processing — it invokes an action. Actions are the flow's public API: named entry points with validated input and a block to execute.
Defining actions
Actions live inside defineFlow:
const myFlow = defineFlow({
kind: "my-app",
actions: {
chat: {
inputSchema: z.object({ message: z.string() }),
block: chatPipeline,
userMessage: (input) => input.message,
},
reset: {
inputSchema: z.object({}),
block: resetHandler,
},
saveArtifact: {
inputSchema: artifactInputSchema,
block: updateArtifact,
},
},
});
Each action has:
inputSchema— Zod schema that validates every incoming request. Invalid input is rejected before any block runs.block— The block (or pipeline) to execute. This is where the work happens.userMessage(optional) — Extracts a display string from the input. The framework emits it as a user-rolemessageitem before execution begins, so the conversation history shows what the user said. The emitted item is what transports, replay, and observability read; it also feeds future turns'history. It does not directly feed this turn's LLM call — that's the generator'suserslot. Wiring both to the same source is safe; the framework deduplicates equivalent content at message assembly. See Generator context > User slot for the generator-side counterpart.taskOf(optional) — Names the task a request is for, read from the input:taskOf: (input) => input.taskId. When it returns an id, the framework stamps it astaskIdon the user-role message and on every item the request emits, so a screen showing one task can pick out its lines withitemsForTaskafter a reload. A block that marks its own task, as a task board's worker does, keeps its own id. Returnundefinedto stamp nothing. The id is a label for display: it never routes the request or decides who may run it.concurrency(optional) — Declares what happens when requests collide on the same session. See Concurrency policies.
Invoking actions
From the client SDK
import { createClient } from "@flow-state-dev/client";
const fsd = createClient({ userId: "user_1", baseUrl: "http://localhost:3000" });
const app = fsd.flow("my-app");
// New session (auto-created)
const sent = await app.send("chat", { message: "Hello!" });
// Existing session
await app.send("chat", { message: "Hello!" }, { sessionId: "sess_123" });
// Wait for it to settle, or follow it live with sent.stream({ ... })
const { status } = await sent.wait();
From React
const session = useSession(sessionId);
await session.sendAction("chat", { message: "Hello!" });
Over HTTP
POST /api/flows/:kind/actions/:action # New session
POST /api/flows/:kind/:sessionId/actions/:action # Existing session
The server returns 202 Accepted immediately with a requestId. Execution happens asynchronously — the client connects to the SSE stream to receive results:
GET /api/flows/:kind/requests/:requestId/stream
How an action executes
Client Server
| |
|-- POST /actions/chat ------>|
| { input, userId } |-- validate input
| |-- resolve/create session
|<-- 202 { request: { id } }-|
| |-- execute block (async)
|-- GET /requests/:id/stream->|
|<-- SSE events --------------| item.added, content.delta, ...
|<-- request.completed -------|
| |
Step by step:
- Validate — Input is checked against
inputSchema. Bad input returns 400. - Session — Resolved from
sessionIdor created new. - User message — If
userMessageis defined, a user-role message item is emitted to the stream, stamped with the tasktaskOfnames, if any. - Execute — The root block runs asynchronously. Items stream to the client as they're produced.
- Complete — Lifecycle hooks fire. The stream emits
request.completed(orrequest.failed).
Typed actions
For compile-time type safety, pass your flow's type to fsd.flow. Action names and inputs are then checked against the flow definition:
import type { myFlow } from "./flows/my-app";
const app = fsd.flow<typeof myFlow>("my-app");
await app.send("chat", { message: "Hello!" }); // Type-checked
await app.send("reset", {}); // Type-checked
await app.send("invalid", { message: "Hello!" }); // TypeScript error
Use import type so the flow's server code stays out of a browser bundle.