A browser send follows one path to the capability

Goal: trace one user action from a click to the server and back, and know which file owns each step.

Foldkit27 is the browser UI framework in rat-stack. A Foldkit program keeps its state in a Model. Events arrive as Messages.

An update function takes the Model and a Message, and returns a new Model plus any Commands. A Command is a named task that runs outside update and ends by sending another Message.

The docs browser at /search is the worked example. Its search box calls the search capability21.

1. The view sends a Message

The Search button sends SubmittedSearch. The view does no fetching.

apps/web/src/features/app.ts · a3f5a148 · A click is a Message
          Button.view(
            {
              onClick: Message.SubmittedSearch(),
              toView: ({ button }) => h.button(button, ["Search"]),
              type: "button",
            },

Every Message the program accepts is declared in one union:

apps/web/src/client/docs/message.ts · a3f5a148 · SubmittedSearch, SucceededSearch, FailedSearch
export const Message = defineMessageUnion({
  ChangedUrl: { url: Url },
  ClickedLink: { request: UrlRequest },
  CompletedLoadExternal: {},
  CompletedNavigate: {},
  FailedRead: {
    generation: Schema.Finite,
    message: Schema.String,
    notFound: Schema.Boolean,
  },
  FailedSearch: { generation: Schema.Finite },
  SubmittedSearch: {},
  SucceededRead: { document: ReadOutput, generation: Schema.Finite },
  SucceededSearch: { generation: Schema.Finite, result: SearchOutput },
  UpdatedQuery: { value: Schema.String },
});

2. update returns a Command

update sets the search to Loading, increases a generation counter, and returns the SearchDocs Command:

apps/web/src/features/app.ts · a3f5a148 · Loading now; the search runs as a Command
const searchFor = (model: AppModel, query: string): DocsReturn => {
  const generation = model.generation + 1;

  return {
    commands: [SearchDocs({ generation, query })],
    model: { ...model, generation, query, search: SearchState.Loading() },
  };
};

update does no I/O. It describes the next Model and the work to run.

3. The Command calls the capability over RPC

apps/web/src/client/docs/command.ts · a3f5a148 · SearchDocs ends in a result Message
export const SearchDocs = Command.define("SearchDocs", {
  args: { generation: Schema.Finite, query: Schema.String },
  execute: ({ query, generation }) =>
    DocumentQueries.use((queries) => queries.search(query)).pipe(
      Effect.map((result) => Message.SucceededSearch({ generation, result })),
      Effect.orElseSucceed(() => Message.FailedSearch({ generation }))
    ),
  messages: [Message.SucceededSearch, Message.FailedSearch],
});

DocumentQueries builds its RPC client from the shared contracts. The rat-stack-boundaries/no-browser-server-imports lint rule lets browser modules import @rat-stack/core/contracts and @rat-stack/capability/rpc-group, never a handler28. AGENTS.md

apps/web/src/client/docs/queries.ts · a3f5a148 · The client group comes from the contracts
const { group } = toRpcGroup([searchContract, readContract]);

const protocol = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
  Layer.provide([FetchHttpClient.layer, RpcSerialization.layerJson])
);

DocumentQueries keeps each search result for 30 seconds. A repeated query in that window is answered from the cache, with no fresh RPC. The RPC call runs on a cache miss:

apps/web/src/client/docs/queries.ts · a3f5a148 · A 30-second cache in front of the search RPC
  const search = yield* Cache.make({
    capacity: Number.POSITIVE_INFINITY,
    lookup: (query: string) => client.search({ limit: 10, query }),
    timeToLive: "30 seconds",
  });

On the server, implement binds the same searchContract to the content store:

apps/site/src/capabilities/search.ts · a3f5a148 · The server stays authoritative
export const search = implement(searchContract, ({ limit, query }) =>
  ContentStore.use((store) => store.search(query, limit)).pipe(
    Effect.map((matches) => ({ matches, total: matches.length }))
  )
);

4. update folds the result into the Model

apps/web/src/features/app.ts · a3f5a148 · Only the latest search may land
    SucceededSearch: ({ generation, result }) => ({
      model:
        generation === model.generation
          ? { ...model, search: SearchState.Loaded({ result }) }
          : model,
    }),

The generation check drops a stale reply. If the user searches twice, the first reply carries an old generation, and update keeps the Model unchanged.

Why one path

Each step has one owner, so each step can be tested alone.

SearchState names four states: Idle, Loading, Loaded, and Failed. The view matches on all four, so no flag combination can show a spinner and an error at once. model.ts

Next

Read UI behaviour is a scene8 to test this program without a browser. Then read One capability, every surface21 for the server half of the contract.

CAFE27 names where Foldkit sits in the stack.

Sources

  1. rat-stack AGENTS.md at a3f5a14. GitHub joelhooks/rat-stack. Web feature blueprint and the no-browser-server-imports boundary. Accessed 2026-11-08.
  2. Docs browser Messages. GitHub joelhooks/rat-stack. defineMessageUnion lists every event the docs browser handles. Accessed 2026-11-08.
  3. Docs browser Model. GitHub joelhooks/rat-stack. SearchState is Idle, Loading, Loaded or Failed; the Model holds a generation counter. Accessed 2026-11-08.
  4. Docs browser Commands. GitHub joelhooks/rat-stack. SearchDocs calls DocumentQueries and maps the result to SucceededSearch or FailedSearch. Accessed 2026-11-08.
  5. Docs browser RPC client. GitHub joelhooks/rat-stack. toRpcGroup builds the client group from searchContract and readContract; the protocol posts to /rpc. Accessed 2026-11-08.
  6. Docs browser update and view. GitHub joelhooks/rat-stack. SubmittedSearch returns SearchDocs; SucceededSearch applies only when its generation matches. Accessed 2026-11-08.
  7. search capability. GitHub joelhooks/rat-stack. implement binds searchContract to the content store on the server. Accessed 2026-11-08.

Last changed 2026-11-08 in b8f4ccf. Source on GitHub. Change log.

Linked from

  1. Agent guide. An Effect stack so pure (aspirational) Kit Langton will blush.
    Link context

    A browser send follows one path to the capability — In the Foldkit docs browser, a search is a Message, then an update, then a named Command that calls the search capability over RPC on a cache miss and returns a result Message.

  2. Change log. What changed in the files served here, newest first.

    A browser send follows one path to the capability — new page: Initial commit from joelhooks/rat-stack template

  3. Full agent guide. An Effect stack so pure (aspirational) Kit Langton will blush.
  4. One capability, every surface. A schema-typed contract and one handler feed every interface rat-stack keeps.

    Read A browser send follows one path to the capability to see search called over RPC.¶

  5. Rat Stack lore | rat-stack. Short, source-grounded notes on the ideas and decisions behind rat-stack.
    Link context

    A browser send follows one path to the capability — In the Foldkit docs browser, a search is a Message, then an update, then a named Command that calls the search capability over RPC on a cache miss and returns a result Message.

  6. Source change history (log.md). The generated change log as Markdown.

    A browser send follows one path to the capability — new page: Initial commit from joelhooks/rat-stack template

Unlinked mentions (11)