Talos docs

The authoring SDK

Since 0.6

The installed editor type-checks TypeScript behaviours and describes a project's screens.

Plain text

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:

  1. ARGUS_GAMEPLAY, taken as given (and, for the service, a named toolchain root or the SDK it checks with);
  2. the nearest packages/gameplay/dist at or above the scene or project: a checkout;
  3. $ARGUS_SDK/packages/gameplay/dist;
  4. <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.js resolves to ./x.ts, which is what moduleResolution: "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.mjs writes 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 sdkId is not the digest of its own files.
  • A manifest cannot attest to less than the SDK. sdk.json is 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):

  1. ARGUS_SDK, or --sdk on argus-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.
  2. ARGUS_TOOLCHAIN_ROOT or --toolchain-root, the developer override, which selects checkout-based tools instead. Same manifest and fingerprint rules apply to what it finds.
  3. <exe dir>/sdk, then <exe dir>/../sdk — the installed layout, bin/ and sdk/ as siblings.
  4. 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":

WhatThe message namesThe repair
Nothing at the paththe pathinstall the SDK, or set ARGUS_SDK
A manifest that is not one, or an id that is not the digest of its listingwhat is wrong with itre-run the installer
A file in the tree the listing does not name, or a listing naming nothing for a roleup to eight unlisted paths, or the roles it names nothing forre-run the installer
A file that is absent, truncated or altered, the runtime includedup to eight paths that disagreere-run the installer
An SDK for another Talos release, or another <os>-<arch>both stringsinstall 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 .tsx screen against the packed @argus/ui — through the aliases its exports map 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.

OptionWhat it is for
--node, --esbuildthe native binaries for the platform being packed; required when it is not this machine
--platformthe <os>-<arch> the manifest records; defaults to this machine's
--self-test-onlyre-run the self-test against an SDK already packed
--linkhard-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:

TargetHeaderArchitecture read from
windows-*MZ then PE\0\0IMAGE_FILE_HEADER.Machine
linux-*\x7fELFe_machine
macos-*Mach-O, or a universal binarycputype

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.