Module 3: GitLab CI/CD and Headless SDK

Headless SDK: Python and TypeScript

Headless SDK: Python and TypeScript

Overview

The Claude Code headless SDK is the agent's programmatic interface — the same capability you use interactively from the terminal, but accessible from code. It's what makes cross-platform portability possible (capsule 01 of the module introduced it as a concept). This capsule teaches you to use the SDK from Python and TypeScript scripts, the differences between the two, and when to choose each.

By the end, you'll be able to write a script in Python or TypeScript that invokes the SDK with a prompt and context, receives the response as structured data, and processes it programmatically. Without this, capsules 04-05 are blind execution.


The SDK as an Abstraction Layer

┌──────────────────────────────────────────┐
│   INTERACTIVE USE (terminal mode)         │
│   → You open Claude Code                  │
│   → You converse naturally                │
│   → Output formatted for humans           │
│   → Conversational prompts                │
└──────────────────────────────────────────┘

┌──────────────────────────────────────────┐
│   USE WITH THE HEADLESS SDK               │
│   → Your script invokes the API           │
│   → You pass structured prompts           │
│   → You receive JSON with tokens & output │
│   → You process programmatically          │
│   → Reproducible, testable, parallelizable│
└──────────────────────────────────────────┘

The SDK isn't "less" than interactive mode — it's the same thing accessible programmatically. The model is the same, the capabilities are the same. Only the interface changes: terminal vs code.


Setup: Python

Install the SDK

pip install anthropic

Recommended version: pin it in requirements.txt:

anthropic>=0.39.0,<1.0.0

Basic call

"""Basic call to the Anthropic SDK."""
import os
from anthropic import Anthropic

# The client reads ANTHROPIC_API_KEY from the environment
client = Anthropic()

response = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=1024,
    messages=[
        {"role": "user", "content": "Summarize what CI/CD is in 2 sentences."}
    ],
)

print(response.content[0].text)
print(f"Tokens: {response.usage.input_tokens} in / {response.usage.output_tokens} out")

Output:

CI/CD is a software practice where code changes are integrated (CI)
and deployed (CD) automatically via pipelines. It lets you catch issues
early and deliver functionality continuously.

Tokens: 32 in / 78 out

Response components

response.content        # list of blocks (typically 1 block of type "text")
response.content[0].text # the generated text
response.usage.input_tokens  # tokens consumed on input
response.usage.output_tokens # tokens generated on output
response.stop_reason    # "end_turn", "max_tokens", "stop_sequence"
response.model          # model used (echo of the request)
response.id             # unique identifier of the call

Model: when to use which

# Economical, fast, enough for general analysis
model="claude-haiku-4-5"

# Cost/quality balance, usable for deep code review
model="claude-sonnet-5"

# Maximum quality, for complex reasoning (expensive)
model="claude-opus-5"

For generic CI/CD: Haiku. For code review in critical modules (auth, payments): Sonnet. Opus rarely justifies itself in CI.


Setup: TypeScript

Install the SDK

npm install @anthropic-ai/sdk
# or
pnpm add @anthropic-ai/sdk
# or
yarn add @anthropic-ai/sdk

Basic call

// scripts/analyze.ts
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();  // reads ANTHROPIC_API_KEY from the env

async function main() {
  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 1024,
    messages: [
      { role: "user", content: "Summarize what CI/CD is in 2 sentences." }
    ],
  });

  // The first block is usually text
  const block = response.content[0];
  if (block.type === "text") {
    console.log(block.text);
  }
  console.log(`Tokens: ${response.usage.input_tokens} in / ${response.usage.output_tokens} out`);
}

main();

TypeScript types

The SDK exports useful types:

import Anthropic, {
  type Message,
  type MessageParam,
  type ContentBlock,
  type TextBlock,
} from "@anthropic-ai/sdk";

function processResponse(response: Message): string {
  const textBlocks = response.content.filter(
    (block): block is TextBlock => block.type === "text"
  );
  return textBlocks.map((b) => b.text).join("\n");
}

