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
esbuild/esbuild | esbuild/esbuild.exe the transpiler, the same one a checkout builds with
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/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
fixtures/render/fonts/* the engine's default font and fallback chain, 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
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 fonts or other starter assets: a project's own ui/fonts/ holds the faces it brings (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, api_closure):
- 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.
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 ~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 |
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 the current release: 191 files, 142.9 MiB on linux-x64 and about 104.2 MiB on windows-x64, nearly all of it the Node runtime. @argus/ui is 85 of those files and 324 KiB of them, the same on every platform.
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.