Skip to main content

Core API

@flow-state-dev/core — Isomorphic builders, type contracts, and item taxonomy.

Block Builders​

handler(config)​

Create a synchronous logic block.

import { handler } from "@flow-state-dev/core";

const myHandler = handler({
name: "my-handler",
inputSchema: z.object({ value: z.string() }),
outputSchema: z.object({ result: z.string() }),
sessionStateSchema: z.object({ count: z.number().default(0) }),
targetStateSchemas: {
research: z.object({ progress: z.number() }),
},
execute: async (input, ctx) => {
await ctx.session.incState({ count: 1 });
// ctx.targets.research is StateRef<{ progress: number }> | undefined
await ctx.targets.research?.patchState({ progress: 50 });
return { result: input.value.toUpperCase() };
},
});

generator(config)​

Create an LLM-calling block with tool loop support.

import { generator } from "@flow-state-dev/core";

const myGenerator = generator({
name: "my-gen",
model: "openai/gpt-6-luna",
prompt: "You are a helpful assistant.",
inputSchema: z.object({ message: z.string() }),
outputSchema: z.object({ response: z.string() }),
targetStateSchemas: {
research: z.object({ progress: z.number() }),
},
user: (input, ctx) => {
const progress = ctx.targets.research?.state.progress ?? 0;
return `progress:${progress} — ${input.message}`;
},
tools: [myTool],
search: true,
context: [myContextFn],
history: true,
repair: { mode: "auto", maxAttempts: 3 },
});

Repair config (repair) — recovers structured output that fails outputSchema:

  • mode?: "auto" | "rescue" | "fail" — auto (default) repairs; fail throws on the first mismatch; rescue defers to a .rescue() handler.
  • maxAttempts?: number — deterministic repair attempts (JSON parse / jsonrepair / unwrap) before escalating.
  • coerce?: boolean | { model } — LLM coercion: when deterministic repair can't recover the output (e.g. the model returned the right data under the wrong field names), one model call reshapes it to the schema. On by default in auto mode, using intent/utility; pass false to disable or { model } to override the coercion model. Runs only on the path that would otherwise throw.

Identity config:

  • itemVisibility?: { client: boolean; history: boolean } — Declares the generator's visibility. Governs auto-emission of conversational items (messages, reasoning, tool outputs). { client: true, history: true } = user-facing (client + history). { client: true, history: false } = task-executor (client, not history). { client: false, history: false } = observability-only (neither). When unset, the generator performs no auto-emission — only its typed block_trace flows via graph edges. No position-inferred default; every generator declares.
  • agentName?: string — Stable name stamped on every emitted item. Defaults to the block's name when itemVisibility is set. Generators that share an agentName represent the same logical agent; distinct names stay isolated. Used by the client for per-agent rendering and by items.selectForContext({ agentName }) for scoped context assembly.

Callbacks:

  • onCompleted?: (output, ctx, meta: GeneratorCompletedMeta) => void | Promise<void> — Fires after a successful execute. meta.model is a ModelIdentity with the resolved model that produced the output. Existing two-argument callbacks ((output, ctx)) continue to work. See lifecycle hooks and the worked example.
  • onErrored?: (error, ctx) => void | Promise<void> — Fires when execute fails.

Search config:

  • search?: boolean | GeneratorSearchConfig — Enable provider-native web search. true uses defaults; pass a config object for fine-grained control (maxUses, allowedDomains, blockedDomains, userLocation, searchDepth). See Web search for the per-provider field mapping and how this differs from the standalone tools.search tool (whose tier knob does not apply here).

Provider tools:

  • providerTools?: ProviderTool[] — Raw provider-defined tool objects passed directly to the AI SDK, bypassing the block lifecycle.

providerTool(name, tool)​

Create a provider tool wrapper for use in generator({ providerTools }).

import { providerTool } from "@flow-state-dev/core";
import { anthropic } from "@ai-sdk/anthropic";

const codeExec = providerTool("code_execution", anthropic.tools.codeExecution());

Returns { __providerTool: true, name: string, tool: unknown }.

