Agent Code Architecture & Secure API Key Handling
BACK TO BLOG

April 10, 2026
13 min read

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")
  • .env is 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 401 errors by refreshing the key and retrying
  • Keys are validated before storage
  • If using an apiKeyHelper from 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.

Agent Code Architecture & Secure API Key Handling | Blog Blueforge