Creating Custom Amplifier Modules

This guide shows how to create custom modules for Amplifier, based on real experience building the email assistant example. We'll cover the patterns, pitfalls, and best practices learned from actual implementation.

When to Create a Module vs Inline Code

Create a Module When
  • Functionality is reusable across apps
  • It provides general-purpose capabilities
  • No app-specific context required
  • You want to distribute it in the ecosystem
Use Inline Code When
  • Tightly coupled to your app's architecture
  • Needs runtime context (like specific IDs)
  • Only this app will use it
  • Rapid prototyping and iteration

Module Structure (Standard Pattern)

your-module/
├── pyproject.toml                      # Package metadata
├── amplifier_module_tool_yourname/
│   ├── __init__.py                     # mount() function
│   ├── tool.py                         # Tool implementation
│   └── manager.py                      # State/business logic (if needed)
└── README.md

Example: pyproject.toml

[project]
name = "amplifier-module-tool-email"
version = "1.0.0"
description = "Email management for Amplifier"
requires-python = ">=3.11"
dependencies = []

[project.entry-points."amplifier.modules"]
tool-email = "amplifier_module_tool_email:mount"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Critical: Database Paths in Modules

When your module needs file paths (like SQLite database), paths in config are relative to where Python runs, not where bundle.md is.

For modules in modules/tool-email/ loaded from bundle.md at project root:

# In bundle.md
tools:
  - module: tool-email
    source: ./modules/tool-email
    config:
      db_path: ../data/emails.db  # Relative to where module runs

The ../ is because modules run from their own directory context.

The mount() Function (Entry Point)

Every module must have a mount() function:

async def mount(coordinator, config: dict | None = None):
    """Mount the module.

    Args:
        coordinator: ModuleCoordinator from amplifier-core
        config: Configuration from bundle

    Returns:
        Optional cleanup function
    """
    config = config or {}

    # Create your tool/hook/provider
    tool = EmailTool(config)

    # Register with coordinator
    await coordinator.mount("tools", tool, name=tool.name)

    return  # Or return cleanup function

Pattern: Tool with Embedded State

For tools that need to maintain state (like database connections, API clients), use the embedded manager pattern. This is how tool-issue works.

Manager Class (Holds State)

# manager.py
import sqlite3

