This guide is written for AI agents. Every step is a copy-paste shell block that runs on a Linux workstation and talks to the Proxmox VE REST API with curl. Nothing has to be installed or run on the Proxmox host, and no console interaction is needed. Setup is unattended (autounattend.xml on a second CD-ROM), and the VM comes up with a static IP and OpenSSH Server that accepts your SSH key.
Result
| Item | Value |
|---|---|
| OS | Windows 10 Pro 22H2 (English, x64), not activated |
| VM | SeaBIOS, cpu=host, 2 vCPU, 4 GB RAM, 64 GB SATA disk (defaults; see Resources), Intel E1000E NIC (no VirtIO drivers needed) |
| Network | Static IPv4 set at first logon |
| Login | Local administrator $VM_USER over SSH with your key; default SSH shell is PowerShell |
| Agent work | 6 command blocks and one wait loop |
Requirements
- Workstation:
bash,curl,jq,genisoimage(ormkisofs),ssh, and an SSH key pair (~/.ssh/id_ed25519.pub). - Proxmox VE 8.x/9.x reachable on port 8006. The examples log in as
root@pam. Another user needsVM.Allocate,VM.Config.*,VM.PowerMgmt,Datastore.AllocateSpace,Datastore.AllocateTemplateandSys.Modify(required bydownload-url). - About 6 GB free on the ISO storage, 64 GB on the disk storage.
- A free IP address and VMID. The VM needs Internet access (OpenSSH Server is installed from Windows Update).
Resources
| CPU | RAM | Disk | |
|---|---|---|---|
| Minimum (Microsoft: Windows 10 system requirements) | 1 GHz | 2 GB (64-bit) | 32 GB |
| Defaults in this guide (verified) | 2 vCPU | 4 GiB | 64 GiB |
Microsoft gives no recommended values beyond the minimum. With the defaults, Step 5 to SSH login took about 13 minutes. To change them after installation, run pve PUT /nodes/$PVE_NODE/qemu/$VMID/config -d cores=4 -d memory=8192; the new values take effect after the VM is stopped and started again.
Step 1. Set variables
Edit the values, then run the block. All later blocks use these variables in the same shell. VMNAME becomes the Windows computer name (max 15 characters). Do not use &, < or > in VM_PASS.
PVE_HOST=192.168.1.13 # Proxmox VE host
PVE_NODE=pve # node name
PVE_USER=root@pam
PVE_PASS='your-root-password'
ISO_STORE=local # storage with "ISO image" content
DISK_STORE=local-lvm # storage for VM disks
BRIDGE=vmbr0
CORES=2 # vCPUs (see "Resources")
MEMORY=4096 # MiB
DISK_GB=64
VMID=801
VMNAME=win10
IP=192.168.1.81
PREFIX=24
GW=192.168.1.1
DNS=8.8.8.8
VM_USER=agent
VM_PASS='ChangeMe-2026'
SSH_PUBKEY=$(cat ~/.ssh/id_ed25519.pub)
Step 2. Log in to the API
Creates a ticket (valid 2 hours) and two helpers: pve (API call) and wait_task (waits for a Proxmox task and prints its exit status, OK on success).
PVE=https://$PVE_HOST:8006/api2/json
read -r TICKET CSRF < <(curl -sk -d "username=$PVE_USER" --data-urlencode "password=$PVE_PASS" $PVE/access/ticket | jq -r '.data|"\(.ticket) \(.CSRFPreventionToken)"')
pve(){ local m=$1 p=$2; shift 2; curl -sSk -X "$m" -b "PVEAuthCookie=$TICKET" -H "CSRFPreventionToken: $CSRF" "$PVE$p" "$@"; }
wait_task(){ local u s; u=$(jq -rn --arg u "$1" '$u|@uri'); while sleep 5; do s=$(pve GET /nodes/$PVE_NODE/tasks/$u/status | jq -r '.data|"\(.status) \(.exitstatus)"'); [ "${s%% *}" = stopped ] && { echo "${s#* }"; return; }; done; }
pve GET /version | jq -c .data
Step 3. Download the ISO to Proxmox
Microsoft only hands out a time-limited (24 h) download link. The block requests it the same way the download page does (including the page’s anti-bot fingerprint call), then lets Proxmox download the ISO. Skipped if the file already exists. Expected output: the ISO name and OK (about 5.8 GB).
UA='Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0'
REF=https://www.microsoft.com/en-us/software-download/windows10ISO
MSAPI=https://www.microsoft.com/software-download-connector/api
SID=$(cat /proc/sys/kernel/random/uuid)
ED=$(curl -s -A "$UA" $REF | grep -oE '<option value="[0-9]+">Windows' | head -1 | grep -oE '[0-9]+')
JS=$(curl -s -A "$UA" "https://ov-df.microsoft.com/mdt.js?instanceId=560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175&PageId=si&session_id=$SID")
curl -s -o /dev/null -A "$UA" "$(echo "$JS" | grep -oE 'url:"[^"]+' | head -1 | cut -c6-)&mdt=$(date +%s%3N)&rticks=$(echo "$JS" | grep -oE 'rticks="\+[0-9]+' | grep -oE '[0-9]+')"
SKU=$(curl -s -A "$UA" "$MSAPI/getskuinformationbyproductedition?profile=606624d44113&ProductEditionId=$ED&SKU=undefined&friendlyFileName=undefined&Locale=en-US&sessionID=$SID" | jq -r '.Skus[] | select(.Language=="English") | .Id' | head -1)
URL=$(curl -s -A "$UA" -e $REF "$MSAPI/GetProductDownloadLinksBySku?profile=606624d44113&productEditionId=undefined&SKU=$SKU&friendlyFileName=undefined&Locale=en-US&sessionID=$SID" | jq -r '.ProductDownloadOptions[] | select(.Uri|test("x64")) | .Uri' | head -1)
ISO=$(basename "${URL%%\?*}"); echo "$ISO"
pve GET "/nodes/$PVE_NODE/storage/$ISO_STORE/content?content=iso" | grep -q "iso/$ISO\"" && echo exists || \
wait_task "$(pve POST /nodes/$PVE_NODE/storage/$ISO_STORE/download-url -d content=iso -d filename=$ISO --data-urlencode "url=$URL" | jq -r .data)"
If URL is empty, Microsoft rejected the request (rate limit or blocked IP range). Wait and retry, or download the ISO in a browser and upload it with the same upload call used in Step 4.
Step 4. Create the autounattend ISO and upload it
Windows Setup searches the root of every removable drive for autounattend.xml. The generic Windows 10 Pro installation key selects the Pro edition and skips the key prompt; it does not activate Windows. First-logon commands set the static IP, install OpenSSH Server, register your public key for administrators, make PowerShell the default SSH shell, and start sshd last. Expected output: OK.
mkdir -p /tmp/$VMNAME
cat > /tmp/$VMNAME/autounattend.xml <<EOF
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage><UILanguage>en-US</UILanguage></SetupUILanguage>
<InputLocale>en-US</InputLocale><SystemLocale>en-US</SystemLocale><UILanguage>en-US</UILanguage><UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DiskConfiguration><Disk wcm:action="add"><DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
<CreatePartitions><CreatePartition wcm:action="add"><Order>1</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition></CreatePartitions>
<ModifyPartitions><ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Format>NTFS</Format><Letter>C</Letter><Active>true</Active></ModifyPartition></ModifyPartitions>
</Disk></DiskConfiguration>
<ImageInstall><OSImage><InstallTo><DiskID>0</DiskID><PartitionID>1</PartitionID></InstallTo></OSImage></ImageInstall>
<UserData><ProductKey><Key>VK7JG-NPHTM-C97JM-9MPGT-3V66T</Key></ProductKey><AcceptEula>true</AcceptEula><FullName>$VM_USER</FullName></UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>$VMNAME</ComputerName><TimeZone>UTC</TimeZone>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<InputLocale>en-US</InputLocale><SystemLocale>en-US</SystemLocale><UILanguage>en-US</UILanguage><UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE><HideEULAPage>true</HideEULAPage><HideLocalAccountScreen>true</HideLocalAccountScreen><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE>
<UserAccounts><LocalAccounts><LocalAccount wcm:action="add"><Name>$VM_USER</Name><Group>Administrators</Group><Password><Value>$VM_PASS</Value><PlainText>true</PlainText></Password></LocalAccount></LocalAccounts></UserAccounts>
<AutoLogon><Enabled>true</Enabled><Username>$VM_USER</Username><Password><Value>$VM_PASS</Value><PlainText>true</PlainText></Password><LogonCount>1</LogonCount></AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add"><Order>1</Order><CommandLine>powershell -NoProfile -Command "Get-NetAdapter | New-NetIPAddress -IPAddress $IP -PrefixLength $PREFIX -DefaultGateway $GW; Get-NetAdapter | Set-DnsClientServerAddress -ServerAddresses $DNS; Start-Sleep 20"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>2</Order><CommandLine>powershell -NoProfile -Command "Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>3</Order><CommandLine>powershell -NoProfile -Command "New-Item -ItemType Directory -Force C:\ProgramData\ssh; Set-Content -Path C:\ProgramData\ssh\administrators_authorized_keys -Value '$SSH_PUBKEY'"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>4</Order><CommandLine>icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant *S-1-5-32-544:F /grant *S-1-5-18:F</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>5</Order><CommandLine>reg add HKLM\SOFTWARE\OpenSSH /v DefaultShell /t REG_SZ /d C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe /f</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>6</Order><CommandLine>netsh advfirewall firewall add rule name=sshd dir=in action=allow protocol=TCP localport=22</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>7</Order><CommandLine>net accounts /maxpwage:unlimited</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>8</Order><CommandLine>powershell -NoProfile -Command "Set-Service sshd -StartupType Automatic; Start-Service sshd"</CommandLine></SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
EOF
genisoimage -quiet -J -r -V UNATTEND -o /tmp/$VMNAME/$VMNAME-unattend.iso /tmp/$VMNAME/autounattend.xml
wait_task "$(pve POST /nodes/$PVE_NODE/storage/$ISO_STORE/upload -F content=iso -F filename=@/tmp/$VMNAME/$VMNAME-unattend.iso | jq -r .data)"
Step 5. Create and start the VM
SATA and E1000E use drivers that ship with Windows, so no VirtIO driver ISO is needed. With SeaBIOS and an empty disk first in the boot order, the VM boots the ISO without the “Press any key to boot from CD” prompt, and boots the installed disk after Setup reboots. Expected output: OK.
wait_task "$(pve POST /nodes/$PVE_NODE/qemu -d vmid=$VMID -d name=$VMNAME -d ostype=win10 -d cpu=host -d cores=$CORES -d memory=$MEMORY \
--data-urlencode "sata0=$DISK_STORE:$DISK_GB" --data-urlencode "net0=e1000e,bridge=$BRIDGE" \
--data-urlencode "ide2=$ISO_STORE:iso/$ISO,media=cdrom" --data-urlencode "ide3=$ISO_STORE:iso/$VMNAME-unattend.iso,media=cdrom" \
--data-urlencode "boot=order=sata0;ide2" -d start=1 | jq -r .data)"
Step 6. Wait for SSH and log in
Setup, two reboots and the first-logon commands run without interaction. sshd is started by the last command, so a successful key login means everything is done. Verified time from Step 5 to login: about 13 minutes.
SSHO="-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
until ssh $SSHO $VM_USER@$IP exit 2>/dev/null; do sleep 30; done
ssh $SSHO $VM_USER@$IP '(Get-CimInstance Win32_OperatingSystem).Caption; Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Manual | Select-Object IPAddress,PrefixLength'
Step 7. Clean up (optional)
Detach both CD-ROMs and delete the answer-file ISO from storage.
pve PUT /nodes/$PVE_NODE/qemu/$VMID/config -d delete=ide3 --data-urlencode "ide2=none,media=cdrom"
pve DELETE /nodes/$PVE_NODE/storage/$ISO_STORE/content/$ISO_STORE:iso/$VMNAME-unattend.iso
Troubleshooting
wait_taskprints an error instead ofOK: list the latest failed tasks and their status text withpve GET "/nodes/$PVE_NODE/tasks?errors=1" | jq -r '.data[:3][] | "\(.type) \(.id) \(.status)"'.Permission check failedon create: a non-root user must create the VM in a pool it has rights on (add-d pool=<pool>).- No SSH after 45 minutes: open the VM console in the Proxmox web UI. If Setup shows a dialog, the answer file was not applied (check that the second CD-ROM is attached and the XML is well-formed with
xmllint --noout). If the desktop is up but SSH is closed,Add-WindowsCapabilityprobably could not reach Windows Update; check the IP settings. - To start over:
pve POST /nodes/$PVE_NODE/qemu/$VMID/status/stop, thenpve DELETE "/nodes/$PVE_NODE/qemu/$VMID?purge=1&destroy-unreferenced-disks=1".