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: "preset/fast",
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, forEachBackground, doUntil, doWhile, loopBack, work, workIf, waitForWork, 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, resources, client },
user: { stateSchema, resources, client },
request: { onStarted, onCompleted, onErrored, onFinished, onStepErrored },
});

export default myFlow({ id: "default" });

Resources

defineResource(config)

Create a portable resource definition. Can be used in flow scope configs and in block-level resource declarations (sessionResources, userResources, orgResources):

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

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

// Use in flow scope config
session: { resources: { plan: planResource } }

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

Resource content options:

  • 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 content methods:

  • await ctx.session.resources.plan.readContent() → rendered content or null
  • await ctx.session.resources.plan.readContentRaw() → raw stored content or null
  • await ctx.session.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: "preset/fast",
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/**",
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)
  • 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)
  • 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
  • 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",
sessionResources: { files: filesNamespace },
execute: async (input, ctx) => {
const ref = await ctx.session.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.

clientData is the previous name for client.derived and is deprecated. Setting both client and clientData on the same scope throws at definition time; setting only clientData emits a one-time deprecation warning.

Voice Types

SpeechModel

Provider-agnostic interface for text-to-speech synthesis.

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

const model: SpeechModel = {
modelId: "gpt-4o-mini-tts",
generate: async (options) => ({ audio: uint8Array, mediaType: "audio/mp3" }),
};

TranscriptionModel

Provider-agnostic interface for speech-to-text transcription.

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

const model: TranscriptionModel = {
modelId: "gpt-4o-mini-transcribe",
transcribe: async (options) => ({ text: "Hello" }),
};

VoiceConfig

Flow-level voice configuration. Set on defineFlow({ voice }).

type VoiceConfig = {
tts?: {
model: string | SpeechModel;
voice?: string;
speed?: number;
};
};

OutputAudioContent

Content part for synthesized audio.

type OutputAudioContent = {
type: "output_audio";
audio: string; // base64
mediaType: string; // "audio/mp3", "audio/wav", etc.
transcript?: string;
duration?: number;
};

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)