Skip to content

EN Windows Service

D0n9X1n edited this page Jul 25, 2026 · 8 revisions

Windows: run in the background

Recommended built-in option: Task Scheduler, as a per-user task.

One-time setup

Run PowerShell as your normal user:

npm install -g copilot-relay@latest
copilot-relay auth

Create the scheduled task

$node = (Get-Command node).Source
$main = Join-Path (npm root -g) "copilot-relay\dist\main.js"

$action = New-ScheduledTaskAction -Execute $node -Argument "`"$main`" start"

$trigger = New-ScheduledTaskTrigger -AtLogOn

$settings = New-ScheduledTaskSettingsSet `
  -StartWhenAvailable `
  -ExecutionTimeLimit ([TimeSpan]::Zero) `
  -RestartCount 10 `
  -RestartInterval (New-TimeSpan -Minutes 1) `
  -MultipleInstances IgnoreNew `
  -AllowStartIfOnBatteries `
  -DontStopIfGoingOnBatteries

Register-ScheduledTask `
  -TaskName "copilot-relay" `
  -Action $action `
  -Trigger $trigger `
  -Settings $settings `
  -Description "Run copilot-relay for Claude Code" `
  -Force

Start-ScheduledTask -TaskName "copilot-relay"

Verify the execution time limit actually took

Do this once, immediately after registering. It is the difference between a relay that runs indefinitely and one that dies silently in three days:

(Get-ScheduledTask -TaskName "copilot-relay").Settings.ExecutionTimeLimit

You want PT0S. If it shows PT72H, the setting did not take — PowerShell's [TimeSpan]::Zero does not always serialize to PT0S, and it fails silently. Force it through the XML in that case:

$task = Get-ScheduledTask -TaskName "copilot-relay"
$task.Settings.ExecutionTimeLimit = "PT0S"
Set-ScheduledTask -InputObject $task
(Get-ScheduledTask -TaskName "copilot-relay").Settings.ExecutionTimeLimit  # PT0S

Why these settings

Four of these are load-bearing. The Task Scheduler defaults are actively wrong for a long-lived service.

-ExecutionTimeLimit ([TimeSpan]::Zero) is the one that bites hardest. The default is PT72H — 3 days — and Task Scheduler terminates the task when it is reached. A perfectly healthy relay dies after 72 hours, and because it looks like an ordinary stop you get no error anywhere. Zero means no limit. Verify it took, as above.

-DontStopIfGoingOnBatteries and -AllowStartIfOnBatteries are both required on a laptop, because both underlying defaults work against you: StopIfGoingOnBatteries defaults to true (Windows stops the task the moment you unplug) and DisallowStartIfOnBatteries also defaults to true (it will not start while on battery at all).

Note that -DisallowStartIfOnBatteries is not a cmdlet parameter — it is the name of the underlying XML property. Passing it to New-ScheduledTaskSettingsSet does nothing useful; use -AllowStartIfOnBatteries.

Executing node.exe directly rather than wrapping in powershell.exe. npm's global install on Windows creates .cmd, .ps1, and shell shims, and (Get-Command copilot-relay).Source resolves to one of those. A shim makes cmd.exe/powershell.exe the parent and node.exe a child, so Task Scheduler supervises the wrapper rather than the relay itself. Microsoft documents that a task is "stopped" but does not specify how far down a process tree that reaches, so the safe structure is to not have a tree: calling node dist\main.js start makes the relay the task's own process, and start/stop map 1:1 to it with no assumption about tree-walking behavior.

-RestartCount 10 at 1-minute intervals bounds retries. These settings live under RestartOnFailure and apply only when the task fails — a process exiting 0 is a successful completion, so a clean copilot-relay stop is never undone by them. The relay validates upstream Copilot access at startup and exits 1 if that fails; at logon the network is often not up yet, so the first attempt legitimately fails. Ten retries over ten minutes covers a slow network without retrying forever against a real misconfiguration.

-MultipleInstances IgnoreNew is already the Task Scheduler default; it is specified here to document the intent that a second instance must never start alongside the first.

copilot-relay start runs in the foreground, which is what Task Scheduler expects — it treats the running process as the running task.

Verifying it actually works

Three layers. Each proves strictly more than the one before it, and the first two pass on a relay that cannot serve a single request.

Layer 1 — is the process alive?

Get-ScheduledTask -TaskName "copilot-relay" | Select-Object State
Get-ScheduledTaskInfo -TaskName "copilot-relay" |
  Select-Object LastRunTime, LastTaskResult, NumberOfMissedRuns

