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 | Pass | Account | Desktop / GUI | Network | Failure handling |
|---|---|---|---|---|---|
FirstLogonCommands | oobeSystem | First interactive user (typically the local admin you created) | Yes — user desktop is loading | Yes | Continues by default; OOBE proceeds even if a command fails |
RunSynchronousCommand | specialize | SYSTEM | No GUI — headless | Driver-dependent (VMs reliable, bare metal flaky) | Exit codes logged, not enforced. <WillReboot> controls
reboot-and-resume. 30-min cap per command on 22H2+. |
SetupComplete.cmd | n/a — runs after specialize and OOBE-defaults, before user logon | SYSTEM | No GUI | Yes (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)? →
RunSynchronousCommandinspecialize. 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.
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.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.<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 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-Unattendor by copying the file intoC:\Windows\Setup\Scripts\in a mounted WIM. - Write it from
RunSynchronousCommandin specialize — usecmd /cwith a here-doc-styleechochain (clunky but works) orpowershell -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
| Character | Escape in XML as |
|---|---|
< | < |
> | > |
& | & |
" (only inside an attribute value) | " |
In <CommandLine> element content (not an attribute), " is fine unescaped. & however must always
be & — 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
- Missing
-NoProfile. Without it, PowerShell loads$PROFILEon 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:PSModulePathon developer machines. Always pass-NoProfile. - Forgetting
-ExecutionPolicy Bypass. The default policy on a fresh Windows install isRestrictedfor non-admin SKUs; a.ps1file will refuse to run.-ExecutionPolicy Bypassapplies to the current process only — it does not change the machine policy and is the right knob for unattend. - Network-dependent commands in
specialize. DNS and DC connectivity are not guaranteed yet. Move anything that resolves a hostname or hits a UNC path toSetupComplete.cmdorFirstLogonCommands. %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).- Using
wscriptinstead ofcscriptfor VBS.wscriptshows a GUI window. AnyMsgBoxcall halts setup waiting for a click that nobody is there to make. Alwayscscript //nologo. FirstLogonCommandsruns at MEDIUM integrity, not HIGH. The first user is an admin, butEnableLUA=1is on by default — UAC applies. Commands likeSet-MpPreference,Set-Service, and mostHKLMwrites fail with "access denied" even though the user is technically in the Administrators group. If you need elevation, either move the command toSetupComplete.cmd(true SYSTEM context) or wrap it in a scheduled task that runs as SYSTEM.
<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.Related pages
- First-run command snippets — copy-paste examples of common commands tagged with the right entry point.
- Sysprep workflows A/B/C/D — covers
SetupComplete.cmdghosting during image-refresh cycles. - Adding drivers to unattend.xml —
when to invoke
pnputilfromSetupComplete.cmdvs baking drivers into the image. - Windows 11 OOBE & hardware bypass status — interaction with OOBE skip flags that affect FirstLogonCommands timing.
- Back to the unattend.xml builder.