Generate a complete Foldkit program based on this description:
$ARGUMENTS
Phase 1: Analyze the Description
Before writing any code, analyze the description to identify:
- Domain entities: nouns that become Model fields (e.g., "todos", "user", "score")
- User interactions: verbs that become Messages (e.g., "add", "delete", "filter", "submit")
- Async operations: external data that becomes Commands (e.g., "fetch weather", "save to localStorage")
- Real-time needs: streaming data that becomes Subscriptions (e.g., "live updates", "countdown", "WebSocket")
- Pages/navigation: URL structure that becomes routes (e.g., "home page", "detail page")
- UI component needs: interactive widgets that map to Foldkit UI components (e.g., "dropdown" → Menu, "modal" → Dialog, "tabs" → Tabs, "autocomplete" → Combobox, "date picker" → DatePicker, "file upload" → FileDrop, "reorderable list" → DragAndDrop, "toast/notification" → Toast, "hover tooltip" → Tooltip)
- Form validation needs: required fields, format checks, async uniqueness →
foldkit/fieldValidation module (see Phase 4)
- Date handling: birthdays, deadlines, scheduling →
Calendar module + Ui.DatePicker or Ui.Calendar
- File handling: uploads, attachments, images →
File module + Ui.FileDrop
- Host embedding: the program runs inside another app ("a widget in our React app", "embed this in an existing page", "the host needs to control it") →
Runtime.makeElement plus the Runtime.embed lifecycle handle, with Flags for initial data and Ports for ongoing communication in both directions. repos/foldkit/examples/embedding/ is the canonical reference: a plain TypeScript host driving a Foldkit widget end to end
Present this analysis to the user before proceeding.
If the description is detailed and unambiguous, summarize the analysis and confirm before moving on. But if there are gaps (unclear state transitions, vague UI requirements, unspecified error handling, missing edge cases, ambiguous domain boundaries, unclear counter/reset semantics), ask targeted clarifying questions before proceeding. Don't ask open-ended questions like "anything else?". Ask specific questions about the gaps you found.
UX/behavior gaps:
- "Should the todo list persist across page reloads (localStorage), or start fresh each session?"
- "When the API call fails, should the app show an inline error or a dialog?"
- "You mentioned 'users can edit items'. Is that inline editing or a separate edit page?"
Domain-logic gaps (easy to miss, expensive to fix):
- "When the user skips an interval, does that count as 'completed' for purposes of the streak?"
- "Does a counter that tracks 'completed' increments on successful actions only, or on skipped actions too?"
- "If the user triggers a reset mid-flow, does the counter reset with it, or persist across resets?"
- "You mentioned 'after N events, trigger X'. Is that N events total, or N events since the last X?"
- "On the Nth action in a cycle, which action does it trigger, the cycle's first or last?"
Domain-logic questions often surface off-by-one bugs before they hit the code. If the description has any counter, cycle, streak, or "after N" phrase, ask about edge cases at 0 and 1 and N specifically.
The goal is to resolve ambiguity early so the generated code matches what the user actually wants, not what you assumed.
Phase 2: Study Reference Examples
Read the architecture and conventions guides to internalize the rules:
- Architecture guide: TEA structure, file organization, type patterns
- Conventions guide: naming, Effect-TS patterns, anti-patterns
- Verification checklist: not just for Phase 5, also the generation bar. Skim the Quality Bar section now so you generate code that already meets it rather than code that will fail review.
If you have access to a context7 MCP tool, use it to look up Effect-TS documentation when you're unsure about an API. Effect is a large library. Verify function signatures rather than guessing.
Quality exemplars
Two codebases are the quality bar for generated apps. Not just "patterns to copy" but "the level of craft to match":
${CLAUDE_SKILL_DIR}/../../packages/typing-game/client/src/: production multi-page app: Submodels, OutMessage, update/view decomposition, curried handler extraction, subscription patterns, domain modules.
${CLAUDE_SKILL_DIR}/../../packages/website/src/: production Foldkit website: page organization, shared view primitives, route-driven rendering, idiomatic domain separation.
Before generating, spot-check at least ONE file from each (the shape of update.ts / how handlers get extracted / how domain files are structured) and match that level of craft in your output. The generated code should be indistinguishable from hand-written exemplar code.
Then read the tier-specific example files that match the app's complexity. Always read at least one tier-specific example. Never generate from memory alone.
Complexity tiers
Tier 1: Single page, no async, minimal state:
Read ${CLAUDE_SKILL_DIR}/../../examples/counter/src/main.ts
Tier 2: Timers, subscriptions, simple stateful apps:
Read ${CLAUDE_SKILL_DIR}/../../examples/stopwatch/src/main.ts (timer via subscription, Duration field pattern) and ${CLAUDE_SKILL_DIR}/../../examples/todo/src/main.ts (CRUD with localStorage via flags)
Tier 3: Async operations, loading/error states, API calls, form validation:
Read ${CLAUDE_SKILL_DIR}/../../examples/weather/src/main.ts (HTTP with HttpClient) and ${CLAUDE_SKILL_DIR}/../../examples/form/src/main.ts (uses foldkit/fieldValidation; see the Form Validation section in Phase 4)
Tier 4: URL routing, multiple pages, query parameters:
Read ${CLAUDE_SKILL_DIR}/../../examples/routing/src/main.ts and ${CLAUDE_SKILL_DIR}/../../examples/query-sync/src/main.ts
Tier 5: Complex state, nested domain models, CRUD, drag-and-drop:
Read ${CLAUDE_SKILL_DIR}/../../examples/shopping-cart/src/main.ts (nested domain schemas, cart state) and ${CLAUDE_SKILL_DIR}/../../examples/kanban/src/main.ts (CRUD with Ui.DragAndDrop, flags restoring from localStorage, subscriptions)
Tier 6: Submodels, OutMessage, multi-step forms, auth flows, multi-module apps:
Read ${CLAUDE_SKILL_DIR}/../../examples/auth/src/main.ts (login/signup with Submodels, OutMessage, protected routes) and ${CLAUDE_SKILL_DIR}/../../examples/job-application/src/main.ts (multi-step form with deeply nested Submodels in step/, Ui.DatePicker, Ui.FileDrop, Ui.Menu, Calendar module for date handling)
Tier 7: Real-time, WebSocket, Managed Resources, production-grade:
Read ${CLAUDE_SKILL_DIR}/../../packages/typing-game/client/src/update.ts, then explore its page/home/ and page/room/ directories for the full Submodel/OutMessage pattern.
Read examples from the target tier AND all lower tiers. A Tier 4 app should reflect patterns from Tiers 1-3 as well.
Phase 2.5: Identify Foldkit UI Component Opportunities
Foldkit ships accessible UI components that handle keyboard navigation, ARIA attributes, and focus management automatically. Before generating, check if any part of the app maps to a built-in component:
| User Need |
Foldkit Component |
What you get for free |
| Modal/dialog/confirmation |
Dialog |
Focus trapping, Escape to close, scroll locking, backdrop |
| Tabbed content |
Tabs |
Arrow key navigation, aria-selected, roving tabindex |
| Dropdown menu |
Menu |
Arrow keys, typeahead search, aria-expanded, click-outside |
| Autocomplete/tag input |
Combobox |
Filtering, arrow key selection, aria-activedescendant |
| Select dropdown |
Select |
Keyboard selection, aria-selected, positioning |
| Single selection from options |
RadioGroup |
Arrow key cycling, aria-checked |
| On/off toggle |
Switch |
Spacebar toggle, aria-checked |
| Boolean option |
Checkbox |
Spacebar toggle, aria-checked, indeterminate |
| Expandable section |
Disclosure |
Enter/Space toggle, aria-expanded |
| Floating content on hover/click |
Popover |
Positioning, click-outside, focus management |
| Hover tooltip |
Tooltip |
Show-delay, keyboard dismiss, positioning, aria-describedby |
| Single-select list |
Listbox |
Arrow keys, typeahead, aria-selected |
| Text input |
Input |
Consistent styling/behavior wrapper |
| Multi-line text |
Textarea |
Auto-resize, consistent styling |
| Form group |
Fieldset |
Disabled state propagation, grouping |
| Styled button |
Button |
Consistent click/keyboard handling |
| Inline calendar grid |
Calendar |
Month navigation, keyboard nav, aria-selected, date constraints |
| Date input + popover |
DatePicker |
Calendar popover, input masking, keyboard nav, constraints |
| File upload zone |
FileDrop |
Drag-and-drop, click-to-browse, accept filters, validation |
| Reorderable list |
DragAndDrop |
Pointer + keyboard drag, drop zones, announcement region |
| Transient notifications |
Toast |
Auto-dismiss, pause-on-hover, stacking, role=status/alert |
Each component is a Foldkit Submodel with its own Model, Message, init, update, and view. To use one:
- Add its Model to your Model:
confirmDialog: Ui.Dialog.Model
- Add a
Got* Message: GotConfirmDialogMessage with { message: Ui.Dialog.Message }
- Initialize in init:
confirmDialog: Ui.Dialog.init({ id: 'confirm-dialog' })
- Delegate in update:
GotConfirmDialogMessage: ({ message }) => ...
- Embed in view via
h.submodel: h.submodel({ slotId: 'confirm-dialog', view: Ui.Dialog.view, model: model.confirmDialog, toParentMessage: message => GotConfirmDialogMessage({ message }) }) (add viewInputs for components whose view takes them)
Always prefer Foldkit UI components over hand-rolling interactive widgets. They make accessibility the default, not an afterthought.
For form inputs specifically: every text input, textarea, and button in a form MUST use Ui.Input, Ui.Textarea, and Ui.Button respectively. This is not optional, even though raw input/textarea HTML elements are available from html<Message>(). The form example (examples/form/src/main.ts:347-403) defines inputFieldView and textareaFieldView helpers that wrap Ui.Input.view and Ui.Textarea.view with label + validation feedback. Copy that helper pattern. Raw input/textarea are for non-form cases (search fields, inline editors) where you're intentionally working below the Ui component layer, and even then, reach for the Ui component first.
If the app uses UI components, always read the ui-showcase example first to understand how components are wired. This is the canonical reference for Foldkit UI integration patterns:
${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/main.ts: root wiring, Got* delegation, toParentMessage helpers
${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/message.ts: how UI component Messages are structured
${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/model.ts: how UI component Models are composed
${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/update.ts: how UI component updates are delegated
${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/toast.ts: read when using Ui.Toast: Toast is unique in that it's parameterized on a payload schema via Ui.Toast.make(PayloadSchema), returning a typed module you import from
For apps using Ui.DatePicker, Ui.FileDrop, or other recently-added components, also read the job-application example (see Tier 6 below). It's the most complete real-world integration of these components together.
Phase 3: Determine File Organization
Match the file structure to the app's complexity. The architecture stays the same at every scale; only the file organization changes.
What lives in which file
Beyond the tier-based layouts below, follow these "schema placement" rules to avoid model.ts bloat:
model.ts holds the Model schema + any schemas that are fields of Model (or composed into fields, like form state / submit state unions). Nothing else.
command.ts holds schemas for the payloads commands send to / receive from external systems, in particular the persistence schema that saveState serializes and flags deserializes. The persistence schema is a command-layer concern, not a model concern; it often looks like a subset of Model but it isn't part of Model.
domain/*.ts holds domain entity schemas and pure operations on them.
message.ts holds messages (only).
route.ts holds route variants + router pipelines.
A common mistake (because kanban colocates SavedBoard in model.ts): putting the persistence schema in model.ts because "it's schema." It's schema for the persistence layer, not for the Model. Move it to where it's used.
Single file (Tier 1-2, under ~300 lines):
src/main.ts ← Model, Message, init, update, view
src/entry.ts ← Runtime.makeApplication + Runtime.run
Split commands + messages (Tier 3, has async operations):
src/main.ts ← Model, init, update, view
src/entry.ts ← Runtime.makeApplication + Runtime.run
src/message.ts ← Message definitions
src/command.ts ← Command functions
Important rule: if you extract command.ts, you MUST also extract message.ts. Commands reference Message constructors (e.g. SucceededFetchWeather({...})) as their Effect return values. If Messages live in main.ts and Commands live in command.ts, command.ts imports from main.ts and main.ts uses Commands from command.ts, a circular import. Pull Messages out first, then both main.ts and command.ts import from message.ts.
Full split (Tier 4-5, multiple concerns):
src/main.ts ← init, update, view
src/entry.ts ← Runtime.makeApplication + Runtime.run
src/model.ts ← Model schema
src/message.ts ← Message definitions
src/command.ts ← Command functions
src/route.ts ← Route parser (if routing)
src/view.ts ← View functions (if view is large)
src/domain/ ← Shared domain schemas (if multiple entities)
Submodel directories (Tier 6-7, independent modules):
src/main.ts ← Root init, update, view
src/entry.ts ← Runtime.makeApplication + Runtime.run
src/model.ts ← Root model (contains submodels)
src/message.ts ← Root messages + Got* bridging
src/command.ts ← Shared commands
src/route.ts ← Route parser
src/domain/ ← Shared domain schemas
src/page/
featureA/
main.ts ← Submodel init, update, view
message.ts ← Submodel messages + OutMessage
command.ts ← Submodel commands
featureB/
...
Phase 3.3: Architecture sketch (Tier 4+ only)
For Tier 4+ apps (routing, domain modules, multiple entities, submodels) produce a compact sketch BEFORE generating implementations. Tier 1-3 apps are small enough to generate in one pass; Tier 4+ apps burn a lot of effort if the structure is wrong.
The sketch has five parts. Emit them inline in the conversation, get confirmation, THEN scaffold:
- File tree: the exact paths you will create. Match Phase 3's organization.
- Model shape: the top-level
S.Struct fields and their types. Not the full schema, just the shape.
- Message list: every Message you plan to define, grouped by category (clicks, inputs, commands, out-messages).
- Route list: if routing, every
r('...', {...}) with params and the path each maps to.
- Domain operations: for each file in
domain/, the operations it will expose (Link.byNewest, Link.filterByTag, etc.).
Example for a Tier 4 link saver:
### Sketch
Files:
src/main.ts, entry.ts, model.ts, message.ts, command.ts, route.ts
src/domain/link.ts, index.ts
src/story.test.ts, scene.test.ts
Model:
route: AppRoute
links: ReadonlyArray<Link>
newLinkForm: NewLinkForm (url: Field<string>, title/description/tagsInput: string, submitState)
Messages:
Clicks: ClickedSaveLink, ClickedDeleteLink
Inputs: UpdatedLinkUrl, UpdatedLinkTitle, UpdatedLinkDescription, UpdatedLinkTagsInput, BlurredLinkUrl
Commands: SubmittedNewLinkForm, SucceededSaveLinks, FailedSaveLinks
Routing: ClickedLink, ChangedUrl, CompletedNavigateInternal, CompletedLoadExternal
Toggles: ToggledFavorite
Routes:
HomeRoute → /
NewLinkRoute → /new
TagFilterRoute → /tag/:tag
NotFoundRoute → /* fallback
Domain:
Link: schema + byNewest, filterByTag, toggleFavorite, remove, updateById
After emitting the sketch, ask the user to confirm or adjust. Don't start scaffolding or generation until they do. If the user confirms silently (e.g. "looks good, continue"), proceed. If they adjust, iterate on the sketch. Don't write code against a version they haven't approved.
This step is frequently tempting to skip because the agent "knows what it's doing." Skip it and you ship a fully-generated app that turns out to need structural changes. That's the expensive form of iteration. The sketch is the cheap form.
Phase 3.5: Scaffold the Project
Before generating code, scaffold a runnable project using create-foldkit-app:
npx create-foldkit-app@latest
Run with no flags to drop into the interactive prompts; pick the counter example as the base (simplest starting point) and the user's preferred package manager. The generated project includes:
package.json with all Foldkit and Effect dependencies
vite.config.ts with Tailwind and the Foldkit Vite plugin
tsconfig.json with strict TypeScript settings
index.html with the root container
src/styles.css with Tailwind import
AGENTS.md with Foldkit conventions
Offer the Foldkit subtree
After scaffolding, offer to vendor Foldkit in as a git subtree so future AI sessions can reference the full source, examples, and docs directly from the user's project. Commit the scaffold first so subtree has a base commit to merge into:
git init # if not already a git repo
git add .
git commit -m "chore: initial commit"
git subtree add --prefix=repos/foldkit https://github.com/foldkit/foldkit.git main --squash
This is optional but strongly recommended. The scaffolded AGENTS.md includes a subtree_prompted: false line that agents check on future sessions. If the subtree is absent and this flag is false, the agent offers to add it. Handling it up front here means the user's next AI session already has full context. If the user declines, update the line to subtree_prompted: true so they aren't asked again.
To refresh the subtree later: git subtree pull --prefix=repos/foldkit https://github.com/foldkit/foldkit.git main --squash.
Replace the scaffold
Then replace the counter example code in src/main.ts (and add additional source files as needed) with the generated app code.
Phase 3.7: Ground the Foldkit APIs
Before writing any code, READ the type signatures of every Foldkit module you will use. Guessing signatures wastes cycles: each wrong guess is a tsc error, a re-read, an edit, another typecheck. Five minutes of reading prevents thirty minutes of iteration.
The exact files to read
For each Foldkit module you plan to use, read the .d.ts at the paths below. Read the public surface; you don't need the internals. Write a short signature crib in your working notes so you don't have to re-check while generating.
# Every app
<project>/node_modules/foldkit/dist/index.d.ts # top-level re-exports
<project>/node_modules/foldkit/dist/html/index.d.ts # html<Message>(), element signatures, Attribute<Message>, empty, keyed
<project>/node_modules/foldkit/dist/message/index.d.ts # m()
<project>/node_modules/foldkit/dist/schema/index.d.ts # ts(), r()
<project>/node_modules/foldkit/dist/struct/index.d.ts # evo(): check nested-update signature
<project>/node_modules/foldkit/dist/runtime/runtime.d.ts # ApplicationInit, RoutingApplicationInit, makeApplication, makeElement
# If using routing
<project>/node_modules/foldkit/dist/route/parser.d.ts # literal, slash, string, int, Route.root, Route.mapTo, Route.oneOf, Route.parseUrlWithFallback
<project>/node_modules/foldkit/dist/url/index.d.ts # toString
<project>/node_modules/foldkit/dist/navigation/index.d.ts # pushUrl, load: all return Effect<void> (no Effect.ignore needed)
# If using async / side effects
<project>/node_modules/foldkit/dist/command/index.d.ts # Command.define: result schemas are required
<project>/node_modules/foldkit/dist/dom/index.d.ts # focus, advanceFocus, scrollIntoView, showDialog, closeDialog, clickElement, lockScroll, unlockScroll, inertOthers, restoreInert, detectElementMovement, waitForAnimationSettled. For time/random/uuid/delay use Effect's Clock, Random, Effect.uuid, Effect.sleep + Duration directly.
# If using subscriptions
<project>/node_modules/foldkit/dist/subscription/index.d.ts # Subscription.make<Model, Message>, Subscription.lift, Subscription.aggregate
# If using mount / managed-resource / custom-element
<project>/node_modules/foldkit/dist/mount/index.d.ts # Mount.define: for per-instance VNode lifecycle
<project>/node_modules/foldkit/dist/managedResource/public.d.ts # ManagedResource.make / lift / aggregate + tag: for stateful runtime objects keyed on Model condition
<project>/node_modules/foldkit/dist/customElement/index.d.ts # CustomElement.define: for typed bindings to native web components
# If using forms
<project>/node_modules/foldkit/dist/fieldValidation/public.d.ts # Field (tagged union), makeRules({required?, rules}), validate, allValid; rule constructors on the Rule namespace (Rule.url(options), Rule.email, Rule.minLength, Rule.pattern, Rule.fromSchema, ...)
# Rule.Rule is [Predicate, Rule.RuleMessage], NOT {test, message}. Field.Invalid has `errors: NonEmptyArray<string>`, not `error: string`.
# If using any UI component
<project>/node_modules/foldkit/dist/ui/<component>/public.d.ts # Model, Message, init, update, view (Submodel-shaped) or ViewConfig (render-helper-shaped), OutMessage when applicable
# Check: is it a Submodel (Menu/Listbox/Combobox/Calendar/Disclosure/Dialog/Popover/etc.) embedded via h.submodel, or a stateless render helper (Button/Input/Textarea/Select/Fieldset) called directly with a ViewConfig? Submodels carry their own Model/Message/update/OutMessage; render helpers don't. Check ViewInputs (for Submodels) or ViewConfig (for helpers) for the slot callbacks (toView, itemToConfig, etc.).
# If using dates
<project>/node_modules/foldkit/dist/calendar/index.d.ts # CalendarDate, today.local (returns Effect<CalendarDate>); for raw millis use Clock.currentTimeMillis
What to record in the crib
For each symbol you'll call, write one line:
html<Message>(): { div, input (VOID), textarea, button, Class, Href, For, Id, Role, OnClick(Message), OnInput(value=>Message), OnBlur(Message), OnSubmit(Message), keyed, empty, ... }
Route.mapTo(schema)(parser): curried
pushUrl(path): Effect<void> // NOT fallible, no Effect.ignore needed
urlToString(url: Url): string
Ui.Input.view({ id, value, onInput, isInvalid?, type?, placeholder?, toView: (attrs) => Html })
// attrs: { label: Attribute<M>[], input: Attribute<M>[], description: Attribute<M>[] }
Field (schema): NotValidated | Validating | Valid | Invalid(errors: NonEmpty<Rule Message>)
Specific API pitfalls the generator hits repeatedly
Record these in the crib and keep them visible while generating:
input and br and other void elements take ONLY attributes: input([...]), never input([...], []). textarea and button DO take children.
UrlRequest tags are Internal and External, not InternalUrl / ExternalUrl.
OnClick and OnSubmit take a Message directly, not a () => Message. Only OnInput takes (value) => Message because it needs the input value.
keyed, empty are properties on the record returned by html<Message>(): accessed as h.keyed and h.empty after const h = html<Message>(). They are not top-level exports of foldkit/html.
- Attribute helpers are specific:
Value(...), Type(...), Placeholder(...), Href(...), Target(...), Rel(...), Rows(n), Id(...), For(...), Role(...), AriaLabel(...). There is no generic Attr('...', '...').
ApplicationInit<Model, Message, Flags> has no URL parameter. For routed apps, use RoutingApplicationInit<Model, Message, Flags>: the second arg is url: Url.
Route.mapTo takes the route schema, not a factory function. pipe(literal('new'), Route.mapTo(NewLinkRoute)). NOT Route.mapTo(() => NewLinkRoute()).
Effect.ignore is ONLY for fallible Effects. pushUrl(path).pipe(Effect.as(Message())). No Effect.ignore because pushUrl returns Effect<void>.
Command.define requires result Message schemas after the name: Command.define('Fetch', SucceededFetch, FailedFetch). Infallible Commands only need one result: Command.define('ReadClock', RecordedTime).
makeRules takes { required?: Rule.RuleMessage, rules: Array<Rule.Rule> } where Rule.Rule = [Predicate, Rule.RuleMessage]: a tuple, NOT { test, message }. Rule constructors live on the Rule namespace (Rule.url({ message }), Rule.email(message?), Rule.minLength(n, message?), Rule.pattern(regex, message?), Rule.fromSchema(schema, message)).
Field.Invalid has errors: NonEmptyArray<string>, not error: string. Use Array.headNonEmpty(errors) to get the first message; use Rule.resolveMessage(message, value) to resolve a rule message to its final string.
- Route variants are
HomeRoute, NewLinkRoute, etc., with the Route suffix. Every exemplar uses this convention.
- Routers are callable for printing:
homeRouter() returns '/', tagFilterRouter({ tag: 'foo' }) returns '/tag/foo'. Never hand-construct URLs.
Phase 4: Generate the App
Generate files following the architecture and conventions guides exactly. Write all source files into the scaffolded project's src/ directory. For each file, follow these rules:
Model
- Define as
S.Struct with Effect Schema types
- Use discriminated unions for state:
Idle | Loading | Error | Ok, never booleans for multi-valued state
- Use
Option for fields that may be absent. Never empty strings or null
- Prefix Option-typed fields with
maybe: maybeCurrentUser, maybeError
- For async data, define
Idle, Loading, Error, Ok variants with ts() and compose into an S.Union. See Discriminated Unions for State in conventions.md
- For apps with multiple domain entities referenced across modules, extract shared schemas into
src/domain/ (e.g., domain/product.ts, domain/session.ts). See the shopping-cart and auth examples for this pattern, and read ${CLAUDE_SKILL_DIR}/../../packages/website/src/page/projectOrganization.ts for guidance on when and how to structure domain modules
Messages
Follow the four-group layout strictly:
// Group 1: All m() declarations, no blank lines between them
const ClickedSubmit = m('ClickedSubmit')
const UpdatedEmail = m('UpdatedEmail', { value: S.String })
const SucceededLogin = m('SucceededLogin', { user: User })
const FailedLogin = m('FailedLogin', { error: S.String })
const CompletedFocusInput = m('CompletedFocusInput')
// Group 2: Union + type (no blank line between them)
const Message = S.Union([
ClickedSubmit,
UpdatedEmail,
SucceededLogin,
FailedLogin,
CompletedFocusInput,
])
type Message = typeof Message.Type
Name messages by category:
Clicked*: button/link clicks
Updated*: input value changes (with { value: S.String }) and external state updates from subscriptions (UpdatedRoom, UpdatedPlayerProgress)
Submitted*: form submissions
Succeeded* / Failed*: paired, for commands that can meaningfully fail
Completed*: fire-and-forget (verb+object: CompletedFocusInput)
Got*: child module results via OutMessage pattern
Loaded*: data restored from storage
Pressed*: keyboard input
Blurred*: focus loss
Selected*: choice made from a list
Toggled*: binary state flip
Every message must carry meaning. No NoOp.
Flags (if the initial Model needs side effects)
- Define a
Flags Schema for data the initial Model needs from side effects
- Define
flags as an Effect<Flags> that computes the values (localStorage reads, current time, etc.)
- Pass the result into init. Never perform side effects at module level or inside init directly
- See the flags section in architecture.md for the full pattern
Init
- Return
[Model, ReadonlyArray<Command<Message>>]
- If flags are used, accept them as the first parameter:
(flags: Flags) => [Model, Commands] or (flags: Flags, url: Url) => [Model, Commands]
- Include startup Commands (initial fetch, focus first input, etc.)
- Use callable Schema constructors for the initial Model:
Model({ field: value })
Update
- Use
M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({...})). Never switch
- Every case returns
[Model, ReadonlyArray<Command<Message>>]
- Use
evo(model, { field: () => newValue }) for immutable updates
- In
evo, use point-free field transformers when the update only depends on that field's current value: items: Array.map(updateItem), count: Number.increment, stepTabs: Tabs.reflectSelectedTab(value, steps). Use () => value for replacement values from Messages, child updates, Commands, or other Model fields.
- Extract complex handlers to separate functions when a case exceeds ~15 lines
- For Submodels: return
[Model, ReadonlyArray<Command<Message>>, Option.Option<OutMessage>]
- See the OutMessage pattern in architecture.md. Child modules signal to parents via
Option.some(OutMessage), parents handle with Got* Messages and M.tagsExhaustive
Commands
- Define Command identities with
Command.define, passing result Message schemas after the name. Result types are required
- Always assign definitions to PascalCase constants. Never inline in pipe chains
- Definitions live where they're produced, colocated with the update function
- Let TypeScript infer return types. No explicit
Command<typeof A> annotations
- Use
Effect.gen for multi-step async
- Always
Effect.catch(() => Effect.succeed(FailedX(...))) for fallible Effects. Commands never throw. Exception: if the Effect is infallible at the type level (Clock.currentTimeMillis, Effect.uuid, Random.nextIntBetween, etc.), no catch is needed and no Failed* Message is needed. Follow the types: if there's no error channel, there's nothing to catch.
- Use
Effect.provide for services
- Factory functions named by action:
fetchWeather, not fetchWeatherCommand
- Fire-and-forget Commands return
Completed* Messages
- Use Foldkit's
Dom module for DOM operations (Dom.focus, Dom.scrollIntoView, Dom.showDialog, Dom.lockScroll, etc.) and Effect built-ins for everything else (Clock.currentTimeMillis, Random.nextIntBetween, Effect.uuid, Effect.sleep(Duration.millis(...))). See DOM and Effect Helpers in architecture.md
- For HTTP requests, use
HttpClient from @effect/platform. See the weather example for the pattern
Form Validation
When the app has form inputs that need validation (required fields, format checks, async uniqueness checks), use foldkit/fieldValidation. Do not hand-roll validation state.
import {
Field,
Invalid,
NotValidated,
Rule,
Valid,
Validating,
allValid,
makeRules,
validate,
} from 'foldkit/fieldValidation'
const nameRules = makeRules({
rules: [Rule.minLength(2, 'Name must be at least 2 characters')],
})
const emailRules = makeRules({
required: 'Email is required',
rules: [Rule.email('Please enter a valid email address')],
})
const Model = S.Struct({
name: Field(S.String),
email: Field(S.String),
// ...
})
Field(valueSchema) builds a tagged union: NotValidated | Validating | Valid | Invalid. The value Schema should match what the control actually holds as the user edits, not the type you parse it into: Field(S.String) for text inputs, Field(S.Array(S.String)) for a multi-select. A checkbox's boolean usually stays plain S.Boolean in the Model unless it needs the validation lifecycle. Rules stay separate in makeRules. Use validate(rules)(value) in update handlers to transition a field, and gate submission with allValid([[state, rules], ...]), which gates one field value type per call (combine calls with && across types). Omit required from makeRules to make a field optional.
Canonical reference: ${CLAUDE_SKILL_DIR}/../../examples/form/src/main.ts (async email uniqueness check with version-based cancellation) and ${CLAUDE_SKILL_DIR}/../../examples/job-application/src/step/ (validated multi-step forms across submodels).
Dates and File Uploads
For date handling (birthday, deadlines, scheduling):
- Use the
Calendar module: Calendar.CalendarDate, Calendar.today.local (Effect returning today's date in the user's timezone), Calendar.make(year, month, day), Calendar.addDays, etc.
- Use
Ui.DatePicker (input + popover calendar) or Ui.Calendar (inline grid) for the UI
- Seed the initial date via flags when needed. See
job-application example, which uses Calendar.today.local in its flags Effect
For file uploads (resumes, images, attachments):
- Use the
File module for file primitives
- Use
Ui.FileDrop for a drag-and-drop + click-to-browse zone with validation
Ui.FileDrop.ReceivedFiles is a NonEmptyArray<File> OutMessage. Empty selections never fire
- Canonical reference:
${CLAUDE_SKILL_DIR}/../../examples/job-application/src/step/attachments.ts
View
- Bind the html factory inside each view function (never at module level):
const h = html<Message>() as the first line of the function body. Reach for elements, attributes, and event handlers off h: h.div, h.Class, h.OnClick. For Submodel views (children embedded via h.submodel), brand with Submodel.defineView<Model, Message> and bind const h = html<Message>() inside the body. The child dispatches in its own Message type and the parent declares the wrap at the embed site via toParentMessage.
- Use
h.Class(...) for Tailwind classes
- Use
clsx from the clsx package for conditional class composition: h.Class(clsx('base-classes', { 'active-class': isActive, 'bg-blue-500': variant === 'Primary' })). Use clsx whenever classes depend on model state, boolean flags, or discriminated union tags. Never string concatenation, template literals, or && expressions.
- Pattern match on model state:
M.value(model.state).pipe(M.tagsExhaustive({...}))
- Use
Option.match for conditional rendering based on Option fields
- Use
h.keyed('div')(routeOrStateTag, attrs, children) on layout branches
- Delegate complex sections to extracted view functions
- Wire events to messages:
h.OnClick(ClickedSubmit()) (Message directly, not a callback), h.OnInput(value => UpdatedEmail({ value })) (callback that maps the value to a Message)
- Use Foldkit UI components when the interaction matches (Dialog for modals, Tabs for tabbed content, etc.)
Runtime Wiring
- Use
Runtime.makeApplication for apps that own the page. Add routing: { onUrlRequest, onUrlChange } for apps with URL routing. The view returns a Document ({ title, canonical?, ogUrl?, body }); the runtime applies title and the canonical / og:url tags after every render
- Use
Runtime.makeElement for a widget embedded on a page it does not own. The view returns Html and the runtime never touches the document <head>. No routing config
- See the With and Without URL Routing section in architecture.md for the full pattern
- Include
ClickedLink and ChangedUrl Messages for programs with routing, with proper InternalUrl/ExternalUrl handling in update
- Always end with
Runtime.run(application) for a page-owning app. When a host application controls the program's lifecycle, end with Runtime.embed(element) instead and hand the returned handle to the host; mirror repos/foldkit/examples/embedding/src/host.ts for the host side and its main.ts for the widget side
- Name the variable holding a
makeApplication result application, and the variable holding a makeElement result element
Routes (if multi-page)
- Use bidirectional parser:
r(), string(), int(), literal(), slash(), Route.mapTo(), Route.oneOf()
- Define route schemas with
r('RouteName', { param: S.String })
- Suffix route variant constants with
Route: HomeRoute, NewLinkRoute, NotFoundRoute. Every exemplar (auth, shopping-cart, routing) does this. Disambiguates the route schema from views, models, or UI components with matching tag names.
- Build each route as a Router:
const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute)). Routers are callable: homeRouter() returns '/', tagFilterRouter({ tag: 'foo' }) returns '/tag/foo'. This is the print side of the bidirectional parser.
- Never hand-construct paths with template strings.
Href(homeRouter()) not Href('/'). navigateInternal(newLinkRouter()) not navigateInternal('/new'). Href(tagFilterRouter({ tag: tagName })) not Href(`/tag/${encodeURIComponent(tagName)}`). The router handles encoding and keeps the URL shape in one place so a refactor changes one file, not every call site.
- Key view content on
model.route._tag
- Use
pushUrl from foldkit/navigation in Commands for programmatic navigation. In the ClickedLink handler's Internal case, use urlToString(url) from foldkit/url. Never reconstruct the URL from url.pathname + search + hash manually; that path drops the ? prefix and hash silently.
- In the
ClickedLink handler, don't pre-update model.route. The runtime fires ChangedUrl after pushUrl resolves, which updates the route. Pre-updating creates a double-write.
Subscriptions (if real-time)
- Define with
Subscription.make<Model, Message>()(entry => ({ key: entry(fields, callbacks) })). The builder callback receives an entry(fields, callbacks) helper. fields is the bare field map (no S.Struct wrap), callbacks carries modelToDependencies, dependenciesToStream, and optional equivalence
modelToDependencies extracts Subscription parameters from Model
dependenciesToStream builds Stream<Message> from dependencies
- Subscriptions auto-start/stop based on Model state. Never manually managed
- For Subscriptions with no Model dependencies (always active), pass
{} as the entry fields argument and return {} from modelToDependencies
- To embed child Subscriptions, use `Subscription.lift(childRecord)<Parent, Parent>({ toChi
…(truncated)
1---2name: generate-program3description: Generate a complete, idiomatic Foldkit program from a natural language description. Use when the user wants to create a new Foldkit program, scaffold a project, or says things like "build me a..." or "I want a program that... Use when this capability is needed.4---56Generate a complete Foldkit program based on this description:78**$ARGUMENTS**910## Phase 1: Analyze the Description1112Before writing any code, analyze the description to identify:13141. **Domain entities**: nouns that become Model fields (e.g., "todos", "user", "score")152. **User interactions**: verbs that become Messages (e.g., "add", "delete", "filter", "submit")163. **Async operations**: external data that becomes Commands (e.g., "fetch weather", "save to localStorage")174. **Real-time needs**: streaming data that becomes Subscriptions (e.g., "live updates", "countdown", "WebSocket")185. **Pages/navigation**: URL structure that becomes routes (e.g., "home page", "detail page")196. **UI component needs**: interactive widgets that map to Foldkit UI components (e.g., "dropdown" → Menu, "modal" → Dialog, "tabs" → Tabs, "autocomplete" → Combobox, "date picker" → DatePicker, "file upload" → FileDrop, "reorderable list" → DragAndDrop, "toast/notification" → Toast, "hover tooltip" → Tooltip)207. **Form validation needs**: required fields, format checks, async uniqueness → `foldkit/fieldValidation` module (see Phase 4)218. **Date handling**: birthdays, deadlines, scheduling → `Calendar` module + `Ui.DatePicker` or `Ui.Calendar`229. **File handling**: uploads, attachments, images → `File` module + `Ui.FileDrop`2310. **Host embedding**: the program runs inside another app ("a widget in our React app", "embed this in an existing page", "the host needs to control it") → `Runtime.makeElement` plus the `Runtime.embed` lifecycle handle, with Flags for initial data and Ports for ongoing communication in both directions. `repos/foldkit/examples/embedding/` is the canonical reference: a plain TypeScript host driving a Foldkit widget end to end2425Present this analysis to the user before proceeding.2627If the description is detailed and unambiguous, summarize the analysis and confirm before moving on. But if there are gaps (unclear state transitions, vague UI requirements, unspecified error handling, missing edge cases, ambiguous domain boundaries, unclear counter/reset semantics), ask targeted clarifying questions before proceeding. Don't ask open-ended questions like "anything else?". Ask specific questions about the gaps you found.2829**UX/behavior gaps:**3031- "Should the todo list persist across page reloads (localStorage), or start fresh each session?"32- "When the API call fails, should the app show an inline error or a dialog?"33- "You mentioned 'users can edit items'. Is that inline editing or a separate edit page?"3435**Domain-logic gaps (easy to miss, expensive to fix):**3637- "When the user skips an interval, does that count as 'completed' for purposes of the streak?"38- "Does a counter that tracks 'completed' increments on successful actions only, or on skipped actions too?"39- "If the user triggers a reset mid-flow, does the counter reset with it, or persist across resets?"40- "You mentioned 'after N events, trigger X'. Is that N events total, or N events since the last X?"41- "On the Nth action in a cycle, which action does it trigger, the cycle's first or last?"4243Domain-logic questions often surface off-by-one bugs before they hit the code. If the description has any counter, cycle, streak, or "after N" phrase, ask about edge cases at 0 and 1 and N specifically.4445The goal is to resolve ambiguity early so the generated code matches what the user actually wants, not what you assumed.4647## Phase 2: Study Reference Examples4849Read the architecture and conventions guides to internalize the rules:5051- [Architecture guide](architecture.md): TEA structure, file organization, type patterns52- [Conventions guide](conventions.md): naming, Effect-TS patterns, anti-patterns53- [Verification checklist](checklist.md): not just for Phase 5, also the generation bar. Skim the **Quality Bar** section now so you generate code that already meets it rather than code that will fail review.5455If you have access to a context7 MCP tool, use it to look up Effect-TS documentation when you're unsure about an API. Effect is a large library. Verify function signatures rather than guessing.5657### Quality exemplars5859Two codebases are the _quality bar_ for generated apps. Not just "patterns to copy" but "the level of craft to match":6061- `${CLAUDE_SKILL_DIR}/../../packages/typing-game/client/src/`: production multi-page app: Submodels, OutMessage, update/view decomposition, curried handler extraction, subscription patterns, domain modules.62- `${CLAUDE_SKILL_DIR}/../../packages/website/src/`: production Foldkit website: page organization, shared view primitives, route-driven rendering, idiomatic domain separation.6364Before generating, spot-check at least ONE file from each (the shape of `update.ts` / how handlers get extracted / how domain files are structured) and match that level of craft in your output. The generated code should be indistinguishable from hand-written exemplar code.6566Then read the tier-specific example files that match the app's complexity. **Always read at least one tier-specific example.** Never generate from memory alone.6768### Complexity tiers6970**Tier 1: Single page, no async, minimal state:**71Read `${CLAUDE_SKILL_DIR}/../../examples/counter/src/main.ts`7273**Tier 2: Timers, subscriptions, simple stateful apps:**74Read `${CLAUDE_SKILL_DIR}/../../examples/stopwatch/src/main.ts` (timer via subscription, `Duration` field pattern) and `${CLAUDE_SKILL_DIR}/../../examples/todo/src/main.ts` (CRUD with localStorage via flags)7576**Tier 3: Async operations, loading/error states, API calls, form validation:**77Read `${CLAUDE_SKILL_DIR}/../../examples/weather/src/main.ts` (HTTP with `HttpClient`) and `${CLAUDE_SKILL_DIR}/../../examples/form/src/main.ts` (uses `foldkit/fieldValidation`; see the Form Validation section in Phase 4)7879**Tier 4: URL routing, multiple pages, query parameters:**80Read `${CLAUDE_SKILL_DIR}/../../examples/routing/src/main.ts` and `${CLAUDE_SKILL_DIR}/../../examples/query-sync/src/main.ts`8182**Tier 5: Complex state, nested domain models, CRUD, drag-and-drop:**83Read `${CLAUDE_SKILL_DIR}/../../examples/shopping-cart/src/main.ts` (nested domain schemas, cart state) and `${CLAUDE_SKILL_DIR}/../../examples/kanban/src/main.ts` (CRUD with `Ui.DragAndDrop`, flags restoring from localStorage, subscriptions)8485**Tier 6: Submodels, OutMessage, multi-step forms, auth flows, multi-module apps:**86Read `${CLAUDE_SKILL_DIR}/../../examples/auth/src/main.ts` (login/signup with Submodels, OutMessage, protected routes) and `${CLAUDE_SKILL_DIR}/../../examples/job-application/src/main.ts` (multi-step form with deeply nested Submodels in `step/`, `Ui.DatePicker`, `Ui.FileDrop`, `Ui.Menu`, `Calendar` module for date handling)8788**Tier 7: Real-time, WebSocket, Managed Resources, production-grade:**89Read `${CLAUDE_SKILL_DIR}/../../packages/typing-game/client/src/update.ts`, then explore its `page/home/` and `page/room/` directories for the full Submodel/OutMessage pattern.9091Read examples from the target tier AND all lower tiers. A Tier 4 app should reflect patterns from Tiers 1-3 as well.9293## Phase 2.5: Identify Foldkit UI Component Opportunities9495Foldkit ships accessible UI components that handle keyboard navigation, ARIA attributes, and focus management automatically. Before generating, check if any part of the app maps to a built-in component:9697| User Need | Foldkit Component | What you get for free |98| ------------------------------- | ----------------- | --------------------------------------------------------------- |99| Modal/dialog/confirmation | `Dialog` | Focus trapping, Escape to close, scroll locking, backdrop |100| Tabbed content | `Tabs` | Arrow key navigation, aria-selected, roving tabindex |101| Dropdown menu | `Menu` | Arrow keys, typeahead search, aria-expanded, click-outside |102| Autocomplete/tag input | `Combobox` | Filtering, arrow key selection, aria-activedescendant |103| Select dropdown | `Select` | Keyboard selection, aria-selected, positioning |104| Single selection from options | `RadioGroup` | Arrow key cycling, aria-checked |105| On/off toggle | `Switch` | Spacebar toggle, aria-checked |106| Boolean option | `Checkbox` | Spacebar toggle, aria-checked, indeterminate |107| Expandable section | `Disclosure` | Enter/Space toggle, aria-expanded |108| Floating content on hover/click | `Popover` | Positioning, click-outside, focus management |109| Hover tooltip | `Tooltip` | Show-delay, keyboard dismiss, positioning, aria-describedby |110| Single-select list | `Listbox` | Arrow keys, typeahead, aria-selected |111| Text input | `Input` | Consistent styling/behavior wrapper |112| Multi-line text | `Textarea` | Auto-resize, consistent styling |113| Form group | `Fieldset` | Disabled state propagation, grouping |114| Styled button | `Button` | Consistent click/keyboard handling |115| Inline calendar grid | `Calendar` | Month navigation, keyboard nav, aria-selected, date constraints |116| Date input + popover | `DatePicker` | Calendar popover, input masking, keyboard nav, constraints |117| File upload zone | `FileDrop` | Drag-and-drop, click-to-browse, accept filters, validation |118| Reorderable list | `DragAndDrop` | Pointer + keyboard drag, drop zones, announcement region |119| Transient notifications | `Toast` | Auto-dismiss, pause-on-hover, stacking, role=status/alert |120121Each component is a Foldkit Submodel with its own Model, Message, init, update, and view. To use one:1221231. Add its Model to your Model: `confirmDialog: Ui.Dialog.Model`1242. Add a `Got*` Message: `GotConfirmDialogMessage` with `{ message: Ui.Dialog.Message }`1253. Initialize in init: `confirmDialog: Ui.Dialog.init({ id: 'confirm-dialog' })`1264. Delegate in update: `GotConfirmDialogMessage: ({ message }) => ...`1275. Embed in view via `h.submodel`: `h.submodel({ slotId: 'confirm-dialog', view: Ui.Dialog.view, model: model.confirmDialog, toParentMessage: message => GotConfirmDialogMessage({ message }) })` (add `viewInputs` for components whose view takes them)128129**Always prefer Foldkit UI components over hand-rolling interactive widgets.** They make accessibility the default, not an afterthought.130131**For form inputs specifically:** every text input, textarea, and button in a form MUST use `Ui.Input`, `Ui.Textarea`, and `Ui.Button` respectively. This is not optional, even though raw `input`/`textarea` HTML elements are available from `html<Message>()`. The form example (`examples/form/src/main.ts:347-403`) defines `inputFieldView` and `textareaFieldView` helpers that wrap `Ui.Input.view` and `Ui.Textarea.view` with label + validation feedback. Copy that helper pattern. Raw `input`/`textarea` are for non-form cases (search fields, inline editors) where you're intentionally working below the Ui component layer, and even then, reach for the Ui component first.132133If the app uses UI components, **always read the ui-showcase example first** to understand how components are wired. This is the canonical reference for Foldkit UI integration patterns:134135- `${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/main.ts`: root wiring, `Got*` delegation, `toParentMessage` helpers136- `${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/message.ts`: how UI component Messages are structured137- `${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/model.ts`: how UI component Models are composed138- `${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/update.ts`: how UI component updates are delegated139- `${CLAUDE_SKILL_DIR}/../../examples/ui-showcase/src/toast.ts`: read when using `Ui.Toast`: Toast is unique in that it's parameterized on a payload schema via `Ui.Toast.make(PayloadSchema)`, returning a typed module you import from140141For apps using `Ui.DatePicker`, `Ui.FileDrop`, or other recently-added components, also read the `job-application` example (see Tier 6 below). It's the most complete real-world integration of these components together.142143## Phase 3: Determine File Organization144145Match the file structure to the app's complexity. The architecture stays the same at every scale; only the file organization changes.146147### What lives in which file148149Beyond the tier-based layouts below, follow these "schema placement" rules to avoid model.ts bloat:150151- **`model.ts`** holds the `Model` schema + any schemas that are fields of Model (or composed into fields, like form state / submit state unions). Nothing else.152- **`command.ts`** holds schemas for the payloads commands send to / receive from external systems, in particular the persistence schema that `saveState` serializes and `flags` deserializes. The persistence schema is a command-layer concern, not a model concern; it often looks like a subset of Model but it isn't part of Model.153- **`domain/*.ts`** holds domain entity schemas and pure operations on them.154- **`message.ts`** holds messages (only).155- **`route.ts`** holds route variants + router pipelines.156157A common mistake (because kanban colocates `SavedBoard` in `model.ts`): putting the persistence schema in `model.ts` because "it's schema." It's schema for the persistence layer, not for the Model. Move it to where it's used.158159**Single file** (Tier 1-2, under ~300 lines):160161```162src/main.ts ← Model, Message, init, update, view163src/entry.ts ← Runtime.makeApplication + Runtime.run164```165166**Split commands + messages** (Tier 3, has async operations):167168```169src/main.ts ← Model, init, update, view170src/entry.ts ← Runtime.makeApplication + Runtime.run171src/message.ts ← Message definitions172src/command.ts ← Command functions173```174175**Important rule:** if you extract `command.ts`, you MUST also extract `message.ts`. Commands reference Message constructors (e.g. `SucceededFetchWeather({...})`) as their Effect return values. If Messages live in `main.ts` and Commands live in `command.ts`, `command.ts` imports from `main.ts` _and_ `main.ts` uses Commands from `command.ts`, a circular import. Pull Messages out first, then both `main.ts` and `command.ts` import from `message.ts`.176177**Full split** (Tier 4-5, multiple concerns):178179```180src/main.ts ← init, update, view181src/entry.ts ← Runtime.makeApplication + Runtime.run182src/model.ts ← Model schema183src/message.ts ← Message definitions184src/command.ts ← Command functions185src/route.ts ← Route parser (if routing)186src/view.ts ← View functions (if view is large)187src/domain/ ← Shared domain schemas (if multiple entities)188```189190**Submodel directories** (Tier 6-7, independent modules):191192```193src/main.ts ← Root init, update, view194src/entry.ts ← Runtime.makeApplication + Runtime.run195src/model.ts ← Root model (contains submodels)196src/message.ts ← Root messages + Got* bridging197src/command.ts ← Shared commands198src/route.ts ← Route parser199src/domain/ ← Shared domain schemas200src/page/201 featureA/202 main.ts ← Submodel init, update, view203 message.ts ← Submodel messages + OutMessage204 command.ts ← Submodel commands205 featureB/206 ...207```208209## Phase 3.3: Architecture sketch (Tier 4+ only)210211For Tier 4+ apps (routing, domain modules, multiple entities, submodels) produce a compact sketch BEFORE generating implementations. Tier 1-3 apps are small enough to generate in one pass; Tier 4+ apps burn a lot of effort if the structure is wrong.212213The sketch has five parts. Emit them inline in the conversation, get confirmation, THEN scaffold:2142151. **File tree**: the exact paths you will create. Match Phase 3's organization.2162. **Model shape**: the top-level `S.Struct` fields and their types. Not the full schema, just the shape.2173. **Message list**: every Message you plan to define, grouped by category (clicks, inputs, commands, out-messages).2184. **Route list**: if routing, every `r('...', {...})` with params and the path each maps to.2195. **Domain operations**: for each file in `domain/`, the operations it will expose (`Link.byNewest`, `Link.filterByTag`, etc.).220221Example for a Tier 4 link saver:222223```224### Sketch225226Files:227 src/main.ts, entry.ts, model.ts, message.ts, command.ts, route.ts228 src/domain/link.ts, index.ts229 src/story.test.ts, scene.test.ts230231Model:232 route: AppRoute233 links: ReadonlyArray<Link>234 newLinkForm: NewLinkForm (url: Field<string>, title/description/tagsInput: string, submitState)235236Messages:237 Clicks: ClickedSaveLink, ClickedDeleteLink238 Inputs: UpdatedLinkUrl, UpdatedLinkTitle, UpdatedLinkDescription, UpdatedLinkTagsInput, BlurredLinkUrl239 Commands: SubmittedNewLinkForm, SucceededSaveLinks, FailedSaveLinks240 Routing: ClickedLink, ChangedUrl, CompletedNavigateInternal, CompletedLoadExternal241 Toggles: ToggledFavorite242243Routes:244 HomeRoute → /245 NewLinkRoute → /new246 TagFilterRoute → /tag/:tag247 NotFoundRoute → /* fallback248249Domain:250 Link: schema + byNewest, filterByTag, toggleFavorite, remove, updateById251```252253After emitting the sketch, ask the user to confirm or adjust. Don't start scaffolding or generation until they do. If the user confirms silently (e.g. "looks good, continue"), proceed. If they adjust, iterate on the sketch. Don't write code against a version they haven't approved.254255This step is frequently tempting to skip because the agent "knows what it's doing." Skip it and you ship a fully-generated app that turns out to need structural changes. That's the expensive form of iteration. The sketch is the cheap form.256257## Phase 3.5: Scaffold the Project258259Before generating code, scaffold a runnable project using `create-foldkit-app`:260261```bash262npx create-foldkit-app@latest263```264265Run with no flags to drop into the interactive prompts; pick the counter example as the base (simplest starting point) and the user's preferred package manager. The generated project includes:266267- `package.json` with all Foldkit and Effect dependencies268- `vite.config.ts` with Tailwind and the Foldkit Vite plugin269- `tsconfig.json` with strict TypeScript settings270- `index.html` with the root container271- `src/styles.css` with Tailwind import272- `AGENTS.md` with Foldkit conventions273274### Offer the Foldkit subtree275276After scaffolding, offer to vendor Foldkit in as a git subtree so future AI sessions can reference the full source, examples, and docs directly from the user's project. Commit the scaffold first so subtree has a base commit to merge into:277278```bash279git init # if not already a git repo280git add .281git commit -m "chore: initial commit"282git subtree add --prefix=repos/foldkit https://github.com/foldkit/foldkit.git main --squash283```284285This is optional but strongly recommended. The scaffolded `AGENTS.md` includes a `subtree_prompted: false` line that agents check on future sessions. If the subtree is absent and this flag is false, the agent offers to add it. Handling it up front here means the user's next AI session already has full context. If the user declines, update the line to `subtree_prompted: true` so they aren't asked again.286287To refresh the subtree later: `git subtree pull --prefix=repos/foldkit https://github.com/foldkit/foldkit.git main --squash`.288289### Replace the scaffold290291Then replace the counter example code in `src/main.ts` (and add additional source files as needed) with the generated app code.292293## Phase 3.7: Ground the Foldkit APIs294295Before writing any code, READ the type signatures of every Foldkit module you will use. Guessing signatures wastes cycles: each wrong guess is a tsc error, a re-read, an edit, another typecheck. Five minutes of reading prevents thirty minutes of iteration.296297### The exact files to read298299For each Foldkit module you plan to use, read the `.d.ts` at the paths below. Read the public surface; you don't need the internals. Write a short signature crib in your working notes so you don't have to re-check while generating.300301```302# Every app303<project>/node_modules/foldkit/dist/index.d.ts # top-level re-exports304<project>/node_modules/foldkit/dist/html/index.d.ts # html<Message>(), element signatures, Attribute<Message>, empty, keyed305<project>/node_modules/foldkit/dist/message/index.d.ts # m()306<project>/node_modules/foldkit/dist/schema/index.d.ts # ts(), r()307<project>/node_modules/foldkit/dist/struct/index.d.ts # evo(): check nested-update signature308<project>/node_modules/foldkit/dist/runtime/runtime.d.ts # ApplicationInit, RoutingApplicationInit, makeApplication, makeElement309310# If using routing311<project>/node_modules/foldkit/dist/route/parser.d.ts # literal, slash, string, int, Route.root, Route.mapTo, Route.oneOf, Route.parseUrlWithFallback312<project>/node_modules/foldkit/dist/url/index.d.ts # toString313<project>/node_modules/foldkit/dist/navigation/index.d.ts # pushUrl, load: all return Effect<void> (no Effect.ignore needed)314315# If using async / side effects316<project>/node_modules/foldkit/dist/command/index.d.ts # Command.define: result schemas are required317<project>/node_modules/foldkit/dist/dom/index.d.ts # focus, advanceFocus, scrollIntoView, showDialog, closeDialog, clickElement, lockScroll, unlockScroll, inertOthers, restoreInert, detectElementMovement, waitForAnimationSettled. For time/random/uuid/delay use Effect's Clock, Random, Effect.uuid, Effect.sleep + Duration directly.318319# If using subscriptions320<project>/node_modules/foldkit/dist/subscription/index.d.ts # Subscription.make<Model, Message>, Subscription.lift, Subscription.aggregate321322# If using mount / managed-resource / custom-element323<project>/node_modules/foldkit/dist/mount/index.d.ts # Mount.define: for per-instance VNode lifecycle324<project>/node_modules/foldkit/dist/managedResource/public.d.ts # ManagedResource.make / lift / aggregate + tag: for stateful runtime objects keyed on Model condition325<project>/node_modules/foldkit/dist/customElement/index.d.ts # CustomElement.define: for typed bindings to native web components326327# If using forms328<project>/node_modules/foldkit/dist/fieldValidation/public.d.ts # Field (tagged union), makeRules({required?, rules}), validate, allValid; rule constructors on the Rule namespace (Rule.url(options), Rule.email, Rule.minLength, Rule.pattern, Rule.fromSchema, ...)329# Rule.Rule is [Predicate, Rule.RuleMessage], NOT {test, message}. Field.Invalid has `errors: NonEmptyArray<string>`, not `error: string`.330331# If using any UI component332<project>/node_modules/foldkit/dist/ui/<component>/public.d.ts # Model, Message, init, update, view (Submodel-shaped) or ViewConfig (render-helper-shaped), OutMessage when applicable333# Check: is it a Submodel (Menu/Listbox/Combobox/Calendar/Disclosure/Dialog/Popover/etc.) embedded via h.submodel, or a stateless render helper (Button/Input/Textarea/Select/Fieldset) called directly with a ViewConfig? Submodels carry their own Model/Message/update/OutMessage; render helpers don't. Check ViewInputs (for Submodels) or ViewConfig (for helpers) for the slot callbacks (toView, itemToConfig, etc.).334335# If using dates336<project>/node_modules/foldkit/dist/calendar/index.d.ts # CalendarDate, today.local (returns Effect<CalendarDate>); for raw millis use Clock.currentTimeMillis337```338339### What to record in the crib340341For each symbol you'll call, write one line:342343```344html<Message>(): { div, input (VOID), textarea, button, Class, Href, For, Id, Role, OnClick(Message), OnInput(value=>Message), OnBlur(Message), OnSubmit(Message), keyed, empty, ... }345Route.mapTo(schema)(parser): curried346pushUrl(path): Effect<void> // NOT fallible, no Effect.ignore needed347urlToString(url: Url): string348Ui.Input.view({ id, value, onInput, isInvalid?, type?, placeholder?, toView: (attrs) => Html })349 // attrs: { label: Attribute<M>[], input: Attribute<M>[], description: Attribute<M>[] }350Field (schema): NotValidated | Validating | Valid | Invalid(errors: NonEmpty<Rule Message>)351```352353### Specific API pitfalls the generator hits repeatedly354355Record these in the crib and keep them visible while generating:356357- **`input` and `br` and other void elements take ONLY attributes**: `input([...])`, never `input([...], [])`. `textarea` and `button` DO take children.358- **`UrlRequest` tags are `Internal` and `External`**, not `InternalUrl` / `ExternalUrl`.359- **`OnClick` and `OnSubmit` take a Message directly**, not a `() => Message`. Only `OnInput` takes `(value) => Message` because it needs the input value.360- **`keyed`, `empty` are properties on the record returned by `html<Message>()`**: accessed as `h.keyed` and `h.empty` after `const h = html<Message>()`. They are not top-level exports of `foldkit/html`.361- **Attribute helpers are specific**: `Value(...)`, `Type(...)`, `Placeholder(...)`, `Href(...)`, `Target(...)`, `Rel(...)`, `Rows(n)`, `Id(...)`, `For(...)`, `Role(...)`, `AriaLabel(...)`. There is no generic `Attr('...', '...')`.362- **`ApplicationInit<Model, Message, Flags>` has no URL parameter.** For routed apps, use `RoutingApplicationInit<Model, Message, Flags>`: the second arg is `url: Url`.363- **`Route.mapTo` takes the route schema, not a factory function.** `pipe(literal('new'), Route.mapTo(NewLinkRoute))`. NOT `Route.mapTo(() => NewLinkRoute())`.364- **`Effect.ignore` is ONLY for fallible Effects.** `pushUrl(path).pipe(Effect.as(Message()))`. No `Effect.ignore` because `pushUrl` returns `Effect<void>`.365- **`Command.define` requires result Message schemas after the name**: `Command.define('Fetch', SucceededFetch, FailedFetch)`. Infallible Commands only need one result: `Command.define('ReadClock', RecordedTime)`.366- **`makeRules` takes `{ required?: Rule.RuleMessage, rules: Array<Rule.Rule> }` where `Rule.Rule = [Predicate, Rule.RuleMessage]`**: a tuple, NOT `{ test, message }`. Rule constructors live on the `Rule` namespace (`Rule.url({ message })`, `Rule.email(message?)`, `Rule.minLength(n, message?)`, `Rule.pattern(regex, message?)`, `Rule.fromSchema(schema, message)`).367- **`Field.Invalid` has `errors: NonEmptyArray<string>`, not `error: string`.** Use `Array.headNonEmpty(errors)` to get the first message; use `Rule.resolveMessage(message, value)` to resolve a rule message to its final string.368- **Route variants are `HomeRoute`, `NewLinkRoute`, etc., with the `Route` suffix.** Every exemplar uses this convention.369- **Routers are callable for printing**: `homeRouter()` returns `'/'`, `tagFilterRouter({ tag: 'foo' })` returns `'/tag/foo'`. Never hand-construct URLs.370371## Phase 4: Generate the App372373Generate files following the architecture and conventions guides exactly. Write all source files into the scaffolded project's `src/` directory. For each file, follow these rules:374375### Model376377- Define as `S.Struct` with Effect Schema types378- Use discriminated unions for state: `Idle | Loading | Error | Ok`, never booleans for multi-valued state379- Use `Option` for fields that may be absent. Never empty strings or null380- Prefix Option-typed fields with `maybe`: `maybeCurrentUser`, `maybeError`381- For async data, define `Idle`, `Loading`, `Error`, `Ok` variants with `ts()` and compose into an `S.Union`. See Discriminated Unions for State in [conventions.md](conventions.md)382- For apps with multiple domain entities referenced across modules, extract shared schemas into `src/domain/` (e.g., `domain/product.ts`, `domain/session.ts`). See the shopping-cart and auth examples for this pattern, and read `${CLAUDE_SKILL_DIR}/../../packages/website/src/page/projectOrganization.ts` for guidance on when and how to structure domain modules383384### Messages385386Follow the four-group layout strictly:387388```ts389// Group 1: All m() declarations, no blank lines between them390const ClickedSubmit = m('ClickedSubmit')391const UpdatedEmail = m('UpdatedEmail', { value: S.String })392const SucceededLogin = m('SucceededLogin', { user: User })393const FailedLogin = m('FailedLogin', { error: S.String })394const CompletedFocusInput = m('CompletedFocusInput')395396// Group 2: Union + type (no blank line between them)397const Message = S.Union([398 ClickedSubmit,399 UpdatedEmail,400 SucceededLogin,401 FailedLogin,402 CompletedFocusInput,403])404type Message = typeof Message.Type405```406407Name messages by category:408409- `Clicked*`: button/link clicks410- `Updated*`: input value changes (with `{ value: S.String }`) and external state updates from subscriptions (`UpdatedRoom`, `UpdatedPlayerProgress`)411- `Submitted*`: form submissions412- `Succeeded*` / `Failed*`: paired, for commands that can meaningfully fail413- `Completed*`: fire-and-forget (verb+object: `CompletedFocusInput`)414- `Got*`: child module results via OutMessage pattern415- `Loaded*`: data restored from storage416- `Pressed*`: keyboard input417- `Blurred*`: focus loss418- `Selected*`: choice made from a list419- `Toggled*`: binary state flip420421Every message must carry meaning. No `NoOp`.422423### Flags (if the initial Model needs side effects)424425- Define a `Flags` Schema for data the initial Model needs from side effects426- Define `flags` as an `Effect<Flags>` that computes the values (localStorage reads, current time, etc.)427- Pass the result into init. Never perform side effects at module level or inside init directly428- See the flags section in [architecture.md](architecture.md) for the full pattern429430### Init431432- Return `[Model, ReadonlyArray<Command<Message>>]`433- If flags are used, accept them as the first parameter: `(flags: Flags) => [Model, Commands]` or `(flags: Flags, url: Url) => [Model, Commands]`434- Include startup Commands (initial fetch, focus first input, etc.)435- Use callable Schema constructors for the initial Model: `Model({ field: value })`436437### Update438439- Use `M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({...}))`. Never switch440- Every case returns `[Model, ReadonlyArray<Command<Message>>]`441- Use `evo(model, { field: () => newValue })` for immutable updates442- In `evo`, use point-free field transformers when the update only depends on that field's current value: `items: Array.map(updateItem)`, `count: Number.increment`, `stepTabs: Tabs.reflectSelectedTab(value, steps)`. Use `() => value` for replacement values from Messages, child updates, Commands, or other Model fields.443- Extract complex handlers to separate functions when a case exceeds ~15 lines444- For Submodels: return `[Model, ReadonlyArray<Command<Message>>, Option.Option<OutMessage>]`445- See the OutMessage pattern in [architecture.md](architecture.md). Child modules signal to parents via `Option.some(OutMessage)`, parents handle with `Got*` Messages and `M.tagsExhaustive`446447### Commands448449- Define Command identities with `Command.define`, passing result Message schemas after the name. Result types are required450- Always assign definitions to PascalCase constants. Never inline in pipe chains451- Definitions live where they're produced, colocated with the update function452- Let TypeScript infer return types. No explicit `Command<typeof A>` annotations453- Use `Effect.gen` for multi-step async454- Always `Effect.catch(() => Effect.succeed(FailedX(...)))` for fallible Effects. Commands never throw. **Exception:** if the Effect is infallible at the type level (`Clock.currentTimeMillis`, `Effect.uuid`, `Random.nextIntBetween`, etc.), no `catch` is needed and no `Failed*` Message is needed. Follow the types: if there's no error channel, there's nothing to catch.455- Use `Effect.provide` for services456- Factory functions named by action: `fetchWeather`, not `fetchWeatherCommand`457- Fire-and-forget Commands return `Completed*` Messages458- Use Foldkit's `Dom` module for DOM operations (`Dom.focus`, `Dom.scrollIntoView`, `Dom.showDialog`, `Dom.lockScroll`, etc.) and Effect built-ins for everything else (`Clock.currentTimeMillis`, `Random.nextIntBetween`, `Effect.uuid`, `Effect.sleep(Duration.millis(...))`). See DOM and Effect Helpers in [architecture.md](architecture.md)459- For HTTP requests, use `HttpClient` from `@effect/platform`. See the weather example for the pattern460461### Form Validation462463When the app has form inputs that need validation (required fields, format checks, async uniqueness checks), use `foldkit/fieldValidation`. Do not hand-roll validation state.464465```ts466import {467 Field,468 Invalid,469 NotValidated,470 Rule,471 Valid,472 Validating,473 allValid,474 makeRules,475 validate,476} from 'foldkit/fieldValidation'477478const nameRules = makeRules({479 rules: [Rule.minLength(2, 'Name must be at least 2 characters')],480})481482const emailRules = makeRules({483 required: 'Email is required',484 rules: [Rule.email('Please enter a valid email address')],485})486487const Model = S.Struct({488 name: Field(S.String),489 email: Field(S.String),490 // ...491})492```493494`Field(valueSchema)` builds a tagged union: `NotValidated | Validating | Valid | Invalid`. The value Schema should match what the control actually holds as the user edits, not the type you parse it into: `Field(S.String)` for text inputs, `Field(S.Array(S.String))` for a multi-select. A checkbox's boolean usually stays plain `S.Boolean` in the Model unless it needs the validation lifecycle. Rules stay separate in `makeRules`. Use `validate(rules)(value)` in update handlers to transition a field, and gate submission with `allValid([[state, rules], ...])`, which gates one field value type per call (combine calls with `&&` across types). Omit `required` from `makeRules` to make a field optional.495496Canonical reference: `${CLAUDE_SKILL_DIR}/../../examples/form/src/main.ts` (async email uniqueness check with version-based cancellation) and `${CLAUDE_SKILL_DIR}/../../examples/job-application/src/step/` (validated multi-step forms across submodels).497498### Dates and File Uploads499500For date handling (birthday, deadlines, scheduling):501502- Use the `Calendar` module: `Calendar.CalendarDate`, `Calendar.today.local` (Effect returning today's date in the user's timezone), `Calendar.make(year, month, day)`, `Calendar.addDays`, etc.503- Use `Ui.DatePicker` (input + popover calendar) or `Ui.Calendar` (inline grid) for the UI504- Seed the initial date via flags when needed. See `job-application` example, which uses `Calendar.today.local` in its flags Effect505506For file uploads (resumes, images, attachments):507508- Use the `File` module for file primitives509- Use `Ui.FileDrop` for a drag-and-drop + click-to-browse zone with validation510- `Ui.FileDrop.ReceivedFiles` is a `NonEmptyArray<File>` OutMessage. Empty selections never fire511- Canonical reference: `${CLAUDE_SKILL_DIR}/../../examples/job-application/src/step/attachments.ts`512513### View514515- Bind the html factory inside each view function (never at module level): `const h = html<Message>()` as the first line of the function body. Reach for elements, attributes, and event handlers off `h`: `h.div`, `h.Class`, `h.OnClick`. For Submodel views (children embedded via `h.submodel`), brand with `Submodel.defineView<Model, Message>` and bind `const h = html<Message>()` inside the body. The child dispatches in its own Message type and the parent declares the wrap at the embed site via `toParentMessage`.516- Use `h.Class(...)` for Tailwind classes517- Use `clsx` from the `clsx` package for conditional class composition: `h.Class(clsx('base-classes', { 'active-class': isActive, 'bg-blue-500': variant === 'Primary' }))`. Use `clsx` whenever classes depend on model state, boolean flags, or discriminated union tags. Never string concatenation, template literals, or `&&` expressions.518- Pattern match on model state: `M.value(model.state).pipe(M.tagsExhaustive({...}))`519- Use `Option.match` for conditional rendering based on Option fields520- Use `h.keyed('div')(routeOrStateTag, attrs, children)` on layout branches521- Delegate complex sections to extracted view functions522- Wire events to messages: `h.OnClick(ClickedSubmit())` (Message directly, not a callback), `h.OnInput(value => UpdatedEmail({ value }))` (callback that maps the value to a Message)523- Use Foldkit UI components when the interaction matches (Dialog for modals, Tabs for tabbed content, etc.)524525### Runtime Wiring526527- Use `Runtime.makeApplication` for apps that own the page. Add `routing: { onUrlRequest, onUrlChange }` for apps with URL routing. The `view` returns a `Document` (`{ title, canonical?, ogUrl?, body }`); the runtime applies `title` and the canonical / og:url tags after every render528- Use `Runtime.makeElement` for a widget embedded on a page it does not own. The `view` returns `Html` and the runtime never touches the document `<head>`. No `routing` config529- See the With and Without URL Routing section in [architecture.md](architecture.md) for the full pattern530- Include `ClickedLink` and `ChangedUrl` Messages for programs with routing, with proper `InternalUrl`/`ExternalUrl` handling in update531- Always end with `Runtime.run(application)` for a page-owning app. When a host application controls the program's lifecycle, end with `Runtime.embed(element)` instead and hand the returned handle to the host; mirror `repos/foldkit/examples/embedding/src/host.ts` for the host side and its `main.ts` for the widget side532- Name the variable holding a `makeApplication` result `application`, and the variable holding a `makeElement` result `element`533534### Routes (if multi-page)535536- Use bidirectional parser: `r()`, `string()`, `int()`, `literal()`, `slash()`, `Route.mapTo()`, `Route.oneOf()`537- Define route schemas with `r('RouteName', { param: S.String })`538- **Suffix route variant constants with `Route`**: `HomeRoute`, `NewLinkRoute`, `NotFoundRoute`. Every exemplar (auth, shopping-cart, routing) does this. Disambiguates the route schema from views, models, or UI components with matching tag names.539- Build each route as a Router: `const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute))`. **Routers are callable**: `homeRouter()` returns `'/'`, `tagFilterRouter({ tag: 'foo' })` returns `'/tag/foo'`. This is the print side of the bidirectional parser.540- **Never hand-construct paths with template strings.** `Href(homeRouter())` not `Href('/')`. `navigateInternal(newLinkRouter())` not `navigateInternal('/new')`. `Href(tagFilterRouter({ tag: tagName }))` not ``Href(`/tag/${encodeURIComponent(tagName)}`)``. The router handles encoding and keeps the URL shape in one place so a refactor changes one file, not every call site.541- Key view content on `model.route._tag`542- Use `pushUrl` from `foldkit/navigation` in Commands for programmatic navigation. In the `ClickedLink` handler's `Internal` case, use `urlToString(url)` from `foldkit/url`. Never reconstruct the URL from `url.pathname + search + hash` manually; that path drops the `?` prefix and hash silently.543- In the `ClickedLink` handler, **don't pre-update `model.route`**. The runtime fires `ChangedUrl` after `pushUrl` resolves, which updates the route. Pre-updating creates a double-write.544545### Subscriptions (if real-time)546547- Define with `Subscription.make<Model, Message>()(entry => ({ key: entry(fields, callbacks) }))`. The builder callback receives an `entry(fields, callbacks)` helper. `fields` is the bare field map (no `S.Struct` wrap), `callbacks` carries `modelToDependencies`, `dependenciesToStream`, and optional `equivalence`548- `modelToDependencies` extracts Subscription parameters from Model549- `dependenciesToStream` builds `Stream<Message>` from dependencies550- Subscriptions auto-start/stop based on Model state. Never manually managed551- For Subscriptions with no Model dependencies (always active), pass `{}` as the `entry` fields argument and return `{}` from `modelToDependencies`552- To embed child Subscriptions, use `Subscription.lift(childRecord)<Parent, Parent>({ toChi553554…(truncated)