PowerShell and the Claude API: Invoke-RestMethod, Unicode and streaming

How to call Claude from PowerShell so non-Latin text survives both the request and the response: a ready function for 5.1 and 7, HTTP error handling, parallel requests and streaming via HttpClient.

5 min · · development · автоматизация

PowerShell is a natural way to connect the Claude API to Windows administration: analysing event logs, generating reports, processing exports. The request itself is made with the built-in Invoke-RestMethod, and the main difficulties are about encoding: in Windows PowerShell 5.1, Cyrillic and other non-Latin text easily turns into mojibake in both the request and the response. Below is a simple example, a robust function for both versions, error handling, parallel requests and streaming.

Key and environment variable

Create a key of the form fetch_... in the Zukko dashboard. The full key is shown only once. Set it as an environment variable:

bash
$env:ZUKKO_API_KEY = 'fetch_...'
[Environment]::SetEnvironmentVariable('ZUKKO_API_KEY', 'fetch_...', 'User')

The first line only affects the current session; the second saves the variable for the user, and it will appear in new PowerShell windows. Don't write the key into the scripts themselves: they often get copied and pushed to shared repositories.

A simple request in PowerShell 7

Zukko accepts the OpenAI format at https://api.zukko.pro/v1. For PowerShell 7 (preferably 7.4 or newer) this is enough:

bash
$body = @{
    model      = 'claude-sonnet-5'
    max_tokens = 400
    messages   = @(
        @{ role = 'user'; content = 'Explain the difference between Get-Content and Get-Content -Raw' }
    )
} | ConvertTo-Json -Depth 10

$response = Invoke-RestMethod -Uri 'https://api.zukko.pro/v1/chat/completions' `
    -Method Post `
    -Headers @{ Authorization = "Bearer $env:ZUKKO_API_KEY" } `
    -ContentType 'application/json; charset=utf-8' `
    -Body ([System.Text.Encoding]::UTF8.GetBytes($body))

$response.choices[0].message.content

Two details people most often get wrong:

  • -Depth 10 on ConvertTo-Json. The default depth is 2, and nested objects turn into strings like System.Collections.Hashtable, so the API receives a broken request.
  • The body as UTF-8 bytes. [System.Text.Encoding]::UTF8.GetBytes($body) guarantees that non-Latin text is sent as UTF-8 regardless of the PowerShell version and its encoding settings.

Why non-Latin text breaks in PowerShell 5.1

In Windows PowerShell 5.1 the problem can come from two sides:

  1. The request. If you pass a string to -Body, 5.1 may not encode it as UTF-8. The fix is the same: send bytes.
  2. The response. If the response header doesn't specify a charset, Invoke-RestMethod in 5.1 may decode the body as something other than UTF-8, and Russian or other non-Latin text in the answer becomes unreadable. A reliable workaround is to get the response with Invoke-WebRequest and decode the raw bytes as UTF-8 yourself.

One more detail: for non-Latin text to display correctly in the 5.1 console, run [Console]::OutputEncoding = [System.Text.Encoding]::UTF8.

A function for PowerShell 5.1 and 7

The function below works in both versions: it sends the body as bytes, decodes the response itself, supports a system prompt and accepts text from the pipeline.