sequencer(config)​

Create a pipeline composition block.

import { sequencer } from "@flow-state-dev/core";

const pipeline = sequencer({
name: "my-pipeline",
inputSchema: z.object({ message: z.string() }),
container: { component: "pipeline-view", label: "Processing" },
});

Methods: step, stepIf, map, parallel, forEach, forEachSideChain, doUntil, doWhile, loopBack, sideChain, sideChainIf, waitForSideChain, tap, tapIf, rescue, branch, stepAll, stepAny, race, exitIf

router(config)​

Create a runtime block-selection block.

import { router } from "@flow-state-dev/core";

const myRouter = router({
name: "mode-router",
inputSchema: z.object({ mode: z.string() }),
targetStateSchemas: {
coordinator: z.object({ step: z.number() }),
},
routes: [chatBlock, agentBlock],
execute: async (input, ctx) => {
const step = ctx.targets.coordinator?.state.step ?? 0;
return step > 0 ? agentBlock : chatBlock;
},
});

Flow​

defineFlow(definition)​

Create a flow type.

import { defineFlow } from "@flow-state-dev/core";

const myFlow = defineFlow({
kind: "my-app",
requireUser: true,
actions: { /* ... */ },
session: { stateSchema, client },
user: { stateSchema, client },
resources: { /* accessor → defineResource / defineResourceCollection */ },
request: { onStarted, onCompleted, onErrored, onFinished, onStepErrored },
});

export default myFlow();

Resources​

defineResource(config)​

Create a portable resource definition. scope is required and must be "session", "user", or "org". Register the result on a flow's resources map or a block's resources map:

import { defineFlow, defineResource, handler } from "@flow-state-dev/core";

const planResource = defineResource({
scope: "session",
stateSchema: z.object({ steps: z.array(z.string()).default([]) }),
writable: true,
});

// Use in the flow's resources map
defineFlow({
kind: "planner",
resources: { plan: planResource },
actions: { /* ... */ },
});

// Or declare on blocks — collected and merged into the flow automatically
const myHandler = handler({
name: "plan-manager",
resources: { plan: planResource },
execute: async (input, ctx) => { /* ... */ },
});

Resource options:

  • scope: "session" | "user" | "org" — required. Any other value throws defineResource() requires an explicit scope of "session", "user", or "org" (got …)
  • content?: string — inline definition-time body
  • contentFile?: string | AnchoredPath — load initial body from a file path (mutually exclusive with content). A bare string resolves from the working directory; { path, importerUrl: import.meta.url } resolves relative to the declaring module first
  • render?: (content, state) => string | Promise<string> — optional renderer for readContent()
  • llmReadable?: boolean — allows read access when readResourceContentTool() is installed
  • llmWritable?: boolean — allows write access when writeResourceContentTool() is installed
  • secret?: Array<keyof State> — state fields left out of client snapshots, state endpoints, live deltas and the debug endpoints (content is not covered). See Secret fields

Runtime resource state methods (ResourceRef). The mutators resolve to void — read the result back off the synchronous ref.state getter:

  • patchState(updates) — merge fields into the stored state
  • setState(next) — replace the stored state
  • updateState(updater) — read-modify-write through a callback that may run more than once
  • incState({ field: delta }) — add to number-valued fields
  • pushState(field, value) — append one value to an array-valued field
  • getOrPatchState(key, compute) — resolves to state[key] if present, otherwise runs compute, stores it under key, and returns it

incState and pushState are keyed to TState on a ResourceRef<TState> whose state type is written out: number fields for incState, array fields and their element type for pushState. A handle read off ctx.resources.<name> is not narrowed to the resource's schema, so a wrong-kind delta there is caught at runtime — FlowError with code resource_delta_refused, and nothing written. An absent or null field counts as 0 / [] rather than a wrong kind. See Writing resource state.

Runtime resource content methods:

  • await ctx.resources.plan.readContent() → rendered content or null
  • await ctx.resources.plan.readContentRaw() → raw stored content or null
  • await ctx.resources.plan.writeContent("...") → overwrite stored content

