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:
$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:
$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.contentTwo details people most often get wrong:
-Depth 10onConvertTo-Json. The default depth is 2, and nested objects turn into strings likeSystem.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:
- 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. - The response. If the response header doesn't specify a charset,
Invoke-RestMethodin 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 withInvoke-WebRequestand 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.
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 beforeswitch: insideswitch,$_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. -UseBasicParsingis 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 themessagesarray serializes to JSON correctly with either one or two elements.
Examples:
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:
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]:
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:
(Invoke-RestMethod 'https://api.zukko.pro/v1/models' -Headers @{ Authorization = "Bearer $env:ZUKKO_API_KEY" }).data.idor 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.
Related articles
- Translating subtitles with a neural network: SRT without broken timecodesThe main risk when translating SRT with a neural network is a broken file structure. A scheme where timecodes never reach the model, and a Python script with validation.
- Editing texts with a neural network: proofreading, style and cuttingDifferent kinds of editing need different prompts. Wording for proofreading, style and cutting, how to get edits with explanations and how not to lose the author's voice.
- A neural network for writing scripts: Bash, PowerShell and PythonA model writes scripts for routine fast, but running them blindly is risky. How to state the task, demand a safe dry-run mode and check the result before launch.
- Dify in Docker: installation, first-run setup and connecting modelsInstall Dify with Docker Compose, create the admin account and connect Claude, GPT and DeepSeek models on one key. Plus what to do when containers will not start.