kitesurf
AGENTS.md1.5k starsChanged 3 days agoApache-2.0View on GitHub
Guidance for contributors and coding agents. It records the constraints that are not obvious from the code. Keep it short: state protocols and invariants as comments at the code that owns them, point at code instead of restating it, and fix or delete anything here that becomes wrong. Kitesurf is a browser engine that runs on Cloudflare Workers. Each CDP WebSocket session runs in one engine Worker. The engine parses HTML, runs page JS in per-page sub-isolates and renders frames…
- Deletes or force-pushes
- Installs packages
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
The file
We split this AGENTS.md into the rules it gives an agent
11 rules. About 0 are common, 5 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 87
Do not undo these.
- Distinctive: line 90
Every outbound request needs a deadline, and an ignored response body must be cancelled.
- Very specific: line 142
Use an explicit KITESURF_PORT or --port when other engines may be running, and stop only the wrangler and workerd processes you started.
- Distinctive: line 163
Read git log --oneline first.
- Distinctive: line 165
Keep commits atomic: one reason per commit.
- Very specific: line 167
Do not commit plans, findings, inventories or scratch reproductions.
- Distinctive: line 173
Put durable engine behavior (layout, geometry, parsing, traversal, other stateful work) in Rust.
- Very specific: line 185
Every tracked TypeScript file must be covered by a typechecked tsconfig, and every tsconfig must set types (scripts/check-ts-coverage.mjs).
- Very specific: line 192
Never run a full suite unless asked.
- Very specific: line 196
Do not put -- before test arguments.
- Very specific: line 197
Prefer a hermetic e2e test (packages/engine/test/e2e/.e2e.test.ts) over a script or demo.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md 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
AGENTS.md
Guidance for contributors and coding agents. It records the constraints that are not obvious from the code. Keep it short: state protocols and invariants as comments at the code that owns them, point at code instead of restating it, and fix or delete anything here that becomes wrong.
What this is
Kitesurf is a browser engine that runs on Cloudflare Workers. Each CDP WebSocket session runs in one engine Worker. The engine parses HTML, runs page JS in per-page sub-isolates and renders frames with Blitz (Stylo, Taffy, Parley and vello_cpu). The Rust code compiles to a few megabytes of compressed WebAssembly behind a small TypeScript layer.
Kitesurf is a proof of concept and many parts are incomplete. Read the relevant code before assuming standards compliance.
Repository map
A pnpm monorepo. One line per package; read the code for details.
packages/engine/: the engine Worker. CDP over WebSocket, PageScript orchestration and the wasm.src/index.tsroutes,src/cdp/holds CDP methods,src/eval/the sub-isolate and subresource machinery, andsrc/tty/the terminal UA styles.packages/page-script/: the per-page runtime, bundled to a string and loaded throughenv.LOADER. Owns navigation, script execution and CDP DOM/eval routing.state.tsis the shared mutable state and holdsksGlobal, the package's typed view ofglobalThis(PageScriptGlobals, which extends dom-shim'sDomShimGlobals). Declare a new page global there; computed keys go throughksGlobalByKey. Engine hooks go on the internals record instead (KitesurfInternalsinpage-script/src/internal-state.ts).packages/dom-shim/: the in-sandbox DOM (buildDocumentand the Element, Node, Event and CSSOM classes), bundled to a string.packages/core/: Rust to wasm on Blitz.DomHandle, paint and screenshots, PDF, boa eval, the network provider and thetty_*terminal renderer.pkg/is staged bypnpm run build. The Rust crates form a Cargo workspace rooted at the repository'sCargo.toml.packages/renderer-core/: the renderer isolate's smaller wasm, theSceneRasterizerslice of core included with#[path].packages/page-renderer/: the stateless raster worker. It pulls scenes from PageScript over the render port and owns the wasm resource cache.packages/worker-script/: the loaded worker that runs a page'snew Worker().packages/script-gate/: the shared script-execution policy (SRI, CSPscript-src, JS MIME andnosniff, mixed content, the iframe sandbox capability set), so the engine and PageScript bundle one copy.packages/jsrpc-types/: type-only JSRPC contracts between the engine and its loaded workers. Both ends of a call import from here; do not redeclare a wire shape on one side.packages/scene-host-wasm/: the CLI's local scene rasterizer, staged intopackages/engine/public/rbi/. The scene protocol is documented inpackages/engine/src/rbi.ts.packages/kitesurf-cli/: the complete Rust terminal client, including Browser Run and OAuth through public APIs, with its own Cargo workspace and lockfile.packages/engine/test/fixtures-worker/: the fixtures Worker that serves/__fixtures/*in dev and tests only.scripts/: development and CI tooling, including the WPT runners, the lint rules and the vendored crate patch series. Each tool has a Bazel target inscripts/BUILD.bazel.tests/: the WPT allow-list, expectations and pinned revision. Bump the revision on purpose withpnpm wpt:setup --update; an unpinned corpus shows upstream changes as regressions.
Architecture
Engine-wide invariants. The owning code carries the detail; keep these in sync with it.
- Runtime code generation (
Runtime.evaluate,eval,new Function, computedimport()) runs on boa inside the per-target PageScript isolate, in dev and production, never in the parent Worker. The loaded worker never gets a native-eval compatibility flag. - The CDP WebSocket bounds the session. Per-connection state lives in
handleWebSocket's closure; no Durable Object holds it and nothing persists across connections. - Each
Page.navigateloads a fresh anonymous PageScript worker withenv.LOADER.loadand kills the previous one. Useload(), never a namedLOADER.get. - Wasm panics unwind (
packages/core/.cargo/config.toml, symlinked intorenderer-core), so the live DOM survives a panic.scene-host-wasmstaysabort. - The DevTools frontend is optional:
--config=devtoolsstages Chrome's prebuilt one at/inspector/, deploys always include it, andwrangler devfalls back to Chrome's hosted one without it (resolveInspectorBaseUrl, README). Always senddevtoolsFrontendUrl, or Chrome's inspect link does nothing. /devtools/page/<id>selects page mode, which never announces the page as a child target; Puppeteer must use/devtools/browser.- CDP types come from
devtools-protocol. DeclareKitesurf.*methods and extra fields incdp/protocol-extensions.ts, and bump the rootpnpm.overridespin with the engine's version. - A new target has a live
about:blankdocument:ensurePageScript(cdp/dom.ts) starts a lazy isolate, because many clients evaluate without navigating. - Layout is incremental. Work done at box construction needs explicit
CONSTRUCT_BOXdamage. - Partial frames are composited in the renderer isolate (
SceneRasterizer), so CDP clients never see one. The terminal scene stream is the exception; seepackages/engine/src/rbi.ts. history.pushStatestays in the document unless the navigate URL opts in with?ks_refetch_on_pushstate=1.
Platform quirks with in-tree mitigations
Do not undo these.
- The sandbox ships a pure-JS
MessagePort; page JS must never reach the native one. - Every outbound request needs a deadline, and an ignored response body must be cancelled.
We do not shim single-subtest differences from the spec in the platform; we pin the test and report
upstream. Reproduce by running the snippet under node and under
wrangler dev --compatibility-date=<date>.
Editing the in-isolate code
The loaded workers (dom-shim, page-script, page-renderer, worker-script) are TypeScript
bundled to strings that the engine passes to env.LOADER.load. All of them typecheck under
pnpm run check; do not add @ts-nocheck. Several page-script import specifiers
(cloudflare:*, node:*, ./wasm-glue.js, ./core_bg.wasm, ./dom-shim.js) must survive
bundling unchanged: they are external and resolved by the loaded worker's module map.
Changes under those packages, and under packages/core/, need a restart of pnpm run dev, which
rebuilds them with Bazel; packages/engine/src hot-reloads.
console.log in the page isolate surfaces as CDP Runtime.consoleAPICalled.
Building
Bazel builds and tests everything; the root package.json scripts are front ends to it. Add a
build step or check as a Bazel target, not as a pnpm script or a script that CI calls directly.
pnpm run build (bazel run //packages/engine:dev_build) copies the wasm, the loaded-worker
bundles and the RBI files into the source tree, where wrangler dev and tools outside Bazel read
them. The exceptions run cargo through rustup: the vendor patch workflow and the
memprof build (pnpm run dev:memprof).
Vendored crate patches
Bazel applies scripts/patches/<crate>-<version>/ to the registry source itself, so it builds only
committed patch files. Cargo builds patched crates (blitz, parley, taffy, stylo, selectors, boa and others) through the
root Cargo.toml's [patch.crates-io], pointing at packages/core/target/vendor-patched/.
pnpm run vendor:apply materializes each crate as a git repository: the registry source tagged
vendor-base, then the series from scripts/patches/<crate>-<version>/ applied with git am.
To change a patch, iterate with cargo check from packages/core/, commit inside the vendor
tree, then run pnpm run vendor:regen -- <crate>; Bazel sees the change only after the regen.
Uncommitted or unregenerated work is lost when the tree is rebuilt, which any patch-file change or
rm -rf packages/core/target causes. Full workflow: scripts/patches/README.md.
Running
pnpm install # first time
pnpm run dev # Bazel build, then wrangler dev on :8787
pnpm run check # bazel test //:check, then the tracked-file checks
pnpm run test:e2e <file-substring>
pnpm run test:wpt --filter <substring>
pnpm run test:playwright --filter <substring>
pnpm run test:puppeteer --filter <substring> # after pnpm run pptr:setupUse an explicit KITESURF_PORT or --port when other engines may be running, and stop only the
wrangler and workerd processes you started.
- Deploying:
pnpm run deploy [wrangler deploy args]builds with--config=devtoolsand uploads:distand the assets unbundled (scripts/engine-deploy.mjs) to wrangler's account.pnpm run deploy:previewdoes the same withwrangler versions upload, which moves no traffic.packages/engine/wrangler.jsoncstays a template with no account or routes. Agents never deploy. - Tracing: send a
traceparentheader (or a bare trace id in it) withpnpm dev:tracingrunning. - WPT:
test:wptstarts, health-checks and stops its own engines every--batch-sizetests. It refuses to start if a port in its range is taken.-j Nruns N engines and uses portsporttoport+2N-1. It fails on any deviation fromtests/wpt-expectations.json, including unexpected passes; rebaseline with--update-expectationsin a separate commit. Tests with variants are enrolled once per variant. - testdriver:
test_driver.click,send_keysandActions().send()dispatch real CDP input. Other actions keep upstream's "not implemented" error, andin_automationstaysfalse.
Conventions
Branches and commits
- Branch from an up-to-date
origin/mainbefore the first edit; never commit onmain. - Read
git log --onelinefirst. Use a one-line imperative summary of about 70 characters, and a body that explains why. - Keep commits atomic: one reason per commit. Squash work-in-progress commits before review.
- Sign off every commit (
git commit -s); see CONTRIBUTING.md. - Do not commit plans, findings, inventories or scratch reproductions.
PLAN.md,TODO.mdandFINDINGS.mdare gitignored for that purpose. Keep durable findings as a code comment. - Agents do not push; leave that to the person they work for.
Code style
- Put durable engine behavior (layout, geometry, parsing, traversal, other stateful work) in Rust. Keep TypeScript to API wiring, lifecycle and callback delivery, unless the logic owns page-realm semantics or Rust cannot observe the state it needs.
- TypeScript shim classes use explicit getters and setters, with closure-scoped helpers for shared state.
- In Rust, pair
borrow()andborrow_mut()with an explicitdrop()before returning, and validate node ids before passing them to Blitz. - Comment blocks have at most four lines of at most 100 columns and no blank interior lines
(
scripts/lint/comment-policy.mjslists the exemptions). - Hand-written type declarations go in
.tsfiles, never.d.ts:skipLibCheckwould skip them. A declaration for a module supplied at runtime (./wasm-glue.js,./__ks_*.js) usesexport declareand the@ks-declaration-twinmarker, which fails the build if it is bundled. - Every tracked TypeScript file must be covered by a typechecked tsconfig, and every tsconfig must
set
types(scripts/check-ts-coverage.mjs). - In bundled packages, write a function inside a block as
const f = function …. esbuild hoists block-level function declarations out of their block underalwaysStrict.
Tests
- Never run a full suite unless asked; the runners are slow and memory-hungry. CI runs the
Playwright lane, but not the e2e or WPT lanes. Before reporting work complete, run
pnpm run checkand the smallest relevant e2e subset. Uncaught workerd errors fail a run even if assertions pass. - Do not put
--before test arguments; pnpm forwards it and the runners misread it. - Prefer a hermetic e2e test (
packages/engine/test/e2e/**.e2e.test.ts) over a script or demo. They boot the engine in-process with@cloudflare/vitest-pluginand drive it over CDP. Give each test its own markup withsetContent(test/e2e/set-content.ts); add a/__fixtures/*route only when the page needs a real origin. - For a WPT failure: reproduce the smallest failing unit in a scratch script, fix it, rerun the
affected subtree with
--filter, rebaseline in a separate commit, then add a hermetic e2e test.
Out of scope unless asked
- Running iframe JS in the parent isolate. Iframes render, and script-free same-origin
contentDocumentsupports a partial mutation subset. - Service and shared workers. Dedicated workers work, except module workers and
importScripts. - A live SVG DOM. Inline
<svg>is painted from a usvg snapshot that DOM mutations refresh; SVG-specific interfaces areElementaliases. - Synchronous XHR. workerd has no way to block the JS thread on network I/O.
- A fake
crypto.subtle; Workers provide the real one. - WebGL, WebGPU, WebAudio, WebRTC, MediaStream and anti-bot fingerprinting.
- Persistent storage across navigations or connections, and WebDriver; Kitesurf speaks CDP.
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.

