Agent guide for writing stx
A prescriptive guide for AI coding agents writing .stx files, produced by auditing a real
production app (bughq, stx 0.2.113) against the framework surface
it was failing to use.
The twelve chapters are posted as comments below. Each rule is written as RULE → WHY (with framework
file:line) → WRONG (real code) → RIGHT → CHECK (a runnable command).
Why this exists
bughq is ~6,500 lines of stx written largely by AI agents. It works. It is also, structurally, an app that never switched the framework on:
Pages carrying their own <!DOCTYPE> | 24 |
Internal plain <a href="/…"> | 276 (264 after the 12 deliberate data-no-router opt-outs) |
<StxLink> elements | 29, in 11 files — none in SiteNav.stx (35 anchors) or SiteFooter.stx (26) |
Files with an inline <style> block | 23 under views/partials/components (6 more are SVG assets, where it is legitimate) |
| Stores | 0 |
Uses of @stacksjs/components (45 primitives installed) | 0 |
useSeoMeta calls | 0 |
onMount / onDestroy in any .stx | 0 |
| Prohibited DOM calls | 68 across 10 files |
The root cause is one line of the framework:
// process.js:311
const hasDoctype = /<!DOCTYPE\s/i.test(output), hasSections = /@section\s*\(/.test(output)
if (!hasDoctype || hasSections) { // ← a doctype opts the page out of layouts entirelyEvery page that writes its own document silently disables layout resolution, which disables runtime
head config, which is why useHead/useSeoMeta cannot work on those pages, which is why the shell is
duplicated 24 times, which is why the app/marketing boundary exists, which is why
interceptAllLinks: true is load-bearing. One regex, the whole architecture downstream of it.
A companion RFC covering the framework-side changes is filed separately.
The rules
| # | Rule |
|---|---|
| 1 | Never write <!DOCTYPE>, <html>, <head> or <body> in a page or component |
| 2 | Never write vanilla DOM code |
| 3 | Never write vanilla CSS — Crosswind only |
| 4 | Use StxLink for every internal navigation |
| 5 | Use useHead / useSeoMeta — never hand-write <title> or meta in a page |
| 6 | Use @stacksjs/components before hand-rolling any UI primitive |
| 7 | Cross-page state goes in a store, not duplicated per page |
| 8 | Use directives, not JS that builds markup |
| 9 | Never hand-import an auto-imported symbol |
| 10 | Annotate every parameter, every return, and all server data |
| 10b | Shared types live in types/ as ambient declarations |
| 11 | At most two script blocks per file; client, setup and lang="ts" are redundant |
| 12 | Never let user data reach a script block unencoded |
Rule 10 needs saying out loud
A .stx script block is compiled as TypeScript whether or not you type it —
shouldTranspileTypeScript() (utils.js:28-33) returns true unless the block is tagged
lang="js", and server blocks go through new Bun.Transpiler({ loader: 'ts' })
(variable-extractor.js:218).
So there is no "JavaScript phase" of an stx codebase and no migration to schedule. There is only typed TypeScript and untyped TypeScript — and untyped TypeScript is strictly worse than JavaScript, because it pays the cost of a compiler and delivers none of the guarantee. Annotate as you write, not in a later pass.
The audited app had 12 annotations across 123 functions, leaving ~58 of 70 parameters
implicitly any. It was described as TypeScript throughout.
Know what annotations are worth right now: nothing verifies them. tsc --noEmit cannot see
inside .stx files. Verified by putting const x: number = 'a string' in a client block — tsc
silent, page renders 200, linter silent. Three gates, none caught it. That is an argument for
fixing the toolchain, not for skipping types, but it does mean a wrong annotation is a real
defect rather than something a compiler catches. The framework's own useLocalStorage declares
: void while returning a ref (#1795) — that is what unchecked types decay into.
Two auto-import consequences that will bite:
- Only
import typeis ever acceptable, and only for types. Auto-imported symbols have ambient declarations indist/stx.d.ts. - Never write the generic form at a client call site. Detection matches
\b<symbol>\s*\((client-script.js:315), souseLocalStorage<string>(…)puts a<where the(must be — the symbol is never detected and you get aReferenceError. Annotate the destination instead.
Verification
| Check | Result |
|---|---|
file:line citations resolved and in range | 1,843 / 1,850 (99.6%) |
| Citations to a non-existent file | 1 |
| Citations overshooting EOF | 4, by 1–7 lines |
| Findings behind the guide | 60 raised → 33 confirmed, 27 refuted under adversarial review |
Errata: several chapters say @extends/@section/@yield are used zero times. Precisely: zero
times in pages. The two layout files do define slots (layouts/default.stx:39,42,44,
layouts/marketing.stx:6,7,11,12,13) that nothing fills.
Chapters
- Project topology and configuration
- Page anatomy — never write a document
- Layouts and the application shell
- Head, SEO and structured data
- Server blocks and the server-to-client bridge
- State: signals, stores and composables
- Templates: directives and bindings
- No vanilla JS — the strict-mode mandate
- Navigation and routing
- Components
- Styling with Crosswind, and TypeScript
- Verification and enforcement
Correction (post-filing)
Three figures in the table above were wrong when this issue was first published, and have been fixed:
| Figure | Published | Correct | Cause |
|---|---|---|---|
StxLink uses | 57 | 29 | counted <StxLink and </StxLink as separate uses |
| Internal anchors | 299 | 276 | grep 'href="/' also matched 23 <link rel=stylesheet href="/…"> |
Files with <style> | 29 | 23 | 6 of the 29 are SVGs under resources/assets/, where <style> is normal |
Commands the corrected numbers come from:
grep -rho '<StxLink' resources/ | wc -l # 29
grep -rhoE '<a [^>]*href="/' resources/ | wc -l # 276
grep -rl '<style>' resources/views resources/partials resources/components | wc -l # 23The conclusions are unchanged — if anything StxLink adoption is half what was reported. The chapter
comments below were independently checked: 1,843 of 1,850 file:line citations resolve and are in
range (99.6%).