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
Anthropic Console for platform.claude.com dey manage API access. Sign up, then create key under Settings → API Keys (the docs link go straight to platform.claude.com/settings/keys). Console go show the key one time. E dey start with sk-ant-, and you no fit retrieve am again. Copy am immediately, or delete am and issue new one.
For money matter: as of July 2026, API no get ongoing free tier. Anthropic pricing docs talk say new users dey receive small free credits to test with. The exact amount na wetin Console show you during signup. Once the credit finish, you must fund the account before requests go succeed. This one separate from claude.ai subscription. Pro or Max plan no include API credit, and API key no give you the chat app. If you dey compare subscription with API, that trade-off na separate topic: which Claude plan you actually need.
Create the key for only one project or server. If key leak, and with enough time e go happen, you go want revoke am without breaking everything else 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/nullUse 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 anthropicYou 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 how to read 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 way of working. First, anthropic.Anthropic() without arguments dey read the key from the environment; never 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 usual output wey beginners dey get:
[TextBlock(citations=None, text='A systemd unit file is...', type='text')]That one no be bug; na the object's repr. Response fit contain different block types (text, tool calls, thinking), so iterate through am and check block.type == "text" before you touch .text. Put that loop there from day one, and one whole type of "e dey print garbage" confusion no go happen.
Use the exact model ID claude-opus-4-8. Current-generation IDs no get date. No follow the habit (or old blog post) wey tell you make you add date suffix; that one go produce 404, as we explain below.
Di tool wey dey work: explain
Na the full program be this. E take input from stdin, stream diagnosis as e dey happen, and 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. 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 run through sudo, or the env file need get group wey your admin user belong to. Choose one deliberately; no loosen the file permission to 644.)
Why streaming. client.messages.stream dey print tokens as dem arrive instead of staying silent until the whole generation finish. E also avoid HTTP timeouts for long outputs. The SDK go reject very large max_tokens values for non-streaming calls for this same reason. 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. Before you build retry loop, remember say the SDK already retries 429s and 5xx errors by itself, twice by default with exponential backoff (max_retries on the client). By the time your except run, all retries don finish. So for CLI, the correct action na to report the error and exit, no be to sleep and keep hammering.
Control cost
Dis one deserve im own section because API no get built-in monthly limit apart from wetin you configure, and every mistake for here dey add up quietly.
max_tokens na your maximum spend for each call. Output tokens cost pass input tokens for Opus 4.8, five times the input price, and max_tokens na hard limit for how many tokens model fit produce. Runaway prompt no fit cost more output than wetin you allow. Set the size based on the job: 1,500 plenty for log diagnosis; classification task need 100. If response stop halfway with stop_reason: "max_tokens", you set the limit too low. Increase am deliberately instead of using very large value by default.
Count am before you send. Input dey cost money too, and logs dey bulky. API get counting endpoint wey free to use, but e get im 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 to prevent accidental piping of 2 GB log through the tool. No use tiktoken for this. Na OpenAI tokenizer, and e dey count Claude tokens lower by about 15–20% for normal text, and even more for code.
Choose model for each task, no be loyalty. As of July 2026, Opus 4.8 (claude-opus-4-8) dey cost $5 per million input tokens and $25 per million output tokens. Haiku 4.5 (claude-haiku-4-5) na $1/$5 with 200K context. Sonnet 5 (claude-sonnet-5) dey between dem at $3/$15, with introductory $2/$10 price until August 31, 2026. For practical example, 2,000-token log excerpt with 500-token answer cost about $0.0225 for Opus and $0.0045 for Haiku. Start with Opus while you dey assess output quality. Then test the same prompts with Haiku. For high-volume, simple transformations, e often dey give almost the same result at one-fifth of the price. Check the current numbers for 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 no get human waiting for am 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 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 onCache writes cost about 1.25x the input price. Cache reads cost about 0.1x, with 5-minute TTL. So the second call inside the window don already help pay for the first one. Two things fit cause problem. Cached prefix must pass per-model minimum, usually few thousand tokens for Opus. So short system prompt fit no cache at all without any obvious error. Also, if cache_read_input_tokens remain zero across identical calls, something for your prefix dey change for every request. Timestamp na the usual cause.
Remember wetin count as input. System prompts, tool definitions, and the complete history wey you resend for every turn in multi-turn conversations all dey bill as input tokens. Chat loop wey never trim history dey make cost grow quadratically. E good make you understand the full accounting before you build conversational system: 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.targetsudo 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 nowNotice 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 mode, pamoja strings wey you go see
401 for key wey dey work. Exception message na:
anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}, 'request_id': 'req_011CSHoEeqs5C35K2UUqR7Fy'}If key dey work for your shell but service dey return 401, service no ever receive am. Remember say systemd no dey read .bashrc; check say EnvironmentFile= dey point to correct path. Other causes na quotes wey you paste inside env file (ANTHROPIC_API_KEY="sk-ant-..."; systemd dey remove the quotes, but your shell wrapper's . file go keep dem inside value if you quote am anyhow), whitespace for end, or key wey you revoke for Console last week.
404 because model typo. The most common version of this problem 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 exact as dem dey written: claude-opus-4-8, claude-haiku-4-5, claude-sonnet-5. Copy dem from models documentation; no use memory or old tutorial.
429 rate_limit_error. Error type string na rate_limit_error, and response get retry-after header wey show how many seconds you suppose wait. SDK don already retry two times with backoff before you see exception. So if 429 dey continue, e mean say your sustained rate really pass your tier limit. Batch the work or spread am out; no make retry loop tighter.
E print object, no be 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 dey do am correctly; copy the loop.
error: externally-managed-environment. You run pip install against Ubuntu 24.04 system Python. Use the venv; no ever use --break-system-packages for server wey matter to you.
Answers wey cut short. response.stop_reason == "max_tokens" mean say model reach your output cap for middle of the thought. Na as e suppose work; increase the cap deliberately.
After your first app work, build AI agent with Claude go turn those same API calls into agent wey dey use tools.
FAQ
How much does the Claude API cost to try?
E really cost small for tool like this. As of July 2026, Opus 4.8 cost $5 per million input tokens and $25 per million output, so normal log diagnosis, with some thousand tokens going in and some hundred coming out, dey around two cents. For Haiku 4.5 ($1/$5), e dey below half cent. One month of daily digests cost less than one coffee. The main risk no be price per call; na unbounded loops and unbounded max_tokens. Na why this guide set both of dem directly.
Is there a free tier for the Claude API?
No ongoing free tier dey as of July 2026. Anthropic pricing documentation talk say new users receive small free credits to test the API. Na one-time trial, and Console go show the exact amount when you sign up. After that, you go fund the account. If wetin you want na zero marginal cost per request instead of frontier quality, you fit self-host open-weight model with Ollama and pay with RAM instead of tokens.
How do I keep my API key safe on a server?
Never put am for code, never put am for git, never export am from .bashrc, and never type am for shell wey go keep am for history. Put am for root-owned file with 600 permissions. Load am per-process: use wrapper script for interactive work and EnvironmentFile= for systemd. Use one key for each server or project, so if key leak, you fit revoke am with controlled action instead of affecting everything. If the key ever enter paste site or git commit, revoke am for Console immediately. Deleting the commit no remove the leaked key.
Which Claude model should I start with?
Start with claude-opus-4-8 while you dey check whether the output good enough to build on. You want judge the idea with full quality, and for hobby volume, the cost difference na only cents. After you settle the prompt, run your real inputs again with claude-haiku-4-5. For summarization, classification, and log triage, e dey often perform almost as well at one-fifth of the price. Choose Haiku or Sonnet based on measurement, not by default.