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
| Aspect | Python | TypeScript |
|---|---|---|
| Verbosity | Less code, more implicit | More explicit (types) |
| Compile-time errors | No | Yes (with correct types) |
| CI/CD ecosystem | Mature (pytest, requests, GitPython) | Mature (octokit, jest) |
| Async/await | Available but less idiomatic | Native, idiomatic |
| Performance | Slower (interpreted) | Faster (V8) |
| Learning curve | Lower | Medium (if you don't know TS) |
| Typical use cases | Data, ML, scripts, automation | Apps, 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:
- Reads
ANTHROPIC_API_KEYfrom the environment - Makes a simple call to the model with a prompt
- Prints the output
- Handles API errors with exit code 1
Exercise 2: Structured JSON (Medium)
Modify the script to:
- Ask the model for output in JSON with specific fields
- Clean code fences if there are any
- Parse with JSON error handling
- Access specific fields
Exercise 3: A well-structured script (Hard)
Implement the complete structure (Python or TypeScript):
- ReviewConfig (classes/interfaces)
- ReviewResult
- A
review()function separate frommain() - Robust error handling
- 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_KEYfrom the environment automatically — don't pass it as an argument - The system prompt separates the agent's "role" from the data
- Validate
content[0].typebefore 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
- Anthropic Python SDK — Official repo with docs
- Anthropic TypeScript SDK — Official repo
- Anthropic API Reference — Complete messages endpoint
- Anthropic Models Documentation — Model comparison
- Anthropic Pricing — Costs per model
- System prompts best practices — When and how to use them