FoldKit
FoldKit is the Elm architecture on Effect: a Model,
messages, a pure update, Commands and Subscriptions. effect-uai is Effect
on the server. Put them together and the whole app is one language: the same
Schemas define the tools on the server and type the state in your Model.
It also fits by shape. @effect-uai/ui gives you a chat as a fold
(initial + step), and a fold is exactly what update runs. No atoms, no
adapter.
The contract
Tools as Schemas only, imported by server and page:
export const GetWeather = { name: "get_weather", description: "The weather right now in a city.", inputSchema: Schema.Struct({ city: Schema.String }), outputSchema: Schema.Struct({ city: Schema.String, temperatureC: Schema.Number, observedAt: Schema.DateFromString, }),} as const
export const ChatUi = UiContract.make({ tools: [GetWeather, GetDate] })The server builds its tools with Tool.make({ ...GetWeather, run }) and
serves the run over an Effect HttpRouter route (server.ts).
The Model holds the thread
const fold = Thread.presetFor<ChatPayloads>()
export const Model = Schema.Struct({ prompt: Schema.String, thread: ChatUi.Thread, request: Schema.NullOr(Request), // the request in flight})ChatUi.Thread is a Schema of the thread typed by the contract, so the
Model stays a Schema, as FoldKit wants. update folds every envelope:
GotEnvelope: ({ envelope }) => ({ model: { ...model, thread: fold.step(model.thread, envelope) },}),The request is a Subscription
Sending sets request; a Subscription keyed on it streams the answer:
dependenciesToStream: ({ request }) => request === null ? Stream.empty : transport.start(request.input, request.chatId).pipe( Stream.mapEffect(ChatUi.decode), Stream.map((envelope) => Message.GotEnvelope({ envelope })), Stream.catchCause(() => Stream.empty), Stream.concat(Stream.make(Message.EndedRequest())), ),Stop sets request: null. FoldKit tears the stream down, the fetch is
aborted, and the server stops the model. Stop is a pure Model change, not an
imperative cancel.
The input id is random, so a Command creates it (Input.withId) and
update stays pure.
In the view
Narrowing a tool block by name types its partial and outcome (a decoded
Success or a typed Failure):
if (block.name === "get_weather" && block.outcome?._tag === "Success") { const { city, temperatureC, observedAt } = block.outcome.value // observedAt: Date}Run it
cd examples/foldkit-chatpnpm installOPENAI_API_KEY=sk-... pnpm devOpen http://localhost:4320; the server runs on :4321 and Vite proxies /api to it.