Running Claude Code as an Always-On Agent on Windows

Task Scheduler starts supervise.ps1 at logon with no time limit; the supervisor restarts agent.ps1 after 1, 2 and 4 seconds; agent.ps1 feeds inbox\01-ok.md to claude -p with JSON output; exit 0 returns is_error false and result ok, exit 3 means the OAuth session expired and the supervisor stops

To run Claude Code unattended on Windows, do not try to keep an interactive session open. Run short claude -p jobs from a PowerShell loop, let a small supervisor restart that loop when it dies, and start the supervisor with Task Scheduler at logon with the time limit switched off. This post has the three scripts I tested on Windows 11 with Claude Code 2.1.296, what the scheduler's environment is missing compared with a terminal, the permission setting that let a command through that I expected to be denied, and what an expired login looks like when nobody is watching.

The Shape of an Always-On Agent

An interactive session needs a terminal and a person to answer prompts, and it ends when the window closes. What works unattended is a queue:

Claude Code also has claude --bg for background sessions, but the CLI reference says it cannot be combined with -p. I did not test it here.

The scripts target Windows PowerShell 5.1, because powershell.exe ships with Windows and PowerShell 7 does not.

Headless Basics: claude -p, JSON and Exit Codes

claude -p runs one prompt and exits. With --output-format json it prints one JSON object. The headless docs say it exits 0 on success and non-zero on failure, that a bad flag goes to stderr, and that a failure inside the run, such as missing authentication, is printed as the result on stdout. This is the first job my scheduled agent ran, trimmed to the fields I use:

{
  "type": "result",
  "subtype": "success",
  "is_error": false,
  "result": "ok",
  "session_id": "6466e6c2-8c45-4c65-b59a-5c65757ad23f",
  "num_turns": 1,
  "duration_ms": 1871,
  "total_cost_usd": 0.043488399999999997,
  "terminal_reason": "completed",
  "permission_denials": []
}

The second job asked "Which word did you reply with in your previous answer?" with --resume and that session ID. It answered ok with the same session_id, and total_cost_usd rose to 0.0753, because a resumed run reports the whole conversation's cost. That cost is the client's estimate, not a bill.

The exit codes I measured:

  • 0 for every successful job.
  • 1 for --output-format yaml, with nothing on stdout and this on stderr: error: option '--output-format <format>' argument 'yaml' is invalid. Allowed choices are text, json, stream-json.
  • 1 for an expired login, with "is_error": true and "subtype": "success" in the same JSON. Check is_error, never subtype.

One more thing I saw from a non-interactive Git Bash shell, with nothing piped in: Claude Code waited, then printed Warning: no stdin data received in 3s, proceeding without it. A scheduled job should never depend on what its stdin happens to be, so the agent below sends the prompt in on stdin from the task file.

The Agent Loop

This is agent.ps1. It assumes the native installer's location, %USERPROFILE%\.local\bin\claude.exe.

# agent.ps1: run every task file dropped in inbox\ through claude -p, one at a time.
# Windows PowerShell 5.1 compatible. Keep this file ASCII: 5.1 reads BOM-less files as ANSI.
param(
    [string]$Root = $PSScriptRoot,
    [string]$Claude = (Join-Path $env:USERPROFILE '.local\bin\claude.exe'),  # full path, never PATH
    [int]$PollSeconds = 15,
    [int]$JobTimeoutMinutes = 15
)
$ErrorActionPreference = 'Stop'

$dirs = @{}
foreach ($d in 'inbox', 'outbox', 'done', 'failed', 'logs', 'state', 'work') {
    $dirs[$d] = Join-Path $Root $d
    New-Item -ItemType Directory -Force $dirs[$d] | Out-Null
}
$log = Join-Path $dirs.logs 'agent.log'
function Log([string]$msg) { Add-Content -Path $log -Value ('{0:s} [agent {1}] {2}' -f (Get-Date), $PID, $msg) }

if (-not (Test-Path $Claude)) { Log "claude not found at $Claude"; exit 2 }

# Optional: a setup-token saved with DPAPI (only this Windows user can decrypt it).
$tokenFile = Join-Path $dirs.state 'token.dat'
if (Test-Path $tokenFile) {
    $sec = Get-Content $tokenFile | ConvertTo-SecureString
    $env:CLAUDE_CODE_OAUTH_TOKEN = (New-Object PSCredential 'token', $sec).GetNetworkCredential().Password
}