bash
function Invoke-Zukko {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Prompt,
        [string]$Model = 'claude-sonnet-5',
        [string]$System,
        [int]$MaxTokens = 800
    )
    process {
        if (-not $env:ZUKKO_API_KEY) { throw 'Set the ZUKKO_API_KEY environment variable' }

        $messages = [System.Collections.ArrayList]@()
        if ($System) { [void]$messages.Add(@{ role = 'system'; content = $System }) }

Notes:

  • $err = $_ saves the error before switch: inside switch, $_ refers to the value being tested, not the exception.
  • The status code comes from $err.Exception.Response.StatusCode; the property exists on both the 5.1 exception and the PowerShell 7 exception, and the [int] cast gives a number. The error response body is in $err.ErrorDetails.Message.
  • -UseBasicParsing is needed in 5.1 so the cmdlet doesn't try to parse the response with the Internet Explorer engine; in PowerShell 7 the parameter is kept for compatibility and does nothing.
  • Messages are collected in an ArrayList, so the messages array serializes to JSON correctly with either one or two elements.

Examples:

bash
Invoke-Zukko 'Write a regular expression for an IPv4 address'

Get-Content .\report.csv -Raw -Encoding UTF8 |
    Invoke-Zukko -System 'Briefly describe this export and find anomalies'

Get-WinEvent -LogName System -MaxEvents 50 |
    Format-List TimeCreated, Id, LevelDisplayName, Message | Out-String |
    Invoke-Zukko -System 'Group the events and point out what needs attention' -Model 'glm-5.3-flash'

To save the answer to a file: Invoke-Zukko 'Draft an email' | Set-Content .\draft.txt -Encoding UTF8.

Parallel requests

PowerShell 7 has ForEach-Object -Parallel. Functions from the main session aren't visible inside the parallel block, so the request is written right in the block, while environment variables are available as usual:

bash
Get-ChildItem .\docs -Filter *.md | ForEach-Object -Parallel {
    $body = @{
        model      = 'glm-5.3-flash'
        max_tokens = 500
        messages   = @(
            @{ role = 'system'; content = 'Summarize the text in 5 bullet points.' },
            @{ role = 'user'; content = (Get-Content $_.FullName -Raw -Encoding UTF8) }
        )
    } | ConvertTo-Json -Depth 10

    $r = Invoke-RestMethod -Uri 'https://api.zukko.pro/v1/chat/completions' -Method Post `
        -Headers @{ Authorization = "Bearer $env:ZUKKO_API_KEY" } `
        -ContentType 'application/json; charset=utf-8' `
        -Body ([System.Text.Encoding]::UTF8.GetBytes($body))

    Set-Content -Path ($_.FullName + '.summary.txt') -Value $r.choices[0].message.content -Encoding utf8
} -ThrottleLimit 4

-ThrottleLimit 4 keeps at most four requests in flight; if you get 429, lower it. Windows PowerShell 5.1 has no -Parallel; there, use Start-Job (each job runs in a separate process, which is slower) or process files sequentially with Invoke-Zukko.

Streaming the answer

Invoke-RestMethod waits for the full response, so printing text as it's generated requires the .NET HttpClient. The server sends data: {...} lines, with data: [DONE] last. Before it comes a chunk with usage and an empty choices array, so we check Count before touching choices[0]:

bash
Add-Type -AssemblyName System.Net.Http

$json = @{
    model      = 'claude-sonnet-5'
    max_tokens = 800
    stream     = $true
    messages   = @(@{ role = 'user'; content = 'Write a checklist for auditing Active Directory permissions' })
} | ConvertTo-Json -Depth 10

$client = [System.Net.Http.HttpClient]::new()
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Post, 'https://api.zukko.pro/v1/chat/completions')
$request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $env:ZUKKO_API_KEY)
$request.Content = [System.Net.Http.StringContent]::new($json, [System.Text.Encoding]::UTF8, 'application/json')

The ::new() syntax works from PowerShell 5.0 on, so the code fits 5.1 as well. The StreamReader is explicitly given UTF-8, so non-Latin text in the stream doesn't break.

Models and cost

The model is passed with -Model. Zukko prices per 1M tokens (input/output): claude-sonnet-5 and claude-opus-5-5 $0.30 / $0.30, gpt-5.6-sol $0.30 / $0.30, gpt-5.6-luna $0.10 / $0.10, glm-5.3-flash $0.04 / $0.04. The current list:

bash
(Invoke-RestMethod 'https://api.zukko.pro/v1/models' -Headers @{ Authorization = "Bearer $env:ZUKKO_API_KEY" }).data.id

or the "Models" page in the dashboard. Example: a daily event-log report with 6,000 input and 600 output tokens, 30 runs a month. On claude-sonnet-5: 30 × (6,000 + 600) × $0.30 / 1,000,000 = $0.0594 a month. On glm-5.3-flash: 30 × 6,600 × $0.04 / 1,000,000 = $0.00792. Check whether the cheap model is enough for your report on your own data: compare both models' answers on the same week of logs.

FAQ

"Could not create SSL/TLS secure channel" in PowerShell 5.1

On older systems the .NET Framework may offer outdated TLS versions by default. Before the request, run [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12. PowerShell 7 doesn't need this.

The answer arrives as "Привет"

The response was decoded as something other than UTF-8, which is typical for Invoke-RestMethod in 5.1. Use the Invoke-Zukko function above: it decodes the response bytes itself. If the garbling is only in the console while the file is fine, set [Console]::OutputEncoding = [System.Text.Encoding]::UTF8.

How do I run the script on a schedule?

With Task Scheduler: the action is powershell.exe -NoProfile -File C:\Scripts\report.ps1 (or pwsh.exe for PowerShell 7). Set ZUKKO_API_KEY for the account the task runs under. It's convenient to give such a script its own key with a daily dollar limit; see key limits.

Can I use the Anthropic format?

Yes: POST https://api.zukko.pro/v1/messages with the anthropic-version: 2023-06-01 header; the answer text is in .content[0].text. The format is described in the Messages API reference, and the OpenAI format in the chat completions reference.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

PowerShell and the Claude API: Invoke-RestMethod and UTF-8 · Zukko