# How to Migrate from Framework-Based Agents to Custom Implementations: A 12-Factor Guide

> Migrate from framework-based agents to custom implementations with this 12-factor guide. Extract agent loops, externalize prompts to BAML, and replace tool executors for full control.

- Repository: [HumanLayer/12-factor-agents](https://github.com/humanlayer/12-factor-agents)
- Tags: migration-guide
- Published: 2026-05-19

---

**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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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:

```typescript
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`](https://github.com/humanlayer/12-factor-agents/blob/main/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:

```typescript
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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/packages/create-12-factor-agent/template/src/agent.ts). The `Thread` class manages event history, while `agentLoop` replaces the framework's execution cycle:

```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() {
    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:

```typescript
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`](https://github.com/humanlayer/12-factor-agents/blob/main/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:

```baml
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`](https://github.com/humanlayer/12-factor-agents/blob/main/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 `agentLoop` in 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 `handleNextStep` to reduce indirection.
- Implement **custom state persistence** using the `Thread` and `ThreadStore` patterns from [`packages/create-12-factor-agent/template/src/state.ts`](https://github.com/humanlayer/12-factor-agents/blob/main/packages/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`](https://github.com/humanlayer/12-factor-agents/blob/main/workshops/2025-05/walkthrough/08-server.ts).
- Remove the framework dependency entirely, keeping only the lightweight `@boundaryml/baml` client 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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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.