SSD Nodes Learn
Guides Matt ConnorBy Matt Connor

Claude API tutorial: your first app on a VPS

Get a Claude API key, secure it on Ubuntu 24.04, ship a Python log-explainer with streaming, typed error handling, and real cost control.

What you are building

A command-line tool on a fresh Ubuntu 24.04 VPS that you pipe an error message or a chunk of log into and get back a plain-English diagnosis: journalctl -u nginx -n 50 | explain. It is maybe sixty lines of Python, and it exercises everything a real Claude API application needs — a key that is stored properly, a virtualenv, the SDK's response shapes, streaming, the typed exception chain, and a systemd unit so it runs without you.

I picked this project deliberately. Most "first API app" tutorials have you build a chatbot you will never open again. A log explainer earns its keep on a server from day one, and it forces you through the two things beginners actually get wrong: reading the response object correctly, and controlling spend. The API bills per token with no ceiling other than the ones you set, so cost control is a design input here, not an afterthought — the same discipline that matters when you graduate to running Claude Code on this same VPS in tmux.

Get an API key from the Console

API access is managed in the Anthropic Console at platform.claude.com — sign up, then create a key under Settings → API Keys (the docs link straight to platform.claude.com/settings/keys). The key is shown once, starts with sk-ant-, and cannot be retrieved again — copy it immediately or delete and reissue.

On money: as of July 2026 there is no ongoing free tier for the API. Anthropic's pricing docs say new users receive a small amount of free credits to test with; the exact amount is whatever the Console shows you at signup, and once it is gone you fund the account before requests succeed. This is separate from a claude.ai subscription — a Pro or Max plan does not include API credit, and an API key does not give you the chat app. If you are weighing subscription against API, that trade-off is its own topic: which Claude plan you actually need.

Create the key scoped to one project or server. When a key leaks — and on a long enough timeline, one will — you want to revoke it without breaking everything else you own.

Keep the key out of .bashrc

The reflexive move is export ANTHROPIC_API_KEY=sk-ant-... in ~/.bashrc. Don't. Three separate problems:

  • Every process inherits it. An environment variable exported in your login shell propagates to everything you start — the web app, the crash reporter that helpfully dumps its environment into a bug report, the phpinfo() page someone left enabled. The key's exposure surface becomes "everything this user ever runs."
  • Typing it lands in ~/.bash_history. Run the export by hand once and your key sits in a plaintext file, forever, and syncs into every backup of your home directory.
  • It isn't there when systemd needs it. Services do not read your .bashrc, so the pattern fails exactly when you promote the script to a unit — usually as a mysterious 401 at 6 a.m.

The right pattern on a server is a dedicated environment file with 600 permissions, loaded only by the process that needs it:

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 rather than an editor if you want to keep the key out of editor swap files; either way, verify with ls -l /etc/claude-explain.env that it reads -rw------- and is owned by root. Interactive shells get the key per-invocation through a wrapper (below), and systemd gets it through EnvironmentFile= — root reads the file before dropping privileges, so the service user never needs read access to it. The key never appears in code, in git, in ps output, or in shell history.

Install the SDK in a venv

Ubuntu 24.04 ships Python 3.12 with PEP 668 enforcement, so a bare pip install anthropic against the system interpreter fails with error: externally-managed-environment. That error is the OS working as intended — use a 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

No activation ceremony needed on a server: calling /opt/explain/venv/bin/python directly always uses the venv's packages.

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 in those twelve lines carry most of the API's mental model. First, anthropic.Anthropic() with no arguments reads the key from the environment — never pass it as a string literal. Second, response.content is a list of content blocks, not a string. Print it directly and you get the classic first-timer output:

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

That is not a bug; it is the object's repr. Responses can contain multiple block types (text, tool calls, thinking), so you iterate and check block.type == "text" before touching .text. Wire that loop in on day one and an entire class of "it prints garbage" confusion never happens.

Use the exact model ID claude-opus-4-8. Current-generation IDs are dateless — resist the muscle memory (or the old blog post) telling you to append a date suffix; that produces a 404, covered below.

The actual tool: explain

Here is the full program — stdin in, streamed diagnosis out, errors handled:

#!/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 it as /opt/explain/explain.py, then add a wrapper that loads 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 needs to run via sudo or the env file needs a group your admin user belongs to — pick one deliberately rather than loosening the file to 644.)

Why streaming. client.messages.stream prints tokens as they arrive instead of sitting silent for the full generation, and it sidesteps HTTP timeouts on long outputs — the SDK will actually refuse very large max_tokens values on non-streaming calls for exactly that reason. If you need the assembled object afterwards, call stream.get_final_message() inside the with block.

Why that exception order. The SDK raises typed exceptions, most-specific first: RateLimitError is a 429 and carries a retry-after header telling you how long to wait; APIStatusError covers other non-2xx responses (check e.status_code >= 500 for server-side trouble); APIConnectionError means the request never got a response at all. And before you build a retry loop: the SDK already retries 429s and 5xx errors itself, twice by default with exponential backoff (max_retries on the client). By the time your except runs, the retries are spent — so the right move in a CLI is to report and exit, not to sleep and hammer.

Cost control

This deserves its own section because the API has no built-in monthly cap beyond what you configure, and every mistake here compounds silently.

max_tokens is your per-call spend ceiling. Output tokens are the expensive direction — on Opus 4.8, five times the input price — and max_tokens is a hard cap on how many the model may produce. A runaway prompt cannot cost more output than you allowed. Size it to the job: 1,500 is plenty for a log diagnosis; a classification task needs 100. If responses stop mid-sentence with stop_reason: "max_tokens", you sized it too tight — raise it consciously rather than defaulting to huge.