class EmailManager:
    """Manages email state: IMAP connection, database, operations."""

    def __init__(self, db_path: str):
        self.db_path = db_path
        self.conn = sqlite3.connect(db_path)
        self.imap = None
        self._init_db()

    def _init_db(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS emails (
                id TEXT PRIMARY KEY,
                sender TEXT,
                subject TEXT,
                body TEXT
            )
        """)

    def connect_imap(self, server, email, password):
        import imaplib
        self.imap = imaplib.IMAP4_SSL(server)
        self.imap.login(email, password)

    def fetch_emails(self, limit=20):
        # IMAP operations...
        pass

    def get_email(self, email_id):
        cursor = self.conn.execute(
            "SELECT * FROM emails WHERE id = ?", (email_id,)
        )
        return cursor.fetchone()

Tool Class (Exposes Operations)

# tool.py
from amplifier_core import ToolResult
from .manager import EmailManager

class EmailTool:
    """Tool that wraps EmailManager and exposes operations to AI."""

    name = "email"
    description = "Manage emails: fetch, reply, archive"

    def __init__(self, email_manager: EmailManager):
        self.email_manager = email_manager

    @property
    def input_schema(self) -> dict:
        return {
            "type": "object",
            "properties": {
                "operation": {
                    "type": "string",
                    "enum": ["fetch", "get", "reply", "archive"],
                    "description": "Operation to perform"
                },
                "params": {
                    "type": "object",
                    "description": "Parameters for operation"
                }
            },
            "required": ["operation"]
        }

    async def execute(self, input: dict) -> ToolResult:
        operation = input.get("operation")
        params = input.get("params", {})

        if operation == "fetch":
            emails = self.email_manager.fetch_emails(
                limit=params.get("limit", 20)
            )
            return ToolResult(
                output=f"Fetched {len(emails)} emails",
                data={"emails": emails},
                success=True
            )
        elif operation == "get":
            email = self.email_manager.get_email(params.get("email_id"))
            if not email:
                return ToolResult(
                    success=False,
                    error={"message": "Email not found"}
                )
            return ToolResult(
                output="Email retrieved",
                data=email,
                success=True
            )
        # ... more operations

Mount Function (Wires It Together)

# __init__.py
from .manager import EmailManager
from .tool import EmailTool

async def mount(coordinator, config: dict | None = None):
    """Mount email tool with embedded state."""
    config = config or {}

    # Create manager (holds state)
    db_path = config.get("db_path", "emails.db")
    email_manager = EmailManager(db_path)

    # Create tool (exposes to AI)
    tool = EmailTool(email_manager)

    # Register with coordinator
    await coordinator.mount("tools", tool, name=tool.name)

    return

Critical Learning: Accessing Tools from Your App

Common Mistake: Importing Managers Directly

❌ Wrong:

# Your web app
from my_module.manager import EmailManager

email_mgr = EmailManager()  # Creates NEW instance
# This is separate from the one in Amplifier sessions!

This creates a duplicate instance. The web app and Amplifier sessions don't share state.

✅ Correct Pattern: Use PreparedBundle.create_session()
# Your web app
from amplifier_foundation.registry import load_bundle

# Load and prepare bundle
bundle = await load_bundle("./bundle.md")
prepared_bundle = await bundle.prepare()  # This loads and validates modules

# Create session using PreparedBundle (not manual AmplifierSession)
session = await prepared_bundle.create_session(
    session_id="temp-for-tool-access"
)

# Get the mounted tool from coordinator
email_tool = session.coordinator.get("tools", "email")

# Now use it!
result = await email_tool.execute({
    "operation": "fetch",
    "params": {"limit": 50}
})

Key points:

  • prepare() loads and validates all modules
  • create_session() properly initializes the session with mounted modules
  • coordinator.get("tools", "email") accesses mounted tools
  • This is the SAME instance that AI sessions use - shared state!

Real Example: Email Assistant

The examples/email-assistant/ directory contains a complete, working implementation showing all these patterns in practice.

What It Demonstrates

Key Files to Study

File What It Shows
modules/tool-email/__init__.py Module mount() function, wiring EmailManager → EmailTool
modules/tool-email/manager.py State management: IMAP, SQLite, email operations
modules/tool-email/tool.py Operation-based tool exposing manager to AI
backend/main.py How web app accesses tool through coordinator
backend/amplifier_service.py Creating sessions, streaming events, accessing tools
bundle.md Bundle configuration with local module reference

Common Pitfalls & Solutions

Pitfall 1: Wrong Import Path

❌ Error: from amplifier_foundation.bundle import load_bundle

Correct import:

from amplifier_foundation.registry import load_bundle

Pitfall 2: Wrong Method Name

❌ Error: bundle.compile()

Correct method:

composed = bundle.compose()
mount_plan = composed.to_mount_plan()

Pitfall 3: Async Loading Not Awaited

❌ Error: bundle = load_bundle(path)

load_bundle, prepare(), and create_session() are all async:

bundle = await load_bundle(path)
prepared = await bundle.prepare()  # Also async!
session = await prepared.create_session(...)  # Also async!

Pitfall 4: Wrong Session Method

❌ Error: async for event in session.run_async(prompt)

AmplifierSession doesn't have run_async(). Use:

response = await session.execute(prompt)
# Returns the full response as a string

Pitfall 5: ToolResult Attribute

❌ Error: result.data

ToolResult uses .output, not .data:

# Wrong
return ToolResult(data={"emails": emails})
emails = result.data

# Correct
return ToolResult(output={"emails": emails})
emails = result.output

Pitfall 6: Module File Paths

❌ Error: Paths relative to bundle.md don't work in modules

Module file paths are relative to module directory, not bundle.md:

# bundle.md at root, module in modules/tool-email/, want data/ at root:
tools:
  - module: tool-email
    source: ./modules/tool-email
    config:
      db_path: ../data/emails.db  # ../ goes up from module dir

Pitfall 7: Context Field Format

❌ Error: Using list for context
# Wrong
context:
  - file: context/guidelines.md

Context should be referenced via @mentions in markdown, not YAML:

# Correct - no context field, use @mentions
---
bundle:
  name: my-bundle
---

# Instructions

@my-bundle:context/guidelines.md

Pitfall 5: Accessing Tools Outside Sessions

Problem: Web API needs tool access

Solution: Use PreparedBundle.create_session() to get properly initialized tools:

# In app startup
bundle = await load_bundle("./bundle.md")
prepared = await bundle.prepare()  # Loads all modules

# Create temp session with prepared bundle
temp_session = await prepared.create_session(
    session_id="temp-for-tool-access"
)

# Get tool from coordinator using .get()
my_tool = temp_session.coordinator.get("tools", "my-tool-name")

# Store globally for API endpoints
app.state.my_tool = my_tool

# Use in API endpoints
@app.post("/api/endpoint")
async def endpoint():
    result = await app.state.my_tool.execute({...})
    return result.data

Why this works: prepare() actually loads the modules from their sources (git/local), validates them, and create_session() mounts them properly.

Module Configuration in Bundle

Local Module (Development)

tools:
  - module: tool-email
    source: file://./modules/tool-email
    config:
      db_path: data/emails.db

Git Module (Distribution)

tools:
  - module: tool-email
    source: git+https://github.com/you/amplifier-module-tool-email@main
    config:
      db_path: data/emails.db

Testing Your Module

Unit Test the Manager

# test_manager.py
from amplifier_module_tool_email.manager import EmailManager

def test_email_manager():
    mgr = EmailManager(":memory:")  # SQLite in-memory
    mgr.connect_imap("imap.gmail.com", "test@test.com", "pass")
    assert mgr.imap is not None

Integration Test the Tool

# test_tool.py
from amplifier_module_tool_email import mount
from amplifier_core import ModuleCoordinator

async def test_email_tool():
    coordinator = ModuleCoordinator()
    await mount(coordinator, {"db_path": ":memory:"})

    email_tool = coordinator.tools.get("email")
    assert email_tool is not None

    result = await email_tool.execute({
        "operation": "list",
        "params": {"limit": 10}
    })
    assert result.success

End-to-End Test with Session

async def test_with_session():
    from amplifier_foundation.registry import load_bundle

    # Load and prepare your bundle
    bundle = await load_bundle("./bundle.md")
    prepared = await bundle.prepare()

    # Create session (modules are loaded and mounted)
    session = await prepared.create_session(session_id="test")

    # AI can now use your tool!
    response = await session.execute("Fetch my emails")
    print(response)

    # Check that your tool was mounted
    my_tool = session.coordinator.get("tools", "email")
    assert my_tool is not None

Distributing Your Module

Option 1: Local Development

# In bundle.md, reference local path
source: file://./modules/tool-email

Option 2: Git Repository

# Push to GitHub
git init
git add .
git commit -m "Initial module"
git push origin main

# In bundle.md
source: git+https://github.com/you/amplifier-module-tool-email@main

Option 3: PyPI Package

# Build and publish
uv build
uv publish

# In bundle.md (no source needed, just module name)
tools:
  - module: tool-email

Real-World Learnings

Lesson 1: Start Simple, Then Modularize

When building the email assistant, we initially created inline tools (EmailReplyTool, EmailArchiveTool as separate classes). This was faster for prototyping. Once the pattern was clear, we refactored into a proper module with an operation-based tool.

Recommendation: Build inline first, extract to module when stable.

Lesson 2: Operation-Based > Multiple Tools

Initially designed as 3 separate tools (email_reply, email_archive, email_label). Refactored to 1 tool with operations.

Why better:

Lesson 3: Test with Real Amplifier, Not Mocks

Critical Mistake Made

Initially implemented with mock/fake responses instead of real AmplifierSession. This seemed faster but was fundamentally broken. The "working" demo wasn't actually working.

Lesson: Always use real AmplifierSession from the start. Mocks hide integration issues.

Lesson 4: Bundle Configuration Matters

The bundle must include your module with correct source path:

# Relative path from bundle.md location
source: file://./modules/tool-email

# Absolute path
source: file:///home/user/project/modules/tool-email

# Git URL
source: git+https://github.com/owner/repo@main

Lesson 5: Coordinator is the Source of Truth

Don't create parallel instances. If your app needs access to a tool's state:

  1. Load bundle: bundle = await load_bundle("./bundle.md")
  2. Prepare it: prepared = await bundle.prepare()
  3. Create session: session = await prepared.create_session(...)
  4. Get tool: tool = session.coordinator.get("tools", "tool-name")
  5. Store reference and use throughout your app

Critical: prepare() is what actually loads modules from their sources. Without it, you just have config.

Module Patterns in the Ecosystem

Module Pattern Study For
tool-filesystem Stateless tool Simple operations, no embedded state
tool-bash Stateless with config Configuration-driven behavior
tool-issue Operation-based with state Embedded manager, multiple operations
tool-web Stateless with HTTP client External API integration
hooks-logging Hook with file I/O Event-driven, persistent state

Next Steps

The Email Assistant Example

Located at examples/email-assistant/ in the Amplifier repository.

Shows: FastAPI + React + Amplifier with custom tool-email module, real AI integration, and proper architecture.

Run it: cd examples/email-assistant && ./start.sh