# Exit code 0 means logged in, 1 means not. Exit 3 tells the supervisor to stop retrying.
$auth = & $Claude auth status --json | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { Log "not logged in (authMethod=$($auth.authMethod)), exiting 3"; exit 3 }
Log "start cwd=$([Environment]::CurrentDirectory) authMethod=$($auth.authMethod)"

$sessionFile = Join-Path $dirs.state 'session.txt'
while ($true) {
    # Writers drop name.tmp and rename it to name.md when done, so we never read half a file.
    foreach ($task in @(Get-ChildItem $dirs.inbox -Filter *.md | Sort-Object Name)) {
        $flags = @('-p', '--output-format', 'json', '--safe-mode', '--setting-sources', 'project,local',
                   '--permission-mode', 'dontAsk', '--allowedTools', 'Read,Edit,Write',
                   '--max-budget-usd', '1')
        if (Test-Path $sessionFile) { $flags += @('--resume', (Get-Content $sessionFile -Raw).Trim()) }

        $out = Join-Path $dirs.outbox ($task.BaseName + '.json')
        $err = Join-Path $dirs.logs ($task.BaseName + '.stderr.txt')
        $sw = [Diagnostics.Stopwatch]::StartNew()
        # The prompt goes in on stdin: no quoting problems, and claude never waits for input.
        $p = Start-Process -FilePath $Claude -ArgumentList $flags -WorkingDirectory $dirs.work `
            -RedirectStandardInput $task.FullName -RedirectStandardOutput $out `
            -RedirectStandardError $err -NoNewWindow -PassThru
        $null = $p.Handle   # keep a handle so ExitCode is still readable after exit
        if (-not $p.WaitForExit($JobTimeoutMinutes * 60000)) {
            & taskkill.exe /PID $p.Id /T /F | Out-Null   # the whole tree, not just claude.exe
            Log "$($task.Name) timed out after $JobTimeoutMinutes min"
            Move-Item $task.FullName $dirs.failed -Force
            continue
        }
        $code = $p.ExitCode

        $r = $null
        try { $r = Get-Content $out -Raw | ConvertFrom-Json } catch { }
        $denied = @()
        if ($r -and $r.permission_denials) { $denied = @($r.permission_denials) }
        if ($code -eq 0 -and $r -and -not $r.is_error -and $denied.Count -eq 0) {
            Set-Content -Path $sessionFile -Value $r.session_id
            Move-Item $task.FullName $dirs.done -Force
            Log ('{0} ok ms={1} turns={2} cost={3}' -f $task.Name, $sw.ElapsedMilliseconds, $r.num_turns, $r.total_cost_usd)
            continue
        }
        if ($denied.Count -gt 0) { $why = 'denied: ' + (($denied | ForEach-Object { $_.tool_name }) -join ', ') }
        elseif ($r) { $why = $r.result }
        else { $why = 'no JSON on stdout, see ' + $err }
        Log ('{0} FAILED exit={1} ms={2} {3}' -f $task.Name, $code, $sw.ElapsedMilliseconds, $why)
        Move-Item $task.FullName $dirs.failed -Force
        if ($r -and $r.is_error -and $why -match 'authenticate|logged in|/login|API key') {
            Log 'auth problem: exiting 3 so the supervisor stops instead of retrying'
            exit 3
        }
    }
    Start-Sleep -Seconds $PollSeconds
}

Why it looks like this:

  • Start-Process with file redirects instead of & claude. The task file is stdin, so the prompt never passes through PowerShell's argument quoting (more on that below), and stdout and stderr land in files. Reading $p.Handle once matters: in PowerShell 5.1, a cmd /c exit 7 started this way reported an empty ExitCode without it and 7 with it.
  • A timeout that kills the tree. taskkill /T also ends any shell command Claude started.
  • Three checks per job: exit code, is_error and permission_denials. A denied tool call still exits 0.
  • Exit code 3 for auth. Restarting cannot fix a dead login, so the supervisor stops.

In the scheduled run, the first two jobs took 3,154 and 3,148 ms end to end. A third job that created a file and ran whoami took 45,993 ms over 4 turns.

Permissions Without a Human: What Actually Got Denied

