Skip to main content

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:

  • content controls access to the resource's content body (the "file" part)
  • A projection field (expose, exclude, or data) 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:

PermissionEffect
readClient can fetch content via fetchContent()
prefetchContent is included inline in the state snapshot (no separate fetch needed)

For collections, two additional permissions control mutations:

PermissionEffect
createClient can create new items via POST
updateClient can modify item content via PATCH
deleteClient can remove items via DELETE

On a projected collection, content.create, content.update, and content.delete are a build-time error. Those collections are read-only on the client too.

Grant update alongside create​

Creating an item is two writes, state then content, and the second one can fail on its own — leaving an item that exists with no content row. Repairing that means a PATCH to the item's content endpoint, which needs update. A collection granting create by itself has a gap: an authorized client can end up holding an item it can neither fill nor remove.

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. A create isn't final the moment it returns has the full sequence and the two other things that can go wrong in that window.

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's clientData.
  • The snapshot's prefetched window includes clientData for 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.

Pair state.read with a projection​

state.read: true opens the endpoint. It does not decide what comes back. With no expose, exclude or data, each item's clientData is the stored row unchanged, which is rarely what you want for a row the server also writes to.

Name the fields instead:

client: {
state: { read: true },
expose: ["id", "title", "status", "assignee"],
}

An allowlist and an omit-list behave differently as the row grows. A field added to the schema later is published by default under exclude, and stays private under expose until someone adds it. For a row carrying anything the server set for itself, such as a lease, a claim, a retry count or an audit trail, prefer expose.

How a read is scoped​

An org-scoped collection resolves against the organization the session is bound to, and a session takes that binding from the authenticated principal when it is created. Opening state.read on one makes it readable by that organization's members. A user-scoped collection resolves the same way, against the session's user. Pick the scope a collection lives in before you open the read, because that scope is the boundary the read enforces.

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.

The typing is a compile-time convenience. The runtime payload is the same JsonValue the server sends either way — the hook applies the projection-backed cast at its boundary so call sites don't. The type parameter defaults to unknown, so an untyped call site keeps working.

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 (from expose, 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:

MethodPathPurpose
GET/sessions/:id/resources/:ref/contentFetch single resource content
GET/sessions/:id/resources/:ref/:topic/contentFetch collection item content
POST/sessions/:id/resources/:refCreate collection item
PATCH/sessions/:id/resources/:ref/:topic/contentUpdate item content
DELETE/sessions/:id/resources/:ref/:topicDelete collection item
GET/sessions/:id/resources/:ref/:topicFetch 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. The two write endpoints refuse in their own ways: POST can answer 400 or 409, and DELETE can answer 409 — see what the write endpoints refuse.

: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.

What the write endpoints refuse​

Both write endpoints settle the item's state before they change anything else, so either can come back 409 Conflict.

POST returns 400 for a state schema it can't seed​

The create route carries no initial state, so the new item's state is seeded from the collection's stateSchema parsed against {}. A schema that can't produce a valid, settled object from {} is refused with 400, naming the field. A required field with no .default() is the usual cause; give it one.

stateSchema: z.object({
title: z.string(), // 400 — nothing to seed it with
status: z.string().default("draft"), // fine
})

The same bar applies to collection.create() in flow code, and to every other resource state write. The schema has to settle is the full rule.

409 on a topic that already exists, or an item that moved​

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. Committing state first is what stops two simultaneous creates from pairing one client's state with the other's body: the client that wins the topic owns it, and the one that loses is turned away before it writes any content. The cost is a brief window where the item is already visible but its body hasn't landed yet.

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.

Other things can happen in that window without showing up as an error. Nothing fails; a wrong body is simply what you read back.

  • If a DELETE lands 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 PATCH lands in that window, it returns 200 and is then overwritten by the create that was still finishing.

Two habits cover all of it: 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 client config: 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. live is purely additive.
  • Per-item state updates live (including a delete, which marks the item gone). The collection's count and 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​