ursprung is German for origin. It is also a full-stack TypeScript application framework written from scratch — its own bundler, its own JSX runtime, its own type stripping — targeting Cloudflare Workers, with AI agents as its first-class users.
It is not finished, but it runs. ursprung compiles both applications in this repository end to end — its own parser, printer, module graph and emitter, and no bundler afterwards — and the published package carries the authoring surface they are written against. The demo is interactive and reactive: a handler runs in the browser without being on the wire, and a signal updates one position of the document in place. v0 is being built one slice at a time, in the open. The dev log is the readable version of that, and the blog is the long-form one.
npm workspace monorepo, three members:
packages/ursprung— theursprungpackage, published to npm.apps/demo— the canonical demo app, the application v0 is proven by and the one each slice is built against. Deployed to demo.ursprung.dev. It is a lab bench: every capability the walking skeleton can prove is mounted on it and labelled.apps/web— ursprung.dev itself: the site, the about and glossary pages, the dev log and the blog. Not published, and built by ursprung as of 2026-08-10 — five Routes, one Layout, Server components, no hand-written Worker.
The two applications stay separate on purpose. A slice needs an application it can
break, and apps/web deploys to production on every push to main; two
applications also mean the framework gets used by something that did not grow up
beside it.
npm install
npm test # Vitest, every workspace
npm run typecheck # tsc --noEmit at the root, then in each workspace
npm run lint # oxlint
npm run fmt # oxfmt, rewrites in place
npm run dev --workspace ursprung-web # the site in `wrangler dev` on :8787
npm run dev --workspace ursprung-demo # the demo app in `wrangler dev`The root scripts act on the whole monorepo. Serving or deploying one app is that
app's own script, reached with npm run <script> --workspace <package-name> or by
running it from the app's directory.
npm run build compiles packages/ursprung into dist/, which is what gets
published — JavaScript and declarations, no TypeScript. npm install runs it for
you, so a fresh clone needs nothing extra; run it again after editing anything under
packages/ursprung/src, because both applications build themselves by invoking the
compiled bin and would otherwise test the previous one. The other compile step is
Tailwind, which runs as the last step of each application's own build — and Wrangler
runs that build itself, before serving or deploying, so there is nothing to invoke by
hand.
Node is the only runtime you need, and both workflows pin it to 24. npm
installs and runs the scripts, Node executes them, and Vitest runs the tests.
Every dependency is pinned to an exact version too — see
## Versions are pinned.
The framework is being specified before it is written, and the record of that is in the repo:
CONTEXT.md— the glossary. The project's ubiquitous language; these terms are used verbatim in issues, commits and code. Published, and mirrored by hand, at ursprung.dev/glossary.docs/adr/— the decisions that are hard to reverse, one file each, with the reasoning that is not obvious from the code.apps/web/public/posts/— the blog, in source form. Each post is served as the markdown file itself..scratch/ursprung-v0/— the working surface: an open map of undecided questions, one ticket per question, plus the research and prototypes they produced. Notes for agents, not documentation; expect them to be half-finished.CLAUDE.md— how this repository is put together and why, aimed at whoever (or whatever) is about to change it. The most useful file here if you are reading the code.
Every push builds through
Workers Builds — two
projects, one per application, ursprung-web and ursprung-demo — and every
build produces a
preview URL:
- a per-commit URL,
<version-prefix>-<worker>.<subdomain>.workers.dev, for the exact version that was built; - a per-branch URL,
<branch>-<worker>.<subdomain>.workers.dev, that always points at the head of that branch.
Pushes to main run wrangler deploy, which ships to ursprung.dev and
demo.ursprung.dev and still mints a per-commit URL. Pushes to any other branch
run wrangler versions upload, which uploads a version without shifting
production traffic; Cloudflare posts both URLs as a comment on the pull request.
Preview URLs are public. Pull requests from forks are not built, so they get no preview URL until the branch is pushed to this repository.
Publishing is driven by changesets — nothing is published from a laptop, and no version number is typed by hand.
- Describe the change when you make it.
npm run changesetasks for a bump type and a summary, and writes a markdown file under.changeset/. Commit it alongside the change. A change that does not touch the published package needs no changeset;npm run changeset:statussays what is pending. - Merge to
main..github/workflows/publish.ymlcollects every pending changeset into a Version Packages pull request that bumpspackages/ursprung/package.json, writesCHANGELOG.mdand deletes the changeset files. It reopens and rewrites that pull request as more changesets land, so it is always the full set of what the next version would contain. - Merge the Version Packages pull request. That push is the release: the same workflow runs format/lint/typecheck/test, packs the tarball, then publishes it, tags the commit and creates the GitHub Release from the changelog entry.
So a GitHub Release is now an output of publishing rather than the trigger for it, and the tag is created by the tooling rather than typed. The check that the tag matched the manifest went with it — there is nothing left to mistype.
Tags are named ursprung@<version> from now on, where releases up to v0.0.7
were tagged v<version> by hand. Changesets derives that from the workspace — a
repository holding one package with no workspaces gets v<version>, and anything
else gets <name>@<version> — so it is not configurable, and the tag list has a
visible break at the changeover.
Every pull request also gets a comment saying which packages its changesets would
release, or that it has none, from
.github/workflows/changeset-status.yml. Pull requests from forks do not get one:
that would need pull_request_target, which changesets' documentation warns
against, and this repository's own pull requests all come from branches pushed
here.
Packing and publishing are separate jobs, which is changesets' recommended
shape for trusted publishing. Everything that runs repository code — the install,
both prepare compiles — happens in the job holding no credential, and the job
holding id-token: write receives only the finished tarball as an artifact.
Authentication is npm trusted
publishing over GitHub's OIDC — the
job requests id-token: write and exchanges that token for a short-lived
credential, so there is no NPM_TOKEN secret in this repository. The same
token signs the
provenance attestation
npm shows next to each version.
Three things are load-bearing for that to keep working, all configured on npmjs.com under the package's Settings → Trusted publisher:
- the repository (
bndkt/ursprung), - the workflow file name (
publish.yml), - the environment (
npm), matchingenvironment:in the job.
Renaming or moving the workflow breaks publishing, which is why the changesets
move kept the file called publish.yml and kept the npm environment on the job
that publishes — none of the three changed, so the trusted publisher needed no
edit. The environment sits on that job alone: it is skipped on every push that is
not a release, and a job skipped by if never reaches its environment's
protection rules, so ordinary pushes to main do not queue for approval.
repository.directory in the package manifest must also keep pointing at
packages/ursprung, since provenance verification checks the manifest's
repository against where the workflow ran.
One repository setting is load-bearing too: Settings → Actions → General → Allow GitHub Actions to create and approve pull requests must be on, or the
job that opens the Version Packages pull request fails.