# 12-Factor Agents ## Introduction 12-Factor Agents is a methodology and framework for building production-grade LLM-powered applications that are reliable, maintainable, and scalable. Inspired by the 12-Factor App principles, this project provides practical design patterns for creating AI agents that go beyond simple prompt-tool-loop architectures. The methodology emphasizes owning your prompts, controlling your context window, managing execution state, and building agents that can pause, resume, and interact with humans through structured tool calls. The project includes both educational content explaining each of the 12 factors and practical implementations including a TypeScript starter template (`create-12-factor-agent`), workshop materials, and code examples. Rather than prescribing a heavyweight framework, 12-Factor Agents advocates for modular, composable concepts that can be integrated into existing applications. The approach prioritizes deterministic code over agentic loops, allowing builders to maintain control over their application's behavior while leveraging LLM capabilities for decision-making at key points. ## APIs and Key Functions ### Creating a Basic Agent Loop ```typescript import { b } from "./baml_client"; export interface Event { type: string; data: any; } export class Thread { events: Event[] = []; constructor(events: Event[]) { this.events = events; } serializeForLLM() { // Custom serialization - can be JSON, XML, or any format return JSON.stringify(this.events); } } export async function agentLoop(thread: Thread): Promise { while (true) { // Ask LLM to determine next step based on context const nextStep = await b.DetermineNextStep(thread.serializeForLLM()); switch (nextStep.intent) { case "done_for_now": // Agent is finished, return response return nextStep.message; case "add": // Tool call - append to thread thread.events.push({ type: "tool_call", data: nextStep }); // Execute tool deterministically const result = nextStep.a + nextStep.b; // Append result to thread thread.events.push({ type: "tool_response", data: result }); continue; default: throw new Error(`Unknown intent: ${nextStep.intent}`); } } } // Usage const thread = new Thread([ { type: "user_input", data: "can you add 3 and 4?" } ]); const response = await agentLoop(thread); console.log(response); // "The sum of 3 and 4 is 7." ``` ### Defining Tools as Structured Outputs (BAML) ```rust // baml_src/tool_calculator.baml class Add { intent "add" a int b int } class Subtract { intent "subtract" a int b int } class Multiply { intent "multiply" a int b int } class Divide { intent "divide" a int b int } class RequestMoreInformation { intent "request_more_information" message string } class DoneForNow { intent "done_for_now" message string } // Main prompt function with tool choices function DetermineNextStep(thread: string) -> ( DoneForNow | Add | Subtract | Multiply | Divide | RequestMoreInformation ) { client "openai/gpt-4o" prompt #" {{ _.role("system") }} You are a helpful calculator assistant. You can perform arithmetic operations and request clarification when needed. Available tools: - add, subtract, multiply, divide: perform calculations - request_more_information: ask for clarification - done_for_now: return final answer to user {{ _.role("user") }} Here's what has happened so far: {{ thread }} What should the next step be? "# } ``` ### CLI Interface with Human Interaction ```typescript import { agentLoop, Thread } from "./agent"; import * as readline from 'readline'; export async function cli() { const args = process.argv.slice(2); if (args.length === 0) { console.error("Error: Please provide a message"); process.exit(1); } const message = args.join(" "); const thread = new Thread([ { type: "user_input", data: message } ]); // Run agent loop const result = await agentLoop(thread); let lastEvent = result.events.slice(-1)[0]; // Handle clarification requests while (lastEvent.data.intent === "request_more_information") { const response = await askHuman(lastEvent.data.message); thread.events.push({ type: "human_response", data: response }); const newResult = await agentLoop(thread); lastEvent = newResult.events.slice(-1)[0]; } console.log(lastEvent.data.message); } async function askHuman(message: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(`${message}\n> `, (answer: string) => { rl.close(); resolve(answer); }); }); } // Usage // npx tsx src/index.ts 'can you multiply 3 and xyz' // Agent: Can you clarify what 'xyz' represents? // User: > 5 // Agent: The result of 3 multiplied by 5 is 15. ``` ### Express API with State Management ```typescript import express from 'express'; import { agentLoop, Thread, Event } from './agent'; const app = express(); app.use(express.json()); // In-memory thread storage class ThreadStore { private threads: Map = new Map(); save(id: string, thread: Thread): void { this.threads.set(id, thread); } get(id: string): Thread | undefined { return this.threads.get(id); } } const store = new ThreadStore(); // Create new conversation thread app.post('/thread', async (req, res) => { const { message } = req.body; const threadId = crypto.randomUUID(); const thread = new Thread([ { type: "user_input", data: message } ]); try { const result = await agentLoop(thread); store.save(threadId, result); res.json({ thread_id: threadId, events: result.events, response_url: `/thread/${threadId}/response` }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get thread state app.get('/thread/:id', (req, res) => { const thread = store.get(req.params.id); if (!thread) { return res.status(404).json({ error: 'Thread not found' }); } res.json({ events: thread.events }); }); // Resume thread with human response app.post('/thread/:id/response', async (req, res) => { const thread = store.get(req.params.id); if (!thread) { return res.status(404).json({ error: 'Thread not found' }); } const { type, message, approved, comment } = req.body; if (type === 'approval') { if (approved) { thread.events.push({ type: 'approval_granted', data: comment }); } else { thread.events.push({ type: 'approval_denied', data: comment }); } } else { thread.events.push({ type: 'human_response', data: message }); } const result = await agentLoop(thread); store.save(req.params.id, result); res.json({ events: result.events }); }); app.listen(3000, () => { console.log('Server running on port 3000'); }); ``` ### Custom Context Window Serialization ```typescript export class Thread { events: Event[] = []; // XML-based serialization for token efficiency serializeForLLM(): string { return this.events.map(event => { const dataStr = typeof event.data === 'string' ? event.data : JSON.stringify(event.data, null, 2); return `<${event.type}>\n${dataStr}\n`; }).join('\n\n'); } } // Example output: // // can you add 3 and 4? // // // // { // "intent": "add", // "a": 3, // "b": 4 // } // // // // 7 // ``` ### Human-in-the-Loop with HumanLayer ```typescript import { HumanLayer } from 'humanlayer'; const hl = new HumanLayer({ apiKey: process.env.HUMANLAYER_API_KEY, }); export async function agentLoopWithApprovals(thread: Thread): Promise { while (true) { const nextStep = await b.DetermineNextStep(thread.serializeForLLM()); switch (nextStep.intent) { case "divide": thread.events.push({ type: "tool_call", data: nextStep }); // Request human approval for high-stakes operations const approval = await hl.requireApproval({ fn: 'divide', kwargs: { a: nextStep.a, b: nextStep.b }, agentMessage: `Divide ${nextStep.a} by ${nextStep.b}?`, }); if (approval.approved) { const result = nextStep.a / nextStep.b; thread.events.push({ type: "tool_response", data: result }); } else { thread.events.push({ type: "approval_denied", data: approval.comment }); } continue; case "request_more_information": thread.events.push({ type: "tool_call", data: nextStep }); // Contact human for clarification const contact = await hl.contactHuman({ agentMessage: nextStep.message, }); thread.events.push({ type: "human_response", data: contact.humanMessage }); continue; case "done_for_now": thread.events.push({ type: "done_for_now", data: nextStep }); return thread; default: // Handle other tools... break; } } } // Usage const thread = new Thread([ { type: "user_input", data: "divide 100 by 3" } ]); const result = await agentLoopWithApprovals(thread); ``` ### A2H Protocol Types ```typescript import { z, ZodSchema } from 'zod'; // Message sent by agent to human type MessageSpec> = { agentMessage: string; response_schema?: T; channel_id?: string; }; export type Message = ZodSchema> = { apiVersion: "proto.a2h.dev/v1alpha1"; kind: "Message"; metadata: { uid: string }; spec: MessageSpec; status?: { humanMessage?: string; response?: z.infer; }; }; // Approval request with schema export const ApprovalSchema = z.object({ approved: z.boolean(), comment: z.string().optional(), }); export type ApprovalRequest = Message; // New conversation from human type NewConversationSpec = { user_message: string; channel_id: string; agent_name?: string; raw?: Record; }; export type NewConversation = { apiVersion: "proto.a2h.dev/v1alpha1"; kind: "NewConversation"; metadata: { uid: string }; spec: NewConversationSpec; }; // Usage in agent const approvalRequest: ApprovalRequest = { apiVersion: "proto.a2h.dev/v1alpha1", kind: "Message", metadata: { uid: crypto.randomUUID() }, spec: { agentMessage: "Approve deletion of production database?", response_schema: ApprovalSchema, channel_id: "email:admin@company.com" } }; ``` ### Webhook-Based Pause/Resume Pattern ```typescript import express from 'express'; import { HumanLayer } from 'humanlayer'; const app = express(); const hl = new HumanLayer({ apiKey: process.env.HUMANLAYER_API_KEY, webhookUrl: 'https://myapp.com/webhook' }); // Start async agent task app.post('/thread', async (req, res) => { const { message } = req.body; const threadId = crypto.randomUUID(); const thread = new Thread([ { type: "user_input", data: message } ]); store.save(threadId, thread); // Process asynchronously processThread(threadId).catch(console.error); // Return immediately res.json({ thread_id: threadId, status: "processing" }); }); async function processThread(threadId: string) { const thread = store.get(threadId); while (true) { const nextStep = await b.DetermineNextStep(thread.serializeForLLM()); if (nextStep.intent === "request_more_information") { thread.events.push({ type: "tool_call", data: nextStep }); // Pause and wait for webhook await hl.contactHuman({ agentMessage: nextStep.message, channelId: threadId // Use thread ID to route response }); store.save(threadId, thread); return; // Exit - will resume via webhook } // Handle other intents... } } // Resume on webhook app.post('/webhook', async (req, res) => { const { channel_id, human_message } = req.body; const threadId = channel_id; const thread = store.get(threadId); thread.events.push({ type: "human_response", data: human_message }); // Resume processing processThread(threadId).catch(console.error); res.json({ status: "resumed" }); }); app.listen(3000); ``` ### Control Flow with Break/Continue ```python def handle_next_step(thread: Thread): while True: next_step = await determine_next_step(thread.to_prompt()) if next_step.intent == 'request_clarification': thread.events.append({ 'type': 'request_clarification', 'data': next_step }) await send_message_to_human(next_step) await db.save_thread(thread) # Break - async step, resume via webhook break elif next_step.intent == 'fetch_open_issues': thread.events.append({ 'type': 'fetch_open_issues', 'data': next_step }) issues = await linear_client.issues() thread.events.append({ 'type': 'fetch_open_issues_result', 'data': issues }) # Continue - sync step, pass to LLM immediately continue elif next_step.intent == 'create_issue': thread.events.append({ 'type': 'create_issue', 'data': next_step }) await request_human_approval(next_step) await db.save_thread(thread) # Break - async step, resume via webhook break elif next_step.intent == 'done_for_now': thread.events.append({ 'type': 'done_for_now', 'data': next_step }) # Done - return to caller return thread ``` ### Testing Agent Prompts with BAML ```rust // baml_src/agent.baml function DetermineNextStep(thread: string) -> (DoneForNow | Add | Subtract) { client "openai/gpt-4o" prompt #" {{ _.role("system") }} You are a calculator assistant. {{ _.role("user") }} {{ thread }} What's the next step? "# } test TestAddition { functions [DetermineNextStep] args { thread #" can you add 3 and 4? "# } // Assert the intent is correct assert {{ this.intent == "add" }} assert {{ this.a == 3 }} assert {{ this.b == 4 }} } test TestMidConversation { functions [DetermineNextStep] args { thread #" add 5 and 10, then multiply by 2 { "intent": "add", "a": 5, "b": 10 } 15 "# } // Should request multiply next assert {{ this.intent == "multiply" }} assert {{ this.a == 15 }} assert {{ this.b == 2 }} } ``` ### Creating a Starter Agent Project ```bash # Using npx (Node.js) npx create-12-factor-agent my-agent cd my-agent # Install dependencies npm install # Set up environment export OPENAI_API_KEY=sk-... # or BASETEN_API_KEY, ANTHROPIC_API_KEY, etc. # Run the agent npm run dev # Available commands npm run build # Compile TypeScript npm test # Run BAML tests npx baml-cli test # Test prompts with assertions # Project structure # my-agent/ # ├── src/ # │ ├── index.ts # Entry point # │ ├── agent.ts # Agent loop logic # │ ├── cli.ts # CLI interface # │ ├── server.ts # Express API # │ ├── state.ts # State management # │ └── a2h.ts # A2H protocol types # ├── baml_src/ # │ ├── agent.baml # Main prompt function # │ └── tools.baml # Tool definitions # ├── package.json # └── tsconfig.json ``` ## Summary and Integration Patterns 12-Factor Agents provides a comprehensive methodology for building production-ready AI agents through twelve core principles: converting natural language to tool calls, owning your prompts, controlling context windows, treating tools as structured outputs, unifying execution and business state, enabling pause/resume, contacting humans through tools, owning control flow, compacting errors, keeping agents small and focused, triggering from anywhere, and treating agents as stateless reducers. These principles work together to create reliable, maintainable systems that can be debugged, tested, and scaled. The framework supports multiple integration patterns including CLI-based agents for development and testing, REST APIs with Express for web applications, webhook-based async processing for long-running tasks, and email/Slack integration through HumanLayer for human-in-the-loop workflows. The modular design allows developers to adopt individual factors incrementally rather than requiring a full framework rewrite. By emphasizing deterministic code over pure agentic loops, 12-Factor Agents enables builders to maintain control while leveraging LLM capabilities, resulting in applications that meet production quality standards and can be confidently deployed to customers.