Count before you send. Input costs money too, and logs are bulky. The API has a counting endpoint that is free to use (it has 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 it to guard against accidentally piping a 2 GB log through the tool. Do not use tiktoken for this — that is OpenAI's tokenizer, and it undercounts Claude tokens by roughly 15–20% on typical text, and by more on code.

Pick the model per task, not per loyalty. As of July 2026, Opus 4.8 (claude-opus-4-8) runs $5 per million input tokens and $25 per million output; Haiku 4.5 (claude-haiku-4-5) is $1/$5 with a 200K context; Sonnet 5 (claude-sonnet-5) sits between at $3/$15, with introductory $2/$10 pricing through August 31, 2026. Concretely: a 2,000-token log excerpt with a 500-token answer costs about $0.0225 on Opus and $0.0045 on Haiku. Start on Opus while you are judging output quality, then try the same prompts on Haiku — for high-volume, simple transformations it is often indistinguishable at a fifth of the price. Verify current numbers at the pricing page before hard-coding any of this into a budget.

Batches for anything that can wait. The Batches API processes requests asynchronously at 50% of standard prices, and most batches complete within an hour. Nightly digests, backfills, bulk classification — anything without a human waiting belongs there.

Prompt caching for repeated context. If every call re-sends the same big system prompt or runbook, mark it 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 about 0.1x, on a 5-minute TTL — so the second call within the window already pays for the first. Two catches. The cached prefix must clear a per-model minimum — a few thousand tokens on Opus — so a short system prompt silently won't cache at all. And if cache_read_input_tokens stays zero across identical calls, something in your prefix changes every request (a timestamp is the usual culprit).

Remember what counts as input. System prompts, tool definitions, and — in multi-turn conversations — the entire history you re-send every turn are all billed as input tokens. A chat loop that never trims history grows quadratically in cost. The full accounting is worth understanding before you build anything conversational: how Claude token usage and billing actually add up.

Run it under systemd

The payoff for the environment-file discipline: a timer that summarizes yesterday's 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

Note what EnvironmentFile= buys you: systemd reads the root-owned, mode-600 file before dropping to the unprivileged explain user, so the process gets the variable while the user cannot read the key file. The systemd-journal group grants log access. Test with a manual systemctl start and read journalctl -u log-digest.service — do not wait for 06:15 to find a typo. When this pattern outgrows a shell pipeline, the same key-in-env-file approach carries straight into Claude-powered n8n workflows on the same box.

Failure modes, with the strings you will see

401 on a working key. The exception reads:

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

If the key works in your shell but the service 401s, the service never received it — remember systemd does not read .bashrc; check EnvironmentFile= points at the right path. Other causes: quotes pasted into the env file (ANTHROPIC_API_KEY="sk-ant-..." — systemd keeps the quotes out, but your shell wrapper's . file keeps them in the value if you quoted oddly), trailing whitespace, or a key you revoked in the Console last week.

404 from a model typo. The single most common version of this is date-suffixing a 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_011CSHoEeqs5C35K2UUqR7Fy'}

Current-generation IDs are exact as written — claude-opus-4-8, claude-haiku-4-5, claude-sonnet-5. Copy them from the models documentation, never from memory or an old tutorial.

429 rate_limit_error. The error type string is rate_limit_error and the response carries a retry-after header with the seconds to wait. The SDK has already retried twice with backoff before you see the exception, so persistent 429s mean your sustained rate genuinely exceeds your tier — batch the work or spread it out, don't tighten the retry loop.

It prints the object, not the text. Output looks like [TextBlock(citations=None, text='...', type='text')]. You printed response.content instead of iterating blocks and reading .text from the ones where block.type == "text". Every SDK example above does it correctly; copy the loop.

error: externally-managed-environment. You ran pip install against Ubuntu 24.04's system Python. Use the venv — never --break-system-packages on a server you care about.

Truncated answers. response.stop_reason == "max_tokens" means the model hit your output cap mid-thought. Working as designed; raise the cap deliberately.

FAQ

How much does the Claude API cost to try?

Genuinely little for a tool like this. As of July 2026, Opus 4.8 costs $5 per million input tokens and $25 per million output, so a typical log diagnosis — a couple thousand tokens in, a few hundred out — is around two cents, and on Haiku 4.5 ($1/$5) under half a cent. A month of daily digests costs less than a coffee. The risk isn't the per-call price; it's unbounded loops and unbounded max_tokens, which is why both get set explicitly in this guide.

Is there a free tier for the Claude API?

No ongoing free tier as of July 2026. Anthropic's pricing documentation says new users receive a small amount of free credits to test the API — a one-time trial, with the exact amount shown in the Console at signup — after which you fund the account. If your goal is zero marginal cost per request rather than frontier quality, the alternative is to self-host an open-weight model with Ollama and pay in RAM instead of tokens.

How do I keep my API key safe on a server?

Never in code, never in git, never exported from .bashrc, never typed into a shell where history will keep it. Put it in a root-owned file with 600 permissions, load it per-process — a wrapper script for interactive use, EnvironmentFile= for systemd — and scope one key per server or project so revoking a leaked key is surgery, not amputation. If the key ever touches a paste site or a git commit, revoke it in the Console immediately; deleting the commit does not un-leak it.

Which Claude model should I start with?

Start with claude-opus-4-8 while you are evaluating whether the outputs are good enough to build on — you want to judge the idea at full quality, and at hobby volume the cost difference is cents. Once the prompt is settled, rerun your real inputs on claude-haiku-4-5; for summarization, classification, and log triage it is frequently as good at a fifth of the price. Move to Haiku or Sonnet by measurement, not by default.

#claude-api#python#ai#vps#anthropic