SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

Claude API: Build Your First VPS App with Python

Learn how to secure a Claude API key on Ubuntu 24.04 and build a Python log explainer with streaming, typed errors, systemd, and token cost limits.

Wetin you dey build

Na command-line tool for fresh Ubuntu 24.04 VPS. You go pipe error message or part of log enter am, and e go return plain-English diagnosis: journalctl -u nginx -n 50 | explain. E fit be around sixty lines of Python, and e go make you practise everything wey real Claude API application need: key wey you store properly, virtualenv, SDK response shapes, streaming, typed exception chain, and systemd unit so e fit run without you.

I choose this project on purpose. Most “first API app” tutorials make you build chatbot wey you no go open again. Log explainer dey useful for server from day one, and e force you go through the two things wey beginners dey commonly get wrong: how to read response object correctly, and how to control spending. API dey charge per token, and na only the limits wey you set fit cap the cost. So cost control na part of the design here, no be something to add later. Na the same discipline wey matter when you move to run Claude Code for this same VPS inside tmux.

Get API key from Console

You go manage API access for Anthropic Console at platform.claude.com. Sign up, then create key under Settings → API Keys (the docs link go straight to platform.claude.com/settings/keys). Console go ask you to name the key, choose when e go expire, and link am to yourself or service account. Anthropic recommend service account keys for production services and anything wey multiple people share. Console go show the key only once. E start with sk-ant-, and you no fit retrieve am again. Copy am immediately, or delete am and issue another one.

About money: as of October 2026, API no get ongoing free tier. Anthropic pricing docs talk say new users receive small free credit to test with. The exact amount na wetin Console show you when you sign up. After the credit finish, you need fund the account before requests go succeed. This one separate from claude.ai subscription, and API key no give you the chat app. Pro plan no include API credit, but Max and Team plans get am: as of October 2026, $100 per month for Max 5x and $200 for Max 20x. Team pool get $20 per Standard seat and $100 per Premium seat, with $500 as the limit. You claim am by linking a Console organization from your claude.ai billing settings. Unused credit expire at the end of each billing cycle and e no cover Claude Code. The details dey for how Max and Team monthly API credits dey work. If you dey compare subscription with API, that trade-off na separate topic: which Claude plan you really need.

Create the key with scope for one project or server. If key leak, and time pass enough, one go leak eventually. You want revoke that key without breaking every other thing wey you own.

Keep the key comot for .bashrc

The reflexive move na export ANTHROPIC_API_KEY=sk-ant-... for ~/.bashrc. No do am. Three different problems dey:

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

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

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 keep the key comot for editor swap files; either way, verify with ls -l /etc/claude-explain.env say e dey read -rw------- and root own am. Interactive shells get the key per-invocation through wrapper (below), and systemd get am through EnvironmentFile=, root read the file before e drop privileges, so service user no ever need read access to am. The key never show for code, git, ps output, or shell history.

Install SDK inside one venv

Ubuntu 24.04 dey release Python 3.12 with PEP 668 enforcement, so bare pip install anthropic against system interpreter go fail with error: externally-managed-environment. Na OS dey work as e suppose; 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 activation ceremony for server: if you call /opt/explain/venv/bin/python directly, e go always use the packages wey dey inside 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-5-5",
    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)

Things for those twelve lines carry most of the API mental model. First, anthropic.Anthropic() wey no get arguments dey read the key from environment; never pass am as string literal. Second, response.content na list of content blocks, e 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')]

This no be bug; na the object's repr. Responses fit contain different block types (text, tool calls, thinking), so you go iterate through dem and check block.type == "text" before you touch .text. For Opus 5.5, this matter from the very first call: thinking dey always on. So unless the model skip thinking for simple request, the list go start with thinking block wey text empty by default, and response.content[0].text no be your answer. If you wire that loop from day one, one whole class of “it prints garbage” confusion no go happen.

Use the exact model ID claude-opus-5-5. From the 4.6 generation, model IDs no get date, and the dateless ID na the pinned snapshot. So no follow the muscle memory or old blog post wey tell you make you append date suffix to current model; that one go produce 404, as we explain below. Older models work the other way: Haiku 4.5 canonical ID na the dated claude-haiku-4-5-20251001.

Di actual tool: explain