TypeScript advantage: error detection at compile time. If you pass a wrongly typed parameter, the compiler warns you before running.


Comparison: Python vs TypeScript

AspectPythonTypeScript
VerbosityLess code, more implicitMore explicit (types)
Compile-time errorsNoYes (with correct types)
CI/CD ecosystemMature (pytest, requests, GitPython)Mature (octokit, jest)
Async/awaitAvailable but less idiomaticNative, idiomatic
PerformanceSlower (interpreted)Faster (V8)
Learning curveLowerMedium (if you don't know TS)
Typical use casesData, ML, scripts, automationApps, frontends, modern tools

How to choose

Choose Python if:

  • Your team already uses Python in the project
  • You're going to integrate with data libraries (pandas, numpy)
  • You want a more concise script with less ceremony
  • The code you're going to process is Python

Choose TypeScript if:

  • Your main project is TypeScript/JavaScript
  • You want strict types in your CI script
  • You're going to integrate with modern tooling (Vite, esbuild)
  • Your team prefers async/await as the main pattern

Practical reality: both work equally well. The choice usually depends on the team's stack, not the SDK's capabilities.


Pattern: A Well-Structured CI Script

A CI script shouldn't be a monolith. Minimal recommended structure:

In Python

"""scripts/code_review.py — code review script for CI."""
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from anthropic import Anthropic, APIError


@dataclass
class ReviewConfig:
    """Review configuration."""
    model: str
    max_tokens: int
    conventions_file: Path
    diff_file: Path


@dataclass
class ReviewResult:
    """Structured result of the review."""
    summary: str
    comments: list[dict]
    tokens_used: dict


def load_config() -> ReviewConfig:
    """Load config from the environment."""
    return ReviewConfig(
        model=os.environ.get("CLAUDE_MODEL", "claude-haiku-4-5"),
        max_tokens=int(os.environ.get("CLAUDE_MAX_TOKENS", "4000")),
        conventions_file=Path("CLAUDE.md"),
        diff_file=Path("pr_diff.txt"),
    )


def review(config: ReviewConfig, client: Anthropic) -> ReviewResult:
    """Run the review and return a structured result."""
    diff = config.diff_file.read_text()
    conventions = (
        config.conventions_file.read_text()
        if config.conventions_file.exists()
        else "No documented conventions."
    )
    
    prompt = build_prompt(diff, conventions)
    response = client.messages.create(
        model=config.model,
        max_tokens=config.max_tokens,
        messages=[{"role": "user", "content": prompt}],
    )
    
    text = response.content[0].text
    parsed = parse_review_json(text)
    
    return ReviewResult(
        summary=parsed["summary"],
        comments=parsed["comments"],
        tokens_used={
            "input": response.usage.input_tokens,
            "output": response.usage.output_tokens,
        },
    )


def build_prompt(diff: str, conventions: str) -> str:
    """Build the review prompt."""
    return f"""[The full prompt with conventions and diff goes here]"""


def parse_review_json(text: str) -> dict:
    """Parse the JSON from the response, handling code fences."""
    text = text.strip()
    if text.startswith("```"):
        text = "\n".join(text.split("\n")[1:-1])
    return json.loads(text)


def main() -> int:
    try:
        config = load_config()
        client = Anthropic()
        result = review(config, client)
        
        # Save the result for the next workflow step
        Path("review_result.json").write_text(
            json.dumps({
                "summary": result.summary,
                "comments": result.comments,
                "tokens_used": result.tokens_used,
            }, indent=2)
        )
        
        print(f"Review generated: {len(result.comments)} comments")
        return 0
    
    except APIError as e:
        print(f"Anthropic API ERROR: {e}", file=sys.stderr)
        return 1
    except json.JSONDecodeError as e:
        print(f"ERROR parsing JSON response: {e}", file=sys.stderr)
        return 1
    except Exception as e:
        print(f"Unexpected ERROR: {e}", file=sys.stderr)
        return 1


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

Advantages of this structure:

  • Single-responsibility functions (testable)
  • Configuration separated from behavior
  • Explicit error handling with exit codes
  • Output saved to a file for the next workflow step
  • Type hints for future confidence

In TypeScript

// scripts/code-review.ts
import Anthropic from "@anthropic-ai/sdk";
import * as fs from "node:fs";
import * as path from "node:path";

interface ReviewConfig {
  model: string;
  maxTokens: number;
  conventionsFile: string;
  diffFile: string;
}

interface ReviewComment {
  path: string;
  line: number;
  severity: "critical" | "warning" | "suggestion";
  body: string;
}

interface ReviewResult {
  summary: string;
  comments: ReviewComment[];
  tokensUsed: { input: number; output: number };
}

function loadConfig(): ReviewConfig {
  return {
    model: process.env.CLAUDE_MODEL ?? "claude-haiku-4-5",
    maxTokens: parseInt(process.env.CLAUDE_MAX_TOKENS ?? "4000"),
    conventionsFile: "CLAUDE.md",
    diffFile: "pr_diff.txt",
  };
}

async function review(
  config: ReviewConfig,
  client: Anthropic,
): Promise<ReviewResult> {
  const diff = fs.readFileSync(config.diffFile, "utf-8");
  const conventions = fs.existsSync(config.conventionsFile)
    ? fs.readFileSync(config.conventionsFile, "utf-8")
    : "No documented conventions.";
  
  const prompt = buildPrompt(diff, conventions);
  
  const response = await client.messages.create({
    model: config.model,
    max_tokens: config.maxTokens,
    messages: [{ role: "user", content: prompt }],
  });
  
  const block = response.content[0];
  if (block.type !== "text") {
    throw new Error("Expected text block in response");
  }
  
  const parsed = parseReviewJson(block.text);
  return {
    summary: parsed.summary,
    comments: parsed.comments,
    tokensUsed: {
      input: response.usage.input_tokens,
      output: response.usage.output_tokens,
    },
  };
}

function parseReviewJson(text: string): { summary: string; comments: ReviewComment[] } {
  let cleaned = text.trim();
  if (cleaned.startsWith("```")) {
    cleaned = cleaned.split("\n").slice(1, -1).join("\n");
  }
  return JSON.parse(cleaned);
}

function buildPrompt(diff: string, conventions: string): string {
  return `[Full prompt here]`;
}

async function main(): Promise<number> {
  try {
    const config = loadConfig();
    const client = new Anthropic();
    const result = await review(config, client);
    
    fs.writeFileSync("review_result.json", JSON.stringify(result, null, 2));
    console.log(`Review generated: ${result.comments.length} comments`);
    return 0;
  } catch (error) {
    console.error(`ERROR: ${error instanceof Error ? error.message : error}`);
    return 1;
  }
}

main().then(process.exit);

Advanced SDK Configuration

System prompts (separate from the user message)

response = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=2000,
    system="You are a strict code reviewer. Always output JSON.",  # ← system prompt
    messages=[{"role": "user", "content": user_prompt}],
)