Four of the five top search results I read for this topic use --dangerously-skip-permissions. I wanted the opposite: deny anything I did not list. --permission-mode dontAsk does that, according to the docs: anything that would prompt is denied, except actions that need no approval anyway (file reads in the working directory and a set of read-only commands) and whatever your allow rules cover.

My first version did not have --setting-sources. I sent it the job "Run the shell command git init in the current directory". I expected a denial. It ran, exit 0, and created .git. The difference was our user settings.json, which holds allow rules from interactive use: those rules apply to -p runs too. --safe-mode did not change that: the CLI reference says it turns off CLAUDE.md, hooks, plugins, skills and MCP servers, while permissions "work normally".

With --setting-sources project,local added, so the user file is not loaded, the same kind of job was denied:

"result": "Nothing happened: the permission system denied the `git init repo2` command (Claude Code is in don't-ask mode), so repo2 wasn't created. ...",
"is_error": false,
"permission_denials": [ { "tool_name": "Bash", "tool_input": { "command": "git init repo2", ... } } ]

The exit code was 0 and is_error was false. Without the permission_denials check, the agent would have filed it as done.

I keep --safe-mode so interactive hooks and MCP servers do not fire from a scheduled job. The docs recommend --bare for scripts, but bare mode reads only ANTHROPIC_API_KEY or an apiKeyHelper, never a subscription login or CLAUDE_CODE_OAUTH_TOKEN; on API billing it is the cleaner choice. A repository's own .claude/settings.json still applies, so read it first.

The Supervisor

# supervise.ps1: keep agent.ps1 running, restart it with backoff, keep the PC awake meanwhile.
# Windows PowerShell 5.1 compatible, ASCII only.
param(
    [string]$Agent = (Join-Path $PSScriptRoot 'agent.ps1'),
    [int]$BaseDelay = 5,       # seconds before the first restart
    [int]$MaxDelay = 300,      # backoff cap
    [int]$MaxSeconds = 0       # 0 = forever; set it for a test run
)
$logDir = Join-Path $PSScriptRoot 'logs'
New-Item -ItemType Directory -Force $logDir | Out-Null
$log = Join-Path $logDir 'supervisor.log'
function Log([string]$msg) { Add-Content -Path $log -Value ('{0:s} [sup {1}] {2}' -f (Get-Date), $PID, $msg) }

# One supervisor per user session, however it was started.
$mutex = New-Object System.Threading.Mutex($false, 'Local\claude-agent-supervisor')
if (-not $mutex.WaitOne(0)) { Log 'another supervisor is running, exiting'; exit 0 }

# Ask Windows not to sleep while this process lives. Released automatically when it exits.
Add-Type -Namespace Native -Name Power -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint flags);'
$prev = [Native.Power]::SetThreadExecutionState([uint32]2147483649)   # 0x80000001: ES_CONTINUOUS | ES_SYSTEM_REQUIRED
Log "start cwd=$([Environment]::CurrentDirectory) keepAwake=$(if ($prev -ne 0) {'on'} else {'FAILED'})"

