This guide walks through running the dynamic WinRT projection (@microsoft/dynwinrt) from a plain Node.js process — no Electron, no MSIX packaging step — while still getting the Windows features that require package identity (native notifications, Phi Silica, Store-only APIs).
The WinApp CLI handles all the packaging plumbing for you. This guide covers the flavors of the dev-mode flow, from the simplest one-shot command to a persistent alias you can invoke from any terminal.
- When you need this
- Prerequisites
- Path A — execution alias (recommended for iteration)
- Path B — one-shot
winapp run(recommended for scripts and CI) - Path C — bare
winapp runwithout a local Node link - Why link
node.exeinto the project? - Cleaning up:
winapp unregister - From dev mode to distribution
- Troubleshooting
- You want to call a WinRT API from Node.js on your own machine, without shipping an MSIX
- Some Windows features you want to use require package identity (notifications, Phi Silica, share target, background tasks, most Store-integrated APIs)
- Running
node app.jsfrom a normal terminal fails with errors likeE_APPMODEL_ERROR_NO_PACKAGEorhasPackageIdentity() === false
Running node.exe from a normal terminal has no package identity by default. Windows attaches package identity only to processes that were launched through a registered package. This guide sets up a small loose-layout package around your Node.js script so Windows treats it as a first-class app during development.
- Windows 11 with Developer Mode enabled (Settings → System → For developers)
- Node.js 20+ on your
PATH - A fresh empty folder for the experiment
Install the CLI and initialize the project:
mkdir my-winrt-experiment; cd my-winrt-experiment
npm init -y
npm install --save-dev @microsoft/winappcli
npx winapp init . --use-defaults --add-js-bindingswinapp init writes a Package.appxmanifest, adds @microsoft/dynwinrt + @microsoft/dynwinrt-codegen to package.json, and generates typed bindings under .winapp/bindings/.
If you plan to call a restricted-capability API (for example Phi Silica), edit Package.appxmanifest and add the corresponding capability:
<Capabilities>
<rescap:Capability Name="systemAIModels" />
</Capabilities>Now pick one of the three paths below depending on your workflow.
Best when you want to iterate quickly on the same experiment: register once, then invoke your script many times from any terminal through a personal command name like mynode.exe.
New-Item -ItemType Junction -Path .\.local-node -Target (Split-Path (Get-Command node).Source)This exposes the active Node installation at .local-node without copying its files. See Why link node.exe into the project? below for the rationale.
npx winapp manifest add-alias --name mynode.exe --manifest .\Package.appxmanifestThis inserts an <uap3:Extension Category="windows.appExecutionAlias"> into the manifest, pointing at .local-node\node.exe.
npx winapp run . --exe .local-node\node.exe --no-launch--no-launch registers the package (so the alias becomes resolvable) without starting anything yet. After this step, mynode.exe is available on your PATH and inherits the package identity from your app.
From this terminal or any new one:
mynode.exe app.js
mynode.exe some-other-script.js
mynode.exe --versionEvery invocation runs under your registered package's identity — notifications get an AUMID, Phi Silica sees the capability, etc.
Best when you want a single command that does everything in one go — no persistent alias, no cleanup required. Ideal for automation, CI scripts, or throwaway experiments.
Skip the alias step and let winapp run do register-launch-unregister in one go:
New-Item -ItemType Junction -Path .\.local-node -Target (Split-Path (Get-Command node).Source)
npx winapp run . --exe .local-node\node.exe --args "app.js" --unregister-on-exitWhat this does:
- Registers your project as a loose-layout dev package
- Launches
.local-node\node.exe app.jswith package identity - Waits for the process to exit
- Unregisters the package (
--unregister-on-exit)
Every run pays a couple of seconds for register + unregister, so it's slower than Path A for tight loops, but it leaves nothing behind afterwards.
If you don't want a project-local junction (for example, you just want to run node --version under identity to check something), you can point --exe at the system node.exe directly:
npx winapp run . --exe (Get-Command node).Source --args "app.js" --unregister-on-exitThis works, but it's not recommended for anything you'll iterate on:
- A Node upgrade or
nvm usechanges wherenode.exelives. Your package will silently point at whatever version happens to be first onPATHat register time, not the one you meant. - Windows caches the executable path in the registered manifest. If
node.exemoves, the package can end up pointing at a stale file and fail to launch.
Link Node into the project (Paths A / B) for anything you'll come back to more than once.
Windows loose-layout packages resolve executables relative to the package root. A directory junction exposes your installed Node directory through the package-relative path .local-node, without duplicating the Node installation.
Linking the Node directory at .local-node gives you:
- A stable, in-project path. The manifest always points at
.local-node\node.exe. - No duplicated runtime files. The junction uses Node directly from its installed directory.
- Ability to add an execution alias. Execution aliases can only alias
.exefiles inside the package. A system-widenode.exeisn't eligible.
The junction targets whichever Node installation is active when you create it. If a version manager later switches Node to a different directory, remove and recreate .local-node so it points at the new installation.
Path A registers the package until you tear it down. When you're done experimenting:
npx winapp unregisterThis removes the loose-layout registration and the execution alias. Your project files are untouched.
Path B does this for you every run via --unregister-on-exit.
You can list what's currently registered with Get-AppxPackage | Where-Object PublisherId -eq (…), but the easiest sanity check is mynode.exe --version — if it prints a Node banner, the package is still registered; if it fails with "command not found", it's already gone.
Once your experiment works, packaging for distribution is the same layout with two extra CLI calls:
npx winapp pack # produces a signed .msix
npx winapp sign # signs with your (or a test) certificateThe generated bindings under .winapp/bindings/, your Package.appxmanifest, and your JavaScript code all carry forward unchanged — dev mode is a subset of the same layout, not a separate structure.
For end users installing the MSIX, no dev mode / no winapp run — Windows registers the package the normal way at install time.
For the framework-independent executable, layout, certificate, signing, and clean-machine workflow, see Package a dynwinrt Node.js application as MSIX.
The package isn't registered yet (or was already unregistered). Re-run:
npx winapp run . --exe .local-node\node.exe --no-launchOpen a new terminal after registering; the old one may have a stale PATH.
You're running node.exe directly instead of the aliased mynode.exe (or bypassing winapp run). Only invocations that go through the registered package (alias, or winapp run) get package identity.
Same root cause — the process has no package identity. Use the alias flow (Path A) or winapp run --args (Path B).
Some WinRT APIs are STA-only (roInitialize(0)), some are MTA-only (roInitialize(1)), and some don't care. As a starting point:
- Clipboard, most UI-linked types → STA
- Phi Silica, most background tasks → MTA
If you see RPC_E_CHANGED_MODE (0x80010106), you called roInitialize twice with different apartment values in the same process.
Restart your terminal after adding systemAIModels to Package.appxmanifest and re-registering the package. Windows caches capability grants per registration.