The authoring SDK
The installed editor type-checks TypeScript behaviours and describes a project's screens.
The installed editor type-checks TypeScript behaviours and describes a project's screens. It has no checkout, no system Node, no pnpm and no network, so everything both need ships with it, in one directory: the authoring SDK.
This is the contract between three things that are built and installed separately — the packaging script, the workspace service that uses an SDK, and the installer that puts one on a machine. The MSI (item 2 of the production-readiness plan) replaces the PowerShell installer, not this layout.
A shipped game needs none of this. The SDK is for authoring.
What is in one
<install>/sdk/
sdk.json the manifest: id, platform, versions, every file
node/node | node/node.exe the pinned private runtime
node/node.exe.zst + node.exe.sha256 ...or packed (node/node.zst on Linux), as a release ships it (below)
esbuild/esbuild | esbuild/esbuild.exe the transpiler, the same one a checkout builds with
esbuild/esbuild.exe.zst + esbuild.exe.sha256 ...or packed, as a release ships it (below)
checker/pnpm-workspace.yaml the root marker the harness's paths.ts walks up to
checker/packages/harness/src/typecheck.mjs the checker (imports the compiler from beside itself)
checker/packages/harness/src/typescript.cjs the TypeScript compiler, once: the checker and the install's bundled engine CLI (`cli/`, built with `bundle.mjs --sdk-beside`) both import this file
checker/packages/harness/src/typescript.cjs.zst ...in a release, the compiler packed, and typescript.cjs a loader for it (below)
checker/packages/harness/src/lib.*.d.ts TypeScript's standard-library declarations (the ES2020 closure only: no DOM, no web worker)
checker/packages/api/src/index.ts the @argus/api declaration closure
checker/packages/api/src/*.ts ...and every file that closure reaches
packages/ui/package.json @argus/ui: the exports map the aliases come from
packages/ui/src/**.ts, **.tsx, **.css ...its sources, bundled into every screen
packages/ui/theme/*.css, engine-tokens.json ...and the theme layer (docs/UI.md §10)
packages/gameplay/dist/entries.json @argus/gameplay's build: the built-in behaviours
packages/gameplay/dist/modules/*.js ...a scene names by module,
packages/gameplay/dist/lib/gameplay.js ...the library a behaviour's `import … from
"@argus/gameplay"` is bundled against,
packages/gameplay/dist/lib/types/**.d.ts ...and its declarations for the typecheck. Packing
refuses without them: build the package first
web-runtime/argus_web.js, argus_web_bg.wasm the browser runtime `engine export --target web` uses in an
web-runtime/build-report.json ...install, stamped with the engine version it was built at
(scripts/web/build.mjs); packed with --web-runtime, which
refuses another version's. A release's form; absent otherwise
fixtures/render/fonts/* the engine's default font, fallback chain and three starter
fonts to copy, with
their licences: what an export packs for a project
that ships no fonts (docs/EXPORT.md, "A font")
fixtures/recipes/make-your-own-sounds/tools/synth.mjs
the one script a recipe tells an agent to run (the
sound synthesiser): recipes are `argus://` resources
a sub-agent cannot read, so this one is a file
resources/** every `argus://` resource as a plain file (below)
NOTICES.md third-party notices
fixtures/ in an SDK is runtime content despite its name: the fonts, the building kit (fixtures/kits/fantasy-town) and the recipe script above, kept at their checkout paths so the engine finds them by one relative path in a checkout and in an install.
Resources as files
A sub-agent has no tool for reading an MCP resource, so everything the server serves as argus:// is also carried here, built from the same sources (scripts/sdk/resources.mjs, which scripts/sdk/pack.mjs calls). The path is one rule: argus://X is <sdk>/resources/X, with the extension its kind has.
argus://docs/<slug> resources/docs/<slug>.md the whole document (a #heading is a heading in it)
argus://recipes/<name> resources/recipes/<name>/ README.md first, then every file, whole
argus://recipes/<name>/<path> resources/recipes/<name>/<path>
argus://contract/scene|project resources/contract/scene|project.json
argus://ui/schema[/<section>] resources/ui/schema.json the whole table
argus://ui/components[/<name>] resources/ui/components.json
argus://examples/<id> resources/examples/<id>.ts
argus://rules|components|api resources/rules|components|api.json the pack's whole index (a piece is a key in it)
argus://project/... none: live state of one project; use the project tools
<sdk> is where an agent already finds the guides: docsDir in the notes.read result is <sdk>/docs, resourcesDir is <sdk>/resources, and a new project's AGENTS.md names the absolute <sdk> of the machine that created it. scripts/agent-pack/tests/resource-files.test.mjs holds every argus:// address the guides, the server and the templates mention to a carried file. The engine ships no starter assets except three small starter fonts (Inter Regular and SemiBold, JetBrains Mono Regular, OFL) in fixtures/render/fonts/, to copy into a project's ui/fonts/; the faces a project brings beyond them are its own (docs/UI.md, "Fonts and fallback").
The private runtime is not a Node installation: nothing is added to PATH, no other program can find it, and removing the SDK removes it.
checker/ is laid out exactly as a checkout is, because the checker finds the declarations the way it does at home: it walks up from its own file to pnpm-workspace.yaml and reads packages/api/src/index.ts from there.
Where the gameplay build is found
The engine (crates/app/src/modules.rs), the editor's in-process world (the same code) and the workspace service (crates/workspace-service/src/build/tools.rs) look for packages/gameplay/dist in one order, taking the first that has an entries.json:
ARGUS_GAMEPLAY, taken as given (and, for the service, a named toolchain root or the SDK it checks with);- the nearest
packages/gameplay/distat or above the scene or project: a checkout; $ARGUS_SDK/packages/gameplay/dist;<exe dir>/sdk/packages/gameplay/dist, then<exe dir>/../sdk/packages/gameplay/dist.
A project far from any checkout, with the editor and engine run from a build folder rather than beside an SDK, therefore needs only ARGUS_SDK. An editor started for its control endpoint (--control-port, as editor.start starts one) whose project will not open exits at once with the reason, rather than leaving its caller waiting on an endpoint that only an open project has.
@argus/ui, and why it sits beside checker/ rather than in it
A project's ui/screens/**.tsx import @argus/ui, and describing one is esbuild plus one evaluation (docs/EDITOR.md, "Describing a screen"): the screen is bundled against the package and the bundle is evaluated. That is a different tool from the checker — the engine's own esbuild, not the TypeScript compiler — so the package travels beside checker/, not inside the workspace the checker walks.
It is at packages/ui, the path it has in a checkout, because that is where argus_host::screen::find_ui_package already looked: under <exe dir> and <exe dir>/sdk, walking up, so both install layouts — bin/ and sdk/ as siblings, or an sdk/ folder beside the binaries — find it with no path either side had to invent. Keeping the checkout's path is also what keeps the package-relative style paths in src/components/registry.ts true in an install.
What ships is source, not a build: the package's exports map names ./src/index.ts and ./src/components/index.ts, and esbuild compiles the TypeScript on the way into the bundle. The exports map is the contract — ui_package_aliases turns each entry into one --alias flag, so @argus/ui/components resolves to the file the package says rather than to a guess at its layout — and the packer refuses to write a pack whose exports names a file it does not carry.
test/, fixtures/, strings/ and the dist/ a local tsc leaves do not ship. Nothing a screen can reach is in them, and a pack that swept up whatever was in the working tree would not have the same id twice.
Before this, an installed editor had to be told where the package was with ARGUS_UI_PACKAGE, and without it every screen became ui.screen.unknown at run time. It now needs no such variable; the variable remains for a layout discovery cannot see.
The declaration closure, and why it is a closure
packages/api/src/index.ts is not one file. It re-exports the generated component declarations beside it, and it may grow more neighbours. An SDK that carried only index.ts would produce, for every behaviour a person wrote:
build.typecheck | scenes/jump.ts:0:0 | (api).ts:1:49: TS2307: Cannot find module
'./components.generated.js' or its corresponding type declarations.
— a tooling failure, reported against the author's file, while their real mistakes went undiagnosed because the component types had collapsed to an error type. So the packaging follows the imports from index.ts and carries the whole closure, and the service's own checkout-mode discovery walks it by the same rules (crates/workspace-service/src/build/tools.rs, closure_of):
- relative specifiers only — a package specifier is not part of the API's own closure;
./x.jsresolves to./x.ts, which is whatmoduleResolution: "bundler"does with the specifier TypeScript emits;- nothing outside the checkout, and at most 1024 files.
A declaration that resolves to no file stops the packaging, and on a checkout it fails the build before any check runs, naming the file.
The checker itself is walked the same way. A checkout's fingerprint used to hash assemble.ts and paths.ts by name, which held only while those were the two files the checker imported; one added import would have left every cached answer valid against a checker that had changed. It now hashes whatever the checker reaches — five small files today — so the fingerprint keeps up by itself. A packaged SDK has no such question: the bundle is one file and its bytes are in the id.
Identity
sdkId is the SHA-256 of the SDK's own canonical file listing — every path with its digest and its length, sorted. Nothing else: no build time, no machine name, no version string of its own.
The listing carries every file's path, content hash and length, so the id is a digest over the bytes of the whole SDK — but only because the listing is required to be complete. On its own, a digest of a listing says nothing about a file the listing leaves out, and an emptied listing given a consistent id would have verified against nothing at all: the checker itself could have been replaced and every build would still have reported typecheck: "checked". So the service refuses an SDK holding a file its manifest does not name, and refuses a listing that names nothing for a role the editor cannot work without — the checker bundle, checker/pnpm-workspace.yaml, node, esbuild, at least one checker/packages/api/**.ts declaration, packages/ui/package.json and at least one packages/ui/src/**.ts source.
A packed esbuild. A release ships esbuild zstd-compressed (about 10.6 MB becomes about 3.8 MB of the Windows install): esbuild/esbuild.exe.zst and esbuild/esbuild.exe.sha256, the plain bytes' hash. The esbuild role is filled by the plain file or by both of those, named by the listing, so the id attests to what is run either way. It is unpacked on first use into tools/<sha256>/ under the user's cache (%LOCALAPPDATA%\Argus\cache\tools\, or $XDG_CACHE_HOME/argus/tools), checked against the recorded hash on every use, and the cache keeps the four most recently used copies (crates/export/src/engine_pack.rs, unpack_tool, the deploy engines' unpacker). argus.exe unpacks it at start-up and names it in ARGUS_ESBUILD for everything it starts; opening the SDK finds the same copy. The install folder itself is never written, so its files stay exactly the ones the engine manifest lists. First use costs about 0.15 s, later ones a hash of the copy.
A packed compiler. A release also ships the shared TypeScript compiler zstd-compressed (about 3.6 MB becomes about 0.8 MB): checker/packages/harness/src/typescript.cjs.zst, and in place of the compiler a typescript.cjs of a dozen lines that unpacks it in memory, with the runtime's own zstd (node 22.15 or later; the packer refuses an older one), and runs it as itself. The checker, the install's engine CLI and the typescript role load the same path either way, and nothing is written to disk. Both files are in the listing, so the id attests to the compiler. Unpacking costs 20-30 ms on Linux and nothing measurable on Windows; the warm checker pays it once per start.
A stripped runtime. A Linux release ships node/node without its symbol and debug tables (--strip-node, GNU strip --strip-all): about 120 MB becomes about 105 MB, and nothing node runs uses them. The upstream binary is checked against nodejs.org's SHASUMS256.txt when it is downloaded; what an install verifies is the listing's hash of the stripped bytes, so the packer strips twice and refuses a strip that does not give the same bytes both times, and refuses a stripped runtime that does not answer --version as the upstream one does or cannot write a diagnostic report. The bytes are the same for the same strip; another binutils version may write others, so NOTICES.md names the strip used, with the upstream binary's size and hash. Dynamic symbols, which native addons link against, are kept. A Windows node.exe is not stripped: it carries no symbols (the strip saves 15 KB) and is signed by its publisher, which rewriting it would undo; the packer refuses it.
A packed runtime. A release also ships the runtime zstd-compressed (--pack-node), the way it ships esbuild: node/node.zst (node/node.exe.zst on Windows) and a .sha256 of the plain bytes in place of the binary. Most processes never run Node, so it is not unpacked at start-up: the first tool that asks for it (the agent's critic, the editor's asset tools; installed_node) unpacks it into the user's tool cache, checks the copy against the .sha256, and keeps the answer for the rest of the process. The cache holds the 8 most recently used tools (TOOL_CACHE_ENTRIES). ARGUS_NODE names another runtime and skips all of this, and is the way out where the cache cannot hold a program that runs (a noexec home, a locked-down Windows). Measured on Ares's release round (16 Oct 2026, node v22.17.1): the Linux runtime packs to 28.6 MB, and the SDK is 40.95 MB; Windows node.exe goes from 85.19 MB to 24.25 MB, and the installed payload from 165.98 MB to 112.47 MB. On Linux (Triton's A/B for the 16-Oct merge), node goes from 108.51 MB to a 29.79 MB node.zst, 78.72 MB saved, and the packed install measured 122.50 MB. The installed-size baselines (payload-size-baseline*.txt) are the larger of the 0.7.0 baseline less that saving and the measured install: Windows 120,358,712 B, Linux 122,497,694 B. A fresh user's first critic.check (which unpacks it) took 0.73 s on Linux and 2.33 s on Windows, and the next one 0.45 s and 0.43 s.
No unkeyed digest inside a pack can defend that pack against whoever can rewrite it; this needs write access to the SDK directory or control of ARGUS_SDK. What the two rules buy is that such a rewrite has to replace the whole SDK rather than one file, and the id — which every build records as toolchain.apiSha256 — changes when it does.
That has three consequences worth stating:
- Two packing runs over the same inputs give the same id.
scripts/sdk/pack.mjswrites no timestamps and no absolute paths, and it runs esbuild in the checkout root with relative paths so the bundled checker's own bytes do not depend on where it was built. - One changed declaration is a different SDK. The id is the typecheck fingerprint, so a regenerated component field invalidates the checker image and every cached answer. Source that was accepted against the old declarations is checked again against the new ones.
- A manifest cannot borrow another SDK's identity. The service recomputes the id from the listing and refuses a manifest whose
sdkIdis not the digest of its own files. - A manifest cannot attest to less than the SDK.
sdk.jsonis the one file not in its own listing; every other file in the tree must be named by it, and the roles above must each be named, or the SDK is refused before anything is hashed.
How the service finds and trusts one
In order (crates/workspace-service/src/build/sdk.rs):
ARGUS_SDK, or--sdkonargus-workspace-service serve. An SDK named by hand is the one that was asked for: if it does not verify, that is the answer, not a reason to look elsewhere.ARGUS_TOOLCHAIN_ROOTor--toolchain-root, the developer override, which selects checkout-based tools instead. Same manifest and fingerprint rules apply to what it finds.<exe dir>/sdk, then<exe dir>/../sdk— the installed layout,bin/andsdk/as siblings.- A checkout above the project.
@argus/ui is looked for under the same two places (ARGUS_UI_PACKAGE first, then the project's own tsconfig.json paths, then node_modules/@argus/ui or packages/ui walking up from the project, this executable and the working directory, with sdk/ searched under the last two). One SDK therefore answers for both: the checker the service runs, and the package the describer bundles with.
A project's own files are never searched, and PATH is never searched.
Before it is used, the tree is walked to check that the manifest names all of it, the listing is checked for each role, and then every file the manifest names is hashed and compared. The walk is outside the stamp fast path below, because a file ADDED to the SDK changes no listed file's size or modification time and the stamp would not see it. A full pass over about 143 MB costs about a tenth of a second, and the verdict is stamped in the user's cache directory (%LOCALAPPDATA%\Argus\cache, or $XDG_CACHE_HOME/argus) against each file's size and modification time, so later opens skip the hashing. Nothing is ever written inside the SDK: it may be read-only, and on a shared machine it should be.
Five things are refused, each with its own repair, and never with "turn checking off":
| What | The message names | The repair |
|---|---|---|
| Nothing at the path | the path | install the SDK, or set ARGUS_SDK |
| A manifest that is not one, or an id that is not the digest of its listing | what is wrong with it | re-run the installer |
| A file in the tree the listing does not name, or a listing naming nothing for a role | up to eight unlisted paths, or the roles it names nothing for | re-run the installer |
| A file that is absent, truncated or altered, the runtime included | up to eight paths that disagree | re-run the installer |
An SDK for another Talos release, or another <os>-<arch> | both strings | install the SDK from this release, packed for this machine |
A build that cannot check does not reach Ready. It fails with build.toolchain-unavailable, and no line of the author's code is blamed for it. What was already Ready stays Ready: breaking the SDK under a running editor fails the next build and takes nothing away from what is running.
A project with no TypeScript in it records "typecheck": "not-needed". It is never reported as having passed a check it did not need, and its JavaScript still gets the sandbox, import and module-load checks.
Packing one
node scripts/sdk/pack.mjs --out dist/sdk
It takes the checker from packages/harness/src/assemble.ts, the compiler and its declarations from the checkout's typescript, the API closure from packages/api/src/index.ts, the authoring front end from packages/ui (its package.json, README.md, src/** and theme/**), esbuild from the version this workspace resolved, and — unless --node says otherwise — the Node runtime that is running the script. Then it writes the manifest and runs the offline self-test, which is both halves of what an installed editor does, with PATH emptied and the SDK's own binaries:
- the SDK's own node runs the SDK's own checker on one behaviour that must pass and one that must not. An SDK that answers "fine" to everything is worse than one that will not start;
- the SDK's own esbuild bundles a two-scenario
.tsxscreen against the packed@argus/ui— through the aliases itsexportsmap produces, importing a component from@argus/ui/components— and the SDK's own node evaluates the bundle and reads the description back. A pack short one source file fails here, rather than on the machine it was installed on.
The packing fails if any answer is wrong.
| Option | What it is for |
|---|---|
--node, --esbuild | the native binaries for the platform being packed; required when it is not this machine |
--platform | the <os>-<arch> the manifest records; defaults to this machine's |
--self-test-only | re-run the self-test against an SDK already packed |
--link | hard-link the two large binaries instead of copying them — tests and local runs only |
--pack-esbuild <argus-export> | ship esbuild packed (above), made by argus-export --pack-engine; the release does. --self-test-only then takes --esbuild naming the unpacked copy |
--pack-node <argus-export> | ship the runtime packed the same way (node/node[.exe].zst + .sha256, after --strip-node, so the hash is the stripped bytes'), unpacked into the user's tool cache the first time a tool runs Node (installed_node); the release does. --self-test-only then takes --node naming the unpacked copy |
--pack-typescript <argus-export> | ship the shared compiler packed (above), made by argus-export --pack-engine; the release does |
--strip-node <strip> | ship the runtime stripped (above) by this GNU strip; Linux only, the Linux release does |
One name for a platform
An SDK is built for one platform at a time, because the runtime and esbuild in it are native binaries. That platform is written, compared and printed as <os>-<arch> — windows-x64, linux-x64, macos-arm64 — by the packer, the manifest, the service and the installer alike.
The arch half uses Node's and esbuild's spelling (x64, arm64, ia32) rather than Rust's, because the packer is a Node script reading esbuild's per-platform packages. The service maps its own x86_64 onto x64, so both sides of the comparison are produced by one rule.
This is not decoration. The two sides used to disagree: the packer wrote the platform as an {os, arch} object and the service compared the operating system alone, so an SDK packed as windows-x64 was refused on Windows with "it was built for windows-x64; this is windows" and a real install ended with no SDK at all. --platform now refuses anything outside the nine tokens, naming them, before a byte is packed, and a manifest whose platform is not such a string is a manifest error rather than something to wave through. That is why the schema is 2.
The two binaries, and why they are never guessed
Packing for this machine takes the runtime the packer is already running on and the esbuild this workspace resolved. Packing for any other machine must name both, and the packer refuses rather than substitute:
node scripts/sdk/pack.mjs --out dist/sdk-win --platform windows-x64 \
--node C:\path\to\node.exe --esbuild path\to\@esbuild\win32-x64\esbuild.exe
Leave out --node and it stops: "packing for windows-x64 on linux-x64 needs --node with that platform's node binary: this script's own runtime is a linux-x64 one and would be copied into the pack." That sentence exists because the opposite happened. process.execPath is always a node, so nothing failed, and a Windows SDK was written holding a Linux node under the name node.
Naming a path is not enough either, because a file name proves nothing about a binary. Both files are read before anything is packed and refused unless the header matches the target:
| Target | Header | Architecture read from |
|---|---|---|
windows-* | MZ then PE\0\0 | IMAGE_FILE_HEADER.Machine |
linux-* | \x7fELF | e_machine |
macos-* | Mach-O, or a universal binary | cputype |
So a Linux node handed to a Windows pack is refused with "is not a PE (Windows) executable … It looks like ELF (Linux)", and an arm64 binary in an x64 pack is refused by architecture. Nothing is written when either check fails.
Getting a Windows runtime on another machine. Take it from an installed Node (node.exe from C:\Program Files\nodejs), or download the pinned release and check it against the published SHASUMS256.txt before pointing --node at it:
curl -fSLO https://nodejs.org/dist/v22.17.1/win-x64/node.exe
curl -fSL https://nodejs.org/dist/v22.17.1/SHASUMS256.txt | grep 'win-x64/node.exe' | sed 's|win-x64/||' | sha256sum -c -
The version that ends up in the pack is recorded in sdk.json either way, so what was shipped is never in question.
Cross-packing runs the self-test nowhere: it prints self-test NOT RUN and says to run --self-test-only on the target machine. It is never reported as passed.
Size, for a recent release: about 190 files, about 143 MiB on linux-x64 and about 104 MiB on windows-x64, nearly all of it the Node runtime. @argus/ui is a small part of it, the same on every platform. These figures move from release to release.
Checking a machine
argus-workspace-service sdk --check
argus-workspace-service sdk --sdk "C:\Users\me\AppData\Local\Argus\sdk" --check
It prints what this machine checks with — SDK or checkout, its identity, its versions, the declaration files it carries — and with --check it runs a good and a bad behaviour through the real checker and times them. It exits non-zero when nothing here can check, so an installer or a support script can read the exit code while a person reads the JSON.
{
"mode": "sdk",
"ok": true,
"sdk": { "id": "43aeebb0…", "versions": { "argus": "0.1.0", "typescript": "5.9.3" } },
"check": { "coldMs": 523, "warmMs": 534, "good": [], "bad": ["bad.ts:3:7: TS2322: …"] }
}
Installing one
scripts\windows\install-editor.ps1 -Exe target\release\argus-editor.exe -Sdk dist\sdk
The SDK is copied to <Install>\sdk.incoming, verified there with the service that was just installed, and only then swapped into <Install>\sdk. An upgrade that is interrupted, refused or unverifiable leaves the SDK that was already installed in place and usable; a swap that fails puts the old one back rather than leaving an install with no SDK.
Without -Sdk and with no SDK already installed, the installer warns, and the editor will not check TypeScript: a build containing TypeScript fails, naming the missing SDK. It does not run unchecked.
The check being off
The editor never turns the check off. It used to add --no-typecheck whenever it could not see a checkout, which on an installed editor was always.
The developer bypass is ARGUS_EDITOR_NO_TYPECHECK=1, which the editor passes on as --no-typecheck and announces in its log. A build made that way records "typecheck": "skipped" and a limitation saying how many modules were not checked. It is visibly unchecked output and cannot stand in for a checked build.
Every Ready build's build.json records what checked it:
"toolchain": {
"apiSha256": "43aeebb0…",
"sdk": { "root": "…/sdk", "id": "43aeebb0…", "versions": { … },
"apiFiles": ["checker/packages/api/src/index.ts"] }
}
so a diagnostic can be traced to the exact declarations it was made against.