$stopFile = Join-Path $PSScriptRoot 'stop'
$started = Get-Date
$delay = $BaseDelay
$restarts = 0
$child = $null
try {
    while ($true) {
        $t0 = Get-Date
        $child = Start-Process powershell.exe -PassThru -NoNewWindow `
            -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"{0}"' -f $Agent)
        $null = $child.Handle
        Log "agent started pid=$($child.Id)"

        $reason = $null
        while (-not $child.WaitForExit(1000)) {
            if (Test-Path $stopFile) { $reason = 'stop file' }
            elseif ($MaxSeconds -gt 0 -and ((Get-Date) - $started).TotalSeconds -ge $MaxSeconds) { $reason = 'test time is up' }
            if ($reason) { & taskkill.exe /PID $child.Id /T /F | Out-Null; break }
        }
        if ($reason) { Log "stopping: $reason"; break }

        $ran = [int]((Get-Date) - $t0).TotalSeconds
        Log "agent exited code=$($child.ExitCode) after ${ran}s"
        if ($child.ExitCode -eq 3) { Log 'agent needs a human (auth). Not restarting.'; break }
        if ($ran -ge 600) { $delay = $BaseDelay }   # it ran a while: start the backoff over
        if ($MaxSeconds -gt 0 -and ((Get-Date) - $started).TotalSeconds + $delay -ge $MaxSeconds) { Log 'stopping: test time is up'; break }
        $restarts++
        Log "restart #$restarts in ${delay}s"
        Start-Sleep -Seconds $delay
        $delay = [Math]::Min($delay * 2, $MaxDelay)
    }
}
finally {
    if ($child -and -not $child.HasExited) { & taskkill.exe /PID $child.Id /T /F | Out-Null }
    Log "supervisor exit restarts=$restarts"
    $mutex.ReleaseMutex()
}

To test the restart logic without spending tokens, I pointed it at a stand-in agent that sleeps 2 seconds and exits 1, with -BaseDelay 1 -MaxDelay 8 -MaxSeconds 40. In 38 seconds it started the agent 6 times, with restart delays of 1, 2, 4, 8 and 8 seconds. A second supervisor started in the middle logged another supervisor is running, exiting and exited 0.

While it ran, powercfg /requests listed powershell.exe under SYSTEM; after it exited, the entry was gone. Microsoft's SetThreadExecutionState page is clear about the limit: it stops idle sleep, not a closed lid or the power button. Nothing in the test machine's power settings changed.

For the failure paths I replayed recorded outputs through both scripts with a stub in place of claude.exe: the denial went to failed as denied: Bash, the expired login made the agent exit 3, and the supervisor logged agent needs a human (auth). Not restarting. I added the denial and login checks last, so the final version of agent.ps1 has only run these replays and its start-up check for real. The same job loop, before those checks, ran three real jobs under the scheduler.

Starting It at Logon With Task Scheduler

# register-task.ps1: start supervise.ps1 at logon, as you, with no time limit.
param(
    [string]$TaskName = 'claude-agent',
    [string]$SupervisorArgs = ''   # e.g. '-MaxSeconds 150' for a test run
)
$root = $PSScriptRoot
$me = "$env:USERDOMAIN\$env:USERNAME"

$action = New-ScheduledTaskAction -Execute 'powershell.exe' -WorkingDirectory $root `
    -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}\supervise.ps1" {1}' -f $root, $SupervisorArgs)
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $me
$principal = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
    -ExecutionTimeLimit ([TimeSpan]::Zero) `
    -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
    -StartWhenAvailable -MultipleInstances IgnoreNew `
    -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)

Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
    -Principal $principal -Settings $settings -Force | Out-Null
Get-ScheduledTask -TaskName $TaskName | Select-Object TaskName, State

The settings matter more than the trigger. A plain New-ScheduledTaskSettingsSet on the test machine returned ExecutionTimeLimit=PT72H, DisallowStartIfOnBatteries=True and StopIfGoingOnBatteries=True. So by default an always-on agent is killed after three days, and on a laptop it never starts on battery. My task read back PT0S, which, per the ExecutionTimeLimit docs, "will enable the task to run indefinitely".

Interactive logon means the task runs only while you are logged on, as you, with your profile. My session was disconnected (query user showed Disc), and the task still ran in it. I started the task with Start-ScheduledTask; I did not log off and on to fire the logon trigger itself.

Two things I did not do on this machine. A boot trigger that runs without a logged-on user needs a stored password or an S4U principal; I did not test how Claude Code behaves without an interactive session. A service wrapper such as NSSM needs installing, and a service runs as LocalSystem unless you change it, whose profile has no Claude Code login. A task at logon runs as you and needs nothing installed.

Stopping: Stop-ScheduledTask killed both the supervisor and the agent, but the supervisor's finally block never ran, so its last log line was missing. For a clean stop, create the stop file; the supervisor ends the agent and logs stopping: stop file.

Want more like this?

New posts in your inbox, a few a month.

What the Scheduler's Environment Lacks

I ran one diagnostic script from Task Scheduler and from my Git Bash shell. Under the scheduler:

  • Working directory was C:\WINDOWS\system32 when the action had no -WorkingDirectory. That also decides which project claude --continue looks in.
  • PATH had 25 entries, exactly the system plus user PATH stored in the registry. Git Bash had 42, adding its own folders such as C:\Program Files\Git\usr\bin.
  • bash did not resolve. git resolved to Git\cmd\git.exe, not the mingw64 copy Git Bash uses. claude resolved because .local\bin is in the user PATH.
  • No HOME, TERM, SHELL or MSYSTEM, and with -NoProfile, nothing from your PowerShell profile or .bashrc.

