Client Access
Resources live on the server. By default, clients can't see them. The client config on a resource definition controls what's visible and what operations are allowed.
This is separate from a scope's client block. That declares what scope state crosses to the frontend (expose for verbatim fields, derived for computed values), arriving as a flat projection under clientData.<scope>. Resource client access gives the frontend direct, lazy-loaded access to resource content and metadata through dedicated endpoints and React hooks.
Declaring visibility
Add a client property to defineResource() or defineResourceCollection():
import { defineResource } from "@flow-state-dev/core";
import { z } from "zod";
const soulResource = defineResource({
scope: "session",
stateSchema: z.object({
values: z.array(z.string()).default([]),
tone: z.string().default("balanced"),
}),
content: "## Core Values\n...",
client: {
content: { read: true },
expose: ["tone", "values"],
},
});
Without a client property, the resource is invisible to clients. Adding it opens two channels:
contentcontrols access to the resource's content body (the "file" part)- A projection field (
expose,exclude, ordata) controls what state reaches the client. Omit all three to send the full state.
Without expose, exclude, or data, the full state is sent to the client.
Content permissions
The content object determines what clients can do with the content body:
| Permission | Effect |
|---|---|
read | Client can fetch content via fetchContent() |
prefetch | Content is included inline in the state snapshot (no separate fetch needed) |
For collections, two additional permissions control mutations:
| Permission | Effect |
|---|---|
create | Client can create new items via POST |
update | Client can modify item content via PATCH |
delete | Client can remove items via DELETE |
Grant update alongside create
Creating an item is two writes: the item's state, then its content. The server commits the state first, so the client that wins the race for a topic owns it, and a client that loses is turned away before it writes any content. That ordering is what stops two simultaneous creates from leaving one client's state paired with the other's body.
The tradeoff sits at the other end. If the state write succeeds and the content
write then fails, the item exists with no content row at all — reading its
content gives you null, not an empty string. It still appears in listings, so
nothing is lost quietly, and a PATCH to the item's content endpoint fills it
in. That repair needs update.
(If the collection declares contentTemplate or contentTemplateRef, the
repair part doesn't apply — content is rendered from the item's state, so the
item reads fine and there's nothing to fill in. The failure itself still
happens: if you sent content, the server still tried to store it, so the
request still fails and the item still exists.)
A collection granting create on its own therefore has a gap: an authorized
client can end up holding an item it can neither fill nor remove, because
PATCH and DELETE are both refused. If your clients create items, grant
update too:
client: {
content: { read: true, create: true, update: true },
}
Adding delete gives them a second way out. Collections that are read-only, or
that only your blocks write, are unaffected.
Choosing the projection shape
You have four ways to control what state reaches the client. Pick one — they're mutually exclusive.
expose
A list of state field names to send. Type-checked against the state schema, so typos are caught at build time.
client: {
expose: ["title", "summary", "updatedAt"],
}
exclude
A list of fields to hide. Every other field reaches the client.
client: {
exclude: ["internalNotes", "draftHistory"],
}
data (escape hatch)
A function that takes the state and returns whatever shape you want. Reach for data only when you need computed values that aren't on the state schema — for verbatim passthrough, expose is type-safer.
client: {
data: (state) => ({
title: state.title,
wordCount: state.body.split(/\s+/).length,
}),
}
Identity (no projection)
Omit expose, exclude, and data.
// Collection: identity ships per-item state when state.read is true
client: {
state: { read: true },
// no projection — clientData carries the full state
}
// Single resource: a content-only client stays state-private
client: {
content: { read: true },
// no projection declared → clientData is omitted from the snapshot
}
On a collection, the identity projection is each item's full state; per-item clientData ships when state.read is true. On a single resource, clientData is included only when you declare expose, exclude, or data.
Mutual exclusivity
Setting more than one of expose, exclude, or data throws at definition time:
defineResource() for "memos": client config may set at most one of
`expose`, `exclude`, or `data`. Got: expose, data.
Pick one. If you need both whitelisting and computed fields, use data and write the projection out by hand.
Reach for expose first; use exclude when you have many fields and only need to hide a few; reserve data for computed fields that aren't on the state schema.
Collection example
Collections are where client access gets the most use. A typical artifact or file collection exposes metadata for listing and content for viewing:
const artifactsCollection = defineResourceCollection({
pattern: "artifacts/**",
scope: "session",
stateSchema: z.object({
title: z.string(),
summary: z.string().default(""),
updatedAt: z.number(),
}),
client: {
content: { read: true, update: true },
state: { read: true },
expose: ["title", "summary", "updatedAt"],
},
});
The snapshot carries the collection's total item count and (when prefetchWindow is set) an inline window of the first N items. Per-item clientData is included in that window only when client.state.read: true is set. Clients page through the rest via the list endpoint or useResourceCollectionList. Content is not included; it's fetched per item on demand. See Resource Collections — lazy state for the full mental model.
client.state
Collections (only — single resources gate state via the projection fields directly) can opt into a separate state-read permission:
client: {
content: { read: true, update: true },
state: { read: true },
}
What client.state.read: true enables:
- The list endpoint (
GET /sessions/:id/resources/:ref?limit=…) and the single-item state endpoint succeed; the response carries each item'sclientData. - The snapshot's
prefetchedwindow includesclientDatafor each prefetched item.
Without it, those endpoints return 403 and prefetched carries just the topic for each item. The collection's count is always emitted regardless — counts are a cardinality affordance, not state.
For capability-driven UIs, the resource manifest reports declared permissions per resource so clients can render conditional affordances without hard-coding flow knowledge.
Snapshot shape
When you request session state, resources with client config appear under a resources key:
{
"clientData": { "session": { "modeStatus": { ... } } },
"resources": {
"session": {
"artifacts": {
"items": {
"artifacts/readme.md": {
"clientData": { "title": "README", "summary": "Project overview", "updatedAt": 1712000000 }
},
"artifacts/spec.md": {
"clientData": { "title": "Spec", "summary": "Technical specification", "updatedAt": 1712001000 }
}
}
}
}
}
}
Collection items are keyed by their full storage path. Single resources appear directly under their name without an items wrapper. Resources without client config don't appear at all.
Prefetch
For small resources that clients always need, prefetch: true inlines the content:
client: {
content: { read: true, prefetch: true },
expose: ["tone"],
}
The snapshot then includes a content field alongside clientData:
{
"soul": {
"clientData": { "tone": "balanced" },
"content": "## Core Values\n..."
}
}
Skip prefetch for collections with many items or large content bodies. The default lazy approach fetches content for one item at a time when the user actually needs it.
React hooks
The @flow-state-dev/react package provides three hooks for working with client-visible resources.
Typing clientData
By default clientData reads as unknown. But a definition already knows the shape of its projection, so you don't have to restate it. ClientDataOf<typeof def> pulls that shape out, and each hook takes it as a type parameter:
import type { ClientDataOf } from "@flow-state-dev/core";
import { useResourceCollectionItem } from "@flow-state-dev/react";
import { artifacts } from "./resources"; // a defineResourceCollection(...)
type ArtifactClient = ClientDataOf<typeof artifacts>;
function Artifact({ session, topic }) {
const { item } = useResourceCollectionItem<ArtifactClient>(session, "artifacts", topic);
// item?.clientData is ArtifactClient — no cast
return <strong>{item?.clientData?.title}</strong>;
}
The derived type follows how the projection was declared: expose gives a Pick of the state, exclude an Omit, the identity default the full state, and data the function's return type. Because the type comes from the definition, changing the projection turns a stale read into a compile error instead of a silent mismatch.
This is a type-level convenience — the runtime payload is the same JsonValue the server has always sent. The hook applies the projection-backed cast at its boundary so call sites don't. Hooks default the parameter to unknown, so existing untyped call sites are unaffected.
For the data escape hatch, annotate the function's return so the type is captured precisely (the projection function's state argument is loosely typed, so the return annotation is what threads the shape):
client: {
data: (state): { displayTone: string } => ({ displayTone: String(state.tone) })
}
useResource
For single resources. Metadata is available immediately from the snapshot. Content is fetched on demand.
import { useSession, useResource } from "@flow-state-dev/react";
import type { ClientDataOf } from "@flow-state-dev/core";
import { soul } from "./resources";
function SoulPanel() {
const session = useSession(sessionId);
const { clientData, fetchContent } = useResource<ClientDataOf<typeof soul>>(session, "soul");
const tone = clientData?.tone;
const [content, setContent] = useState<string | null>(null);
const handleOpen = async () => {
const text = await fetchContent();
setContent(text);
};
return (
<div>
<p>Tone: {tone}</p>
<button onClick={handleOpen}>View content</button>
{content && <pre>{content}</pre>}
</div>
);
}
If the resource declared prefetch: true, fetchContent() returns the cached snapshot content without a network request.
useResourceContent
Convenience wrapper that fetches content immediately on mount. Use this when you know the content is always needed.
import { useResourceContent } from "@flow-state-dev/react";
function SoulDisplay() {
const session = useSession(sessionId);
const { clientData, content, isLoading, refetch } = useResourceContent(session, "soul");
if (isLoading) return <p>Loading...</p>;
return <pre>{content}</pre>;
}
refetch() re-fetches the content. The hook also refetches automatically when the session snapshot changes.
useResourceCollection
For collections. Returns items (metadata from the snapshot) and CRUD actions shaped by the declared permissions.
import { useResourceCollection } from "@flow-state-dev/react";
import type { ClientDataOf } from "@flow-state-dev/core";
import { artifacts } from "./resources";
function ArtifactList() {
const session = useSession(sessionId);
const { items, actions } = useResourceCollection<ClientDataOf<typeof artifacts>>(session, "artifacts");
return (
<ul>
{Object.entries(items).map(([key, item]) => {
const data = item.clientData; // typed from the projection — no cast
return (
<li key={key} onClick={() => openArtifact(key)}>
<strong>{data.title}</strong>
<span>{data.summary}</span>
</li>
);
})}
</ul>
);
}
async function openArtifact(key: string) {
const content = await items[key].fetchContent();
// render content...
}
Each item in items has:
clientData— the projected state (fromexpose,exclude,data, or the full state if none is set)fetchContent()— lazy content loader for that specific item
The actions object provides mutation methods based on your declared permissions:
// Create a new item (requires client.content.create)
await actions.create({ topic: "new-doc.md", content: "# New Document" });
// Update content (requires client.content.update)
await actions.update({ topic: "artifacts/readme.md", content: "# Updated" });
// Delete an item (requires client.content.delete)
await actions.delete({ topic: "artifacts/old.md" });
Actions that weren't declared in the resource's client.content config will return a 403 from the server.
Non-React usage
The @flow-state-dev/client package exports createResourceClient for direct HTTP access without React:
import { createResourceClient } from "@flow-state-dev/client";
const resources = createResourceClient();
// Fetch content for a single resource
const { content } = await resources.getResourceContent(sessionId, "soul");
// Fetch content for a collection item
const { content } = await resources.getCollectionItemContent(sessionId, "artifacts", "artifacts/readme.md");
// Mutations
await resources.createCollectionItem(sessionId, "artifacts", { topic: "new.md", content: "..." });
await resources.updateResourceContent(sessionId, "artifacts", "artifacts/readme.md", { content: "..." });
await resources.deleteCollectionItem(sessionId, "artifacts", "artifacts/old.md");
HTTP endpoints
Under the hood, these hooks and clients talk to these endpoints:
| Method | Path | Purpose |
|---|---|---|
GET | /sessions/:id/resources/:ref/content | Fetch single resource content |
GET | /sessions/:id/resources/:ref/:topic/content | Fetch collection item content |
POST | /sessions/:id/resources/:ref | Create collection item |
PATCH | /sessions/:id/resources/:ref/:topic/content | Update item content |
DELETE | /sessions/:id/resources/:ref/:topic | Delete collection item |
GET | /sessions/:id/resources/:ref/:topic | Fetch collection item state |
All paths are relative to /api/flows. Permissions are enforced server-side based on the resource's client.content config. Requests for resources without client config return 404.
:topic is a multi-segment wildcard. Collections whose pattern allows nested keys (e.g. memos/** with topics like p1/fundamentals) work without special encoding — the client encodes slashes as %2F and the server decodes them back into the captured topic. The only restriction: a topic literally named "content" is shadowed by the /:ref/content route and isn't addressable via the state-get endpoint.
POST and DELETE can return 409
Both write endpoints settle the item's state before they change anything else,
so either can come back 409 Conflict.
POST returns 409 when the topic already exists. On SQLite and Postgres that
covers the case where two clients create the same topic at once: one gets
201, the other gets 409 and never writes content, so the stored body
belongs to the client that won.
The filesystem store settles that race among writes through one store
instance. Point a second store at the same directory and both can find the
topic free and both write, so one body overwrites the other and both clients
see a 201. That happens whether the two stores sit in one Node process or
two. It's the same per-instance limit that applies to its compare-and-swap,
laid out in
Concurrency by store. The
in-memory store holds its data in the instance, so two of them are two
separate datasets rather than a race.
DELETE returns 409 when the item's state changed while the request was being
served — between the server reading the item and applying the delete. In
practice that means something removed and recreated it, or a block wrote to it,
in that window. A rejected DELETE leaves the item completely intact,
content included.
Be precise about what this does and doesn't protect. The server reads the
item's current version as part of handling your request, so a DELETE built
from a view you fetched a while ago still deletes whatever is live now. There
is no way yet for a client to attach its own precondition to the request, so
the check covers the server's own window, not the age of your data. If you need
delete-if-unchanged, compare state client-side before you call and accept that
it races.
A 409 on DELETE is worth retrying: re-read the item and decide again, since
it usually means something else touched the item mid-request. Deleting a topic
that does not exist is still a 200, so retrying a delete you already
completed is safe.
A create isn't final the moment it returns
Item state and item content are stored separately, and POST writes them in
that order. So there is a brief window where the item is already visible but
its body hasn't landed yet. Three things follow.
A failed POST doesn't mean nothing was created. If the content write
fails, the request comes back as an error — but the state row committed before
it, so the item is live and listable with no content row. Reading its
content returns null, not an empty string; if you branch on content === ""
you'll take the wrong path. Don't treat the error as a no-op either: retrying
the same topic finds it already there and gets a 409. Repair it with PATCH,
which needs the collection to grant client.content.update. Without that grant
there's no repair route at all, so grant create and update together unless
you have a reason not to.
If the collection declares contentTemplate or contentTemplateRef, only the
repair half changes: content is rendered from the item's state and never read
from the content row, so the item reads fine and there's nothing to fill in. The
server still attempts the content write when you send content, so the request
still fails and the item still exists — treat the failed create the same way.
The other two are worth knowing precisely because they don't show up as an error — nothing fails, and a wrong body is simply what you read back:
- If a
DELETElands in that window, the create's body can be left behind after the item is gone. A later create of the same topic that sends no content will then show the old body as its own. - If a
PATCHlands in that window, it returns200and is then overwritten by the create that was still finishing.
Two practical habits cover all three: send content with every POST so a
new item never inherits an old body, and treat a create as settled only once
you've read the item back. If a create and a delete of the same topic can
overlap in your app, serialize them client-side — the server can't order writes
across two stores for you.
Live updates
When a resource changes during streaming (e.g., a tool creates an artifact), the server emits a resource_change event over the stream. By default this is an invalidation cue, not the data: the React hooks refresh the session snapshot once the request completes, and collection items update in place then. You don't need to poll or manually refetch. If an artifact is created mid-turn, it appears in useResourceCollection's items once the turn finishes.
That batched-at-completion default is the right call for most resources. It avoids a burst of per-change HTTP fetches during artifact-heavy turns, and it never ships content you didn't ask for.
Which mutations announce
- Collections announce every instance mutation, whatever their
clientconfig: state writes (patchState,setState,updateState,incState,pushState), lifecycle changes (create,upsert,delete, including capacity evictions), and content writes (writeContent). - Single resources announce only when
live: true. State writes carry the projected delta; content writes don't (content has no state projection), so a content write falls back to the batched refetch even on a live resource. - Non-live single resources don't stream change events at all — their state and content still load through snapshots and the content endpoint.
A server-side content write can also run a reactTo.contentUpdated reaction. That's the in-flow reactive path — a block that runs as part of the mutating turn — separate from the client projection described here. See Reacting to content changes.
Opt-in mid-stream updates with live: true
Some UIs need the change now, not at completion: a navigator that renders a memo moving through pending → writing → published as the agent works. For those, set live: true on the resource's client config:
const memos = defineResourceCollection({
pattern: "memos/**",
scope: "session",
stateSchema: z.object({
...lifecycleSchema(["pending", "writing", "published"]),
title: z.string(),
}),
client: {
state: { read: true },
live: true,
},
});
With live: true, each mutation carries its projected clientData inline on the resource_change event. The React layer folds that delta straight into the cached snapshot, so a subscribed useResource, useResourceCollectionItem, or useResourceCollectionList reflects the change in the same paint as the server mutation, with no refetch. The list hook applies the same overlay across its items, so a navigator rendering every item's status updates live. This is the resource-side analog of how scope-level state_change updates merge mid-stream (see State & Scopes).
live requires that the resource's clientData actually reach the client. For a collection that means state.read: true or a projection (expose / exclude / data); for a single resource it means a projection. Declaring live without a client-visible projection throws at definition time — there would be nothing to stream.
What ships and what doesn't:
- Only the projected slice travels, never content. Content still loads on demand through its own endpoint.
- The default (un-opted) batched-refetch path is unchanged.
liveis purely additive. - Per-item state updates live (including a delete, which marks the item gone). The collection's
countand list pages aren't tracked mid-stream — they reconcile at the end of the request, when the snapshot refetches.
The lifecycleSchema mixin
A resource needs a status field to project before a UI can render its lifecycle. lifecycleSchema(statuses) returns that field set — a required status enum plus nullable startedAt / completedAt / errorMessage slots — to spread into a stateSchema:
import { lifecycleSchema } from "@flow-state-dev/core";
stateSchema: z.object({
...lifecycleSchema(["pending", "writing", "published"]),
title: z.string(),
})
The nullable fields follow the resource-schema default convention (.nullable().default(null)), so a create or setState call supplies only the status and lets the framework fill the rest. It pairs naturally with live: true, but it's an ordinary schema fragment — use it anywhere you want a status-bearing resource.
Debug and the DevTool
During development, the DevTool's Resources panel reads from a privileged debug endpoint that ignores everything on this page. It shows you the full server-side state, including fields and items that client.data deliberately drops. That's the point of an inspector: the DevTool is talking to the runtime directly, not pretending to be a client.
The panel renders each resource with a Raw / Client / Diff toggle. Raw is the unfiltered server state. Client runs the same expose, exclude, or data projection your production app would receive. Diff shows both side by side so you can see which fields survive and which get stripped.
If your client-side React hook returns fewer fields than the DevTool's Raw view shows, the Client view tells you exactly which fields client.data is dropping. Same story for collections: if the panel lists items your hook doesn't, check whether client.state.read is enabled and whether prefetchWindow is set to a meaningful number.
The endpoint is off by default in production. See Debug vs client state for the full mental model, the reason states the panel surfaces when client.data is missing or throws, and how to enable the endpoint locally.
Where to go next
- Resources Overview — Resource fundamentals, defineResource, content and state
- Resource Collections — Dynamic collections, patterns, eviction, lifecycle hooks
- Client Overview — Session management, streaming, state snapshots
- Client > React — React hooks and component renderers