Agent Code Architecture & Secure API Key Handling
A practical guide for building well-structured, production-ready AI agents.
Introduction
When building an AI agent, two concerns come up immediately:
1. How do I structure my code so it stays maintainable as the agent grows?
2. How do I handle API keys without leaking them or hard-coding them?
These two problems are deeply connected. A poor architecture often leads to API keys ending up in the wrong place (config files, logs, command arguments). A clean architecture makes security almost automatic.
This document answers both questions with concrete patterns drawn from a real-world production codebase.
Part 1 — Agent Code Architecture
The Big Picture
A well-structured agent follows this layered model:
┌─────────────────────────────┐
│ Entry Point │ CLI, HTTP handler, script, etc.
├─────────────────────────────┤
│ Query Engine │ Owns the conversation loop
├─────────────────────────────┤
│ Tool Registry │ What the agent can <em>do</em>
├─────────────────────────────┤
│ System Prompt │ Dynamically built from live tools
├─────────────────────────────┤
│ API Client │ Thin wrapper over the LLM provider
├─────────────────────────────┤
│ Auth / Credentials │ Handles keys, OAuth, rotation
└─────────────────────────────┘
Each layer has a single responsibility. The entry point creates a QueryEngine, the engine drives the agentic loop, and the API client is the only layer that ever touches the API key.
1.1 — The Agentic Loop
The agentic loop is the heart of any agent. It follows a simple pattern:
Send message → Receive response → If tool_use: execute tool → Send result → Repeat
In code:
// agent/QueryEngine.ts
async function* runLoop(messages: Message[]): AsyncGenerator<AgentMessage> {
while (true) {
const response = await apiClient.messages.create({
model: "claude-sonnet-4-6",
system: buildSystemPrompt(tools),
tools: tools.map(toAPISchema),
messages,
})
if (response.stop_reason === "end_turn") {
yield { type: "result", content: extractText(response) }
return
}
if (response.stop_reason === "tool_use") {
// Push the assistant turn into history
messages.push({ role: "assistant", content: response.content })
// Execute each tool call and collect results
const results = await executeToolCalls(response.content, tools)
// Push tool results back as a user turn
messages.push({ role: "user", content: results })
}
}
}
Key insight: the loop does not know anything about API keys or tool implementations. It only orchestrates.
1.2 — The Tool Registry
Tools are the agent's capabilities. Each tool should be a self-contained module that exports:
- A name constant (so renaming never breaks prompt text)
- A schema (for the API call)
- An execute function (the actual implementation)
// tools/ReadFileTool/index.ts
export const READ_FILE_TOOL_NAME = "ReadFile"
export const ReadFileTool = {
name: READ_FILE_TOOL_NAME,
schema: {
name: READ_FILE_TOOL_NAME,
description: "Read the full contents of a file at the given path.",
input_schema: {
type: "object" as const,
properties: {
path: { type: "string", description: "Absolute path to the file." },
},
required: ["path"],
},
},
async execute({ path }: { path: string }): Promise<string> {
const { readFile } = await import("fs/promises")
return readFile(path, "utf-8")
},
}
The registry is simply an array of such objects:
// tools/index.ts
import { BashTool } from "./BashTool"
import { ReadFileTool } from "./ReadFileTool"
import { WriteFileTool } from "./WriteFileTool"
export const ALL_TOOLS = [BashTool, ReadFileTool, WriteFileTool]
1.3 — Dynamic System Prompt
Never hard-code tool names in your system prompt. If you do, any change to the tool registry will silently desync the agent's self-knowledge.The right approach: build the system prompt as a function of the active tool list.
// agent/systemPrompt.ts
import { READ_FILE_TOOL_NAME } from "../tools/ReadFileTool"
import { WRITE_FILE_TOOL_NAME } from "../tools/WriteFileTool"
import { BASH_TOOL_NAME } from "../tools/BashTool"
function buildSystemPrompt(activeTools: Set<string>): string {
const sections: string[] = [
"You are a helpful software engineering agent.",
]
// Only mention a tool if it is actually available
const prefs: string[] = ["Prefer dedicated tools over Bash:"]
if (activeTools.has(READ_FILE_TOOL_NAME))
prefs.push( - Read files: use ${READ_FILE_TOOL_NAME}, NOT cat/head/tail)
if (activeTools.has(WRITE_FILE_TOOL_NAME))
prefs.push( - Write files: use ${WRITE_FILE_TOOL_NAME}, NOT echo or heredoc)
if (activeTools.has(BASH_TOOL_NAME))
prefs.push( - Shell commands: use ${BASH_TOOL_NAME} only as a last resort)
if (prefs.length > 1) sections.push(prefs.join("\n"))
return sections.filter(Boolean).join("\n\n")
}
This pattern comes directly from the free-code codebase (the Claude Code fork you have). The fetchSystemPromptParts() function in QueryEngine.ts receives the live tools array and assembles sections dynamically — never from a static string.
1.4 — The QueryEngine Class
For any non-trivial agent, wrap the loop in a class. This gives you:
- Per-conversation state (message history, usage tracking)
- A clean public API (
submitMessage,interrupt,getMessages) - Reusability across REPL, SDK, and CLI modes
// agent/QueryEngine.ts
export class QueryEngine {
private messages: Message[] = []
private abortController = new AbortController()
constructor(private config: AgentConfig) {}
async *submitMessage(prompt: string): AsyncGenerator<AgentEvent> {
this.messages.push({ role: "user", content: prompt })
yield* this.runLoop()
}
interrupt(): void {
this.abortController.abort()
}
getHistory(): readonly Message[] {
return this.messages
}
}
The ask() function in QueryEngine.ts is a thin convenience wrapper that creates a QueryEngine, calls submitMessage, and tears down afterward. Students should understand this pattern: class for stateful sessions, function wrapper for one-shots.
1.5 — Project File Structure
Here is the folder structure used by the free-code codebase. It is a good model to follow:
src/
entrypoints/ # Entry points (CLI, SDK)
QueryEngine.ts # Session-scoped agentic loop
query.ts # Raw query functions (lower level)
tools.ts # Tool registry
commands.ts # Slash-command registry
Tool.ts # Tool type definition
tools/ # One folder per tool
BashTool/
FileReadTool/
FileWriteTool/
services/
api/ # API client (the ONLY place that sends HTTP requests)
oauth/ # OAuth token management
mcp/ # Model Context Protocol integrations
utils/
auth.ts # API key resolution logic
env.ts # Environment helpers
config.ts # Global config (read/write)
secureStorage/ # OS keychain abstraction
state/ # App-wide state store
hooks/ # React-style hooks (for terminal UI)
components/ # Ink/React terminal UI components
The guiding rule: the services/api/ layer is the only layer that ever constructs HTTP headers or reads the API key. Every other layer requests the key through getAnthropicApiKey() or an auth helper, and the API client is the only consumer.
Part 2 — Secure, Efficient, and Scalable API Key Handling
Why This Matters
An API key is a credential. If it leaks:
- Your account gets billed (or worse, rate-limited) by an attacker
- Rotated keys break all deployments that hard-coded the old one
- Keys in git history persist even after deletion
The patterns below solve all three problems.
2.1 — The Priority Resolution Chain
A production agent should support multiple credential sources so that it works in every environment (local dev, CI, production, team workspaces). Resolve them in a strict priority order:
1. Environment variable ANTHROPIC_API_KEY
2. File descriptor (secure pipe from parent process)
3. External helper script apiKeyHelper (e.g. a vault client)
4. OS keychain macOS Keychain, Secret Service, etc.
5. Config file ~/.config/myapp/config.json ← last resort
In code, this looks like:
// utils/auth.ts
export function getApiKey(): string | null {
// 1. Environment variable — simplest, works in CI
if (process.env.ANTHROPIC_API_KEY) {
return process.env.ANTHROPIC_API_KEY
}
// 2. File descriptor — secure pipe from a trusted parent process
const fromFd = readFromFileDescriptor()
if (fromFd) return fromFd
// 3. External helper — e.g. "aws secretsmanager get-secret-value ..."
const fromHelper = getKeyFromHelperCached()
if (fromHelper) return fromHelper
// 4. OS keychain — macOS Keychain, Linux Secret Service
const fromKeychain = readFromKeychain()
if (fromKeychain) return fromKeychain
// 5. Config file — least secure, last resort
return readFromConfigFile()
}
This is exactly the pattern in src/utils/auth.ts (getAnthropicApiKeyWithSource()). Notice that the function also returns where the key came from (source), which lets you log diagnostics without logging the key itself.
2.2 — Never Hard-Code Keys
Wrong:// ❌ Never do this
const client = new Anthropic({ apiKey: "sk-ant-api03-..." })
Right:
// ✅ Resolve at runtime from the environment
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
Even better: make the Anthropic SDK pick up the key automatically by not passing it at all — it reads ANTHROPIC_API_KEY from the environment by default:
// ✅ Best: SDK reads process.env.ANTHROPIC_API_KEY automatically
const client = new Anthropic()
Set the key once in your shell, never in your source code:
export ANTHROPIC_API_KEY="sk-ant-..."
And always add .env and any config files to .gitignore.
2.3 — Use .env Files for Local Development
For local development, use a .env file + a library like dotenv (Node.js) or python-dotenv (Python). This keeps keys out of your shell history and makes onboarding easy:
<h1 style="font-family:'Playfair Display',serif;font-weight:900;color:#f1f5f9;font-size:2rem;margin-top:3.5rem;margin-bottom:1rem">.env — NEVER commit this file</h1>
ANTHROPIC_API_KEY=sk-ant-...
// main.ts
import "dotenv/config" // loads .env into process.env automatically
<h1 style="font-family:'Playfair Display',serif;font-weight:900;color:#f1f5f9;font-size:2rem;margin-top:3.5rem;margin-bottom:1rem">.gitignore</h1>
.env
.env.local
*.key
2.4 — Cache with TTL (Stale-While-Revalidate)
If your key comes from an external source (vault, IAM role, SSO), fetching it on every API call adds latency. Cache it — but with a TTL so rotation takes effect.
The free-code codebase uses a stale-while-revalidate (SWR) pattern:
// utils/auth.ts
const DEFAULT_TTL_MS = 5 <em> 60 </em> 1000 // 5 minutes
let cache: { value: string; timestamp: number } | null = null
let inflight: Promise<string | null> | null = null
export async function getApiKeyWithCache(): Promise<string | null> {
// If cache is fresh, return it synchronously
if (cache && Date.now() - cache.timestamp < DEFAULT_TTL_MS) {
return cache.value
}
// If stale, return the old value immediately and refresh in background
if (cache) {
if (!inflight) {
inflight = fetchFreshKey().then(value => {
if (value) cache = { value, timestamp: Date.now() }
inflight = null
return value
})
}
return cache.value // serve stale while refreshing
}
// Cold start: wait for the first fetch
if (inflight) return inflight
inflight = fetchFreshKey().then(value => {
if (value) cache = { value, timestamp: Date.now() }
inflight = null
return value
})
return inflight
}
Benefits:
- Zero latency for most requests (returns cached value)
- Background refresh means the key is always nearly fresh
- Single in-flight fetch deduplicates concurrent cold starts
2.5 — Store Keys in the OS Keychain, Not in Files
For desktop apps or CLI tools that store user credentials, prefer the OS keychain over a config file.
// utils/secureStorage/macOsKeychain.ts
import { execa } from "execa"
const SERVICE = "my-agent"
export async function saveKey(username: string, apiKey: string): Promise<void> {
// Encode as hex so the key never appears in process arguments
// (Process monitors only see "security -i", not the password)
const hex = Buffer.from(apiKey, "utf-8").toString("hex")
const command = add-generic-password -U -a "${username}" -s "${SERVICE}" -X "${hex}"\n
await execa("security", ["-i"], { input: command })
}
export function readKey(username: string): string | null {
try {
const { execSync } = require("child_process")
return execSync(security find-generic-password -a ${username} -w -s "${SERVICE}")
.toString()
.trim()
} catch {
return null
}
}
The hex encoding is critical: it ensures the actual key value never appears in the command-line arguments that system monitors (like ps aux) can see.
On Linux, use libsecret / secret-tool. In cross-platform code, use a library like keytar which abstracts over all OS backends.
2.6 — The apiKeyHelper Pattern (External Vault Integration)
The most scalable approach for teams is to delegate key retrieval to an external command. This lets each developer configure their own vault (AWS Secrets Manager, 1Password CLI, HashiCorp Vault, etc.) without changing the agent's code.
Configure it via settings:
{
"apiKeyHelper": "aws secretsmanager get-secret-value --secret-id ANTHROPIC_API_KEY --query SecretString --output text"
}
The agent runs this command, captures stdout, and uses the result as the API key. The agent itself never knows where the key lives.
// utils/auth.ts
async function getKeyFromHelper(helperCommand: string): Promise<string | null> {
const result = await execa(helperCommand, {
shell: true,
timeout: 10_000, // 10 seconds max
reject: false,
})
if (result.failed) {
throw new Error(apiKeyHelper failed: ${result.stderr})
}
const key = result.stdout.trim()
if (!key) throw new Error("apiKeyHelper returned empty output")
return key
}
Security guard: if the helper command comes from a project-level config file (not a global user config), always require explicit user trust before executing it. Running arbitrary shell commands from project settings is a code-execution vector.
if (isHelperFromProjectSettings() && !userHasApprovedThisWorkspace()) {
throw new Error("Security: apiKeyHelper requires workspace trust confirmation.")
}
2.7 — Validate Key Format Before Use
Before saving or using an API key, validate its format. This catches typos and prevents storing garbage.
function isValidApiKey(key: string): boolean {
// Anthropic keys: "sk-ant-..." with alphanumeric chars, dashes, underscores
return /^[a-zA-Z0-9\-_]+$/.test(key)
}
function saveApiKey(key: string): void {
if (!isValidApiKey(key)) {
throw new Error("Invalid API key format.")
}
// ... proceed to store
}
2.8 — Handle Rotation Gracefully
API keys rotate. Your agent should recover automatically from a 401 Unauthorized response:
// services/api/client.ts
async function callWithRetry(request: Request): Promise<Response> {
try {
return await send(request)
} catch (error) {
if (error.status === 401) {
// Key may have rotated — clear cache and retry once
clearKeyCache()
const freshKey = await getApiKeyWithCache()
if (freshKey) {
return send({ ...request, apiKey: freshKey })
}
}
throw error
}
}
For OAuth tokens (like Claude.ai login), the free-code codebase uses a file-lock to prevent multiple processes from refreshing the same expired token simultaneously — a subtle but important race condition to handle in multi-process agents.
2.9 — The Auth Layer Should Be the ONLY Place That Reads Keys
This is the architectural principle that ties everything together.
src/
services/
api/
client.ts ← ONLY file that reads the API key and sets Authorization headers
utils/
auth.ts ← ONLY file that resolves keys from env/keychain/helper
No other module should import getApiKey() directly. Tools, the query engine, commands — none of them should ever see the key value. They talk to the API client, which handles auth internally.
This containment means:
- You can audit all credential handling by reading two files
- You can swap out the auth backend (API key → OAuth → IAM role) without touching the rest of the codebase
- Key values never accidentally end up in logs, error messages, or tool results
Summary Table
| Concern | Bad Practice | Good Practice |
|---|---|---|
| Key storage | Hard-coded in source | Environment variable or OS keychain |
| Key in Git | In committed config files | .gitignore + .env |
| Key in logs | console.log(apiKey) | Log the source, never the value |
| Key rotation | Restart required | TTL cache + 401 retry |
| Key in CLI args | --key=sk-ant-... | Environment variable or file descriptor |
| Multi-env support | Different files per env | Priority resolution chain |
| Team vaults | Manual copy-paste | apiKeyHelper pattern |
| Concurrent fetches | Multiple parallel requests | In-flight deduplication |
| OS persistence | Plaintext config file | OS keychain with hex encoding |
Practical Checklist
Before deploying your agent, verify:
- Keys are not in any committed file (check
git log -p | grep "sk-ant") .envis in.gitignore- The API client is the only module that reads the key
- There is a TTL on any cached key from an external source
- The agent handles
401errors by refreshing the key and retrying - Keys are validated before storage
- If using an
apiKeyHelperfrom project config, trust is confirmed before execution
Key Takeaways
1. Architecture: Layer your agent so that only the API client layer ever reads credentials. Everything else is upstream.
2. Tools: Each tool owns its name as a constant. The system prompt is built from the live tool registry — never a static string.
3. Keys: Prefer environment variables in CI, OS keychain for user-facing apps, and the apiKeyHelper pattern for team vaults. Always cache with a TTL.
4. Security: Keys should never appear in command arguments, log output, or error messages. Validate format before storage. Require explicit trust before running helper commands from project config files.