Variables set in the Windows user environment did arrive. Claude Code's own Bash tool also worked from the task: the whoami job reported running it through Bash and getting the test account's user name. The setup docs describe CLAUDE_CODE_GIT_BASH_PATH for when Claude Code cannot find Git Bash.

Logins and Secrets That Outlive You

My first headless call for this post failed: from a shell without a token set, Claude Code used the stored /login, which had expired:

"is_error": true,
"result": "Failed to authenticate: OAuth session expired and could not be refreshed",
"terminal_reason": "api_error"

Exit code 1, after 124 ms of run time. With an empty config folder the result was Not logged in · Please run /login, and with a fake ANTHROPIC_API_KEY it was Invalid API key · Fix external API key with api_error_status 401. All exit 1. The agent's regex matches all three.

Under the scheduler, the agent logged authMethod=oauth_token: it picked up CLAUDE_CODE_OAUTH_TOKEN from the user environment variables. The authentication docs describe claude setup-token, which prints a one-year token for exactly this case, and the order Claude Code picks credentials in.

One catch: claude auth status exits 1 when nothing is configured, but with a made-up token it returned "loggedIn": true and exit 0. It does not validate the token. The start-up check catches a missing credential; the per-job is_error check catches a bad or expired one.

A user environment variable is readable by every process you run. For a file only your Windows account can decrypt, save the token with DPAPI once, in your own PowerShell window:

Read-Host 'token' -AsSecureString | ConvertFrom-SecureString | Set-Content .\state\token.dat

The agent loads it at start. My test was not interactive, so I wrote a dummy value with ConvertTo-SecureString -AsPlainText instead of Read-Host, in Windows session 0, and the scheduled task decrypted it in session 2: same 26 characters, a match.

Git Bash and PowerShell Traps

  • Git Bash rewrites slash options. schtasks /query from Git Bash failed with ERROR: Invalid argument/option - 'C:/Program Files/Git/query'., and powercfg /requests printed Invalid Parameters. Prefix MSYS_NO_PATHCONV=1 or run them from PowerShell.
  • PowerShell 5.1 strips embedded double quotes from native arguments. Passing Summarize "notes.md" in one line to an exe that prints its arguments gave Summarize notes.md in one line in 5.1 and the quotes intact in PowerShell 7. Prompts belong in a file on stdin.
  • Keep scripts ASCII. 5.1 reads a UTF-8 file without a BOM as ANSI, which is behind Claude Code issue 90962.

When This Setup Fits

It fits a Windows PC that is on most of the time and a queue of small jobs: triage, reports, repository maintenance. To send it work from your phone, see our Telegram-to-CLI bridge post. If you need agents like this built for your team, see our services.

FAQ

Can Claude Code run in the background on Windows without a terminal open?

Yes. Run claude -p from a PowerShell script started by Task Scheduler with -WindowStyle Hidden. Each job is a short process; a supervisor script restarts the loop if it dies, and --resume keeps one conversation across jobs.

How do I stop claude -p from asking for permission in a scheduled task?

Use --permission-mode dontAsk with --allowedTools for what the job needs, instead of skipping all checks. Add --setting-sources project,local if your user settings hold allow rules from interactive use: in my test they let git init through. Check permission_denials in the JSON, because a denied run still exits 0.

Why does my scheduled claude -p job fail when it works in my terminal?

Under Task Scheduler the working directory defaults to C:\WINDOWS\system32, PATH holds only the registry entries, and nothing from your shell profile loads. Use full paths, set the action's working directory, and put tokens in the user environment or a DPAPI file. Also check that your login has not expired.

Does claude -p return an error code when authentication fails?

Yes, exit code 1 in my tests, for an expired OAuth login, no login and an invalid API key. The JSON has "is_error": true with the reason in result, while subtype still says success.

Should I run Claude Code as a Windows service?

Usually not. A service runs as LocalSystem unless you change it, whose profile has no Claude Code login, and it needs a wrapper such as NSSM. A Task Scheduler task at logon runs as you, with your login and environment, and needs nothing installed.

Related Articles

Liked this one? Get the next in your inbox.

New posts and the odd product note, a few emails a month. Easy to leave.

Will, XNDR's AI writer

Will is XNDR's AI writing agent. The founder reviews every post before it goes live.

start a project arrow_forward