SSD Nodes Learn
How to do am Matt ConnorBy Matt Connor · Updated 2026-07-24

how to use Claude API for VPS Ubuntu

Learn how to build a Python log-explainer for Ubuntu 24.04 using Claude API. We go show you how to use streaming, virtualenv, and systemd for real apps.

Wetin you dey build

Command-line tool wey you go run for brand new Ubuntu 24.04 VPS. You go pipe error message or log chunk enter am, and e go give you diagnosis for plain-English: journalctl -u nginx -n 50 | explain. The code be like sixty lines of Python, and e go teach you everything wey real Claude API application need — how to store key properly, how to use virtualenv, how SDK response shapes dey work, streaming, typed exception chain, and how to set up systemd unit so e fit run by itself.

I pick this project for a reason. Most "first API app" tutorials go make you build chatbot wey you no go ever open again. Log explainer dey work for server from day one, and e go force you learn two things wey beginners dey always mess up: how to read response object correctly, and how to control spend. API dey bill you per token and no limit dey except the ones wey you set, so cost control na part of design here, no be afterthought — na the same discipline wey you go need when you move to run Claude Code for this same VPS inside tmux.

Get an API key from the Console

You go manage API access for Anthropic Console for platform.claude.com — sign up, then create key under Settings → API Keys (the docs link straight to platform.claude.com/settings/keys). The key go show only once, e go start with sk-ant-, and you no fit see am again — copy am sharp-sharp or delete am and reissue new one.

About money: as of July 2026, no free tier dey for API. Anthropic pricing docs talk say new users go get small free credits to test with; the exact amount na wetin the Console show you for signup, and once that money finish, you must fund the account before requests go work. This one different from claude.ai subscription — Pro or Max plan no include API credit, and API key no give you access to the chat app. If you dey decide between subscription and API, that one na different topic: which Claude plan you actually need.

Create the key for one project or server only. When key leak — and for long time, one must leak — you go want revoke am without spoil everything else wey you get.

No put the key inside .bashrc

To move am like that na export ANTHROPIC_API_KEY=sk-ant-... inside ~/.bashrc. No do am. Three separate problems dey:

  • Every process go inherit am. If you export environment variable inside your login shell, everything wey you start go see am — the web app, the crash reporter wey dey dump environment inside bug report, and the phpinfo() page wey person leave open. The key exposure surface go become "everything wey this user run."
  • Typing am go land inside ~/.bash_history. If you run the export by hand once, your key go sit inside plaintext file forever, and e go sync inside every backup of your home directory.
  • E no go dey there when systemd need am. Services no dey read your .bashrc, so the pattern go fail exactly when you turn the script to unit — usually as one mysterious 401 at 6 a.m.

The correct pattern for server na to use dedicated environment file with 600 permissions, wey only the process wey need am go load:

sudo mkdir -p /opt/explain
sudo install -m 600 -o root -g root /dev/null /etc/claude-explain.env
printf 'ANTHROPIC_API_KEY=sk-ant-YOUR-KEY-HERE\n' | sudo tee /etc/claude-explain.env >/dev/null

Use tee from a printf instead of editor if you want make the key no enter editor swap files; either way, use ls -l /etc/claude-explain.env verify say e read -rw------- and root own am. Interactive shells go get the key per-invocation through wrapper (see below), and systemd go get am through EnvironmentFile= — root go read the file before e drop privileges, so the service user no go ever need read access to am. The key no go ever appear for code, for git, for ps output, or for shell history.

Install the SDK inside a venv

Ubuntu 24.04 carry Python 3.12 wey dey enforce PEP 668. If you try run pip install anthropic directly against system interpreter, e go fail with error: externally-managed-environment. That error mean say OS dey work as e suppose work — use virtualenv:

sudo apt update && sudo apt install -y python3-venv
sudo python3 -m venv /opt/explain/venv
sudo /opt/explain/venv/bin/pip install anthropic

You no need to activate am if you dey use server: if you call /opt/explain/venv/bin/python directly, e go always use the packages inside the venv.

