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-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; 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({ 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 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

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-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 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) and instance content (writeContent). Default true. Independent of llmWritable. create / getOrCreate / delete are not gated
  • 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
  • onInstanceCreated?: (key, state, ctx) => void — lifecycle hook
  • onInstanceUpdated?: (key, state, prevState, ctx) => void — lifecycle hook
  • onInstanceDeleted?: (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 or undefined
  • getOrCreate(key, initial?) — returns existing if present, creates if not
  • list(prefix?) — list all instances, optionally filtered by prefix
  • delete(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.

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 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)