Docs
← Back to the builder

Running PowerShell, cmd, and VBS from unattend.xml

Three places in unattend.xml run scripts during Windows Setup — FirstLogonCommands (oobeSystem, as the new user), RunSynchronousCommand (specialize, as SYSTEM before OOBE), and SetupComplete.cmd (the batch file at %WINDIR%\Setup\Scripts, as SYSTEM before first logon). Each has its own account, network state, and GUI state. Pick the wrong one and your script fires too early, too late, or with the wrong permissions. This page covers when to pick which, the exact PowerShell / cmd / VBS invocations, the quoting rules, and the five mistakes you'll only catch by reading the wrong log.

Picking an entry point

Start here. The wrong entry point is the most common reason commands "don't run" — they run, just in a context that cannot do the thing you asked.

Entry-point comparison
Entry pointPassAccountDesktop / GUINetworkFailure handling
FirstLogonCommandsoobeSystemFirst interactive user (typically the local admin you created)Yes — user desktop is loadingYesContinues by default; OOBE proceeds even if a command fails
RunSynchronousCommandspecializeSYSTEMNo GUI — headlessDriver-dependent (VMs reliable, bare metal flaky)Exit codes logged, not enforced. <WillReboot> controls reboot-and-resume. 30-min cap per command on 22H2+.
SetupComplete.cmdn/a — runs after specialize and OOBE-defaults, before user logonSYSTEMNo GUIYes (machine has fully booted)Best-effort; non-zero exit codes do not block boot, only logged

The quick decision tree

  • Need a user profile, HKCU writes, shortcut placement, or something that needs a desktop?FirstLogonCommands.
  • Need to install software, edit HKLM, configure services, with no user requirement?SetupComplete.cmd.
  • Need to set something before the first user account exists (some HKLM keys that drive OOBE itself, joining domain offline)?RunSynchronousCommand in specialize. Otherwise avoid — it is the most fragile of the three.

FirstLogonCommands

Runs at the first interactive logon of the first user created during OOBE. The shell is loading, the user has a desktop, and the network is reachable. Good fit for per-user configuration, profile-aware installs, and anything that just needs to run "once at the end of setup."

Invoking powershell.exe with an inline command

<settings pass="oobeSystem">
  <component name="Microsoft-Windows-Shell-Setup"
             processorArchitecture="amd64"
             publicKeyToken="31bf3856ad364e35"
             language="neutral"
             versionScope="nonSxS"
             xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
    <FirstLogonCommands>
      <SynchronousCommand wcm:action="add">
        <Order>1</Order>
        <Description>Set time zone to UTC</Description>
        <CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "Set-TimeZone -Id 'UTC'"</CommandLine>
      </SynchronousCommand>
    </FirstLogonCommands>
  </component>
</settings>

Invoking a .ps1 file

<SynchronousCommand wcm:action="add">
  <Order>2</Order>
  <Description>Run firstrun.ps1</Description>
  <CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -File "C:\Windows\Setup\Scripts\firstrun.ps1"</CommandLine>
</SynchronousCommand>

Invoking a .vbs file

<SynchronousCommand wcm:action="add">
  <Order>3</Order>
  <Description>Run legacy provisioning VBS</Description>
  <CommandLine>cscript //nologo "C:\Windows\Setup\Scripts\provision.vbs"</CommandLine>
</SynchronousCommand>

Always use cscript (console host) rather than wscript (windowed host). wscript will pop a window mid-OOBE, and any MsgBox call will hang the entire first-logon sequence waiting for someone to click OK.

Invoking a .cmd or .bat file

<SynchronousCommand wcm:action="add">
  <Order>4</Order>
  <Description>Run install.cmd</Description>
  <CommandLine>cmd /c "C:\Windows\Setup\Scripts\install.cmd"</CommandLine>
</SynchronousCommand>

Failure handling

FirstLogonCommands is continue-on-error. A failed command (non-zero exit, missing file, syntax error in PowerShell) is logged to C:\Windows\Debug\NetSetup.LOG and C:\Windows\Panther\UnattendGC\setupact.log, but OOBE moves on. If you need a hard stop, wrap your command in a script that exits 0 on success and uses shutdown /r /t 0 (or a deliberate halt marker) on failure.

RunSynchronousCommand (specialize pass)

Runs in the specialize pass — after Setup has applied the generic-image-to-this-machine specialization and just before OOBE begins. Account is SYSTEM, there is no interactive desktop, and network is partially up (DHCP usually completed, but DNS may still be settling). Reserve this for things that genuinely need to happen pre-OOBE.

The XML shape

<settings pass="specialize">
  <component name="Microsoft-Windows-Deployment"
             processorArchitecture="amd64"
             publicKeyToken="31bf3856ad364e35"
             language="neutral"
             versionScope="nonSxS"
             xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
    <RunSynchronous>
      <RunSynchronousCommand wcm:action="add">
        <Order>1</Order>
        <Description>Disable hibernation</Description>
        <Path>cmd /c powercfg /h off</Path>
        <WillReboot>Never</WillReboot>
      </RunSynchronousCommand>
    </RunSynchronous>
  </component>
