How to Migrate from Framework-Based Agents to Custom Implementations: A 12-Factor Guide
Migrate from framework-based agents to custom implementations by extracting the agent loop into explicit TypeScript functions, externalizing prompts to BAML files, and replacing framework tool executors with plain handlers to achieve full control over prompts, tools, and state.
When you first prototype an LLM-driven agent, reaching for a ready-made framework like LangChain or Griptape provides quick plug-and-play functionality, but these libraries lock you into black-box abstractions that hide prompt, tool-definition, and control-flow logic. The humanlayer/12-factor-agents repository demonstrates how to migrate from framework-based agents to custom implementations by pulling core concepts out of the framework and rebuilding them in plain code. This approach lets you own every piece of the system while adhering to 12-Factor Agent principles such as owning your prompts and treating state as data.
Step-by-Step Migration Path
1. Identify the Framework's Agent Loop
Locate the portion of your framework code that calls the LLM, parses its structured output, executes a tool, and appends the result back to the context window. In workshops/2025-05/walkthrough/03-agent.ts, this loop is the heart of the system; extracting it lets you replace the black-box with your own agentLoop implementation.
2. Export the Prompt as a BAML File
Move your prompt text out of the codebase and into a dedicated BAML file. According to the walkthrough in workshops/2025-05/walkthrough/01-agent.baml, this is the only external dependency the tutorial keeps, allowing you to edit the prompt without touching the runtime. Later chapters like 06-agent.baml show how to evolve these prompts while maintaining version control.
3. Define Tool Schema as TypeScript Interfaces
Replace framework-specific tool decorators with native TypeScript interfaces or BAML structs. In packages/create-12-factor-agent/template/src/agent.ts, the CalculatorTool union type provides a type-safe, self-documenting contract that the LLM can call:
export type CalculatorTool = AddTool | SubtractTool | MultiplyTool | DivideTool;
4. Replace the Framework Tool Executor
Remove the framework's runTool abstraction and implement a plain function like handleNextStep. As shown in packages/create-12-factor-agent/template/src/agent.ts, this direct code removal reduces indirection and lets you add custom logging, retries, or side-effects:
export async function handleNextStep(
nextStep: CalculatorTool,
thread: Thread,
): Promise<Thread> {
let result: number;
switch (nextStep.intent) {
case "add": result = nextStep.a + nextStep.b; break;
case "subtract": result = nextStep.a - nextStep.b; break;
case "multiply": result = nextStep.a * nextStep.b; break;
case "divide": result = nextStep.a / nextStep.b; break;
}
thread.events.push({ type: "tool_response", data: result });
return thread;
}
5. Persist Thread State Yourself
Avoid the framework's hidden state store by implementing your own persistence layer. The example ThreadStore implementations in packages/create-12-factor-agent/template/src/state.ts demonstrate how to apply the 12-Factor state-as-data principle using in-memory, file-system, or database storage.
6. Add a Thin HTTP Wrapper
The framework's server scaffolding is optional. The repository ships a minimal Express server in workshops/2025-05/walkthrough/08-server.ts that simply forwards requests to agentLoop without additional framework overhead.
7. Iterate Using 12-Factor Principles
Refactor your implementation using the 12-Factor factors documented in the repository. Factor 2 ("Own your prompts") in content/factor-02-own-your-prompts.md explains why extracting prompts is essential, while Factor 3 ("Own your context window") guides context window management. Reference Factor 6 for launch/pause/resume APIs and Factor 12 for stateless reducer patterns.
8. Remove the Framework Dependency
Run npm uninstall on your framework packages. Your custom code now compiles without any external agent framework; you only keep the lightweight BAML client (@boundaryml/baml) for type-safe LLM calls.
Implementation Examples
Minimal Agent Loop in TypeScript
The core of a framework-free agent lives in packages/create-12-factor-agent/template/src/agent.ts. The Thread class manages event history, while agentLoop replaces the framework's execution cycle:
import { b } from "../baml_client";
export interface Event { type: string; data: any; }
export class Thread {
events: Event[] = [];
constructor(events: Event[]) { this.events = events; }
serializeForLLM() {
return this.events.map(e => this.serializeOneEvent(e)).join("\n");
}
private serializeOneEvent(e: Event) {
return `
<${e.data?.intent || e.type}>
${typeof e.data !== 'object' ? e.data : Object.keys(e.data)
.filter(k => k !== 'intent')
.map(k => `${k}: ${e.data[k]}`).join("\n")}
</${e.data?.intent || e.type}>
`;
}
}
export async function agentLoop(thread: Thread): Promise<Thread> {
while (true) {
const nextStep = await b.DetermineNextStep(thread.serializeForLLM());
thread.events.push({ type: "tool_call", data: nextStep });
switch (nextStep.intent) {
case "done_for_now":
case "request_more_information":
return thread;
case "divide":
return thread; // Pause for approval (Factor 6)
default:
thread = await handleNextStep(nextStep as CalculatorTool, thread);
}
}
}
Express Server Wrapper
Expose your agent via HTTP without framework-specific server code:
import express from "express";
import { Thread, agentLoop } from "./agent";
const app = express();
app.use(express.json());
app.post("/thread", async (req, res) => {
const thread = new Thread(req.body.events ?? []);
const updatedThread = await agentLoop(thread);
res.json(updatedThread);
});
app.listen(3000);
This pattern from workshops/2025-05/walkthrough/08-server.ts forwards requests directly to your custom agentLoop, maintaining full control over routing and middleware.
BAML Prompt Definition
Store your prompt in baml_src/agent.baml to separate it from application logic:
function DetermineNextStep(thread: string) -> DoneForNow {
client Qwen3
tool AddTool { a: number, b: number }
tool SubtractTool { a: number, b: number }
tool MultiplyTool { a: number, b: number }
tool DivideTool { a: number, b: number }
}
The model must return one of the defined tool structs or a completion signal, with the schema enforced by the BAML client at compile time.
Why Migrate to Framework-Free Agents?
Full Control: You can pause and resume execution, inject custom logging, or reroute tool calls without fighting the framework's internal state machine. The agentLoop function in packages/create-12-factor-agent/template/src/agent.ts demonstrates how to implement custom branching logic like pausing for human approval on division operations.
Reduced Surface Area: Fewer dependencies mean easier upgrades and tighter security. By removing frameworks that may handle secrets or hidden state, you eliminate opaque failure modes and reduce your attack surface.
Alignment with 12-Factor Principles: Custom implementations make it easier to enforce Factor 2 (own your prompts), Factor 6 (launch/pause/resume APIs), and Factor 12 (stateless reducer). When you own the code, you can ensure the agent operates as a stateless process with explicit state management via the Thread class.
Summary
- Extract the agent loop from your framework and reimplement it as an explicit function like
agentLoopin plain TypeScript. - Externalize prompts to BAML files to separate configuration from code, following
workshops/2025-05/walkthrough/01-agent.baml. - Define tools as TypeScript interfaces instead of framework-specific decorators for type safety.
- Replace framework executors with plain functions like
handleNextStepto reduce indirection. - Implement custom state persistence using the
ThreadandThreadStorepatterns frompackages/create-12-factor-agent/template/src/state.ts. - Wrap your agent in a thin Express server without framework scaffolding, as shown in
workshops/2025-05/walkthrough/08-server.ts. - Remove the framework dependency entirely, keeping only the lightweight
@boundaryml/bamlclient for structured LLM outputs.
Frequently Asked Questions
What is the "agent loop" in framework-based agents?
The agent loop is the core cycle that calls the LLM, parses its structured output, executes the requested tool, and appends the result back to the conversation context. In frameworks like LangChain, this loop is often hidden behind abstractions like AgentExecutor. When you migrate from framework-based agents to custom implementations, you extract this loop into an explicit function such as agentLoop in packages/create-12-factor-agent/template/src/agent.ts, giving you direct control over execution flow, error handling, and human-in-the-loop pauses.
Why use BAML instead of keeping prompts in the TypeScript code?
BAML (BoundaryML) provides type-safe prompt engineering that separates your prompt templates and tool schemas from application logic. By storing prompts in .baml files as demonstrated in workshops/2025-05/walkthrough/01-agent.baml, you enable non-developers to edit prompts without touching runtime code, while the BAML compiler generates TypeScript types that ensure your tool definitions stay synchronized with the LLM's expected outputs. This aligns with Factor 2 ("Own your prompts") from the 12-Factor Agents methodology.
How do I handle state management without a framework?
Implement a custom Thread class that represents the conversation state as a serializable data structure, following the pattern in packages/create-12-factor-agent/template/src/agent.ts. The Thread maintains an array of Event objects that can be persisted to memory, disk, or a database via the ThreadStore interface shown in packages/create-12-factor-agent/template/src/state.ts. This approach treats state as plain data rather than objects managed by a framework, making it easier to implement features like time-travel debugging and human approvals.
Can I still use tools like calculators or web search in a custom implementation?
Yes, you define tools as TypeScript interfaces or BAML structs and implement their execution in plain functions like handleNextStep. In packages/create-12-factor-agent/template/src/agent.ts, the CalculatorTool union type defines the schema for arithmetic operations, while the handleNextStep function contains the actual implementation logic. This pattern works for any tool—calculators, API calls, or database queries—without requiring framework-specific decorators or callback handlers.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →