@flow-state-dev/core
v0.5.0
Published
Isomorphic builders, type contracts, and item taxonomy for the flow-state-dev block framework.
Maintainers
Readme
@flow-state-dev/core
The building blocks. Define handlers, generators, evaluators, sequencers, routers, and flows — all with end-to-end type safety.
This is the foundation package. Every other package depends on it. It's isomorphic — runs in Node, the browser, edge runtimes, anywhere JavaScript runs.
Installation
pnpm add @flow-state-dev/coreWhat you can build
import { defineFlow, generator, handler, sequencer, router } from "@flow-state-dev/core";
import { z } from "zod";A generator that calls an LLM with tools, history, and streaming:
const agent = generator({
name: "agent",
model: "intent/chat",
prompt: "You are a helpful assistant.",
inputSchema: z.object({ message: z.string() }),
history: true,
user: (input) => input.message,
tools: [readDoc, writeDoc],
itemVisibility: { client: true, history: true },
});The prompt (and user) slots can also be authored in a separate .md file with YAML frontmatter and a LiquidJS body. Load it with loadPromptFile(...) and spread definePromptFile(pf) into the generator config. See the Prompts as Markdown reference.
A handler that validates and transforms:
const counter = handler({
name: "counter",
sessionStateSchema: z.object({ count: z.number().default(0) }),
execute: async (input, ctx) => {
await ctx.session.incState({ count: 1 });
return input;
},
});A sequencer that composes them into a pipeline with error recovery:
const pipeline = sequencer({ name: "chat-pipeline", inputSchema })
.step(analyzeInput)
.stepIf((result) => result.needsContext, enrichWithContext)
.step(agent)
.step(counter)
.rescue([{ when: [ModelError], block: fallback }]);.rescue() is also a method on any block. someBlock.rescue([{ block: fallback }]) returns a block that recovers from its own failure and returns the handler's output instead of throwing — so a single step (or one forEach element, parallel branch, or router route) can fail in isolation while the rest of the chain continues. The chain-level .rescue() above is the same operation applied to the whole sequencer.
A later step can check whether an earlier one was recovered with ctx.wasRescued(blockName | blockDef) — without the recovered value carrying any marker.
Sequencers can optionally declare an outputSchema as a runtime contract on the composed output of the whole chain — validated on every exit path (tail, exitIf, rescue). Call .validate() at build time to catch structural drift early.
const summarize = sequencer({
name: "summarize",
inputSchema: z.object({ text: z.string() }),
outputSchema: z.object({ summary: z.string(), wordCount: z.number() }),
}).step(summarizeBlock);
summarize.validate(); // throws if the tail shape drifts from the declared schemaA router that dispatches to different pipelines at runtime:
const dispatch = router({
name: "mode-router",
routes: [chatPipeline, planPipeline, reviewPipeline],
execute: (input, ctx) => {
const mode = ctx.session.state.mode;
if (mode === "plan") return planPipeline;
if (mode === "review") return reviewPipeline;
return chatPipeline;
},
});A flow that ties it all together:
export default defineFlow({
kind: "my-app",
requireUser: true,
actions: {
chat: {
inputSchema: z.object({ message: z.string() }),
block: dispatch,
userMessage: (input) => input.message,
},
},
resources: {
artifacts: defineResource({
scope: "session",
stateSchema: artifactSchema,
writable: true,
}),
},
session: {
stateSchema: z.object({ mode: z.string().default("chat"), count: z.number().default(0) }),
client: {
derived: {
artifactsList: (ctx) => /* derive list from resource state */,
},
},
},
})();Calling the factory with no argument yields the flow's one instance, whose id is its kind. A definition that should run as several configured copies declares cardinality: "collection" and gives each instance its own id (reviewFlow({ id: "review-east" })); a singleton refuses any other id. See Flow options.
Copies that differ by more than their name declare configSchema on the definition and pass config per copy (reviewFlow({ id: "review-east", config: { model: "opus" } })). The bag is parsed against the schema and frozen at that call; blocks read it as ctx.flow.config, and a block declares what it needs of any flow that installs it with flowConfigSchema. Both options are definition-time and instance-time halves of the same thing — see Flow options. A bag the copy can't take throws FlowConfigRefusalError: its message says why, and its issues list each reason as { path, code, message }, where an empty path means the whole bag and code is Zod's issue code, "no_config_schema" or "block_contributes" (a block's flowConfigSchema would change the bag at that path).
Exports
Main (@flow-state-dev/core)
Block builders:
handler(config)— Synchronous/async logic blockgenerator(config)— LLM call with framework-managed tool loop, streaming, and structured output repair (deterministicjsonrepairthen LLM coercion that reshapes off-schema output to the schema; on by default, configured viarepair.coerce/repair.coerce.model, defaulting tointent/utility)- Provider-native web search: set
search: true(or aGeneratorSearchConfig). This is the model provider's built-in search, distinct from the@flow-state-dev/toolstools.searchtool — the toolstierknob does not apply, and the generator'ssearchDepth("low" | "medium" | "high", OpenAIsearchContextSize) is a different field from the toolssearchDepth("basic" | "advanced"). See Web search. - Human-in-the-loop inside the tool loop: a generator tool can call
ctx.suspend()to gate its own call. The request suspends like any sequencer gate, and on a durable resume the tool re-enters past the approval — prior turns and completed sibling tools replay from the item log, so the model is not re-called for them. Constraints: gate before side effects (the tool re-enters from the top on resume, so guard pre-gate work withrunOnce), one approval gate per model turn (first-suspension-wins), and a gated tool can't becacheable(the cache short-circuits before the tool body). See Generator and router suspend/resume.
- Provider-native web search: set
evaluator(config)— a block that asks an evaluation model typed questions (choice,score,boolean) about one state and returns{ answers }. Confidence appears on an answer only when the model reported it. See Evaluator.choice(instructions, options)/score(instructions, levels)/boolean(instructions, criteria?)— question builders forevaluator.sequencer(config)— Fluent composition DSL (21 methods:step,stepIf,parallel,forEach,forEachSideChain,doUntil,doWhile,map,tap,tapIf,rescue,branch,sideChain,sideChainIf,waitForSideChain,waitForCondition,loopBack,stepAll,stepAny,race,exitIf).forEach()/.forEachSideChain()take a per-item factory(item, index, ctx) => blockin place of a block. A block built that way does not exist whendefineFlowwalks the graph, so declare what the factory can produce withblocks: [...]in the trailing options (IterationOptions/SideChainIterationOptions); declared blocks count as the step's children for the dispatcher address check and resource merging. Redundant on a call that passes a block directly.
router(config)— Runtime block selection from declared routes. Route names must be unique per router (validated at build). The selected branch dispatches through the same replay seam as sequencer children, so on a durable resume the branch decision stays stable — the framework validates the re-run selection against the recorded decision and throwsRouteUnavailableErroron a mismatch — and completed work inside the branch replays instead of re-executing. A router whose branch can suspend needs a pureexecuteselector (no side effects, no ambient state reads); see Control-flow determinismdispatcher(config)— Send one dispatch to one entry the flow declares, in a child session derived from a key or in an existing session named by id. Returns{ sessionId, requestId, adopted }; withwait: true, waits for the request it started and returns{ status, output?, error?, requestId }(completed,failed, orrunningat the host's default deadline, which stops nothing). See Dispatches, entries, anddispatcher()
Block methods (available on every BlockDefinition):
.connectInput(mapper)— adapt input shape at the call boundary.connectOutput(mapper)— transform output shape at the call boundary.mapModelOutput(mapper)— when the block is used as a generator tool, supply a model-visible string representation of its output.as({ name?, description? })— a copy of the block under a new name and/or description, for showing it to a model as a tool or registering it under a key that must match its name. The new name is used everywhere (tool calls, items, traces, resume); the original is unchanged. Adds nothing at run time, unlike.asTool().asTool(opts?)— wrap the block so it emits atool_outputitem when run from a sequencer step (same envelope and lifecycle as the AI SDK tool-loop path)
Background work lifetime: .sideChain(), .sideChainIf(), and .forEachSideChain() queue tasks on a per-request pool, not the sequencer that dispatched them. Inner sequencers do not auto-await their own background work before returning; sibling sequencers run their tasks concurrently. The request executor drains the pool before terminal status — on every outcome, and repeatedly until no task is left, so a task that queues more background work is waited on too. Use .waitForSideChain() when an inner step depends on a queued task completing first — it drains only the calling sequencer's contributions.
Event-driven waits: .waitForCondition(predicate, { timeoutMs, wakeOn? }) suspends the sequencer until a synchronous predicate over the request's item stream returns true (or the timeout fires). Yields { timedOut: boolean }. Use it to coordinate with side-channel state — a worker writing an artifact, a task-board flipping a status, a projected actor resuming a paused review. Predicate helpers ship in @flow-state-dev/core/items: whenResourceChanged({ scope, path, changeType? }), whenResourceMatching({ scope, pattern }) (tiny glob with * and **), and whenAnyItem(predicate) as the generic escape hatch. The optional wakeOn filter lets high-fanout patterns skip predicate re-evaluation on irrelevant item types; @flow-state-dev/orchestration ships onTaskChangeFor(collectionId) for collection-bound waiters.
Waiting for a user: ctx.suspendForUser({ kind, prompt, deadline?, title?, resumeSchema?, data? }) stops the request until a person answers, when your code reads the answer on its next line. kind is "approval", "review" or "input". It needs durable execution, as ctx.suspend() does.
const asked = await ctx.suspendForUser({ kind: "input", prompt: "Which region?", deadline: 2 * DAY });
if (asked.cancelled) return { skipped: true }; // the task was cancelled; nothing here is kept
const region = asked.answer;- An answer comes back as
{ cancelled: false, answer }; an approval as{ cancelled: false, approved: true }or{ cancelled: false, approved: false, deadline }. deadlineis milliseconds from the call. Leave it out and the host'suserDeadlineMsapplies; with neither, the stop waits until it is answered, stopped or cancelled.- At a deadline an approval reads as a no with
deadline: true. A review or an input throwsSuspensionTimeoutError: catch it to go ahead, end or ask again, or leave it and the try fails. - When the task the request works is cancelled while it waits, the call returns
{ cancelled: true }, and any latersuspendForUserin that run returns it at once.
A raw ctx.suspend({ reason: "user_approval" | "user_input" }) is the same kind of stop, read by its reason, with its own timeoutMs as its only deadline: an input that passes it throws SuspensionTimeoutError, and a cancel throws SuspensionRejectedError with cancelled: true. isUserSuspension(suspension) and suspendForUserKindOf(suspension) (from @flow-state-dev/core/types) say whether a suspension waits for a user, and for what.
Flow:
defineFlow(definition)— Create a flow type with actions,internalandtaskentries, scopes, resources, and per-scopeclientblocks. See Dispatches, entries, anddispatcher()
Concurrency policy:
Any entry can declare a concurrency policy that decides what happens when two requests collide on the same key (the session by default). Set it on the entry — an action, an internal entry, a task-board entry, or a webhook / schedule binding — or set a flow-wide default via RequestConfig.concurrency (flow.request.concurrency); resolution is entry.concurrency ?? flow.request.concurrency ?? "allow", the same ladder for every dispatch type.
The policy is enforced by the in-process runner, or across worker processes when the worker adapter supplies a shared lease backend (WorkerAdapter.leaseBackend), as bullmqWorker does. A deployment that hands dispatches to an external queue whose adapter supplies no shared lease backend skips it, so requests on the same key can overlap there.
ConcurrencyConfig is either a bare policy name ("allow" | "queue" | "reject" | "hold" | "defer") or { policy, key }, where key is "session" (default), "user", "none", or a (ctx) => string | undefined function. A key that resolves to undefined means no arbitration — the request runs as allow. The default is allow (run concurrently). hold runs at once and marks the key busy; defer waits until nothing on the key is running or waiting, then runs.
defineFlow({
kind: "support-chat",
request: { concurrency: "queue" }, // flow-wide default
actions: {
respond: { block: respondPipeline }, // inherits "queue"
syncInvoice: { block: invoicePipeline,
concurrency: { policy: "reject", key: "user" } },
},
});Exported types: ConcurrencyConfig, ConcurrencyKey, ConcurrencyKeyContext, ConcurrencyPolicyName, plus the validateConcurrencyConfig validator. The policy is enforced once at the host dispatch seam, so every transport inherits it. See the Concurrency policies reference.
Utility block factories (utility.*):
utility.contextReducer(config)— Generator factory fordistill,denoise, orcompresscontext transformation modes with mode-specific default output schemas ({ distilled, keyPoints },{ cleaned, removedCategories? },{ compressed, compressionRatio?, dropped? })utility.summarizer(config)— Generator factory for brief, detailed, or executive summaries with optional focusobjectivesand a default{ summary, keyPoints? }output contractutility.decomposer(config)— Generator factory that breaks broad requests into executable tasks using a default{ tasks: [{ id, goal, deps?, priority? }] }output contractutility.analyzer(config)— Generator factory for artifact critique/evaluation with configurablecriteriaand a default{ findings, score?, recommendation? }output contractutility.combiner(config)— Handler factory for deterministic artifact merging via concatenation, deduplication, and structural normalization with default{ combined, mergeNotes? }outpututility.intentClassifier(config)— Generator factory for bounded intent classification with required category descriptions and default{ category, confidence, reasoning? }output contractutility.intentRouter(config)— Sequencer factory that composesintentClassifier+routerinto classification-driven branching with category descriptions, handlers, optionalconfidenceThreshold, and optional fallback routingutility.keyedRouter(config)— Router factory for the "pick a block from aRecord<string, Block>by string key" case. Throws with the registered keys (or routes tofallback) when the selected key is unregistered. Input adaptation belongs on the routed blocks via.connectInput(BP-013)utility.cascadingRouter(config)— Sequencer factory that walks a tree of evaluator choice questions. An edge opens only when the model chose it and reported confidence at or above the edge's optionalminConfidence; every other outcome runs the requiredambiguousblock. On evaluation models that report no confidence, every edge goes toambiguous. See cascadingRouter.utility.memoryExtractor(config)— Generator factory for stateless durable-memory extraction with a default{ memories: Array<{ type, content, confidence?, source? }> }output contract (type∈fact | preference | constraint | decision)utility.consultSessionHistoryTool({ name?, model?, budgetTokens?, description?, target? })— A tool for a generator'stoolsthat answers a question from another of the user's conversations without waking it. Input{ sessionId, question }(utility.consultSessionHistoryInputSchema). It reads that session's finished turns throughctx.requestHost.sessions.history, newest first, up tobudgetTokens(defaultutility.CONSULT_DEFAULT_BUDGET_TOKENS, 24,000), and answers on one tool-less generator onmodel(default"intent/utility"). Returns{ reading, of, asOf: { requestId, at } | null, turnsRead, earlierTurnsLeftOut, cut, running }, or{ ok: false, error }startingself(the asking session) orno_session(not the user's, missing, or a host with nosessions.history). The other session gains no items or requests, and the quoted history reaches the model as a record, not instructions. The answering generator's items stay out of the asking session's history and client view.target: { inputSchema, resolve }lets a wrapper name the session its own way. Types:utility.ConsultSessionHistoryOptions,utility.ConsultTarget. See consultSessionHistoryToolutility.historySummary({ summarize, name? })— Keeps a running summary of the turns before a conversation's history window. Returns{ capability, fold, resource }: installcapabilityin the answering generator'suses(it adds an<earlier-conversation>context section and starts that generator's history after the summarized turns), and runfoldbefore the generator (.tap(earlier.fold)).summarizetakes{ summary: string | null, turns: LLMMessage[] }and returns{ summary };utility.summarizerfits. A failedsummarizecall doesn't fail the turn; the next turn retries. Turn the history narrowing off withcapability.presets({ history: false }). See Summarizing turns past the window
Every generator-based utility above accepts an optional itemVisibility ({ client: boolean; history: boolean }) to control whether output is surfaced to the client/history. All default to unset (silent — output flows only via graph edges). Set explicitly to opt in when the utility should be user-facing.
Resources:
defineResource(config)— Portable resource definition. Requiresscope: "session" | "user" | "org". Register on a flow's or block'sresourcesmap.- Supports optional
content/contentFile(mutually exclusive),render,llmReadable, andllmWritablefor resource content workflows.contentFileand file-pathcontentTemplateaccept a bare string (resolved from the working directory) or anAnchoredPath—{ path, importerUrl: import.meta.url }— resolved relative to the declaring module first, with a working-directory fallback stateSchemamay normalize (fill a.default(), strip an undeclared key, map a retired enum value) but has to settle: parsing its own output must yield that same output. A.transform()that returns something new on each pass would move the stored value on every write, so a write that isn't a fixed point of the schema is refused withValidationErrorrather than stored. See the schema has to settle.prefetchMode?: 'eager' | 'lazy'(default'eager') —'lazy'defers the load until the declaring block dispatches. Once the resource is resolved itsref.stategetter is synchronous. Declaring'lazy'on a flow-level single resource throws at build time (no per-block load trigger).sharedToLineage?: boolean(defaultfalse,scope: "session"only) — give the resource ONE identity across a session lineage, so a session, every session dispatched under it, and their own children resolve the same resource through the ordinary resource API. Session state is never shared, and sharing does not serialize writes — two children writing one shared resource is ordinary same-resource contention fenced byexpectedVersion.trueatuser/orgscope throws at build time (those scopes already span every session the principal touches). Also accepted bydefineResourceCollection, applying to every instance. NamedsharedToWorkstreambefore 0.5.reactTo?: { created?, stateUpdated?, deleted?, contentUpdated? }— bind a block (handler/generator/sequencer) to a mutation. Each entry is a bare block or{ block, when }. A state binding (created/stateUpdated/deleted) runs with aResourceChangepayload (key,ref,kind,state,prevState,evicted), typed withresourceChangeSchema(stateSchema). AcontentUpdatedbinding runs after a server-side content write with a minimalResourceContentChangepayload (key,ref,kind), typed withresourceContentChangeSchema(); the blockreadContent()s for the fresh body. The block runs blocking inside the originating turn. See the Reactive blocks reference.- Runtime
ResourceRefprovidesstate(sync getter) pluspatchState,setState,updateState,incState/pushState(below), andgetOrPatchState(key, compute)— get-or-compute over state: returnsstate[key]if present, else runscompute, patches the result underkey, and returns it (callback runs only on a miss, so a fetch happens at most once per stored key and downstream readers reuse the stored copy). A storednullis a hit; acomputeresolving toundefinedstores nothing. No TTL — a per-resource data spine, not a cache. Concurrent misses on the same key within a request are single-flighted (they share onecompute, so a fanned-out read issues one upstream fetch); distinct keys still compute in parallel. incState(increments)/pushState(field, value)— add to number-valued state fields (incState({ calls: 1 })), or append one value to an array-valued one (pushState("errors", "rate_limited")), under the same names scope state uses. Both resolve tovoid; read the result offref.state. Each call is a single guarded mutation, so two callers incrementing one counter both land — on the memory, SQLite and Postgres stores, which compare and swap inside the store; the filesystem store holds the guard per key on the store instance, which covers every context sharing that instance but does not coordinate two stores pointed at one directory. Both are keyed toTStateon aResourceRef<TState>/ResourceContext<TState>whose state type is written out — number fields forincState, array fields and their element type forpushState. A handle read offctx.resources.<name>isResourceRef<any>, so only the delta-is-a-number check survives there. They refuse rather than corrupt: incrementing a field that holds something other than a number, or appending to one that holds something other than a list, throwsFlowError(code: "resource_delta_refused",retryable: false) and leaves the stored value alone. A multi-fieldincStateapplies wholly or not at all. An absent ornullfield is that field's empty state, not a wrong kind of value — it starts from0/[].incStatealso refuses a result that is not finite, since two finite operands can overflow toInfinity, whichz.number()accepts but the stores disagree about how to persist. Both honorwritable: false, and a delta that commits is validated againststateSchemalike any other state write.updateStateWith(ref, updater)/withOutcome(run, updater)(@flow-state-dev/core/helpers) — run a state update whose callback returns what it did, as{ state, result }, instead of assigning it to a variable outside the callback. On the CAS path an updater can run more than once, so an outward-assigned value can describe an attempt that never committed; these return the result belonging to the invocation that did (orundefinedif none completed).withOutcometakes any mutation runner —ref.updateState, a scope'satomicState, or your own wrapper — so one helper covers every retry entry point.scripts/validate-updater-purity.mjs(inpnpm typecheck) rejects the common outward-write forms as a backstop — it catches the naive shapes, not every possible one.readCommitted(ref, project)(@flow-state-dev/core/helpers) — read a resource's state as it is stored now, not as this request first read it, and returnproject(state). A request's resource reads are the snapshot it took when it first touched each key, so a value another request wrote since is invisible toref.state;readCommittedsees it, and writes nothing. Use it when a block polls a value another request will change. It needs a writable resource or collection ref: on one declaredwritable: falseit is refused withresource_read_onlyandprojectnever runs. On a writable resource,projectalways runs, so the result isundefinedonly whenprojectreturnsundefined; a read that can't happen (a collection row deleted since it was fetched) throws instead.
- Supports optional
defineResourceCollection(config)— Dynamic resource collection with pattern-based keys (files/*,files/**,[topic]/observations), requiredscope: "session" | "user" | "org", optionalmaxInstances/eviction, lifecycle hooks, andreactTo(same{ created?, stateUpdated?, deleted?, contentUpdated? }shape asdefineResource; supersedes theonInstance*callbacks for the block case)prefetchMode?: 'eager' | 'lazy'(default'eager') — a loading-cost knob, not an API-shape knob. Eager preloads the whole prefix into a per-request cache so reads resolve instantly;'lazy'reads per access from the store. The call shape is identical in both modes:get/getOptional/list/countall return Promises (alwaysawaitthem), and the mutationscreate/getOrCreate/upsert/deletewere already async. FlippingprefetchModeneeds no call-site changes.'lazy'requireseviction: 'none'(a partial cache can't drive eviction) and throws at build time otherwise.- Runtime
ResourceCollectionRefprovidescreate(),get(),getOrCreate(),upsert(),list(),findBy(),delete(),count() indexes?: { name: (key, state) => value | { version, value } }declares fields the items can be found by;findBy(name, value)returns the matching instances in key order, behind the same owner rules aslist(). Values are strings, numbers or booleans (or arrays of them), compared by equality with types kept apart. SQLite and Postgres read a stored index; memory and filesystem stores scan the collection. The first lookup in a scope builds the index from existing items; bumpingversionrebuilds it. See Looking items up by a fieldcreate(key, initial, { replace: true })— overwrites an existing instance instead of throwing.setStatesemantics; Zod.default(null)fills nullables on both the create and replace branches.maxInstancesonly checked when adding a new instance. Use for setup/reset paths. Awritable: falsecollection refuses that overwrite the same waysetStatedoes; a missing key creates.upsert(key, update, createOnly?)— patch-or-create. On exists: appliesupdateviapatchStatesemantics (other fields preserved). On missing: creates with{ ...createOnly, ...update }(update wins on overlap). ThecreateOnlyextras fill fields you only need to supply at creation time. Use for incremental-update paths that need to handle first-touch in a single call.- "If-exists / if-missing" summary:
createthrows /create({ replace })replaces /getOrCreatereturns as-is /upsertpatches — all four create on missing; replace-on-existing anddeleterefuse whenwritable: false.
defineFacetedCollection(config)— Defines a resource collection whose bodies are classified once on write by the evaluator you pass (evaluator, with a fixed questions object; it receives the body string), storing its answers asfacetsbeside yourstateSchemafields.nameis the accessor it registers under. Returns{ collection, search, reindex, resources }:searchtakes one optional value per choice question plusminConfidenceand returns{ keys }with no model call;reindextakes{ force? }, classifies rows without facets (every row withforce) and returns{ reindexed, failed }, retrying nothing;resourcesis the collection undernameplus the evaluator's declared resources, fordefineFlow({ resources }). See Find by facets.- Throws when defined, naming the problem, when given: a key pattern with parameters, such as
[topic]/observations(use a wildcard pattern liketickets/*);writable: false;client.content.createorclient.content.update; your ownreactTo.contentUpdated(created,stateUpdatedanddeletedare yours); astateSchemathat isn't az.object(), or that already hasfacetsorindexedAs; a question with the idminConfidence; a value that isn't an evaluator block, or an evaluator whose questions are a function; an evaluator whose input schema rejects a string (adapt the body withconnectInput); an evaluator that declaresflowConfigSchema, a resource undername, or a single resource (not a collection) withprefetchMode: "lazy". - Client access: reads and deletes only. Write bodies through a flow action.
- Throws when defined, naming the problem, when given: a key pattern with parameters, such as
isDefinedResourceCollection(value)— Type guard for collection definitionsisCollectionConfig(value)/resourceStorageKeys(configs)(@flow-state-dev/core/types) — The storage-identity rule the engine persists by anddefineFlow's collision check applies.isCollectionConfigis structural: anything with a stringpatternis stored as a collection, keyed on that pattern.resourceStorageKeysmaps each accessor to the slot it persists to: a single resource'sref, else the first accessor its definition appears under.
Capabilities:
defineCapability(config)— Bundle resources, state schemas, targets, and helper functions under a single name. Blocks declare capabilities viauses: [cap]and the framework merges everything transitively.fns: (ctx) => ({ ... })— Helper functions exposed atctx.cap.{name}.{fn}, memoized on first accesspresets— Named opt-in/opt-out bundles of any block config surface. Use.presets({ name: true/false })to configureconfig: { schema?, resolve }— Open, typed configuration. The resolver maps a validated value onto a block surface (like a preset, but value-carrying). Consumers pass it with.config(value), which composes with.presets()in either order.with(bag)— The normalized consumer builder. Collapses.config()and.presets()into one flat call: preset-named keys become preset toggles, the rest become the config value.cap.with({ allowed: ["x"], dynamicActivation: true })≡cap.config({ allowed: ["x"] }).presets({ dynamicActivation: true })..config/.presetsremain the underlying primitives; a preset name colliding with a config field is adefineCapability()erroruses— Capabilities can depend on other capabilities (transitive composition with diamond dedup)- Preset
historykey (generator-only): anItemQuery, or a function of the block context returning one, added to everyctx.session.items.history()read of the generator that installs the capability. The read's own keys win; among capabilities, later keys win.utility.historySummaryuses it to pass{ afterRequestId } - Preset tool slots (generator-only):
toolsis a grant a consuming generator can withhold — a generator that declares its owntools:gets that list and nothing else.controlToolsreaches the model whatever the generator declares, for a control the generator's own configuration asked for (a loader the capability builds and never exports, say) - Factory pattern: wrap
defineCapability()in a function for parameterized capabilities
Capability schema forwarding:
When a block lists a capability in uses, the capability's declared schemas flow into the block's ctx types at factory time. No re-declaration on the block is needed. The forwarded axes are sessionStateSchema, resources (resource handles), targetStateSchemas, sequencerStateSchema (from presets), and stateSchema (the block's own state, ctx.self — valid on any block kind). Block-own declarations merge in; for most axes the block wins on key collision, but stateSchema requires a shared field to be the same schema reference instead (matching the resources/targetStateSchemas merges) — a duplicate field with a different reference throws at build time rather than one side silently winning.
const myCap = defineCapability({
name: "my-cap",
sessionStateSchema: z.object({ ticker: z.string() }),
fns: (ctx) => ({ currentTicker: () => ctx.session.state.ticker }),
});
const myHandler = handler({
name: "my-handler",
uses: [myCap],
execute: async (_input, ctx) => {
// ctx.session.state.ticker — string, from the capability
const t = ctx.session.state.ticker;
},
});Forwarding is direct-only: inner capabilities used by myCap do not propagate to myHandler. Dynamic uses entries (functions) contribute at runtime but not to types.
Tool-result memoization:
createToolCacheCapability(options?)— Capability that installs a per-request LRU store on the active context so any tool block declaringcacheableserves identical calls from cache. Errors are never cached; identical in-flight calls in the same request coalesce.createInMemoryToolCacheStore(options?)— Standalone store factory for advanced wiring (e.g. binding a per-board run-scoped store directly).bindToolCacheStore(ctx, store)— Attach a store to a context without going through the capability path.canonicalizeToolArgs(value)— Deterministic JSON canonicalizer for customkeyFns that want the substrate's default normalization.- Types:
ToolCacheStore,ToolCacheEntry,ToolCacheAccessor,CreateToolCacheCapabilityOptions. - See Flow policy for the full guide, including when to mark a tool cacheable and how Task Board auto-installs the capability.
Context & client data:
contextFn(schemas, fn)— Typed context function for generators (scope-aware, portable)clienton scope configs — Per-scope client view:expose: string[](verbatim passthrough by field name) andderived: { name: fn }(compute functions receive{ state, resources }). State without aclientblock is private.
Prompt formatters (@flow-state-dev/core/prompt):
section,list,keyValues,table,entries,codeBlock,join,when— Composable text formatters for building clean LLM context.sectiontakes a string title (default##) or{ title, level }to nest under another section;tablerenders an array of records as a Markdown table. The samekeyValues/list/tableshapes are auto-registered asfsd_*filters inside.mdprompt templates.
Concurrency (@flow-state-dev/core):
mapLimit(values, maxConcurrency, mapper)— bounded-concurrency async fan-out preserving input order. Use it for async work inside a handler (.parallelfans out blocks, not in-handler async).xmlTag(name, content),renderTaggedContext(tagged, order)— XML tag rendering used by object-form generator contextvalidateTagName(name),RESERVED_TAG_NAMES— Reserved-tag list and validator for object-form context keys
Once per process (@flow-state-dev/core):
firstInProcess(key)— returnstruethe first timekeyis claimed in the process andfalseon every later call. Use it to print a boot diagnostic once per server start. The record is process-global and lasts for the life of the process: it lives onglobalThis, so it survives a dev server re-evaluating modules on each edit, and it is never cleared. Every caller shares one set, so namespace keys by package ("my-pkg/unattended-board"). It only answers "first time?". It is not dev-only and ignoresFSD_QUIET_WARNINGS; the caller decides what to print.
Object-form generator context:
generator({ context: { ... } }) accepts an object whose keys become XML tag names. Multiple sources (the generator itself plus capabilities installed via uses) that contribute to the same key aggregate inside a single tag, instead of producing scattered sections.
generator({
prompt: "You are a research assistant.",
context: {
documents: [doc1, doc2],
userPreferences: () => loadPrefs(),
memory: { shortTerm: items, longTerm: () => loadLongTerm() },
},
uses: [capA, capB], // each may also contribute `documents`
});
// Renders as one combined system message:
// You are a research assistant.
//
// <documents>
// ...all documents from the generator + capA + capB...
// </documents>
// <user-preferences>...</user-preferences>
// <memory>
// <short-term>...</short-term>
// <long-term>...</long-term>
// </memory>Keys may be authored as camelCase, snake_case, or kebab-case (all normalize to kebab-case). Values may be strings, string arrays, nested objects (recursive — produces nested tags), functions resolved at render time, or null placeholders that reserve order but emit nothing if unfilled. String leaves are HTML-escaped so < / > / & in user data don't get read as tags. The original array form is unchanged. See the blocks reference for the full contract.
Slot types:
ToolsSlot— Tools accepted by generators: static array or(ctx) => tools[]UsesSlot— Capabilities accepted by blocks: static array or(ctx) => caps[]InstructionsSlot<TInput>— Pattern-level instructions: static string or(input, ctx) => string | Promise<string>. Parameterize with the pattern's input type to recover a typedinputin the callback.
Type helpers:
StateOf<T>— Extract state type from schema or resourceContextOf<T, Kind>— Get context handle type for scope/resourceResourceContext<T>— Resource context typeBlockInput<T>/BlockOutput<T>— Infer block I/O typesBlockDefinition— The fully-typed return interface ofhandler(),generator(),evaluator(),sequencer(), androuter(). Generics default toZodTypeAny, so unparameterizedBlockDefinitionis the unconstrained "any block" form — useful when an app-level factory needs to accept or return a block without restating the framework's generics.BlockKind—"handler" | "generator" | "evaluator" | "sequencer" | "router"union — useful when writing dispatchers that switch onblock.kind.EvaluatorAnswer,EvaluatorAnswers<Q>— the answer types, for code that consumes an evaluator's output.EvaluationModelis the evaluation model an evaluator accepts.ModelResolver.resolveEvaluationModel(modelId, blockName?)— optional. A custom resolver needs it only to resolve evaluator model strings. See With a custom model resolver.BlockContext— The full block-context interface (the type ofctxinexecute). Generic over the four scope-state types, declared resources, sequencer state, and parent input.BlockResult<TOutput>— The handlerexecutereturn-value union.SessionScopeHandle<TState>/UserScopeHandle<TState>/OrgScopeHandle<TState>/RequestScopeHandle<TState>— The scope handlesctx.session/ctx.user/ etc. resolve to. Use to type a ctx slice (e.g.(input, ctx: { session: SessionScopeHandle<MySessionState> }) => …) instead of hand-rolling a{ session: { patchState: ... } }shape.ctx.session.window— The history window the request loaded:{ turns, startRequestId? }.startRequestIdis the first turn the window holds, present only once earlier turns have fallen out of it.ctx.session.items.turns(query?)lists the window's turns, plus any that have not settled, oldest first asHistoryTurn(requestId,startedAtMs,settled,messages); pass{ requestIds }to read named turns even after they left the window.items.history({ afterRequestId })starts the history after the named turn
ScopeStateOps<TState>— The state-mutation interface every scope handle exposes (patchState,setState,setStateRecord, etc.).LooseBlockContext<TSessionState>— A partialBlockContext: typedctx.sessionstate,ctx.user/ctx.request/ optionalctx.orgwith untyped state,ctx.resourcesasany, and no other slots. Use for helper functions that take a block'sctxas a parameter. A fullBlockContextparameter checks every slot, so it rejects thectxof a block with a typedctx.targets(one that declarestargetStateSchemasor lists a capability inuses). Declaredresourcesdon't cause this. ALooseBlockContexthelper accepts any block'sctx, and inside the block,ctxkeeps its own types.
handler.withDefaults({...})
Partially-applied handler constructor. Bakes in common config so a family of sibling handlers can share scaffolding without restating it per call.
import { handler } from "@flow-state-dev/core";
import { z } from "zod";
const memoHandler = handler.withDefaults({
sessionStateSchema,
resources: { memos: memosCollection },
outputSchema: z.void(),
});
export const commitBullMemo = memoHandler({
name: "commit-memo-p2-bull",
inputSchema: bullThesisSchema,
execute: async (thesis, ctx) => {
// ctx.session.state is typed from sessionStateSchema
// ctx.resources.memos is typed from the resources default
await ctx.resources.memos.get("p2/bull").patchState({ ... });
},
});
// Per-call overrides win: pass `outputSchema` again to replace the default.
export const markError = memoHandler({
name: "mark-error",
inputSchema: z.unknown(),
outputSchema: z.object({ status: z.literal("error"), text: z.string() }),
execute: async (_, ctx) => ({ status: "error" as const, text: "..." }),
});Defaultable fields: sessionStateSchema, userStateSchema,
orgStateSchema, requestStateSchema, sequencerStateSchema,
resources, outputSchema, uses. name, inputSchema, execute, and
description are excluded — those vary per block.
Prompt files (@flow-state-dev/core/prompt-file, @flow-state-dev/engine/prompt-file)
Author a generator's prompt as a .md file. The isomorphic subpath exports parsePromptFile(text, options?), definePromptFile(pf), isPromptFile(value), and the PromptFile / PromptFileConfig / PromptFileParseError / PromptFileLoadError types. The Node-only subpath exports loadPromptFile(specifier, importerUrl, options?), which reads the file and auto-registers sibling .md files as partials; only this subpath imports node:fs, so browser/bundled consumers use parsePromptFile with raw text plus an explicit partials map.
Resolution rule. Relative specifiers resolve against the caller's import.meta.url; absolute specifiers are used as-is (importerUrl ignored); createPromptLoader joins every relPath onto its absolute baseDir. Resolution never consults the process working directory — compute baseDir with resolveBaseDir(candidates, { expect? }) (first candidate dir that exists and contains the expect probe; throws listing all candidates when none qualifies) composed with moduleDir(importerUrl, relative?) (the module's directory, or undefined when a bundler has rewritten import.meta.url to a non-file: URL). Module-relative candidate first, process.cwd()-derived fallback for bundled runtimes that pin cwd (Next.js dev/build).
Two ergonomic shortcuts cut the boilerplate:
- Pass the
PromptFilestraight topromptinstead of spreadingdefinePromptFile(pf).generator({ prompt: loadPromptFile(...), model })expands the file'suser/caching/maxTokens/temperature/name/descriptioninto the config; any sibling field you set explicitly wins (same precedence as...definePromptFile(pf), <overrides>). createPromptLoader(baseDir, options?)(Node subpath) captures an absolutebaseDirplus sharedpartialsDir/filtersonce and returns aload(relPath)function, so call sites drop the repeatedimport.meta.urlargument. Per-callfiltersmerge over the loader's shared filters.
import { generator } from "@flow-state-dev/core";
import {
createPromptLoader,
moduleDir,
resolveBaseDir,
} from "@flow-state-dev/engine/prompt-file";
const PROMPT_ROOT = resolveBaseDir(
[moduleDir(import.meta.url, "./prompts"), path.resolve(process.cwd(), "src/prompts")],
{ expect: "_partials" },
);
const load = createPromptLoader(PROMPT_ROOT);
const analyst = generator({ name: "analyst", model, prompt: load("analyst.prompt.md") });Resource content templates. The same .md format can render resource content against state. The Node subpath exports loadResourceTemplate(specifier, importerUrl, options?) and the isomorphic subpath exports parseResourceTemplate(text, options?), renderResourceTemplate(template, state), and the isResourceTemplate(value) guard. Wire them via contentTemplate (build-time file — a parsed template, a working-directory-relative string path, or an AnchoredPath resolved relative to the declaring module) or contentTemplateRef (live-editable resource) on defineResource() and defineResourceCollection(). See the Resource content from Markdown templates reference.
Voice Provider
VoiceProvider is a single, ability-flagged interface a flow wires to handle one or more voice surfaces: speak (batch TTS), speakStream (streaming TTS), transcribe (STT), and listVoices (catalog). Each provider declares which abilities it supports via the abilities field; runtime type guards (canSpeak, canSpeakStream, canTranscribe, canListVoices) narrow the provider so the matching method is callable without !. Errors thrown by providers carry a discriminated VoiceError.kind so callers can branch on category instead of parsing messages.
This surface replaces the previous resolver-factory pattern (createAiSdkSpeechResolver, createAiSdkTranscriptionResolver) — those helpers and their SpeechResolver / TranscriptionResolver types are removed from core. The field is named abilities (not capabilities) to avoid colliding with the framework's first-class Capability concept (defineCapability, uses: [cap]).
import { canSpeak, type VoiceProvider } from "@flow-state-dev/core";
async function maybeSpeak(provider: VoiceProvider, text: string) {
if (canSpeak(provider)) {
const { audio, mediaType } = await provider.speak({ text });
return { audio, mediaType };
}
return null;
}Exports from the main package and @flow-state-dev/core/types:
- Core contract:
VoiceProvider,VoiceAbilities,SpeakOptions,SpeakResult,SpeakChunk,TranscribeOptions,TranscribeResult,VoiceInfo - Narrowing interfaces:
SpeakCapable,SpeakStreamCapable,TranscribeCapable,ListVoicesCapable - Type guards:
canSpeak,canSpeakStream,canTranscribe,canListVoices - Errors:
VoiceError,VoiceErrorKind - Composite factory:
createCompositeVoiceProviderbuilds a synthetic provider that delegates each ability to a different underlying provider
Per-provider implementations live in separate packages — @flow-state-dev/voice-openai is the first, with @flow-state-dev/voice-elevenlabs to follow.
Types (@flow-state-dev/core/types)
Block, flow, resource, scope, streaming, and model type definitions. Use this subpath for type-only imports.
The subscription event. subscriptionEventSchema (type SubscriptionEvent) is the one shape an outside event takes before it is routed to the sessions that follow its thread: provider, threads (its thread keys, widest first, at least one), kind, object ({ id, version }), sender ({ id, name, role }, where role is one of SUBSCRIPTION_SENDER_ROLES: member, collaborator, bot, outsider or unknown), audience (public or private), title, text (at most MAX_SUBSCRIPTION_EVENT_TEXT, 4,000 characters), labels, url, received and at (epoch ms), and optional deliveryId and closes: true (the last and narrowest of threads closed with this event, as a merged PR does; its repository stays open). The schema is strict, so a key it doesn't name, such as raw headers, is refused. A webhook or a poll maps each event into it; workforce's subscriptions route it.
defineResourceCollection accepts writable?: boolean (default true) and llmReadable?: boolean / llmWritable?: boolean (default false). Declared once, they apply to every instance. writable: false refuses instance patchState / setState / updateState / incState / pushState, collection upsert on an existing key, create(key, initial, { replace: true }) when that key already exists, delete(key) (including when the key is already absent), and instance writeContent; those writes throw a non-retryable FlowError (code resource_read_only) with Resource "<storageKey>" is read-only (state) or Resource "<storageKey>" content is read-only (content). create of a key that does not exist succeeds, including create(key, initial, { replace: true }) when the key is missing. getOrCreate of a missing key creates; of an existing key it returns the instance and does not write. llmReadable exposes instance content to readResourceContentTool() and content search (grepResourceContent / searchResources); llmWritable lets writeResourceContentTool() overwrite an instance body. The write tool is available when llmWritable is true. The write fails when writable is false. The generic tools address resources by scope-qualified uri (e.g. session/files/readme.md). A content-bearing collection uses those tools; keep collection-specific tools for domain logic they do not cover.
defineResourceCollection accepts a prefetchWindow?: number (default 0) that inlines the first N items in the snapshot's prefetched window in lexicographic storage-key order. Per-item clientData in the window appears only when client.state.read: true is also set. CollectionStateClientConfig controls per-item state visibility separately from content; single resources don't accept client.state (state visibility is governed by client.data on those).
defineResourceCollection accepts ownerPrivate?: { param: string }, which serves each row only to the user its owner segment names. Build that segment with ownerSegment(userId) (~ plus the escaped user id) and pass it as param's value in the key. Key segments beginning ~ are reserved for owner-keyed collections (owner-private and owner-writes) in every app: no other collection lists, reads or writes them. The pattern must name param exactly once and must not use **, and the collection may not enable a browser read; each is refused at definition. Once a flow declaring one is registered, Engine refuses any flow whose collection in the same scope could reach its keys. Engine also refuses, in every app, a flow declaring a single resource whose key (its ref, else its accessor name) has a segment beginning ~. See Owner-private collections.
ownerWrites?: { param: string } takes the same key shape and the same startup fence, but every reader the scope serves reads each row, browser routes included, and only the user its owner segment names creates, updates or deletes it. Anyone else's write, through any handle or ref or the browser update and delete routes, throws A row of an owner-writes collection is written only by the user it belongs to. and changes nothing. A collection can't declare both modes, and an owner-writes collection can't set an eviction policy other than "none". See Owner-writes collections.
Set client: { live: true } to stream each mutation's projected clientData as an inline delta that the client merges mid-stream without a refetch (the resource-side analog of state_change). It requires the resource's clientData to be client-visible (state.read: true or a projection on collections; a projection on single resources). lifecycleSchema(statuses) is a convenience export that returns a status enum plus nullable startedAt / completedAt / errorMessage fields to spread into a status-bearing stateSchema.
defineResource and defineResourceCollection carry a derived client-projection type alongside the state type. ClientDataOf<typeof def> extracts it — the Pick from expose, the Omit from exclude, the return type of data, or the full state for the identity default. Pass it to the React hooks (useResource<T>, useResourceCollectionItem<T>, …) so clientData is typed instead of unknown. This is a type-level brand only; the runtime payload stays JsonValue. For data projections, annotate the function's return so the type is captured precisely.
Projected resource collections. defineProjectedResourceCollection({ pattern, scope, stateSchema, read, search, ... }) defines a read-only view of records the app already owns. The framework does not store a copy: ctx.resources.<coll>.get(key) / .getOptional(key) and the client state/content routes call the required read({ key, ctx }) hook and validate the result through stateSchema. search({ query, ctx }) backs list, searchResources (when llmReadable), and the list route. The runtime ref has no create / upsert / delete; client write routes are closed; client.content.create / update / delete is a build-time error. Patterns are wildcard-only (* or **). Scope is session | user | org. The hook ctx carries a server-derived userId, scope, and tenantId. Content templates and client projection work the same as on a store-backed collection.
The harness contract. A harness is a coding agent driven as a block: you hand it a prompt, it runs its own agentic loop, and it hands back a handle describing the run.
- Runtime schemas, from the package root:
harnessRunInputSchema,harnessRunHandleSchema,harnessRunEnvelopeSchema - Types, from
@flow-state-dev/core/types:HarnessBlock(its optionalstopsOnAbortdeclares that when its abort signal fires, the block returns only after its process has exited, so a host may stop a run and start the next in the same directory; a command that process started may still be running),HarnessRunInput,HarnessRunHandle,HarnessRunEnvelope,HarnessSource,HarnessRunStatus,HarnessRunOutcome,HarnessRunUsage,HarnessRunCost,HarnessCostBasis,HarnessResolver,HarnessSessionHook,HarnessCallbackContext harnessSteering(), from the package root: one flow's live steering for a harness turn run in a session. HandonSteerableto the harness block and declareaction({ onTaken? })beside the turn action; it answers{ taken, live }and is declaredconcurrency: "allow",autoResume: false. Offers are kept per session in the server process, so a line sent to another process answerslive: false. Pair it with the client'ssendHarnessLine.harnessEnv({ pass }), from the package root (options typeHarnessEnvOptions): builds a harness'senvfrom an allowlist of variable names. A harness's agent runs as a child process, and with noenvit inherits the server's entireprocess.env. The result holds exactly the named variables that are set, read whenharnessEnvis called. Nothing else is passed,PATHandHOMEincluded. Values come fromprocess.envby default; passenvto read from another source. Where there is noprocess(a browser or edge runtime) and noenvis given, the result is empty rather than an error.- The plan record (a harness conversation's to-do list), from the package root:
OBSERVED_PLAN,observedPlanCollection,observedPlanItemStateSchema, the typeObservedOutcome(withobservedOutcomeSchema), the list markers (OBSERVED_PLAN_LIST,observedPlanListCollection) andreplacePlanList(plan, marker, namespace, items, { now?, requestId? }), which writes a whole-list plan as entries and stampsrequestIdon each. One shape for a coding agent's own to-do list, which a harness that reports one declares and writes. A record of what the agent planned, never a work queue. A plan belongs to the harness conversation:harnessConversationNamespace(conversationId)returns the namespace it is keyed under (conversation/<escaped id>), andplanConversationPrefix(conversationId)the key prefix of its entries. Each run leaves a pointer marker under its own namespace (harnessRunNamespace(ctx)) whoseconversationfield names that namespace. To read one request's plan, list its markers withplanRowsPrefix(requestId, OBSERVED_PLAN_LIST)astopicPrefixand pass them toreadPlanTarget(requestId, markers)(markers typedPlanMarkerRead,{ topic, conversation? }): it returns{ kind: "conversation", namespace, rowsPrefix }when a run left a pointer, and{ kind: "run", rowsPrefix }for a request whose plan is stored under its own runs.planKeySegment(value)is how an id or run name is written as one key segment. The keys, collections,harnessConversationNamespace,planConversationPrefix,planRowsPrefix,planKeySegmentandreadPlanTargetare also exported from@flow-state-dev/core/types, for browser code. Guide: the run's plan. - The pull-request record (the pull requests a session opened), from the package root:
OBSERVED_PULL_REQUESTS,observedPullRequestsCollection(session-scoped, live, readable from the browser),observedPullRequestStateSchema(typeObservedPullRequestState:host,repositoryasowner/name,number,url,title,state,branch,requestId,source("tool"or"branch") andfirstSeenAt),pullRequestKey(ref),pullRequestRefFromUrl(url)(a pull request's link read as a ref, the one reading every writer keys by; typePullRequestLink) and the typePullRequestRef. One row per pull request, keyed by host, repository and number, so a pull request seen twice is one row.writePullRequests(collection, refs, { source, requestId?, now? })is the one writer: it fills only fields that are still empty, never movesrequestIdorfirstSeenAt, and a"tool"sighting upgrades a"branch"row.requestIdis the request that opened the pull request, so a reader narrows a session's rows to one run by it. What counts as opened is a rule:githubPullRequestRule(inDEFAULT_PULL_REQUEST_RULES) reads agh pr createcommand or acreate_pull_requesttool whose output holds the pull request's link. APullRequestRuleis{ name, match(call) };findOpenedPullRequests(item, rules?)applies rules to onetool_outputitem.recordPullRequests(ctx, { rules? })starts the request's recorder, which writes each pull request as its call settles and stops when the request ends; the Codex, Claude Code and Cursor blocks call it.pullRequestWritesSettled(response)resolves once the request's recorder has finished the writes it started, for a writer that runs at request end.pullRequestRecorder({ rules?, name? })is the same recorder as a handler block, for a flow with no harness: add it as a.tapstep. With no collection declared, it records nothing and says so in a status note. The key, collection, schema andpullRequestKeyare also exported from@flow-state-dev/core/types. Guide: pull requests a session opened. - Subscription usage:
harnessUsageWindowSchemafrom the package root, and the typesHarnessUsageWindow,HarnessUsageLevel,HarnessUsageReading,HarnessUsageReader,HarnessUsageReportandHarnessUsageHookfrom@flow-state-dev/core/types. A reader(options?: { signal }) => Promise<HarnessUsageReading>asks a harness how much of each subscription window is used, with no run. A reading iswindows(each window'sid,label,minutes,usedPercent0 to 100 ornullwhen the vendor sent none,level("ok","warning","reached"ornull) andresetsAtin epoch ms, plusplanandaccountId) or one ofapi-key,signed-outandunavailable, each with areasonand no number. A reader returns those states rather than throwing. A harness that reports usage during a run calls aHarnessUsageHook(report, ctx)with oneHarnessUsageReportper window:{ harness, window, observedAt, runStartedAt, accountId }. The schema reads a window with nolevelaslevel: null. Guide: subscription usage. - Run spend:
reportHarnessSpend(ctx, spend)and the typesHarnessRunSpendandHarnessBilling, from@flow-state-dev/core/types. A harness block calls it as a run goes and once more when the run ends however it ends, with{ harness, inputTokens, outputTokens, costUsd, billing, runId, model }; a later report with the samerunIdreplaces the earlier one, never adds to it (a report without one is a settled run of its own, and the request names it with a freshsettled_…id), andmodelis the model the run named: what the agent reported, eachnullwhen it reported none (never 0, never an estimate), andbilling"api-key","subscription"ornull. It returnsfalseand counts nothing outside a request. The run is counted in the request's spend, never inctx.request.tokenUsageor an action'stokenBudget. Guide: what a run spent. harnessFailureKind(error), from the package root (typeHarnessFailureKindfrom@flow-state-dev/core/types):"unknown-conversation"when a harness refused to continue the conversation a run asked toresume,nullfor any other failure. Followscause, so it reads through wrapped errors. A harness package sets it on the error it throws.formatRunDuration(ms), from the package root: a readable string for a duration in milliseconds, rounded to the nearest second.formatRunDuration(324_000)is"5 mins, 24 seconds". A run of an hour or more shows hours and minutes only ("1 hr, 3 mins"). A duration under half a second, a negative number, orNaNgives"less than a second". The Claude Code, Codex and Cursor blocks use it for their run-end status.
Two packages implement it: @flow-state-dev/claude-code (claude-code/sdk) and @flow-state-dev/codex (codex/sdk). @flow-state-dev/harness-manager drives either. Full guide: Coding agents.
Items (@flow-state-dev/core/items)
Output item unions, content types, and stream event helpers. Item types: message, reasoning, component, container, tool_output, status, source, state_change, resource_change,