Invoke-RestMethod http://127.0.0.1:4142/healthz

Expect State: Running, LastTaskResult: 267009 (means currently running — not an error), and ok : True. That endpoint is a static handler: it proves a socket is listening and nothing more. It never contacts GitHub Copilot.

Layer 2 — did config parse and routing resolve?

(Invoke-RestMethod http://127.0.0.1:4142/v1/models).data.id

Expect your configured models, e.g. gpt-5.6-sol[1m] and claude-opus-5. Served from config; also never contacts upstream. A relay whose Copilot token expired an hour ago passes layers 1 and 2.

Layer 3 — end to end

$body = @{
  model      = "gpt-5.6-sol"
  max_tokens = 16
  messages   = @(@{ role = "user"; content = "Reply with the single word: ok" })
} | ConvertTo-Json -Depth 5

Invoke-RestMethod -Method Post http://127.0.0.1:4142/v1/messages `
  -ContentType "application/json" `
  -Headers @{ "anthropic-version" = "2023-06-01" } `
  -Body $body

Content plus non-zero usage proves the whole path: config, token refresh, the Copilot call, and translation back to Claude shape. This is the only check that proves the relay can serve Claude Code. It costs a handful of tokens.

Layers 1 and 2 passing while layer 3 fails means auth or upstream, not the task — run copilot-relay auth and read today's log.

Reading logs

$today = Get-Date -Format "yyyy-MM-dd"
Get-Content "$env:USERPROFILE\.copilot-relay\logs\copilot-relay.$today.log" -Tail 80 -Wait

The log rotates daily and the filename carries the local date. To search every retained day:

Select-String -Path "$env:USERPROFILE\.copilot-relay\logs\copilot-relay.*.log" `
  -Pattern "Startup preflight failed"

Stopping it

Stop now, let it start again at next logon:

Stop-ScheduledTask -TaskName "copilot-relay"

Stop now and do not start at logon:

Stop-ScheduledTask -TaskName "copilot-relay"
Disable-ScheduledTask -TaskName "copilot-relay"

Re-enable:

Enable-ScheduledTask -TaskName "copilot-relay"
Start-ScheduledTask -TaskName "copilot-relay"

copilot-relay stop and Task Scheduler

copilot-relay stop finds and terminates the relay directly. It exits 0, which Task Scheduler reads as normal completion — not a failure — so the restart policy does not fire and it stays stopped until next logon.

To confirm a relay is gone regardless of supervisor:

copilot-relay stop
Get-NetTCPConnection -LocalPort 4142 -State Listen -ErrorAction SilentlyContinue

The second command should return nothing.

Removing it permanently

Stop-ScheduledTask -TaskName "copilot-relay"
Unregister-ScheduledTask -TaskName "copilot-relay" -Confirm:$false

Troubleshooting

Relay dies every ~3 days. -ExecutionTimeLimit did not take. The fingerprint is LastTaskResult = 267014 (SCHED_S_TASK_TERMINATED) with an uptime near 72 hours — that code means the task was terminated rather than that it crashed. Check and fix:

(Get-ScheduledTask -TaskName "copilot-relay").Settings.ExecutionTimeLimit

PT72H is the default and is the bug; you want PT0S. See Verify the execution time limit actually took.

Relay stops when unplugged, or will not start on battery. Both battery defaults work against you — StopIfGoingOnBatteries and DisallowStartIfOnBatteries are both true by default. You need -DontStopIfGoingOnBatteries and -AllowStartIfOnBatteries.

Stop-ScheduledTask leaves the relay running. The task is wrapped in a shim or powershell.exe. Re-register with the node.exe action above.

LastTaskResult is 1. Preflight failed: expired auth or no network. Read today's log, then copilot-relay auth.

LastTaskResult is 267009. Not an error — SCHED_S_TASK_RUNNING, the task is currently running.

LastTaskResult is 267011. SCHED_S_TASK_NOT_SCHEDULED — a property needed to run on a schedule is missing. Re-register the task.

Task registered but never starts. -AtLogOn fires at logon; if you registered it in an already-open session, start it once by hand with Start-ScheduledTask.

Relay runs but Claude Code ignores it. Registration is fine; check ANTHROPIC_BASE_URL in %USERPROFILE%\.claude\settings.json. With claudeSetup: true the relay manages that itself at start.

Best practice

Use a per-user task. Do not run as SYSTEM unless you deliberately manage a separate home directory and token cache — SYSTEM has its own profile, so it will not see the copilot-relay auth you ran as yourself.

Clone this wiki locally