Advantage: the system prompt establishes the agent's "role" and separates it from the data. Cleaner and reusable.

Streaming (for long outputs)

with client.messages.stream(
    model="claude-haiku-4-5",
    max_tokens=4000,
    messages=[{"role": "user", "content": prompt}],
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)
    # Afterward you can access the final message:
    final = stream.get_final_message()

When to use streaming:

  • The expected output is long (>2K tokens)
  • You want to show progress to the user
  • In CI it's uncommon — most of the time you don't need it

Retry with backoff

from anthropic import Anthropic

client = Anthropic(
    max_retries=3,        # automatic retries on transient errors
    timeout=60.0,         # timeout in seconds
)

The SDK handles retries by default. Useful for CI where transient network errors are common.


Common Pitfalls

Error 1: Hardcoding the API key

Symptom: Works locally, fails in CI with "API key missing".

Why it happens: You passed the key as a client argument: Anthropic(api_key="sk-ant-..."). In CI, it's in the environment.

How to fix it: Use the constructor without arguments. The SDK reads ANTHROPIC_API_KEY from the environment automatically:

client = Anthropic()  # ← reads from the env

Error 2: Not handling the content[0].type != "text" case

Symptom: The script fails with AttributeError or TypeError.

Why it happens: You assumed response.content[0].text always exists. But the first block can be tool_use, image, etc.