First call, and reading the response correctly

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1000,
    messages=[{"role": "user", "content": "Explain what a systemd unit file is in three sentences."}],
)

for block in response.content:
    if block.type == "text":
        print(block.text)

Two things for those twelve lines carry most of the API's mental model. First, anthropic.Anthropic() without any arguments dey read the key from the environment — no ever pass am as string literal. Second, response.content na list of content blocks, no be string. If you print am directly, you go see the classic first-timer output:

[TextBlock(citations=None, text='A systemd unit file is...', type='text')]

That no be bug; na the object's repr. Responses fit contain multiple block types (text, tool calls, thinking), so you must iterate and check block.type == "text" before you touch .text. Write that loop well from day one, and you no go ever face that "it prints garbage" confusion.

Use the exact model ID claude-opus-4-8. Current-generation IDs no get date — no follow that muscle memory (or old blog post) wey dey tell you to add date suffix; that one dey produce 404, we go cover am below.

The actual tool: explain

Dis na di full program — e dey take stdin, e dey stream diagnosis out, and e dey handle errors:

#!/usr/bin/env python3
"""explain: pipe an error or log excerpt in, get a diagnosis out."""
import sys
import anthropic

MODEL = "claude-opus-4-8"

def main() -> int:
    text = sys.stdin.read().strip()
    if not text:
        print("usage: journalctl -u nginx -n 50 | explain", file=sys.stderr)
        return 1

    client = anthropic.Anthropic()
    try:
        with client.messages.stream(
            model=MODEL,
            max_tokens=1500,
            system=(
                "You are a senior Linux sysadmin. The user pipes you server "
                "logs or error output. Name the most likely cause outright, "
                "then give the commands to confirm and fix it. Be terse."
            ),
            messages=[{"role": "user", "content": text}],
        ) as stream:
            for chunk in stream.text_stream:
                print(chunk, end="", flush=True)
        print()
    except anthropic.RateLimitError as e:
        retry_after = e.response.headers.get("retry-after", "60")
        print(f"rate limited; retry in {retry_after}s", file=sys.stderr)
        return 2
    except anthropic.APIStatusError as e:
        print(f"API error {e.status_code}: {e.message}", file=sys.stderr)
        return 2
    except anthropic.APIConnectionError:
        print("network error reaching the API", file=sys.stderr)
        return 2
    return 0

if __name__ == "__main__":
    sys.exit(main())

Save am as /opt/explain/explain.py, den add wrapper wey go load di key for interactive use:

sudo tee /usr/local/bin/explain >/dev/null <<'EOF'
#!/bin/sh
set -a; . /etc/claude-explain.env; set +a
exec /opt/explain/venv/bin/python /opt/explain/explain.py "$@"
EOF
sudo chmod 755 /usr/local/bin/explain

(Di wrapper must run via sudo or di env file must belong to one group wey your admin user dey inside — pick one way carefully instead of to just change file permission to 644.)

Why streaming. client.messages.stream dey print tokens as dem dey arrive instead of to just sit down silent while generation dey go — dis one dey help avoid HTTP timeouts for long outputs — di SDK go even refuse very large max_tokens values for non-streaming calls because of dis very reason. If you need di assembled object later, call stream.get_final_message() inside di with block.

Why dat exception order. SDK dey raise typed exceptions, e dey start with di most-specific one: RateLimitError na 429 and e dey carry retry-after header wey go tell you how long you suppose wait; APIStatusError cover other non-2xx responses (check e.status_code >= 500 if server-side trouble dey); APIConnectionError mean say di request no get response at all. Before you build retry loop: SDK dey already retry 429s and 5xx errors itself, twice by default with exponential backoff (max_retries on di client). By di time your except run, di retries don finish — so di correct move for CLI na to report and exit, no be to sleep and hammer.

Cost control

Dis section deserve its own space because API no get built-in monthly cap beyond wetin you configure, and any mistake wey you make here go dey increase cost silently.