</settings>

PowerShell, .ps1, VBS, .cmd

<!-- Inline PowerShell -->
<Path>powershell -NoProfile -ExecutionPolicy Bypass -Command "New-Item -Path 'C:\ProgramData\Setup' -ItemType Directory -Force"</Path>

<!-- .ps1 file -->
<Path>powershell -NoProfile -ExecutionPolicy Bypass -File "C:\Windows\Setup\Scripts\specialize.ps1"</Path>

<!-- .vbs file -->
<Path>cscript //nologo "C:\Windows\Setup\Scripts\specialize.vbs"</Path>

<!-- .cmd file -->
<Path>cmd /c "C:\Windows\Setup\Scripts\specialize.cmd"</Path>

Failure handling — WillReboot matters

RunSynchronousCommand is the only entry point that can hard-stop Setup. The <WillReboot> element controls what happens after the command:

  • <WillReboot>Never</WillReboot> — proceed regardless of return code. Use for cleanup-type commands.
  • <WillReboot>OnRequest</WillReboot> — reboot if the command's exit code requests it (1641 or 3010).
  • <WillReboot>Always</WillReboot> — reboot unconditionally after the command. Setup resumes after the reboot.

A non-zero exit code from a RunSynchronousCommand is logged to setupact.log but does not trigger a global Setup abort — that is a common belief but it is false. Setup continues regardless. The "Windows could not finish configuring the system" dialog at OOBE comes from a different failure path (component initialization, not your script's exit code). Treat <WillReboot> as a flow-control hint (reboot-and-resume), not an error handler. If you need hard failure handling, redirect output to a log file and exit with a known marker, then check that marker from a follow-up step.

Network is partial here. Do not assume DNS works or that a domain controller is reachable in the specialize pass. Networking is driver-dependent: on VMs it is usually up, on bare metal with vendor NICs being injected mid-pass it is hit-or-miss. If you need a network call, do it from SetupComplete.cmd or FirstLogonCommands instead. djoin /requestodj /loadfile works because it reads a local blob, not the network.
30-minute timeout on Win 11 22H2+. A single RunSynchronousCommand that takes longer than ~30 minutes is treated as failed by Windows Setup. setupact.log records SETUP: ASYNC: Command timed out. This is undocumented but consistently observed on builds 22621 and 22631. Status on 26100 is unverified — assume the cap still applies. Keep specialize commands short; defer long-running work to SetupComplete.cmd (no such cap) or schedule via schtasks.
The <Path> length cap is 259 characters. FirstLogonCommands permits a 1024-character <CommandLine>; RunSynchronousCommand tightens that to 259 in <Path>. Beyond ~256 characters builds quietly truncate and silently no-op. If you are approaching the limit, stage your real command in a .cmd or .ps1 file on disk and call that file from <Path>.

SetupComplete.cmd

Lives at C:\Windows\Setup\Scripts\SetupComplete.cmd. Windows runs it once, in SYSTEM context, after specialize and OOBE processing complete but before the first user logon — meaning network is up, services are running, and you have a fully-booted machine but nobody is logged in. This is the workhorse entry point for machine-level configuration.

SetupComplete.cmd is silently disabled on OEM-keyed retail Windows. Per Microsoft Learn (Add a Custom Script to Windows Setup), SetupComplete.cmd only executes on Windows Enterprise editions and Windows Server. On Pro/Home images that ship with an embedded OEM product key (i.e. consumer hardware where the key is injected from BIOS/MSDM into install.wim), the script is silently skipped. There is no error, no log entry, no signal — OOBE just proceeds as if the file did not exist. This is the most common reason small-shop sysadmins find that their carefully crafted post-install never ran. If your image deploys to OEM-keyed Pro/Home, move your post-install work to FirstLogonCommands (oobeSystem) instead.

The file itself

@echo off
:: C:\Windows\Setup\Scripts\SetupComplete.cmd

:: Make sure logs go somewhere we can find them later
set LOG=C:\Windows\Setup\Scripts\setupcomplete.log
echo [%DATE% %TIME%] SetupComplete starting >> %LOG%

:: Invoke PowerShell inline
powershell -NoProfile -ExecutionPolicy Bypass -Command "Set-Service -Name Spooler -StartupType Disabled" >> %LOG% 2>&1

:: Invoke a .ps1 file
powershell -NoProfile -ExecutionPolicy Bypass -File "C:\Windows\Setup\Scripts\post-install.ps1" >> %LOG% 2>&1

:: Invoke a VBS file
cscript //nologo "C:\Windows\Setup\Scripts\legacy.vbs" >> %LOG% 2>&1

:: Invoke a child .cmd or .bat
call "C:\Windows\Setup\Scripts\install-line-of-business.cmd" >> %LOG% 2>&1

echo [%DATE% %TIME%] SetupComplete done >> %LOG%
exit /b 0

Staging the file

Three ways to put a SetupComplete.cmd on the target:

  • Bake it into the image with dism /Image:C:\mount /Apply-Unattend or by copying the file into C:\Windows\Setup\Scripts\ in a mounted WIM.
  • Write it from RunSynchronousCommand in specialize — use cmd /c with a here-doc-style echo chain (clunky but works) or powershell -Command "Set-Content -Path ..." for sane multi-line content.
  • Copy it from a network share in specialize with cmd /c xcopy .... Faster than writing it inline; fails if the NIC is not up yet.

Failure handling

Windows does not block boot on a failing SetupComplete.cmd. It logs failures to C:\Windows\Setup\Scripts\setupcomplete.log (if you redirected output as in the example above) and to C:\Windows\Panther\setupact.log. Always redirect both stdout and stderr from each command, otherwise diagnosing a silent failure means guessing.

Why is SetupComplete always .cmd?

Windows hard-codes the filename and the format. The Setup component that runs the script after OOBE explicitly looks for C:\Windows\Setup\Scripts\SetupComplete.cmd (and, for the rarely-used error path, ErrorHandler.cmd in the same folder). There is no registry switch to point it at a .ps1 or a .exe, and renaming the file to SetupComplete.ps1 simply means Windows will not find it.

The good news: a .cmd body is essentially a launcher. Put your real logic in a .ps1 and have SetupComplete.cmd consist of a single powershell -File ... line. That is the canonical pattern:

@echo off
:: SetupComplete.cmd — thin launcher
powershell -NoProfile -ExecutionPolicy Bypass -File "C:\Windows\Setup\Scripts\post-install.ps1" > "C:\Windows\Setup\Scripts\post-install.log" 2>&1
exit /b 0

Now all your real work lives in a script you can lint, test, and version-control as PowerShell. The .cmd only exists to satisfy Windows' lookup.

Quoting and escaping

Two quoting systems collide inside <CommandLine> and <Path>: XML rules (the document itself) and shell rules (cmd, then PowerShell). Get this wrong and the symptom is usually "the command runs but the argument is empty" or "OOBE shows a fatal error and reboots in a loop."

Rule 1 — escape the XML reserveds

CharacterEscape in XML as
<&lt;
>&gt;
&&amp;
" (only inside an attribute value)&quot;

In <CommandLine> element content (not an attribute), " is fine unescaped. & however must always be &amp; — this is the most common mistake because & is also cmd's command separator.

Rule 2 — quote paths with spaces using double quotes

<!-- Correct: path has a space, wrapped in " -->
<CommandLine>powershell -NoProfile -File "C:\Program Files\MyApp\setup.ps1"</CommandLine>

<!-- Wrong: no quotes, PowerShell sees -File C:\Program then a separate Files\... -->
<CommandLine>powershell -NoProfile -File C:\Program Files\MyApp\setup.ps1</CommandLine>

Rule 3 — for PowerShell inline, use single quotes inside the -Command string

<!-- Outer double quotes for the -Command argument; single quotes inside -->
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "Set-ItemProperty -Path 'HKLM:\SOFTWARE\Foo' -Name 'Bar' -Value 'Baz'"</CommandLine>

This avoids needing to escape double quotes inside double quotes — the XML stays readable, PowerShell parses cleanly, and the registry value ends up as a literal string.

Common gotchas

  1. Missing -NoProfile. Without it, PowerShell loads $PROFILE on every invocation. During Setup the SYSTEM profile rarely exists, but if you have ever deployed a profile via group policy you can hit subtle "module not found" failures because the profile altered $env:PSModulePath on developer machines. Always pass -NoProfile.
  2. Forgetting -ExecutionPolicy Bypass. The default policy on a fresh Windows install is Restricted for non-admin SKUs; a .ps1 file will refuse to run. -ExecutionPolicy Bypass applies to the current process only — it does not change the machine policy and is the right knob for unattend.
  3. Network-dependent commands in specialize. DNS and DC connectivity are not guaranteed yet. Move anything that resolves a hostname or hits a UNC path to SetupComplete.cmd or FirstLogonCommands.
  4. %PATH% is the SYSTEM PATH, not yours. Tools you installed for your own user (anything under %LOCALAPPDATA%\..., npm globals, pyenv shims) are not on PATH for SYSTEM. Either invoke with a full path, or stage the tool to a directory you know is on the SYSTEM PATH (e.g. C:\Windows\System32).
  5. Using wscript instead of cscript for VBS. wscript shows a GUI window. Any MsgBox call halts setup waiting for a click that nobody is there to make. Always cscript //nologo.
  6. FirstLogonCommands runs at MEDIUM integrity, not HIGH. The first user is an admin, but EnableLUA=1 is on by default — UAC applies. Commands like Set-MpPreference, Set-Service, and most HKLM writes fail with "access denied" even though the user is technically in the Administrators group. If you need elevation, either move the command to SetupComplete.cmd (true SYSTEM context) or wrap it in a scheduled task that runs as SYSTEM.
Bonus: cmd line length limit. A single <CommandLine> is capped at roughly 260 characters on older systems and 8191 on modern Windows. If you find yourself near the limit, that is the signal to move the work into a .ps1 file and just invoke it.