Na full program be this: stdin go enter, streamed diagnosis go commot, and errors dey handled:

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

MODEL = "claude-opus-5-5"

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=4096,
            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, then add wrapper wey go load the 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

(The wrapper need to run through sudo, or the env file need get group wey your admin user dey inside. Choose one deliberately; no loosen the file permission to 644.)

Why streaming. client.messages.stream dey print tokens as dem arrive instead of keeping quiet until the whole generation finish. E also avoid HTTP timeouts for long outputs. The SDK go actually reject very large max_tokens values for non-streaming calls for this same reason. text_stream go return only the answer text. With the default thinking display for Opus 5.5, no thinking text dey stream at all, so the output remain clean. If you need the assembled object afterwards, call stream.get_final_message() inside the with block.

Why that exception order. The SDK dey raise typed exceptions, from the most specific one first: RateLimitError na 429 and e carry retry-after header wey tell you how long to wait; APIStatusError cover other non-2xx responses (check e.status_code >= 500 for server-side problem); APIConnectionError mean say the request no receive any response at all. And before you build retry loop: the SDK already retries 429s and 5xx errors by itself, two times by default with exponential backoff (max_retries for the client). By the time your except run, the retries don finish. So for CLI, the correct action na to report the error and exit, no be to sleep and keep hammering.

Cost control

This one need get im own section because API no get built-in monthly limit apart from wetin you configure, and every mistake here dey add up quietly.

max_tokens na the maximum money wey one call fit spend. Output tokens cost pass input tokens; for Opus 5.5, dem cost five times the input price, and max_tokens na hard limit for how many tokens the model fit produce. If prompt run wild, e no fit cost more output than wetin you allow. For the 5.5 models, this limit include thinking too: thinking tokens count toward max_tokens and dem dey bill as output, even though model no return the thinking text to you. Set the size based on the work: 4,096 give terse log diagnosis enough space for the thinking before the answer; classification task need much less. If response stop early with stop_reason: "max_tokens", the limit too tight, so raise am deliberately (or reduce the effort setting make the model think less) instead of using very large value by default.

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

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

Use am to stop yourself from accidentally piping 2 GB log pass the tool, and pass the model wey you really go call: Claude 4.7 and later models dey use newer tokenizer wey dey produce roughly 30% more tokens for the same text than older Claude models. No use tiktoken for this, because na OpenAI tokenizer, no be Claude own, so im counts no go match wetin dem bill you for.

Pick model for each task, no be because of loyalty. As of October 2026, Opus 5.5 (claude-opus-5-5) dey cost $4 per million input tokens and $20 per million output tokens; Haiku 5.5 (claude-haiku-5-5) na $0.10/$0.50 for prompts up to 100,000 tokens ($0.50/$2.50 above that), with 1M context; Sonnet 5.5 (claude-sonnet-5-5) dey between dem at $2/$10. For example: 2,000-token log excerpt with 500-token answer go cost about $0.018 for Opus and $0.00045 for Haiku, plus any thinking wey model do, because dem dey bill am as output. Start with Opus while you dey check output quality, then try the same prompts with Haiku. For high-volume, simple transformations, the result often no get noticeable difference, while per-token price na one-fortieth. Confirm the current numbers for the pricing page before you hard-code any of this into budget.

Use Batches for anything wey fit wait. Batches API dey process requests asynchronously at 50% of standard prices, and most batches dey complete within one hour. Nightly digests, backfills, bulk classification, and anything wey person no dey wait for belong there.

Use prompt caching for context wey dey repeat. If every call dey send the same big system prompt or runbook again, mark am as cacheable:

response = client.messages.create(
    model="claude-opus-5-5",
    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 1.25x the input price with 5-minute TTL, while cache reads cost 0.05x on Opus 5.5 and Sonnet 5.5 (0.1x on most other models). So the second call within the window don already cover the first call cost. Two things fit cause wahala. The cached prefix must reach per-model minimum, which na 512 tokens for Opus 5.5, otherwise short system prompt no go cache at all without warning. And if cache_read_input_tokens remain zero across identical calls, something inside your prefix dey change on every request; timestamp na the usual cause.

Remember wetin count as input. System prompts, tool definitions, and, for multi-turn conversations, the complete history wey you send again on every turn, all dey bill as input tokens. Chat loop wey never trim history dey make cost grow quadratically. You need understand the complete accounting before you build anything conversational: how Claude token usage and billing actually add up.

Run am under systemd

The benefit of this 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

Notice wetin EnvironmentFile= dey give you: systemd dey read the root-owned file with mode-600 before e drop privilege to the unprivileged explain user. So the process get the variable, while the user no fit read the key file. The systemd-journal group dey grant log access. Test am with manual systemctl start and read journalctl -u log-digest.service. No wait till 06:15 before you discover typo. When this pattern pass wetin shell pipeline fit handle, you fit carry the same key-in-env-file approach directly enter Claude-powered n8n workflows for the same box.

Failure modes, with the strings you go see

401 on a working key. The exception 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 return 401, the service no ever receive am. Remember say systemd no dey read .bashrc; check say EnvironmentFile= dey point to the correct path. Other causes na quotes wey you paste inside the env file (ANTHROPIC_API_KEY="sk-ant-...", systemd dey remove the quotes, but your shell wrapper's . file go keep dem inside the value if you quote am anyhow), trailing whitespace, key wey you revoke for the Console last week, or key wey reach the expiration time wey you choose when you create am.

404 from a model typo. The most common version of this problem na to add date suffix to model ID wey no get dated form:

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

Current-generation IDs dey exactly as dem write am, claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5, claude-fable-5-1. Models wey come before the 4.6 generation na the exception: claude-haiku-4-5 na convenience alias for the dated ID claude-haiku-4-5-20251001, and both forms dey work. Copy IDs from the models documentation; no copy am from memory or old tutorial.

429 rate_limit_error. The error type string na rate_limit_error, and the response get retry-after header with the seconds wey you suppose wait. The SDK don already retry twice with backoff before you see the exception. So, persistent 429s mean say your sustained rate really pass your tier limit. Batch the work or spread am out; no make the retry loop tighter.

E dey print the object, no be the text. Output go look like [TextBlock(citations=None, text='...', type='text')]. You print response.content instead of iterating through blocks and reading .text from the ones wey block.type == "text". Every SDK example above do am correctly; copy the loop.

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

Answers wey cut short. response.stop_reason == "max_tokens" mean say the model reach your output cap for the middle of the thought. Na the expected behaviour; raise the cap deliberately, and remember say for the 5.5 models, thinking dey use the same cap.

After your first app don work, build AI agent with Claude go turn those same API calls into agent wey dey use tools.

FAQ

How much e dey cost to try Claude API?

Genuinely, e dey cost very little for tool like this. As of October 2026, Opus 5.5 dey cost $4 for every million input tokens and $20 for every million output tokens. So, normal log diagnosis wey get few thousand tokens for input and few hundred for output go cost around two cents before thinking tokens (wey dem bill as output). For Haiku 5.5 ($0.10/$0.50), e go cost well below one-tenth of one cent. One month of daily digests go cost less than coffee. The real risk no be price per call; na unbounded loops and unbounded max_tokens. Na why this guide set both ones explicitly.

API get free tier for Claude?

No ongoing free tier as of October 2026. Anthropic pricing documentation talk say new users dey receive small amount of free credits to test API. Na one-time trial, and Console go show the exact amount during signup. After that, you need fund the account. Max and Team subscribers na exception: their plans include monthly API credits, as we explain above. If your goal na zero marginal cost per request instead of frontier quality, alternative na to self-host open-weight model with Ollama and pay with RAM instead of tokens.

How I fit keep my API key safe for server?

Never put am for code, never put am for git, never export am from .bashrc, and never type am for shell wey history go keep am. Put am inside file wey root own, with 600 permissions. Load am per process: use wrapper script for interactive work and EnvironmentFile= for systemd. Use one key per server or project, so if key leak, you fit revoke am with small targeted action instead of affecting everything. If the key ever enter paste site or git commit, revoke am for Console immediately. Deleting the commit no go remove the leaked key.

Which Claude model I suppose start with?

Start with claude-opus-5-5 while you dey check whether the outputs good enough to build on. You also want judge the idea with full quality, and for hobby-level usage, the cost difference na only cents. Once the prompt don settle, run your real inputs again with claude-haiku-5-5. For summarization, classification, and log triage, e often good enough at one-fortieth of the per-token price. Move to Haiku or Sonnet based on measurement, not by default.