Skip to content

Calling Claude from Python

Weeks 1–8 call Claude through the Claude Code command line (claude), which runs on your Claude subscription. You never need an API key for these weeks. Every lesson uses one small file, llm.py, so the details below are set up once.

Before week 1

  1. Install Claude Code and log in, if you haven't already. In a terminal:

    claude --version
    

    You should see a version number. If you see command not found, install it from the Claude Code docs.

  2. Check that a one-line call works:

    echo "Say hello in five words." | claude -p
    

    A reply in a few seconds means you're ready.

The helper: llm.py

In week 1 you copy this file into your project as src/scam_checker/llm.py. Read it through once; the comments explain each part.

"""Call Claude through the Claude Code CLI on your subscription, not the API.

Weeks 1-8 use this file. In week 9 you replace the body of call_claude()
with the Anthropic SDK; everything that calls call_claude() stays the same.
"""

from __future__ import annotations

import json
import subprocess
from dataclasses import dataclass
from typing import Any

# Used when you don't pass system=. Without it, the CLI would fall back to
# Claude Code's own long system prompt, written for a coding agent.
DEFAULT_SYSTEM = "You are a helpful assistant. Answer concisely."


class ClaudeError(RuntimeError):
    """The CLI failed, timed out, or reported an error."""


@dataclass
class ClaudeResult:
    text: str
    data: dict[str, Any] | None  # parsed JSON, only when you pass a schema
    input_tokens: int
    output_tokens: int
    cost_usd: float  # what this call would cost on the API; you pay nothing extra
    duration_ms: int


def call_claude(
    prompt: str,
    *,
    system: str | None = None,
    schema: dict[str, Any] | None = None,
    model: str = "haiku",
    timeout: float = 120,
) -> ClaudeResult:
    """Send one prompt to Claude and return the reply.

    The prompt goes in on stdin, so a message that starts with "-" can't be
    mistaken for a command-line flag.
    """
    cmd = ["claude", "-p", "--output-format", "json", "--model", model]
    cmd += ["--tools", ""]  # a plain model call: no file, shell or web tools
    cmd += ["--setting-sources", ""]  # ignore CLAUDE.md and project settings
    cmd += ["--no-session-persistence"]
    cmd += ["--system-prompt", system or DEFAULT_SYSTEM]
    if schema:
        cmd += ["--json-schema", json.dumps(schema)]

    try:
        proc = subprocess.run(
            cmd,
            input=prompt,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,  # a failed call is handled below, not as an exception
        )
    except subprocess.TimeoutExpired as exc:
        raise ClaudeError(f"no reply after {timeout} seconds") from exc

    if proc.returncode != 0:
        raise ClaudeError(proc.stderr.strip() or proc.stdout.strip())

    out = json.loads(proc.stdout)
    if out.get("is_error"):
        raise ClaudeError(str(out.get("result", "unknown error")))

    usage = out.get("usage", {})
    # Count every input token, including any the CLI served from its cache.
    input_tokens = (
        usage.get("input_tokens", 0)
        + usage.get("cache_creation_input_tokens", 0)
        + usage.get("cache_read_input_tokens", 0)
    )
    return ClaudeResult(
        text=out.get("result", ""),
        data=out.get("structured_output"),
        input_tokens=input_tokens,
        output_tokens=usage.get("output_tokens", 0),
        cost_usd=out.get("total_cost_usd", 0.0),
        duration_ms=out.get("duration_ms", 0),
    )


if __name__ == "__main__":
    reply = call_claude("Say hello in five words.")
    print(reply.text)
    print(f"{reply.input_tokens} in, {reply.output_tokens} out, {reply.duration_ms} ms")

What the flags do:

Flag Why it's there
-p Print mode: answer once and exit, instead of opening the chat screen
--output-format json Return a JSON object you can parse, with the reply, token counts and timing
--model haiku The smallest, fastest model. Good enough for most checks; you'll compare models in week 11
--tools "" Turn off Claude Code's own tools (files, shell, web), so this is a plain model call. In week 2 you write the tools
--setting-sources "" Ignore any CLAUDE.md or project settings on your laptop, so results don't depend on which folder you run from
--no-session-persistence Don't save each call as a Claude Code session
--system-prompt The instructions that frame every message, kept separate from the message itself. If you don't pass system=, a one-line default is used; otherwise the CLI would send Claude Code's own long prompt for a coding agent
--json-schema Force the reply to match a JSON shape. The parsed result lands in data

The prompt is sent on standard input rather than as an argument. A scam message that starts with -- would otherwise be read as a flag.

Using it

from scam_checker.llm import call_claude

reply = call_claude("Explain what an SMS sender ID is in two sentences.")
print(reply.text)
print(reply.input_tokens, reply.output_tokens, reply.duration_ms)

With a schema, read reply.data instead of reply.text:

schema = {
    "type": "object",
    "properties": {"verdict": {"type": "string", "enum": ["scam", "not_scam", "unsure"]}},
    "required": ["verdict"],
    "additionalProperties": False,
}
reply = call_claude("Message: You won $5,000! Reply with your NRIC.", schema=schema)
print(reply.data)  # {'verdict': 'scam'}

Things to know

  • Speed: each call takes about 3–15 seconds, because it starts the claude program every time. That's fine for building, and slow for 100 messages in a row. Week 5 shows you how to run several at once.
  • Usage limits: calls count against your subscription's limits. If you hit one, call_claude raises a ClaudeError with the message; wait, then carry on. Week 1 teaches you to handle this properly.
  • Token counts: every call carries about 3,000 input tokens of framing that the CLI adds, even for "Say hi". On the API in week 9 the same call is far smaller, so compare token counts within a week, not across the switch.
  • cost_usd: the CLI reports what the call would cost on the API. You don't pay it, but log it anyway: it's the number week 11 is about.
  • Not for deployed apps: a subscription login may only be used on your own machine, not inside a product other people use. That's why week 9 switches to an API key.

Week 9: the swap

When the checker goes live, you replace the body of call_claude() with a call to the Anthropic Python SDK and keep its name, arguments and return type. Every other file keeps calling call_claude() and doesn't notice. Week 9 walks through it.

Keeping all model calls behind one function is a real engineering habit, not just a workaround. It's what lets you switch models, providers or a test fake without touching the rest of the code.