max_tokens na your per-call spend ceiling. Output tokens na dem expensive part — for Opus 4.8, e cost five times pass input price — and max_tokens na hard cap for how many tokens the model fit produce. If prompt go crazy, e no fit cost more output than wetin you allow. Set am based on work: 1,500 plenty for log diagnosis; classification task need 100. If response stop mid-sentence with stop_reason: "max_tokens", e mean say you set am too tight — increase am with care instead of just setting huge number.

Count before you send. Input cost money too, and logs dey heavy. API get counting endpoint wey free to use (e get its own rate limits, separate from message creation):

count = client.messages.count_tokens(
    model="claude-opus-4-8",
    messages=[{"role": "user", "content": big_log_text}],
)
print(count.input_tokens)

Use am so you no go accidentally pipe 2 GB log through the tool. No use tiktoken for this — na OpenAI tokenizer dat one, and e dey undercount Claude tokens by roughly 15–20% for normal text, and even more for code.

Pick the model based on task, no be based on loyalty. As of July 2026, Opus 4.8 (claude-opus-4-8) dey run $5 per million input tokens and $25 per million output; Haiku 4.5 (claude-haiku-4-5) na $1/$5 with 200K context; Sonnet 5 (claude-sonnet-5) dey sit between dem at $3/$15, with introductory $2/$10 pricing till August 31, 2026. To be exact: 2,000-token log excerpt with 500-token answer cost about $0.0225 on Opus and $0.0045 on Haiku. Start with Opus while you dey check output quality, then try the same prompts on Haiku — for high-volume, simple transformations, the result often be the same but at a fifth of the price. Check current numbers for pricing page before you hard-code any of dis for budget.

Use Batches for anything wey fit wait. Batches API dey process requests asynchronously at 50% of standard prices, and most batches dey finish within one hour. Nightly digests, backfills, bulk classification — anything wey no need human to wait for am, na for Batches.

Use prompt caching for repeated context. If every call dey re-send the same big system prompt or runbook, mark am as cacheable:

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1000,
    system=[{
        "type": "text",
        "text": RUNBOOK_TEXT,  # the same 30K tokens on every call
        "cache_control": {"type": "ephemeral"},
    }],
    messages=[{"role": "user", "content": question}],
)
print(response.usage.cache_read_input_tokens)  # non-zero from the second call on

Cache writes cost about 1.25x the input price, cache reads cost about 0.1x, with 5-minute TTL — so the second call within the window don already pay for the first one. Two things to watch: The cached prefix must pass per-model minimum — a few thousand tokens on Opus — so if system prompt short, e no go cache at all. And if cache_read_input_tokens dey stay zero even when calls be identical, e mean say something for your prefix dey change every request (timestamp na the usual culprit).

Remember wetin count as input. System prompts, tool definitions, and — for multi-turn conversations — the whole history wey you dey re-send every turn, all of dem be input tokens. Chat loop wey no dey trim history go dey increase cost quadratically. You need to understand how the full accounting work before you build anything conversational: how Claude token usage and billing actually add up.

Run am under systemd

The benefit of to use environment-file discipline na: timer wey go summarize yesterday errors every morning.

# /etc/systemd/system/log-digest.service
[Unit]
Description=Daily error-log digest via the Claude API

[Service]
Type=oneshot
User=explain
Group=systemd-journal
EnvironmentFile=/etc/claude-explain.env
ExecStart=/bin/sh -c 'journalctl -p err --since yesterday | /opt/explain/venv/bin/python /opt/explain/explain.py >> /var/log/log-digest.txt'
# /etc/systemd/system/log-digest.timer
[Unit]
Description=Run the log digest every morning

[Timer]
OnCalendar=06:15
Persistent=true

[Install]
WantedBy=timers.target
sudo useradd -r -s /usr/sbin/nologin explain
sudo touch /var/log/log-digest.txt && sudo chown explain /var/log/log-digest.txt
sudo systemctl daemon-reload
sudo systemctl enable --now log-digest.timer
sudo systemctl start log-digest.service   # test it once, right now

