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-5.4-mini",
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;failthrows on the first mismatch;rescuedefers 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 inautomode, usingintent/utility; passfalseto 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 typedblock_traceflows via graph edges. No position-inferred default; every generator declares.agentName?: string— Stable name stamped on every emitted item. Defaults to the block'snamewhenitemVisibilityis set. Generators that share anagentNamerepresent the same logical agent; distinct names stay isolated. Used by the client for per-agent rendering and byitems.selectForContext({ agentName })for scoped context assembly.
Callbacks:
onCompleted?: (output, ctx, meta: GeneratorCompletedMeta) => void | Promise<void>— Fires after a successful execute.meta.modelis aModelIdentitywith 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.trueuses 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 standalonetools.searchtool (whosetierknob 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({ id: "default" });
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 throwsdefineResource() requires an explicit scope of "session", "user", or "org" (got …)content?: string— inline definition-time bodycontentFile?: string | AnchoredPath— load initial body from a file path (mutually exclusive withcontent). A bare string resolves from the working directory;{ path, importerUrl: import.meta.url }resolves relative to the declaring module firstrender?: (content, state) => string | Promise<string>— optional renderer forreadContent()llmReadable?: boolean— allows read access whenreadResourceContentTool()is installedllmWritable?: boolean— allows write access whenwriteResourceContentTool()is installed
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 statesetState(next)— replace the stored stateupdateState(updater)— read-modify-write through a callback that may run more than onceincState({ field: delta })— add to number-valued fieldspushState(field, value)— append one value to an array-valued fieldgetOrPatchState(key, compute)— resolves tostate[key]if present, otherwise runscompute, stores it underkey, 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 ornullawait ctx.resources.plan.readContentRaw()→ raw stored content ornullawait 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-5.4-mini",
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 throwsdefineResourceCollection() requires an explicit scope of "session", "user", or "org" (got …)stateSchema: ZodTypeAny— schema for each instance's statemaxInstances?: 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/upserton an existing key) and instance content (writeContent). Defaulttrue. Independent ofllmWritable.create/getOrCreate/deleteare not gatedllmReadable?: boolean— exposes every instance's content toreadResourceContentTool()and content search (grepResourceContent/searchResources). DefaultfalsellmWritable?: boolean— letswriteResourceContentTool()overwrite an instance body. Defaultfalse; independent ofllmReadableand ofwritableonInstanceCreated?: (key, state, ctx) => void— lifecycle hookonInstanceUpdated?: (key, state, prevState, ctx) => void— lifecycle hookonInstanceDeleted?: (key, ctx) => void— lifecycle hook
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 orundefinedgetOrCreate(key, initial?)— returns existing if present, creates if notlist(prefix?)— list all instances, optionally filtered by prefixdelete(key)— delete an instance (no-op if not found)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.
| Formatter | Signature | Description |
|---|---|---|
section(title, ...content) | (string | { title, level? }, ...string[]) => string | Titled section; default ##, or pass { title, level } (1–6) to nest |
list(items, options?) | (string[], { ordered?, prefix? }) => string | Bullet or numbered list |
keyValues(data) | (Record<string, unknown>) => string | Key-value pairs |
table(rows, options?) | (Record<string, unknown>[], { columns? }) => string | Markdown table; columns default to the key union |
entries(record, formatter) | (Record, fn) => string | Mapped record entries |
codeBlock(code, language?) | (string, string?) => string | Fenced code block |
join(...parts) | (...(string | falsy)[]) => string | Join 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 was the previous name for client.derived. It has been removed: defineFlow throws if a scope config still sets it. Move compute functions under client.derived, and plain passthroughs into client.expose. (The wire shape is unchanged — clients still read snapshot.clientData.<scope>.<name>.)
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)