For explicit LLM access, add tools manually to generators:

import {
generator,
readResourceContentTool,
writeResourceContentTool,
} from "@flow-state-dev/core";

const agent = generator({
name: "agent",
model: "openai/gpt-6-luna",
prompt: "You can inspect and edit approved resource files.",
tools: [readResourceContentTool(), writeResourceContentTool()],
});

defineResourceCollection(config)​

Create a resource collection — a typed set of resources created and destroyed at runtime:

import { defineResourceCollection } from "@flow-state-dev/core";

const filesCollection = defineResourceCollection({
pattern: "files/**",
scope: "session",
stateSchema: z.object({ language: z.string().default("text") }),
maxInstances: 200,
eviction: "lru",
});

Config options:

  • pattern: string — glob pattern: files/* (single-level), files/** (deep), [topic]/observations (parameterized)
  • scope: "session" | "user" | "org" — required. Any other value throws defineResourceCollection() requires an explicit scope of "session", "user", or "org" (got …)
  • stateSchema: ZodTypeAny — schema for each instance's state
  • maxInstances?: number — cap on simultaneous instances (must be >= 1)
  • eviction?: "none" | "lru" | "oldest" — what to do when cap is reached (default: "none" = throw)
  • writable?: boolean — whether blocks can modify instance state (patchState / setState / updateState / incState / pushState / upsert on an existing key), overwrite an existing instance with create(..., { replace: true }), delete an instance (delete, including when the key is absent), and write instance content (writeContent). Default true. Independent of llmWritable. create and getOrCreate of a missing key succeed; getOrCreate of an existing key returns the instance and does not write
  • llmReadable?: boolean — exposes every instance's content to readResourceContentTool() and content search (grepResourceContent / searchResources). Default false
  • llmWritable?: boolean — lets writeResourceContentTool() overwrite an instance body. Default false; independent of llmReadable and of writable
  • stampOrgId?: boolean — when true, creating an instance whose initial state has no orgId writes the organization the run belongs to into it. Creating an instance with an orgId other than the run's organization throws. On any write to an existing instance (including upsert and create(..., { replace: true })), the stored orgId stands: leaving it out keeps it, and naming a different one throws without writing. An instance with no stored orgId keeps none unless the write names the run's own organization; naming any other throws. The stateSchema must declare orgId, or defineResourceCollection throws. Default false
  • secret?: Array<keyof State> — state fields left out of client snapshots, state endpoints, live deltas and the debug endpoints (content is not covered). See Secret fields
  • onInstanceCreated?: (key, state, ctx) => void — lifecycle hook
  • onInstanceUpdated?: (key, state, prevState, ctx) => void — lifecycle hook
  • onInstanceDeleted?: (key, ctx) => void — lifecycle hook
  • indexes?: Record<string, IndexFn | { version: number; value: IndexFn }> — fields the items can be looked up by with findBy. IndexFn is (key, state) => value, where value is a string, number or boolean, an array of them, or null / undefined for no value. A bare function is version 1. Names must be non-empty and must not contain @; a version must be a positive integer; anything else throws when the collection is defined. Two collections in one scope whose patterns can match the same keys must not declare one index name at two versions, or the server refuses to start. See Looking items up by a field

Runtime ResourceCollectionRef methods:

  • create(key, initial?) — create a new instance (throws if exists or at cap with no eviction)
  • get(key) — get existing instance (throws if not found)
  • getOptional(key) — get existing instance or undefined
  • getOrCreate(key, initial?) — returns existing if present, creates if not
  • list(prefix?) — list all instances, optionally filtered by prefix
  • findBy(index, value) — the instances whose declared index index has value (a string, number or boolean), in key order, read behind the same owner rules as list(). Throws when the collection declares no index named index
  • delete(key) — delete an instance. No-op if not found on a writable collection. Throws the read-only error when the collection is writable: false, including when the key is already absent.
  • count() — current instance count

Use in blocks the same way as defineResource:

const fileManager = handler({
name: "file-manager",
resources: { files: filesCollection },
execute: async (input, ctx) => {
const ref = await ctx.resources.files.create("readme.md");
return ref.state;
},
});

Context Functions​

contextFn(schemas, fn)​

Create a typed context function for generators. Provides typed access to scope state via schema inference:

import { contextFn } from "@flow-state-dev/core";
import { section, list } from "@flow-state-dev/core/prompt";

const researchContext = contextFn(
{ session: sessionStateSchema },
({ session }) => {
if (session.coveredTopics.length === 0) return "";
return section("Research Progress", list(session.coveredTopics));
}
);

// Use in any generator
const agent = generator({
context: [researchContext],
// ...
});

Three overloads: (session), (session, user), (session, user, org).

Prompt Formatters​

@flow-state-dev/core/prompt — Composable text formatters for building clean LLM context.

FormatterSignatureDescription
section(title, ...content)(string | { title, level? }, ...string[]) => stringTitled section; default ##, or pass { title, level } (1–6) to nest
list(items, options?)(string[], { ordered?, prefix? }) => stringBullet or numbered list
keyValues(data)(Record<string, unknown>) => stringKey-value pairs
table(rows, options?)(Record<string, unknown>[], { columns? }) => stringMarkdown table; columns default to the key union
entries(record, formatter)(Record, fn) => stringMapped record entries
codeBlock(code, language?)(string, string?) => stringFenced code block
join(...parts)(...(string | falsy)[]) => stringJoin with newlines, filtering falsy
when(condition, content)(boolean, string) => string | ""Conditional inclusion

The same keyValues / list / table shapes are available inside .md prompt templates as the auto-registered fsd_keyValues / fsd_list / fsd_table / fsd_json filters — see Prompts as Markdown.

Concurrency​

mapLimit(values, maxConcurrency, mapper)​

(readonly T[], number | undefined, (value: T, index: number) => Promise<R>) => Promise<R[]>

Runs mapper over values with at most maxConcurrency calls in flight at once, preserving input order. undefined (or any value ≥ length) runs everything concurrently; empty input resolves to []. Use it for bounded async fan-out inside a handler — .parallel fans out blocks, this fans out plain async work.

import { mapLimit } from "@flow-state-dev/core";

// At most 5 quote fetches in flight, results in ticker order.
const quotes = await mapLimit(tickers, 5, (ticker) => fetchQuote(ticker));

Client Data​

client on scope configs​

Declare what slice of state crosses to the browser. Each scope (session, user, org) has a client block with two halves: expose (verbatim passthrough by field name) and derived (computed projections).

defineFlow({
session: {
stateSchema,
client: {
expose: ["progress"],
derived: {
topicList: (ctx) => ctx.state.coveredTopics,
},
},
},
});

derived compute functions receive { state, resources } from their scope. Values must be JSON-serializable. State without a client block is private to the server.

expose and derived share a namespace. A name in both throws at defineFlow. expose names that aren't on the scope's stateSchema throw too.

clientData is not a scope config key. Compute functions go under client.derived, plain passthroughs under client.expose.

Clients read the result at snapshot.clientData.<scope>.<name>.

Voice Types​

Voice config on a flow is defineFlow({ voice }). The speak model id is a string.

TTSConfig​

Settings for text-to-speech on VoiceConfig.tts.

import type { TTSConfig } from "@flow-state-dev/core";

const tts: TTSConfig = {
model: "gpt-4o-mini-tts",
voice: "alloy",
speed: 1,
};

model is optional. Omit it and the provider default is used.

VoiceConfig​

Flow-level voice configuration.

import { defineFlow } from "@flow-state-dev/core";
import type { VoiceConfig } from "@flow-state-dev/core";

const voice: VoiceConfig = {
tts: { voice: "alloy" },
};

defineFlow({
kind: "narration",
voice,
actions: {
// ...
},
});

Pass provider when this flow should use a different VoiceProvider than the server default.

VoiceProvider​

Object that owns speak, speakStream, transcribe, and listVoices. abilities says which of those exist.

import type { VoiceProvider } from "@flow-state-dev/core";

const provider: VoiceProvider = {
id: "demo:1",
providerName: "demo",
abilities: {
speak: true,
speakStream: false,
transcribe: false,
listVoices: false,
},
speak: async ({ text }) => ({
audio: new Uint8Array(),
mediaType: "audio/mpeg",
}),
};

Concrete providers ship in their own packages. See Voice.

OutputAudioContent​

Content part for synthesized audio. Import it from @flow-state-dev/core/items.

import type { OutputAudioContent } from "@flow-state-dev/core/items";

const part: OutputAudioContent = {
type: "output_audio",
audio: "", // base64
mediaType: "audio/mpeg",
transcript: "Hello",
};

Errors​

FlowError​

A small Error subclass author code can throw to attach a machine-readable code and structured details that survive the trip to the trace.

import { FlowError } from "@flow-state-dev/core";

throw new FlowError("Command rejected", {
code: "PATH_OUTSIDE_WORKSPACE",
details: { cwd: "/foo" }
});

new FlowError(message, options) where options is { code?: string; retryable?: boolean; details?: Record<string, unknown>; cause?: unknown }. retryable defaults to false. FlowError.isInstance(value) matches FlowError (and subclasses) by instanceof or by name-tag, which is the dual-realm-safe check.

OutputValidationError​

Runtime-emitted subclass of FlowError. Thrown by the generator runtime when the model's output fails the declared outputSchema. Carries typed details:

type OutputValidationDetails = {
rawOutput: string; // raw text or JSON the model returned
issues: ZodIssue[]; // Zod issues from the failing parse
phase: "stream" | "final";
};

code is "output_validation_error". retryable is false. See Error handling for usage patterns.

StrictSchemaError​

Thrown at generator() construction when an outputSchema is not compatible with OpenAI's strict structured-output mode. Strict mode requires a JSON schema with no open-keyed maps and no conflicting required sets across union variants, so a reachable z.record() or a z.union() of differently-shaped variants is rejected. Subclass of FlowError with code "strict_schema_error" and retryable false. Carries the located violations:

interface StrictViolation {
path: string; // e.g. "$.metrics", "$.items[].scores"
typeName: string; // e.g. "ZodRecord", "ZodUnion"
reason: string;
}
// error.violations: StrictViolation[]

Schema validation​

assertStrictCompatible(schema, label?)​

Throws a StrictSchemaError if schema — after the strict transform strips its optional / default / nullable wrappers — still contains a construct OpenAI strict mode rejects. A no-op on a compatible schema. Generators call it automatically at definition, so you only need it to check a bare schema constant in a test.

import { assertStrictCompatible } from "@flow-state-dev/core";
import { z } from "zod";

// Throws: dynamic-keyed map → additionalProperties=true
assertStrictCompatible(z.object({ scores: z.record(z.string(), z.number()) }));

// Passes: array-of-pairs carries dynamic keys without an open map
assertStrictCompatible(
z.object({ scores: z.array(z.object({ key: z.string(), value: z.number() })) }),
);

makeSchemaStrict(schema, options?)​

Returns a copy of schema with optional / default / nullable wrappers unwrapped so every property lands in the provider's required set. The framework calls it internally before serializing a schema to the AI SDK. Pass { validate: true } to also throw StrictSchemaError when an incompatible construct survives (this is what assertStrictCompatible does). The transform does not rewrite z.record() / z.union() — fix those in the source schema.

Type Helpers​

import { StateOf, ContextOf, ResourceContext, BlockInput, BlockOutput } from "@flow-state-dev/core";

type PlanState = StateOf<typeof planResource>;
type SessionCtx = ContextOf<typeof sessionSchema, "session">;
type Input = BlockInput<typeof myBlock>;
type Output = BlockOutput<typeof myBlock>;

Subpath Exports​

  • @flow-state-dev/core/types — Block, flow, resource, scope, streaming, and model type definitions
  • @flow-state-dev/core/items — Item unions, content types, and stream event helpers
  • @flow-state-dev/core/prompt — Composable prompt formatters (section, list, keyValues, table, entries, codeBlock, join, when)