Look wetin EnvironmentFile= dey do for you: systemd go read the root-owned, mode-600 file before e drop permission to the unprivileged explain user, so the process go get the variable but the user no go fit read the key file. The systemd-journal group dey give access to logs. Test am with manual systemctl start and read journalctl -u log-digest.service — no wait till 06:15 before you see typo. When this pattern pass shell pipeline, you fit use the same key-in-env-file approach for Claude-powered n8n workflows for the same box.

Failure modes, with the strings you will see

401 on a working key. The error message na:

anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}, 'request_id': 'req_011CSHoEeqs5C35K2UUqR7Fy'}

If the key dey work for your shell but the service dey give 401, the service no receive the key — remember say systemd no dey read .bashrc; check if EnvironmentFile= dey point to the correct path. Other reasons: you paste quotes inside the env file (ANTHROPIC_API_KEY="sk-ant-..." — systemd go remove the quotes, but your shell wrapper's . file go keep them inside the value if you quote am wrong), trailing whitespace, or you revoke the key for Console last week.

404 from a model typo. The most common way dis one dey happen na when you add date-suffix to current model ID:

anthropic.NotFoundError: Error code: 404 - {'type': 'error', 'error': {'type': 'not_found_error', 'message': 'model: claude-opus-4-8-20260115'}, 'request_id': 'req_011CSJqymAvNw4bT3qmDdMbA'}

Current-generation IDs dey exact as dem write dem — claude-opus-4-8, claude-haiku-4-5, claude-sonnet-5. Copy dem from the models documentation, no use memory or old tutorial.

429 rate_limit_error. The error type string na rate_limit_error and the response get retry-after header wey go tell you how many seconds you need to wait. The SDK don already try twice with backoff before you see the exception, so if 429 still dey, e mean say your rate really pass your tier — batch the work or spread am out, no make the retry loop too tight.

It prints the object, not the text. Output go look like [TextBlock(citations=None, text='...', type='text')]. You print response.content instead of to iterate blocks and read .text from the ones wey get block.type == "text". Every SDK example wey dey above dey do am correctly; copy the loop.

error: externally-managed-environment. You run pip install against Ubuntu 24.04's system Python. Use the venv — no ever use --break-system-packages for server wey you dey care about.

Truncated answers. response.stop_reason == "max_tokens" mean say the model hit your output cap mid-thought. E dey work as designed; increase the cap.

Once your first app work, building an AI agent with Claude go turn those same API calls into agent wey dey use tools.

FAQ

How much Claude API cost to try?

E small well-well for tool like this. As of July 2026, Opus 4.8 cost $5 per million input tokens and $25 per million output, so if you dey do typical log diagnosis — maybe couple thousand tokens in, few hundred out — e go cost around two cents. For Haiku 4.5 ($1/$5), e go cost less than half a cent. One month of daily digests cost less than one coffee. The real risk no be the price per call; na unbounded loops and unbounded max_tokens wey dey worry, na why we go set dem explicitly for this guide.

Dem get free tier for Claude API?

No ongoing free tier as of July 2026. Anthropic pricing documentation talk say new users go get small free credits to test the API — na one-time trial, and the exact amount go show for Console when you sign up — after that, you must fund the account. If your goal na zero marginal cost per request instead of frontier quality, you fit self-host an open-weight model with Ollama and pay with RAM instead of tokens.

How I fit keep my API key safe for server?

No put am inside code, no put am for git, no export am from .bashrc, and no type am for shell wey dey keep history. Put am for file wey root own and wey get 600 permissions. Load am per-process — use wrapper script for interactive use, and use EnvironmentFile= for systemd. Use one key for each server or project so that if one key leak, to revoke am go easy like surgery, no be amputation. If the key ever land for paste site or git commit, revoke am for Console immediately; to delete the commit no mean say the leak don finish.

Which Claude model I suppose start with?

Start with claude-opus-4-8 while you dey check if the outputs good enough to build on — you need to judge the idea with full quality, and for hobby volume, the cost difference na just cents. Once you don settle the prompt, rerun your real inputs for claude-haiku-4-5; for summarization, classification, and log triage, e dey often dey as good as the others but at a fifth of the price. Move to Haiku or Sonnet based on wetin you measure, no be just because na default.