Skip to main content

Resource Collections

Static resources have a fixed name you declare up front: plan, artifacts, preferences. Resource collections handle the case where you don't know how many instances you'll need. An AI managing a set of files, accumulating observations per topic, or creating workspaces on the fly — these are collection problems.

A collection defines a shared schema and a key pattern. Instances are created and destroyed at runtime. The property name you assign in resources is how you access it at runtime — not the pattern string.

When the app already owns the set, use a projected collection. That is a read-only view: each read asks the app again, and the framework does not store a copy.

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",
});

// Register under any property name:
const fileManager = handler({
name: "file-manager",
resources: { files: filesCollection },
// ^^^^^ this is the access key
execute: async (_input, ctx) => {
const files = ctx.resources.files; // ← access via property name
await files.create("readme.md", { language: "markdown" });
},
});

Patterns​

The pattern string determines which keys a collection can hold.

PatternExample keysBehavior
files/*files/readme.mdSingle-level wildcard. files/src/utils.ts would not match.
files/**files/readme.md, files/src/deep/nested.tsDeep wildcard. Matches any depth.
[topic]/notesreact/notes, rust/notesParameterized segment. The bracketed portion becomes a key parameter.

** must be the last segment. Parameterized segments use [name] syntax.

Runtime API​

Collection entries on ctx.resources are ResourceCollectionRef instances.

Core operations​

The lookups (get, getOptional, list, count) return Promises, so await them. This is the same call shape regardless of prefetchMode — see Eager vs lazy collections.

execute: async (input, ctx) => {
const files = ctx.resources.files;

// Create a new instance — returns a ResourceRef
const ref = await files.create("readme.md", { language: "markdown" });

// Get existing instance (throws if not found)
const existing = await files.get("utils.ts");

// Get or create — returns existing if present, creates with defaults if not
const safe = await files.getOrCreate("config.json", { language: "json" });

// List all instances, optionally filtered by prefix
const allFiles = await files.list();
const srcFiles = await files.list("src/");

// Delete an instance. No-op if not found on a writable collection.
// Throws the read-only error when writable: false, including when
// the key is already absent.
await files.delete("old-file.ts");

// Current instance count
const count = await files.count();
}

Each returned ResourceRef supports the same operations as a static resource: state, patchState(), setState(), updateState(), incState(), pushState(), readContent(), readContentRaw(). patchState, setState, updateState, incState, and pushState all refuse a result that fails stateSchema; see Schema-invalid resource writes. The state getter on a resolved ref is synchronous — you await the lookup, not the read of an already-resolved ref.

Each returned ref also carries path, scope, and uri fields for identity — see Resource identity for details.

Parallel writes

Creating distinct collection instances across concurrent .parallel / .forEach branches is safe: each write commits to its own key, and a later list() or count() in the same request sees all of them. Two branches writing the same instance key are last-writer-wins; incState and pushState are the way to accumulate on a shared key without one branch overwriting the other. See parallelism in control flow.

create({ replace }) and upsert — handling the exists/missing branches​

Two additional operations cover the recurring "is the instance already there?" patterns that show up in setup/reset and incremental-update paths:

execute: async (input, ctx) => {
const files = ctx.resources.files;

// Replace-or-create: overwrites existing state, creates if missing.
// `setState` semantics — Zod `.default(null)` fills nullables, so a
// prior published memo's body/headline reset cleanly on re-run.
await files.create("readme.md", { language: "markdown" }, { replace: true });

// Patch-or-create: applies a delta on exists, creates on missing.
// 2-arg form — the second arg is patched on exists, used as-is on create.
await files.upsert("readme.md", { language: "javascript" });

// 3-arg form: `createOnly` extras fill fields you only need at first
// creation. On exists, only `update` is applied; on missing, the
// instance is created with `{ ...createOnly, ...update }` (update wins
// on overlapping keys).
await files.upsert(
"readme.md",
{ language: "javascript" }, // patch — always applied
{ metadata: { createdBy: "setup" } } // create-only — only on first touch
);
}

The four "if-exists / if-missing" patterns:

APIIf existsIf missing
create(k, s)throwscreates
create(k, s, { replace: true })replaces (setState, defaults fill)creates
getOrCreate(k, init?)returns as-iscreates
upsert(k, update, createOnly?)patchescreates with { ...createOnly, ...update }

create and upsert refuse an initial or merged state that fails stateSchema. The instance is not created or patched.

Both new operations fire the right lifecycle hooks: onInstanceUpdated on the replace/patch branch, onInstanceCreated on the create branch. maxInstances is only checked when adding a new instance — replacing or patching an existing one never trips the guard.

Parameterized patterns​

When a pattern has [name] segments, pass an object key instead of a string:

const topicNotes = defineResourceCollection({
pattern: "[topic]/notes",
scope: "session",
stateSchema: z.object({ entries: z.array(z.string()).default([]) }),
});

// Register under any property name:
resources: { notes: topicNotes }

// At runtime:
const notes = ctx.resources.notes;
const ref = await notes.create({ topic: "react" }, { entries: [] });
// Storage key: "react/notes"

const existing = await notes.get({ topic: "rust" });

The framework resolves { topic: "react" } to the storage key react/notes.

Owner-private collections​

Sometimes a row belongs to one person and nobody else in the org should see it, not even through a wider collection that happens to match its key. Declare the collection owner-private and name the parameter that holds the owner:

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

const drafts = defineResourceCollection({
pattern: "drafts/[owner]/[id]",
ownerPrivate: { param: "owner" },
scope: "org",
stateSchema: draftSchema,
});

// in a block
const userId = ctx.session.identity.userId;
if (!userId) throw new Error("This request has no user.");
await ctx.resources.drafts.create({ owner: ownerSegment(userId), id: "q3-plan" }, state);

ownerSegment(userId) turns a user id into one key segment that starts with ~. The collection serves a row only to the user its owner segment names. For everyone else the row is not there: lists and counts leave it out, getOptional returns nothing, and get or any write throws "A row of an owner-private collection is readable only by the user it belongs to."

No other collection can reach those rows. A key segment starting with ~ is reserved for owner-private and owner-writes collections in every app. A wider collection such as [tenant]/** lists without them, and a write to one through it is refused. That holds in any process over the same store, including a worker that never loaded the owner-private collection.

Overlaps fail at startup. Once a flow declaring an owner-private collection is registered, the app refuses to start if any flow declares a collection in the same scope whose pattern could reach its keys, whichever registers first:

Collection pattern "[tenant]/**" can reach the rows of owner-private collection
"drafts/[owner]/[id]". Only that collection reads or writes them, each for the user it belongs to.

Declaring the same owner-private collection on several flows is fine, and so is a collection in another scope, which can't reach these rows.

Limits. The pattern must declare the named parameter exactly once and must not use **. An owner-private collection has no browser read: client.state.read, client.content.read and client.content.prefetch are each refused when it is defined, so the browser routes never return its rows. A key's first segment starting with ~ is its owner, so no segment before the owner parameter may start with ~; segments after it may. Don't use ~ at the start of a key segment anywhere else. A single resource whose ref, or accessor name when it has no ref, has such a segment is refused when its flow registers, in every app.

Owner-writes collections​

Some rows belong to one person but are meant to be read by everyone, such as the status each person keeps on a shared plan. Declare the collection with ownerWrites and name the parameter that holds the owner:

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

const statuses = defineResourceCollection({
pattern: "statuses/[plan]/[owner]/[id]",
ownerWrites: { param: "owner" },
scope: "org",
stateSchema: statusSchema,
client: { state: { read: true } },
});

// in a block
const userId = ctx.session.identity.userId;
if (!userId) throw new Error("This request has no user.");
await ctx.resources.statuses.create({ plan: "q3", owner: ownerSegment(userId), id: "checkout" }, state);

The key names the owner, as for an owner-private collection. Everyone the scope serves can read, list and count the rows, from the server or the browser. Only the user the owner segment names can create, update or delete one. Anyone else's write throws "A row of an owner-writes collection is written only by the user it belongs to." and changes nothing, whether it comes through the collection, a ref they read, or the browser's update and delete routes, and whichever flow sends it.

The rest works as it does for owner-private collections: no other collection reaches the rows, and an overlapping collection in the same scope stops the app at startup. Declaring one pattern ownerPrivate on one flow and ownerWrites on another is refused the same way.

Limits. The same key rules as owner-private collections apply, except that a browser read is allowed. A collection can't declare both ownerPrivate and ownerWrites, and an owner-writes collection can't set an eviction policy other than "none". Each is refused when the collection is defined.

Eviction​

When maxInstances is set, the collection enforces a cap on live instances:

PolicyBehavior
"none" (default)Throws when the cap is reached. You must delete() before creating more.
"lru"Evicts the least-recently-accessed instance.
"oldest"Evicts the first-created instance.

Setting eviction to "lru" or "oldest" without maxInstances throws at definition time. Without maxInstances, the collection is unbounded.

Set maxInstances for any collection that could grow without limit. An AI creating files in a loop with no cap will cause memory and storage pressure. "lru" is the safest default — it keeps the working set and discards stale entries.

Eager vs lazy collections​

prefetchMode is a loading-cost knob, not an API-shape knob. All reads (get, getOptional, list, count) are async in both modes — you always await them. What prefetchMode changes is when the data loads, not how you call for it. Flipping the mode requires zero call-site changes. The default is "eager".

ModeWhen instances loadWhat a read does
"eager" (default)Whole prefix preloaded into an in-memory cache before any block reads itResolves instantly against the cache
"lazy"Nothing up front; each access fetches from the store on demand and caches the resultOne store read on a cache miss, then cached

Same call shape either way:

const one = await files.get("readme.md");
const all = await files.list();
const n = await files.count();

The ResourceRef you get back has a synchronous .state getter. You await the lookup, not the read of an already-resolved ref:

const ref = await files.get("readme.md");
const title = ref.state.language; // sync

Mutations (create, getOrCreate, upsert, delete) are async in both modes too.

Eager (default)​

Eager is the right default for collections with a bounded, predictable size. The whole set is in memory, so list() and count() are exact and resolve without a store round-trip.

Lazy​

Set prefetchMode: "lazy" to defer loading:

const docs = defineResourceCollection({
pattern: "docs/**",
scope: "session",
stateSchema: z.object({ title: z.string().default("") }),
prefetchMode: "lazy",
});

Reach for lazy when a collection is large or unbounded and a typical request only touches a few keys by name. You pay one store read per key instead of scanning the whole prefix up front.

The cost of list() and count() on a lazy collection​

On a lazy collection, list() or count() reads the entire collection prefix in one unbounded scan. There's no pagination today. That's fine for an occasional full scan, but on a large collection it defeats the point of going lazy. Prefer get(key) by key on lazy collections, and fall back to list()/count() only when you genuinely need the whole set.

Lazy requires eviction: "none"​

A lazy collection only ever holds a partial cache, so eviction can't see the full set to make a correct decision. Combining prefetchMode: "lazy" with any eviction policy other than "none" throws at definition time. maxInstances and eviction still apply under eager mode exactly; under lazy they're best-effort and only see the instances that have been loaded.

A lazy single resource is also restricted: declaring prefetchMode: "lazy" on a single resource at flow level throws, because a flow-level declaration has no per-block dispatch to trigger the load. Declare it on the block that needs it instead.

Observing load costs in the DevTool​

Picking eager or lazy is a guess until you can see what it costs. The DevTool surfaces per-block resource-load metrics: which keys were fetched from the store versus served from cache, how long each fetch took, and whether your eager prefetch is actually earning its keep. Select a block in the trace and open its Resource Loads panel. See Observing resource loads.

Lifecycle hooks​

Collections support per-instance hooks for logging, side effects, or cleanup:

defineResourceCollection({
pattern: "files/**",
scope: "session",
stateSchema: fileSchema,
onInstanceCreated: (key, state, ctx) => {
ctx.log(`Created: ${key}`);
},
onInstanceUpdated: (key, state, prevState, ctx) => {
ctx.log(`Updated: ${key}`);
},
onInstanceDeleted: (key, ctx) => {
ctx.log(`Deleted: ${key}`);
},
});

Hook context provides log(message) and scopeType. Hooks are synchronous.

When a reaction needs to do more than log or mirror, bind a real block instead with reactTo. A reactive block runs inside the turn that caused the change, so it can emit items, call models, run sub-blocks, and show up in traces. Treat reactTo as the upgrade path from these callbacks: keep onInstance* for cheap synchronous side effects, reach for reactTo when the reaction is part of the flow's work. See Reactive blocks.

Block declarations​

Collections work with block-level resource declarations the same way static resources do:

const fileManager = handler({
name: "file-manager",
resources: { files: filesCollection },
execute: async (_input, ctx) => {
await ctx.resources.files.create("output.md", {
language: "markdown",
});
},
});

Sequencers collect collection declarations from child blocks. defineFlow merges them into the flow's resources map. Two blocks declaring different collection refs for the same name will throw at build time. Same ref instance, no conflict.

Storage model​

Collection instances are stored in the same flat resources map as static resources. A collection with pattern files/** stores instances under keys like files/readme.md and files/src/utils.ts. No schema changes to scope records are needed.

When to use collections vs static resources​

Static resources work when you know the names ahead of time. Collections work when you don't.

The deciding factors:

  • Unknown count — you can't enumerate the instances at definition time
  • Independent lifecycles — each instance is created, updated, and potentially deleted on its own schedule
  • Pattern-based organization — instances naturally fit a path structure

If the set is bounded and predictable (three artifact slots), a static resource with an array or record in its state is simpler. Collections add value when the set is dynamic and potentially large.

Exposing collections to clients​

Collections can declare a client config to make their items visible to the frontend. This gives you React hooks for listing items, lazy-loading content, and performing CRUD operations. See Client Access for the full reference.

Writable​

writable controls whether blocks can change an instance's state or content. Default true. 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 all honor it.

Set writable: false and those writes throw. The flag is collection-wide: every instance is covered.

import { defineResourceCollection, handler } from "@flow-state-dev/core";
import { z } from "zod";

const notes = defineResourceCollection({
pattern: "notes/**",
scope: "session",
stateSchema: z.object({ title: z.string().default("") }),
writable: false,
});

const updateNotes = handler({
name: "update-notes",
resources: { notes },
execute: async (_input, ctx) => {
const ref = await ctx.resources.notes.get("onboarding");

await ref.patchState({ title: "Onboarding" });
// throws FlowError: Resource "notes/onboarding" is read-only

await ref.writeContent("# Onboarding");
// throws FlowError: Resource "notes/onboarding" content is read-only
},
});

A refused write throws a FlowError with code resource_read_only and retryable: false, the same error a single resource throws, so a block with a retry policy doesn't run again. State writes use the message Resource "<storageKey>" is read-only. Content writes use Resource "<storageKey>" content is read-only. storageKey is the resolved instance key, for example notes/onboarding.

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. upsert of a missing key creates.

llmWritable controls whether the generic content write tool is offered. writable: false refuses the write.

Projected collections have no writable field. They are read-only on every surface. See projected collections.

LLM access​

A content-bearing collection can opt into the generic content tools, the same way single resources do. Two flags, declared once on the collection and applied to every instance:

  • llmReadable: true — a generator can read instance content with readResourceContentTool(), and find it with grepResourceContent / searchResources.
  • llmWritable: true — a generator can overwrite an instance body with writeResourceContentTool().

Both default to false: a collection that doesn't opt in stays invisible to those tools. The write tool requires llmWritable, not llmReadable. A writable: false collection refuses the write. See Writable.

On a projected collection, searchResources calls the collection's search hook. globResources and grepResourceContent skip it.

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

const notes = defineResourceCollection({
pattern: "notes/**",
scope: "session",
stateSchema: z.object({ title: z.string().default("") }),
llmReadable: true,
llmWritable: true,
});

const editor = generator({
name: "notes-editor",
model: "openai/gpt-5.4-mini",
prompt: "Edit notes on request. Read the current body before rewriting it.",
tools: [readResourceContentTool(), writeResourceContentTool()],
resources: { notes },
});

The tools address an instance by its scope-qualified uri (session/notes/onboarding), so a result from globResources or grepResourceContent feeds straight into a read or write. A collection that opts in uses those generic content tools; declare collection-specific tools for domain logic they do not cover.

Lazy state by default​

Collection state is fetched on demand. For each client-visible defineResourceCollection collection, the snapshot includes count. If you set prefetchWindow, it also includes an inline window of items. Clients fetch a page when they need one. A projected collection appears as { prefetched: [] } with no count; list and search discover instances.

What the snapshot carries for each client-visible collection:

resources: {
session: {
artifacts: {
count: 132, // total items, always emitted
prefetched?: [...] // optional, only when prefetchWindow is set
}
}
}

Listing items from a client​

Two surfaces, same data:

  • HTTP: GET /sessions/:sessionId/resources/:ref?limit=&offset=&topicPrefix= returns one page.
  • React: useResourceCollectionList wraps that endpoint and handles the React state lifecycle.

A minimal example:

const { items, pagination, loadMore } = useResourceCollectionList(session, "artifacts", {
limit: 50,
});

return (
<>
{items.map((item) => <ArtifactRow key={item.topic} item={item} />)}
{pagination?.hasMore && <button onClick={loadMore}>Load more</button>}
</>
);

Each item is a CollectionItemHandle with topic, clientData, and a lazy fetchContent() that hits the existing content endpoint.

The page response shape mirrors the rest of the framework:

{
items: [{ topic: string, clientData?: unknown }],
pagination: { offset, limit, total, hasMore, nextOffset }
}

limit defaults to 50 and is capped at 200.

Filtering with topicPrefix​

Pass topicPrefix to narrow the page. The prefix matches the full storage key, not just the topic suffix. So for a collection with pattern artifacts/**, items have keys like artifacts/projects/abc/spec.md, and topicPrefix: "artifacts/projects/abc" matches all items under that namespace.

useResourceCollectionList(session, "artifacts", {
topicPrefix: "artifacts/projects/abc",
});

This pairs with the namespacing convention from parameterized patterns — [topic]/observations keys naturally compose into prefix queries.

prefetchWindow​

For small, always-needed collections, the snapshot can carry the first N items inline. Set prefetchWindow on the collection definition:

defineResourceCollection({
pattern: "artifacts/**",
scope: "session",
stateSchema: artifactSchema,
client: {
state: { read: true },
data: (state) => ({ title: state.title }),
},
prefetchWindow: 20,
});

The snapshot then includes prefetched: [{ topic, clientData }, ...] for the first 20 items. Consumers render immediately without an extra round-trip; the convenience hook surfaces them as the initial paint. (The DevTool lists a collection's items without applying prefetchWindow. See Debug vs client state.)

Ordering is by lexicographic storage key, not by recency. There's no per-item updatedAt on the storage layer today, so picking the "most recently updated" 20 items would require schema work that's deliberately out of scope for this version. Apps that need recency can encode timestamps into topic keys (e.g., 2026-05-06T12:00|spec.md); a future revision may add a richer ordering model.

The default is 0 (no prefetched window).

Server-side projections​

Scope-level client.derived functions that call collection.list() load the full persisted map. The snapshot is what the client sees; the projection function is not limited to it.

See also​

For a read-only view of data the app already owns, see projected collections.

For relationships between resources or entities rather than many instances of one shape, see Edges.

To find instances by path, content, or relevance once a collection holds many of them, see Searching resources.

To classify each instance's body once when it's written, and search the answers without a model call, define the collection with defineFacetedCollection. See Find by facets.