tanstack-promptable-fullstack-app-template
Skill3 starsChanged 7 days agoMITView on GitHub
**WORKFLOW SKILL** - Architecture contract for TanStack Start apps — schemas, repositories, loader-first routes, and AI-promptable tools.
- Reads credentials
In this repository6 other files it gives its agents.
- AGENTS.mdAGENTS.md
- agentic-uxSkill
- observability-and-envSkill
- promptable-uxSkill
- reference-tech-stackSkill
- repository-architectureSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
20 rules. About 0 are common, 3 are distinctive, and 17 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
17
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 82
Read Core Contract first — it is the non-negotiable architecture.
- Distinctive: line 83
Run the Architecture Checklist before every non-trivial change.
- Very specific: line 97
When the app already sets PROMPT_CONCEPT or already mounts one shell, keep that declaration.
- Distinctive: line 99
When it is not clear which user experience to implement, ask which of the three to build and wait for an answer.
- Very specific: line 141
After a value has crossed a trust boundary, keep schema-inferred types through UI, handlers, and tools — do not widen back to string and …
- Very specific: line 151
Bound the agent loop: every chat() call sets agentLoopStrategy: maxIterations(N) explicitly (default N=10).
- Very specific: line 164
No type erasure: after Schema.parse / validateSearch, carry schema-inferred types through server functions, repos, tools, and components — …
- Very specific: line 169
AI stack is complete: every repo method → server tool + safe handler.
- Very specific: line 174
Direct
@/imports: no re-export index.ts barrels. - Very specific: line 212
Import the file that defines the symbol.
- Very specific: line 237
Do not define new createServerFn inline in route files — keep RPC entry points centralized in serverFns.ts.
- Very specific: line 241
When adding node-only packages, extend vite.config.ts:
- Very specific: line 256
Add your DB driver and auth/crypto libraries when they are not isolated in *.server.ts.
- Very specific: line 276
Put recoverable fields in validateSearch.
- Very specific: line 359
Virtual / computed fields: If semantics cannot live in schema metadata, keep a small registry next to the derivation and expose …
- Very specific: line 365
Validate at the edge: use a runtime validator (Zod, ArkType, Valibot, …) in Start middleware when assembling request context — …
- Very specific: line 367
Do not re-validate context in handlers: no shallow "is this field present?" guards on middleware output.
- Very specific: line 446
Add Part A once for every UX.
- Very specific: line 471
Prompt chrome and mobile first: when it is not clear which user experience to implement, ask Choose a UX and wait, then load promptable-ux …
- Very specific: line 494
Apps built from the template: follow AGENTS.md §15 — e.g.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Companion skills (install if missing)
This template publishes multiple skills. If only this skill is installed, add companions before related work:
observability-and-env(companion) — Logging, error-tracking bootstrap, env schemas, webEnvMiddleware, and shellSession setup. Install when work touches observability, process.env, or browser runtime config.npx skills add carlosvin/tanstack-fullstack-ai-template --skill observability-and-envreference-tech-stack(companion) — Opinionated vendor map for this template's reference app. Install when implementing against the demo stack defaults (UI kit, validator, DB, deploy).npx skills add carlosvin/tanstack-fullstack-ai-template --skill reference-tech-stackrepository-architecture(companion) — Language-agnostic repository architecture: injected collection owners, indexes, stale-data cleanup, and resource lifetime. Install when designing or refactoring repository contracts.npx skills add carlosvin/tanstack-fullstack-ai-template --skill repository-architecturepromptable-ux(companion) — Shared UX for both prompt concepts (hidden side or bottom panel, and prompt-first). Install when placing the prompt, choosing a layout concept, or rendering assistant markdown.npx skills add carlosvin/tanstack-fullstack-ai-template --skill promptable-uxagentic-ux(companion) — Fully agentic tool-only shell. Catalog views are declarative surfaces from tool data. Install when the deployment has no domain screens.npx skills add carlosvin/tanstack-fullstack-ai-template --skill agentic-ux
Discover all skills: npx skills add carlosvin/tanstack-fullstack-ai-template --list
TanStack Fullstack Pattern
Purpose: Capture the interface-first, schema-layered, AI-promptable contract for TanStack Start apps from this template. Day-to-day conventions (UI kit, chat wiring, logging, tests) live in the repo’s AGENTS.md — use this skill for architecture, AGENTS.md for operations.
Companion handbook: AGENTS.md — structure, styling, auth snippets, lint/test tooling, validation checklist, AI chat setup.
Companion skill:
observability-and-env— env schemas, browser shell session, logging/error-tracking bootstrap. Load it for observability work; this skill keeps only the architecture invariants (no vendor-specific logging or APM choices).
Skill routing
| Task | Load |
| New entity, routes, schemas, AI tools, auth, server boundaries | This skill |
Logging, error tracking, instrument.*.mts, src/env/, shellSession, env leaks | observability-and-env |
| "Which package does this template use?" / match the demo app stack | reference-tech-stack |
| Collection repositories, indexes, stale-data cleanup, resource lifetime | repository-architecture |
| Prompt chrome, mobile first, assistant markdown | promptable-ux (after Choose a UX) |
| Fully agentic shell, no domain screens | agentic-ux (after Choose a UX) |
| Architecture + env/logging | This skill + observability-and-env |
| Scaffolding a new app | This skill + reference-tech-stack + the UX skill from Choose a UX |
How to use this skill
- Read Core Contract first — it is the non-negotiable architecture.
- Run the Architecture Checklist before every non-trivial change.
- Jump to Server execution boundaries, Schema Boundaries, Request Context, or Special Patterns only when that concern applies. Special Patterns are not Core Contract. Follow Choose a UX before prompt chrome. Mobile first lives in companion
promptable-ux. - Use AGENTS.md for operational how-to — not for inventing alternate architecture. This skill is vendor-agnostic for UI kits and observability SDKs. Concrete packages for this template live in companion skill
reference-tech-stack; env/logging setup lives inobservability-and-env. Collection ownership, indexes, stale-data cleanup, and resource lifetime live inrepository-architecture. The three user experiences live inpromptable-ux(side and prompt-first) andagentic-ux(fully agentic). When it is not clear which user experience to implement, ask in Choose a UX and wait.
Choose a UX
The UX companion skills cover three experiences. One experience per deployment.
| Experience | Companion skill | Build |
| side | promptable-ux | Domain screens, with the prompt hidden until opened |
| prompt-first | promptable-ux | Bottom-pinned composer, conversation stage, and domain routes |
| agentic | agentic-ux | Thin shell only. No domain screens. Catalog views are declarative surfaces from tool data. The guest kind follows the declared kit |
When the app already sets PROMPT_CONCEPT or already mounts one shell, keep that declaration. This template's three sites stay as they are: side (unset or side), prompt-first, and agentic.
When it is not clear which user experience to implement, ask which of the three to build and wait for an answer. Do not pick one. Do not place a prompt, add domain screens, or start an agentic shell until the developer chooses.
Tri-modal UX architecture (4 seams, 1 shared core)
agentic owns the shared dynamic chat core (AgenticMessageItems + useInlineDetailViews → AgenticA2uiSurface). prompt-first and side embed that core in route chrome (ChatThread + PromptChatProvider, which owns opened details). Branch only at four seams with exhaustive Record<PromptConcept, …> / satisfies maps (PROMPT_CONCEPT=side | prompt-first | agentic):
- Env & session (
shellSession.ts,webEnv.server.ts):PromptConceptSchema, parsePROMPT_CONCEPT, projectpromptConceptviagetBrowserShellSession(observability-and-env). - Root shell (
AppLayout.tsx): readshellSession.promptConcept—AgenticChatProvider+AgenticShellfor agentic;PromptChatProvider+ChatDrawer/PromptBarfor side / prompt-first (promptable-ux/agentic-ux). - Home route (
routes/index.tsx): exhaustive home map — overview loaders for side/prompt-first; agentic returns[]/null(shell owns the stage). - Chat endpoint (
routes/api/chat.ts): shareddataTools(CRUD +showTasksViewTool/showTaskViewTool); addnavigate/invalidateRouterand route system prompts only for side/prompt-first.
Full seam notes: references/tri-modal-ux.md.
Fixed vs swappable stack
Fixed by this skill (TanStack): TanStack Start, Router, and AI (server functions, middleware, file routes, validateSearch, loaders, chat() / tools / SSE). Use @tanstack/cli for current library docs. This repo’s architecture lives in Agent Skills (.agents/skills/*/SKILL.md) only.
Swappable (not prescribed here): runtime validation, database, auth mechanism, AI provider, observability vendors, UI kit, markdown renderer, lint/test/deploy tooling. Patterns stay interface-first (Schema.parse(), repository interfaces, AIAdapterService, ObservabilityService).
Concrete packages for this template: companion skill reference-tech-stack. Env/logging bootstrap: observability-and-env.
Pick one validator library per app and use it consistently across router search, server-fn validators, and AI tool schemas. Code samples below use Zod as the reference syntax (see reference-tech-stack); translate idioms when using ArkType or Valibot.
Common failure modes (avoid these)
- URL-recoverable state ignored: filters/tabs/modals in
useState/useEffect, ornavigateon every search keystroke — usevalidateSearch+ props; debounce free-text (Special Patterns). - Wrong schema edge / half-parse: repository schemas in UI/tools; inbound-only parse;
Array.find/asinstead ofSchema.parse/validateSearch. - Server fn without a tool; UI-only auth; type escape hatches (
any, looseRecord,asafter parse). - Duplicated parent work; server logic in loaders (
process.envsecrets, DB/repo imports in route files). - Wrong primitive / leaky graph:
createServerFnfor non-RPC singletons (usecreateServerOnlyFn); missing*.server.ts/server-onlyimport. - Runtime context guards or casts on middleware context; leaking
serverEnvto the browser (useshellSession). - Barrel / relative in-src imports — import defining files with
@/(routes/**/index.tsxstays). - Prompt chrome / UX invented here — ask Choose a UX and wait; load
promptable-uxoragentic-ux; keep declaredPROMPT_CONCEPT.
Core Contract
- Interfaces: Database, AI, observability (and other externals) sit behind interfaces; implementations are swappable.
- Schemas as the type source: Wire and tool shapes use a runtime validator (Zod, ArkType, Valibot, …) with schema-inferred types. Hand-written interfaces define behavior (
Repository,AIAdapterService, …), not ad-hoc JSON types. - Three schema layers + trust boundaries: Repository (DB-shaped), tools / server-fn (API-shaped, shared between
createServerFnandtoolDefinition), router search (URL-shaped). Translate between layers withSchema.parse(). Untrusted values become typed only at trust boundaries, using the chosen runtime validator (Schema.parse()or equivalent — not parallel parsers):- Repository implementations: documents/rows from a database and JSON from external APIs are parsed with the repository-layer schema before they enter the app.
- URL search params: TanStack Router
validateSearch(the search schema is the validator). - Other untrusted edges:
createServerFn.inputValidator, AItoolDefinitioninputSchema, env (companionobservability-and-env), and third-party widgets that type values as barestring(parse with the same schema). After a value has crossed a trust boundary, keep schema-inferred types through UI, handlers, and tools — do not widen back tostringand re-parse with a helper that duplicates the schema.
- TypeScript inside the typed flow: After trust boundaries, preserve inferred types end-to-end — prefer
satisfies, discriminated unions,as consttuples, and exhaustiveswitch(e.g.defaultbranch callingassertNever) overany, broadunknownplumbing, orascasts (only useasat documented third-party/library seams per AGENTS.md). TypeScript narrows already-typed unions; it is not a substitute forSchema.parse()on untrusted input. - Repository vs tools: Repository implementations use repository-layer schemas only. Server functions and AI tools share the same tools-layer schemas (
.inputValidator/toolDefinitioninputSchema). TanStack AI parses tool arguments withinputSchemabefore serverexecuteand before client.client()handlers, so those handlers receive the parsed value and pass it through. UI and AI consume tools-layer types only — never import repository schemas at those edges. - Server functions: GET queries throw on failure; POST mutations chain
.middleware([requireAuthMiddleware, invalidateMiddleware]); handlers return data or throwHttpError; callers normalize withprocessResponse/safeToolHandler/createSafeServerTool. - Routes: Thin route files (
createFileRoute,validateSearch,loaderDeps,loader,component); page UI insrc/components/. Loaders fetch via server functions — nouseEffectdata fetching for route data. - URL-as-state: Recover filters, sort, selection, bulk-action choices, and modal routes from the path and
validateSearch. Copying that state intouseStatewithuseEffectis a code smell. Keep UI-only fields (selected,bulkStatus) out ofloaderDeps. Page components take loader data, search, and callbacks as props; reset a form withkey, not an effect. Ephemeral chrome (mobile nav, side chat, drafts, spinners) stays local. - Router config bundle: ship a project-local
Linkwrapper withsearch: truedefault (use it for every internal link) and these router defaults together:defaultStaleTime,defaultPreload: 'intent',defaultPreloadStaleTime: 0,scrollRestoration: true,notFoundComponent. - Auth ticket built in middleware: auth middleware enriches
ctx.contextwith a repository-built ticket (this template:context.repository.getUserProfile(email)intobuildAccessTicket; apps may usegetUserAccesswhen roles live there) carrying identity, roles, and guards;Repositorymutations accept aTraceabilityContext(createdBy,lastModifiedBy, …) constructed from that ticket so writes are attributed consistently across UI and AI. - AI tool coverage: expose every repository method as a server AI tool via
createSafeServerTool; add distinct-values tools for enum-ish filters; exposenavigateandinvalidateRouteras client tools. - Promptable by default: root loader checks
getAIAvailability()and only mounts the prompt UI when configured (no disabled state). Where that UI sits is companionpromptable-ux(this template: hidden side panel). Chat input includes abrowserContext(timezone, locale, path) consumed bybuildSystemPromptalongside the auth ticket. - Bound the agent loop: every
chat()call setsagentLoopStrategy: maxIterations(N)explicitly (defaultN=10); tune after measuring — do not rely on the framework default. - Metadata for AI and UI: Attach human-readable descriptions to schema fields (reference — Zod
.describe()→ JSON Schemadescription; ArkType/Valibot have equivalents). Use structured schema extras only for non-description hints —unit,format, optionaltitle. Prefer deriving prompts and UI copy from schemas + JSON Schema export and router introspection over parallel hand-maintained maps. - Parent layouts: Shared
beforeLoad, redirects, and expensive reads belong on the parent layout route; children read parent loader data viagetRouteApi/useLoaderData({ from })— do not duplicate parent work. - Server execution boundaries: Route loaders are isomorphic — they run on the server during SSR and on the client during SPA navigations. Loaders only call exported
createServerFnfromserverFns.ts(e.g.getTasks({ data: deps })). DB access, secrets, and Node-only SDKs live in*.server.tsor behindcreateServerOnlyFn; extendtanstackStart({ importProtection })when adding node packages. - Startup-validated env + typed context + browser shell: Parse env once at startup into
webServerEnvandshellSession; inject vianext({ context }); chain middleware for inferred types; expose browser-safe config only throughgetBrowserShellSession/shellSession— neverserverEnvorwindow.__ENV__. Setup recipe: companion skillobservability-and-env.
Architecture Checklist
Scan before changing code:
- One tools-layer schema per wire shape:
createServerFn.inputValidator(Schema)and AItoolDefinition({ inputSchema })share the same schema — no duplicate hand-written wire types. - Parse both directions: tools → repository inputs and repository rows → tools/API outputs each end in the target layer’s
Schema.parse()(pure mapper functions are fine if the final step is always.parse()). - Trust boundaries use the validator: repository implementations
Schema.parseDB documents / API JSON; routes usevalidateSearch; untyped widget values use the same schema.parse()— noArray.find/ homemade parsers that duplicate enums. - No type erasure: after
Schema.parse/validateSearch, carry schema-inferred types through server functions, repos, tools, and components — do not widen back toRecord<string, unknown>/any. - Repository interfaces = repo-layer types only: mapping lives beside schemas / mappers — not in React components.
- Auth ticket is repository-backed and server-enforced: middleware builds the ticket from the injected repository (this template:
getUserProfile→buildAccessTicket; orgetUserAccesswhen that is the role source); guards run in server handlers, never UI-only. - Writes use
TraceabilityContext: pass audit fields from the ticket (or stockcontext.user.email) through a single context object onRepositorymutations — avoid sprinkling rawemailarguments. Repository implementations must persistcreatedBy/lastModifiedByfrom that context onto the entity. - Navigation is one decision: ship the router defaults bundle and the project
Linkwrapper (search: true) together so URL state survives navigation. - AI stack is complete: every repo method → server tool + safe handler; client
navigate/invalidateRouter; rootgetAIAvailability(); chat payload includesbrowserContext;chat({ agentLoopStrategy: maxIterations(N) }). - Routes:
validateSearch+loaderDeps; duplicatebeforeLoad/ shared loaders only on parent layouts; recover page state from URL path + search params and pass props to isolated components instead ofuseState/useEffect. - Metadata discipline: schema field descriptions for narrative copy; structured extras for units/formats; closed vocabularies =
as consttuple + schema enum + inferred type. - Server boundaries: loaders call
serverFnsonly — noprocess.envsecrets, DB drivers, or repo imports in route files;*.server.tsfor DB drivers / Node SDKs;createServerOnlyFnfor non-RPC infra;importProtectionupdated for new node packages. - Request context: middleware validates once; handlers chain middleware and read
context.*directly — no runtime context helpers, no context casts, no Register; browser sees onlygetBrowserShellSessionoutput (env bootstrap:observability-and-env). - Direct
@/imports: no re-exportindex.tsbarrels; modules undersrc/import the defining file with@/(@/*→./src/*).routes/**/index.tsxstays for file routing. Targets outsidesrc/(package.json, docs, instrument bootstrap) stay relative.
Server execution boundaries
TanStack route loaders are isomorphic — they run during SSR and on client-side navigations. Treat every route module as potentially shipping to the browser.
Forbidden in route files
- Top-level imports of
openMongoRepositoryScope, repositories, database drivers,fs, or other Node-only modules. process.envfor secrets insideloaderbodies.- Inline DB queries or repository calls inside
loader.
Required pattern
Define reads/writes in src/services/api/serverFns.ts. Route loaders only invoke them:
// src/routes/tasks/index.tsx — thin route
export const Route = createFileRoute('/tasks/')({
validateSearch: TasksSearchSchema,
loaderDeps: ({ search }) => search,
loader: ({ deps }) => getTasks({ data: deps }),
})// src/services/api/serverFns.ts — server-only handler body
export const getTasks = createServerFn({ method: 'GET' })
.middleware([repositoryMiddleware])
.inputValidator(TaskFilterSchema.optional())
.handler(async ({ data: filter, context }) => {
const repoFilter = filter ? TaskRepoFilterSchema.parse(filter) : undefined
return context.repository.getTasks(repoFilter)
})Module imports
Import the file that defines the symbol. Use the @/ alias (@/* → ./src/*) for every module under src/. Do not add index.ts files that only re-export. routes/**/index.tsx is the exception: TanStack Start file routing requires that name.
Targets outside src/ stay relative: package.json, docs/help.md?raw, and the instrument bootstrap (instrument.*.mts and the emitted .mjs).
File naming and tripwires
*.server.ts/*.server.tsx: DB clients, repositories with drivers, private API keys, Node-only SDKs (e.g.mongoClient.server.ts,getRepository.server.ts).- When rename is awkward: first line
import '@tanstack/react-start/server-only'.
createServerFn vs createServerOnlyFn
| Primitive | Use when |
createServerFn | Loaders, mutations, and AI tools need to trigger server work over RPC (GET / POST). |
createServerOnlyFn | Internal singletons (DB client factory) that must never be client-callable. |
import { createServerOnlyFn } from '@tanstack/react-start'
import type { Repository } from '@/services/repository/types'
export const readTasks = createServerOnlyFn(async (repository: Repository) => {
return repository.getTasks()
})Do not define new createServerFn inline in route files — keep RPC entry points centralized in serverFns.ts.
Import protection (Vite)
When adding node-only packages, extend vite.config.ts:
tanstackStart({
importProtection: {
behavior: 'error',
client: {
// Reference template uses mongodb + jose — replace with your packages
specifiers: ['<db-driver>', '<auth-crypto-lib>'],
files: ['**/services/db/**', '**/repository/*.server.ts'],
},
},
})Add your DB driver and auth/crypto libraries when they are not isolated in *.server.ts. Set ignoreImporters: ['**/*.test.ts'] if unit tests import server modules in jsdom. Verify with your build command. Docs: npx @tanstack/cli search-docs "import protection" --library start.
If the user asks for DB/secrets in a component or route config
- Stop — explain the isomorphic loader / client-bundle risk.
- Refactor — move logic to
serverFns.ts,*.server.ts, orcreateServerOnlyFn.
Rationalizations (reject these)
| Excuse | Reality |
| “Loader ran on SSR so it’s server-only” | Loaders re-run on client navigations. |
| “Dynamic import in the loader is enough” | Route module static imports still enter the client graph. |
“One-line process.env read won’t matter” | Isomorphic code can expose env reads to the client bundle. |
“useState + useEffect is simpler for this page filter/selection” | Local React state is lost on refresh, cannot be deep-linked or driven by AI navigate, and couples components to effects instead of pure props. |
URL-Recoverable State & Prop-Driven Components
Refresh, share, and AI navigate restore filters, sort, selection, bulkStatus, and modal routes (/tasks/new, /tasks/$taskId/edit) from the path or validateSearch. Mirroring that state with useState / useEffect is a code smell.
- Put recoverable fields in
validateSearch. Put only server-query fields inloaderDeps. - Page components receive data, search, and callbacks as props. Reset forms with
key={${id}-${updatedAt}}, not an effect. - Ephemeral chrome stays local: mobile nav open, side-chat open, drafts, spinners, resize, and scroll.
- Close the mobile nav from link clicks and when
pathnamechanges (back, forward, or client-toolnavigate).
Schema Boundaries
Route search schema → loader → tools schema → server fn → mapping → repo schema → repo
repo output → mapping → tools schema → AI or UI
Layer 1 — Repository (DB-shaped): define in src/services/schemas/repository.ts (target layout; today some apps still colocate in schemas.ts). No field descriptions required here. Infer types from your validator.
Layer 2 — Tools / server functions (API-shaped): one schema for .inputValidator(Schema) and toolDefinition({ inputSchema }). Server functions parse at .inputValidator. Server execute and client .client() handlers already receive the inputSchema parse, so pass those args through.
Reference implementation (Zod) — use ArkType or Valibot with TanStack adapters when preferred:
// Example: tools-layer object — .describe() for text; .meta() for non-description fields
const TaskInputSchema = z.object({
title: z.string().min(1).describe('Short title'),
status: TaskStatusSchema.default('pending').describe('Current status'),
estimateHours: z
.number()
.optional()
.describe('Estimated effort in hours')
.meta({ unit: 'h', format: 'decimal' }),
})Closed vocabularies (enums, tool categories, filter buckets): const VALUES = [...] as const, then a schema enum, attach descriptions for AI/UI, optional structured extras, infer the union type from the schema. Do not treat export const LABELS = { id: 'Display Name' } as const as the authority for the same strings unless it is derived from or validated by that schema.
const TOOL_CATEGORY_VALUES = ['Metadata & Navigation', 'Strategic Objectives'] as const
export const ToolCategorySchema = z
.enum(TOOL_CATEGORY_VALUES)
.describe(
'Used in toolDefinition metadata.category; groups tools and documents allowed values for the LLM.',
)
.meta({ title: 'Tool category' })
export type ToolCategory = z.infer<typeof ToolCategorySchema>Layer 3 — Router search (URL-shaped): local validateSearch schemas; fields are usually optional for partial URLs. TanStack Router runs this schema for you — that is the trust boundary for URL params.
const TasksSearchSchema = z.object({
status: z.enum(TASK_STATUSES).optional(),
priority: z.enum(TASK_PRIORITIES).optional(),
search: z.string().optional(),
})
export const Route = createFileRoute('/tasks/')({
validateSearch: TasksSearchSchema,
loaderDeps: ({ search }) => search,
loader: ({ deps }) => getTasks({ data: deps }),
})Trust boundaries: Untrusted data becomes typed only at the edges. Repository implementations parse DB rows / HTTP JSON with RepoLayerSchema.parse(...) (reference app: parseTaskRepo). Router search is already typed after validateSearch — page components receive inferred search types. Interior code (colors, labels, exhaustive switches) consumes those types; it does not re-implement the enum.
Widget libraries often type onChange as string | null. That is still an untrusted edge — parse with the same schema (OptionalStatusSchema.parse(value)), not STATUSES.find((s) => s === value).
Boundary mapping (mandatory): layer switches happen only in mapper functions; inbound tool payloads become repo inputs with RepoLayerSchema.parse(...), outbound repo documents become tools/API shapes with ToolsLayerSchema.parse(...).
function toRepoCreateInput(tool: z.infer<typeof TaskCreateToolSchema>): TaskRepoInput {
return TaskRepoInputSchema.parse({ title: tool.title, status: tool.status })
}
function toToolTask(row: TaskRepo): z.infer<typeof TaskToolSchema> {
return TaskToolSchema.parse({ id: row.id, title: row.title, status: row.status })
}TypeScript discipline (complements runtime validation): the validator (Schema.parse / validateSearch) checks at trust boundaries; TypeScript keeps the interior honest — exhaustive switch, satisfies, assertNever. Do not add a second parser (Array.find, ad-hoc guards) for the same closed vocabulary.
type TaskStatus = 'pending' | 'done'
const STATUS_LABEL = { pending: 'Pending', done: 'Done' } as const satisfies Record<TaskStatus, string>
function assertNever(x: never): never { throw new Error(`Unexpected ${String(x)}`) }Virtual / computed fields: If semantics cannot live in schema metadata, keep a small registry next to the derivation and expose explainField — do not duplicate fields already described by schemas.
Request Context
Context and philosophy — parse, don't validate
- Validate at the edge: use a runtime validator (Zod, ArkType, Valibot, …) in Start middleware when assembling request context — token/session parsing,
process.env/serverEnv, external headers, repository enrichment. - TypeScript inside handlers: once middleware calls
next({ context }), Start infersctx.contextfrom the middleware chain on that server fn / route. Chain the middleware that provides the fields you need (e.g..middleware([webEnvMiddleware])or.middleware([requireAuthMiddleware])). - Do not re-validate context in handlers: no shallow "is this field present?" guards on middleware output. Parse at true external boundaries only; let middleware chaining carry types downstream. No
Register/ module-augmentation for middleware context.
Field names are app-specific (accessTicket, identity, serverEnv, …). This stock template middleware attaches an accessTicket (JWT identity + repository profile, with roles and guards) plus startup-validated serverEnv and shellSession (see observability-and-env for how those are parsed and injected).
Env and browser shell
Invariant (Core Contract #17): parse env once → inject serverEnv + shellSession via middleware → project shellSession to the browser only via getBrowserShellSession in the root loader.
Do not duplicate the setup recipe here. File layout, env schemas, instrument.*.mts, logger factories, and webEnvMiddleware wiring are in companion skill observability-and-env.
Core rules for agents
- Direct context access — read
ctx.contextfields directly (e.g.context.accessTicket,context.serverEnv,context.shellSession). - No runtime context guards — never add or call wrappers such as
getShellAuthContext(ctx.context),getAccessTicket(ctx.context), oraccessTicketFrom(context). Middleware guarantees shape; missing fields are a middleware bug, not something handlers paper over. - No type bypasses — never cast context with
as unknown,as any, orcontext as SomeContext. Chain middleware (e.g.requireAuthMiddlewareafterauthMiddleware, orwebEnvMiddlewarefor env fields) socontextis inferred. - Maintain boundary validation — keep a runtime validator on external inputs: env, auth claims before enrichment, request bodies, third-party payloads, browser session serialization. Do not duplicate validation on context already built by trusted middleware.
- Env is a startup singleton — parse with the chosen schema library once; put the result on context; do not call
process.envor re-parse in handlers.
Enforce authorization in server handlers for every mutation and sensitive read. UI may hide controls; handlers are authoritative.
export const updateTask = createServerFn({ method: 'POST' })
.middleware([requireAuthMiddleware, invalidateMiddleware])
.handler(async ({ data, context }) => {
context.accessTicket.requireTaskEditor(data.taskId)
const repoPatch = TaskRepoPatchSchema.parse(mapToolUpdateToRepo(data))
return context.repository.updateTask(data.taskId, repoPatch, {
lastModifiedBy: context.accessTicket.identity.email,
})
})Stock template equivalent — same rules; requireAuthMiddleware chains auth so context.accessTicket is inferred. Build a TraceabilityContext (do not pass a bare email string as the mutation’s second argument):
.handler(async ({ data, context }) => {
const repoPatch = TaskRepoPatchSchema.parse(mapToolUpdateToRepo(data))
const trace = updateWriteTrace(context.accessTicket.identity.email)
return context.repository.updateTask(data.taskId, repoPatch, trace)
})Security boundaries
- Never leak
serverEnvto the browser — secrets and server-only config stay on the server; handlers must not return them from server functions or route loaders consumed by client bundles. - Browser-safe projections only — client code receives
shellSessionviagetBrowserShellSession+ root loader (public env fields +app). Do not hand-pick fields fromserverEnv.
Interface Contracts
Repository interfaces reference repository-layer types only. Repository mutations take an optional TraceabilityContext built from the auth ticket (stock template: helpers such as createWriteTrace / updateWriteTrace from context.accessTicket.identity.email) — not ad-hoc optional email parameters at each call site.
Collection-level ownership (indexes, queries, stale-data cleanup), boundary parsing across languages (runtime validators such as Zod or Pydantic, or a decode into the domain type in strongly typed languages), and resource lifetime (AsyncDisposable, Java AutoCloseable, Python context managers) live in companion skill repository-architecture. This section keeps the TanStack contract: tools-layer versus repository-layer types, and TraceabilityContext on writes.
Implementations must persist audit fields from the trace onto the entity (createdBy on create, lastModifiedBy on update). Ignoring the trace argument is a contract violation.
Auth for writes lives on POST server functions (requireAuthMiddleware), not on a second repository type.
interface TraceabilityContext { createdBy?: string; lastModifiedBy?: string }
interface Repository {
getTasks(filter?: TaskRepoFilter): Promise<TaskRepoOutput[]>
createTask(input: TaskRepoInput, trace?: TraceabilityContext): Promise<TaskRepoOutput>
updateTask(id: string, input: Partial<TaskRepoInput>, trace?: TraceabilityContext): Promise<TaskRepoOutput | null>
// … getTask / getDistinctValues / getUserProfile / getUserAccess / deleteTask
}Implementation Flow
- Schemas: repo + tools + search layers; repository I/O and mappers with
Schema.parse(); URL state viavalidateSearch. - Repository: interfaces in
types.ts; seed + production implementations. Collection owners, indexes, and client disposal: companionrepository-architecture. - Server functions:
serverFns.ts— GET queries, POST mutations with shared validators. - AI tools: each server function →
toolDefinition+createSafeServerTool; wire client tools in the prompt shell (AGENTS.md §8). Prompt placement: Choose a UX, thenpromptable-uxoragentic-ux. When it is not clear which user experience to implement, ask which of the three and wait. - Middleware:
start.ts— auth, invalidation, optional pre-auth308redirects for legacy paths. - Routes:
validateSearch,loaderDeps, loaders; parent layouts for sharedbeforeLoad/data. - Chat: adapter,
chat(),buildSystemPrompt, tool list — details in AGENTS.md §8. Follow Choose a UX before wiring chrome. When it is not clear which user experience to implement, ask which of the three and wait. Side and prompt-first loadpromptable-ux. Agentic loadsagentic-uxand skips domain screens.
Per-entity 3-UX checklist
Add Part A once for every UX; add Part B only when the deployment includes route screens (side / prompt-first). Details: references/tri-modal-ux.md.
- Part A — Core (all three modes): three-layer schemas + mappers → repository +
TraceabilityContextwrites →serverFns.ts→toolDefinition/createSafeServerTool+show*ViewToolin shareddataTools→ A2UI builders rendered byAgenticMessageItems(andPromptChatProvider’suseInlineDetailViewsfor side/prompt-first). - Part B — Route extension: thin
validateSearchroutes + prop-driven pages →UserFacingTo/buildSystemPromptlocation context → optional prompt-first landing summaries. Keep/<entity>for drill-down.
Special Patterns (use when the feature applies)
- Overlay repository: a
Repositorydecorator over a read-only upstream plus sparse user overrides; pureapplyOverrides; writes only to overrides. This is optional composition — not a Core Contract read/write factory split. - URL bulk edit: selection and category tabs in search params; batched mutation; per-row auth.
Debounced free-text search (URL-as-state): Keep filters in validated search params +
loaderDeps, but do not control free-text search from the URL on every keystroke. Use an uncontrolled text input (defaultValuefrom the current search param) and a debounced callback — your UI library's debounce hook,@tanstack/pacer, or a small local helper — tonavigate({ replace: true, search })only after the user pauses. Discrete filters (dropdowns, tabs, segmented controls) may navigate immediately. Callnavigatefrom the debounce callback — do not watch a debounced value inuseEffectjust to navigate. UI-agnostic: this skill does not prescribe a component library; follow the project's UI kit in AGENTS.md §3 for concrete input/select components.
const updateSearch = (updates: Partial<Search>, replace = false) => {
navigate({ to: '/tasks', replace, search: (prev) => ({ ...prev, ...updates }) })
}
const debouncedSearch = debounce((value: string) => {
const next = value || undefined
if (next === search.search) return
updateSearch({ search: next }, true)
}, 300)
- **Help surface:** single `docs/help.md` can back `/help`, an AI tool, and suggested prompts (see AGENTS.md).
- **Distinct values:** `getDistinctValues` → GET server fn → read-only AI tool so filters match real data.
- **Dynamic AI navigation:** derive route/help context from `router.flatRoutes` + `validateSearch` introspection where possible.
- **Prompt chrome and mobile first:** when it is not clear which user experience to implement, ask **Choose a UX** and wait, then load **`promptable-ux`** or **`agentic-ux`**. Do not choose the layout inside this skill. Existing deployments keep their declared `PROMPT_CONCEPT`.
## Agent Skills, TanStack CLI, and AI
- **Agent Skills (only standard):** this template’s skills are [agentskills.io](https://agentskills.io) folders — `.agents/skills/<name>/SKILL.md`. Edit those files directly. Install into other projects with `npx skills add carlosvin/tanstack-fullstack-ai-template` (all skills) or `--skill <id>`. List: `npx skills add carlosvin/tanstack-fullstack-ai-template --list`. Similar: `gh skill install carlosvin/tanstack-fullstack-ai-template`. Do not maintain a second skill format (canonical YAML, TanStack Intent package skills, or generated copies).
- **CLI (prefer current docs over memory):** `npx @tanstack/cli --help` → `libraries`, `search-docs "<query>" --library router|start|ai`, `doc <library> <path>`.
- **AI stack:** `@tanstack/ai`, `@tanstack/ai-react`, `/api/chat` — provider table, SSE wiring, system prompt sections, and chat endpoint anatomy are spelled out in **AGENTS.md §8**.
## Use the handbook (AGENTS.md)
| Need | Where |
|------|--------|
| UI kit / packages / lint-test tooling | §3, §10–§11, §15 + **`reference-tech-stack`** |
| Prompt concepts / agentic shell | **`promptable-ux`**, **`agentic-ux`**, §3, §8 |
| Auth, middleware, guards | §5 |
| AI adapters, chat, tools, prompts | §8 |
| Observability, env, `shellSession` | §9, §13 + **`observability-and-env`** |
| Repository ownership / indexes / lifetime | **`repository-architecture`** |
## Verification
**This template repo (skill authors):** edit `.agents/skills/<id>/SKILL.md`, then run `pnpm skills:check` (companions / install commands) and `pnpm skills:waza` (Agent Skills spec; required in CI).
**Apps built from the template:** follow **AGENTS.md** §15 — e.g. `pnpm format && pnpm lint && pnpm test && pnpm build`; smoke with dev server and `/api/health` when configuration allows.Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