How to fix it: Validate:

text_blocks = [b for b in response.content if b.type == "text"]
if not text_blocks:
    raise ValueError("No text in response")
text = text_blocks[0].text

Error 3: Asking for JSON but not parsing it robustly

Symptom: Works most of the time, but sometimes fails with JSONDecodeError.

Why it happens: The model sometimes wraps the JSON in code fences (json ... ), adds text at the start or end, or uses single quotes.

How to fix it: A helper that cleans code fences before parsing (parse_review_json in the examples above). And validation: if the parse fails, log the original text for debugging.

Error 4: Ignoring max_tokens

Symptom: Output cut off abruptly with stop_reason="max_tokens".

Why it happens: The default can be low. If you asked for a review of 30 files with max_tokens=1024, the model falls short.

How to fix it: Calibrate max_tokens based on the case. For code review, 2000-4000 is usually appropriate. If you frequently see max_tokens as the stop_reason, raise it.

Error 5: Not using types in TypeScript

Symptom: Runtime errors the compiler could have caught.

Why it happens: Using any everywhere negates the benefits of TypeScript.

How to fix it: Define interfaces for your data (ReviewComment, ReviewResult, etc.). Use the types the SDK exports.


Diagnosis

Question 1: How does your script read the API key?

If you said "as a client argument", it works locally but fails in CI. The correct way is from the environment.

Question 2: Do you validate that `response.content[0]` is a text block?

If you assume it always is, you'll have bugs when the model uses tools or other block types.

Question 3: If you ask for structured JSON, do you handle code fences?

The model sometimes adds them even if you ask for "only JSON". Cleaning them before parsing avoids intermittent errors.

Question 4: Is your `max_tokens` calibrated for your usage?

If you frequently see stop_reason: "max_tokens", raise it. If you never get close, you can lower it to save.

Question 5: Does your code handle errors with appropriate exit codes?

Without exit codes, the workflow doesn't know if the script failed. Exit 0 = success, exit 1+ = failure.


Exercises

Exercise 1: Basic call with error handling (Easy)

Write a script that:

  1. Reads ANTHROPIC_API_KEY from the environment
  2. Makes a simple call to the model with a prompt
  3. Prints the output
  4. Handles API errors with exit code 1

Exercise 2: Structured JSON (Medium)

Modify the script to:

  1. Ask the model for output in JSON with specific fields
  2. Clean code fences if there are any
  3. Parse with JSON error handling
  4. Access specific fields

Exercise 3: A well-structured script (Hard)

Implement the complete structure (Python or TypeScript):

  1. ReviewConfig (classes/interfaces)
  2. ReviewResult
  3. A review() function separate from main()
  4. Robust error handling
  5. Output to a JSON file for the next step

Compare with the structure shown in "Pattern: A Well-Structured CI Script".


Summary

  • The headless SDK is the programmatic interface to Claude Code — same model, different interface
  • Python and TypeScript have feature parity — the choice depends on the team's stack
  • The client reads ANTHROPIC_API_KEY from the environment automatically — don't pass it as an argument
  • The system prompt separates the agent's "role" from the data
  • Validate content[0].type before accessing .text
  • Clean code fences before parsing JSON
  • Script structure: config + pure functions + main with error handling

Next capsule: 03 — GitLab CI/CD: stages, jobs, artifacts. You have the SDK working. Now you learn GitLab's pipeline model — different from GitHub Actions at the architectural level — to understand where the SDK script fits.


Additional Resources

  1. Anthropic Python SDK — Official repo with docs
  2. Anthropic TypeScript SDK — Official repo
  3. Anthropic API Reference — Complete messages endpoint
  4. Anthropic Models Documentation — Model comparison
  5. Anthropic Pricing — Costs per model
  6. System prompts best practices — When and how to use them