creating-amplifier-modules
Skill17 starsChanged 3 months agoMITView on GitHub
Use when creating a new Amplifier module (tool, hook, orchestrator, context, or provider). Covers the mount() contract, protocol compliance validation, placeholder patterns, and the module directory structure. Prevents the common mistake of creating no-op mount() stubs that fail protocol_compliance validation.
In this repository5 other files it gives its agents.
- context/understanding-mechanisms/mechanisms/agents.mdAGENTS.md
- libraries/AGENTS.mdAGENTS.md
- bundle-to-dotSkill
- per-repo-conventionsSkill
- fixture-skillSkill
The file
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 4 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 142
When Phase 1 needs a module skeleton before full implementation, create a real tool class that returns a "not yet implemented" message.
- Very specific: line 225
Test that mount() registers the tool — not that it returns None:
- Distinctive: line 285
If you find yourself thinking any of these, STOP:
- Distinctive: line 297
After creating a module, verify it will pass protocol compliance:
- Distinctive: line 312
All nine boxes must be checked before committing.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Creating Amplifier Modules
Overview
Amplifier modules are Python packages that extend the runtime with tools, hooks, providers, and other capabilities. Every module has a mount() function that runs at session startup.
Core principle: mount() must register something with the coordinator. A mount() that logs and returns None WILL fail validation.
This matters immediately. Module validation runs every time a session loads. A broken mount() prevents any agent using the behavior from spawning — not just future agents, not "when Phase 2 is done" — right now, on every invocation.
The Iron Law
mount() MUST call coordinator.mount() or return a Tool instance.
A mount() that returns None and calls nothing WILL FAIL with:
"protocol_compliance: No tool was mounted and mount() did not return a Tool instance""Stub" means placeholder that satisfies the protocol — not empty function that does nothing.
Module Directory Structure
modules/tool-{name}/
├── pyproject.toml # name = "amplifier-module-tool-{name}", hatchling
└── amplifier_module_tool_{name}/
└── __init__.py # async mount(coordinator, config)The pyproject.toml entry point wires the module name to mount():
[project]
name = "amplifier-module-tool-{name}"
version = "0.1.0"
description = "Description of what this tool does"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [] # amplifier-core is a peer dependency — do NOT declare it here
[project.entry-points."amplifier.modules"]
tool-{name} = "amplifier_module_tool_{name}:mount"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["amplifier_module_tool_{name}"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"The mount() Contract
Every mount() must:
- Instantiate a tool class
- Call
await coordinator.mount("tools", tool, name=tool.name) - Return a metadata dict (not None)
Complete working example:
"""Amplifier tool module for {name}."""
import logging
from typing import Any
from amplifier_core import ToolResult
logger = logging.getLogger(__name__)
class MyTool:
"""Tool class — the actual capability being registered."""
@property
def name(self) -> str:
return "my_tool"
@property
def description(self) -> str:
# A tool description IS a description surface: it is rendered into the
# tool block on EVERY request of every session that mounts the tool,
# used or not. Write it to
# foundation:context/shared/description-authoring-principles.md --
# trigger first, then USE WHEN, then DO NOT USE WHEN naming what
# should handle the rejected case; zero <example> blocks. One measured
# tool description ran 15,271 chars -- 56% of that session's entire
# tool-description budget -- and drove 7-8x over-delegation (V5).
return "What this tool does. USE WHEN <condition>. DO NOT USE WHEN <condition> -- use <alternative>."
@property
def input_schema(self) -> dict:
return {
"type": "object",
"properties": {
"param": {
"type": "string",
"description": "What this parameter does",
},
},
"required": ["param"],
}
async def execute(self, input_data: dict[str, Any]) -> ToolResult:
"""Execute the tool operation."""
result = do_the_work(input_data["param"])
return ToolResult(success=True, output=result)
async def mount(coordinator: Any, config: dict[str, Any] | None = None) -> dict[str, Any]:
"""Mount the tool into the coordinator."""
tool = MyTool()
await coordinator.mount("tools", tool, name=tool.name)
logger.info("tool-my-tool mounted: registered 'my_tool'")
return {
"name": "tool-my-tool",
"version": "0.1.0",
"provides": ["my_tool"],
}The Placeholder Pattern
When Phase 1 needs a module skeleton before full implementation, create a real tool class that returns a "not yet implemented" message. The tool still has all required properties and registers with the coordinator.
"""Amplifier tool module for {name} — Phase 1 placeholder."""
import logging
from typing import Any
from amplifier_core import ToolResult
logger = logging.getLogger(__name__)
class MyToolPlaceholder:
"""Placeholder tool — registers to satisfy protocol compliance (Phase 1).
Phase 2 will replace this with full implementation.
"""
@property
def name(self) -> str:
return "my_tool"
@property
def description(self) -> str:
return "Description of what this tool will do. Phase 2 implementation pending."
@property
def input_schema(self) -> dict:
return {
"type": "object",
"properties": {
"operation": {
"type": "string",
"description": "Operation to perform",
},
},
"required": ["operation"],
}
async def execute(self, input_data: dict[str, Any]) -> ToolResult:
"""Return not-yet-implemented message."""
return ToolResult(
success=False,
output=(
"Tool not yet implemented. Phase 2 will add full functionality. "
"Use the shell scripts in the bundle for now."
),
)
async def mount(coordinator: Any, config: dict[str, Any] | None = None) -> dict[str, Any]:
"""Mount placeholder tool — satisfies protocol compliance during Phase 1."""
tool = MyToolPlaceholder()
await coordinator.mount("tools", tool, name=tool.name)
logger.info("tool-my-tool mounted: registered placeholder 'my_tool' (Phase 2 pending)")
return {
"name": "tool-my-tool",
"version": "0.1.0",
"provides": ["my_tool"],
}A placeholder tool IS a real tool. It has name, description, input_schema, and execute(). It registers with coordinator.mount(). It just tells callers it's not implemented yet.
Behavior YAML Reference
How the module is referenced in a behavior YAML:
tools:
- module: tool-{name}
source: git+https://github.com/org/repo@main#subdirectory=modules/tool-{name}Use this self-referential git URL form even for modules that live in the bundle's own repo — it resolves the same way no matter which file declares it. A bare relative path (source: ./modules/tool-{name}) resolves relative to whichever bundle file is active at activation time, not the file that declares it, so it only works from a root bundle.md/bundle.yaml and breaks silently when the same line is used in a behaviors/*.yaml or other included file.
Writing Tests
Test that mount() registers the tool — not that it returns None:
import pytest
from unittest.mock import AsyncMock, MagicMock
from amplifier_module_tool_my_tool import mount
@pytest.mark.asyncio
async def test_mount_registers_tool():
"""mount() must register a tool via coordinator.mount()."""
coordinator = MagicMock()
coordinator.mount = AsyncMock()
result = await mount(coordinator)
# Verify coordinator.mount was called (the Iron Law)
coordinator.mount.assert_called_once()
call_args = coordinator.mount.call_args
assert call_args[0][0] == "tools" # first positional arg is "tools"
# Verify return value is metadata dict, not None
assert result is not None
assert "name" in result
assert "provides" in result
@pytest.mark.asyncio
async def test_tool_has_required_properties():
"""Tool class must have name, description, input_schema, execute."""
coordinator = MagicMock()
coordinator.mount = AsyncMock()
await mount(coordinator)
# Get the tool that was registered
tool = coordinator.mount.call_args[0][1]
assert isinstance(tool.name, str) and tool.name
assert isinstance(tool.description, str) and tool.description
assert isinstance(tool.input_schema, dict)
assert callable(tool.execute)Anti-Rationalization Table
| Excuse | Reality |
| "It's just a stub, it doesn't need to register anything" | Protocol validation runs on every module load. No-op stubs fail immediately. |
| "Phase 2 will fill it in" | Phase 2 may be weeks away. The module loads NOW and fails NOW. |
| "I'll add a TODO comment" | The validator doesn't read comments. It checks coordinator.mount() calls. |
"The tests pass with result is None" | Tests that assert result is None are testing the bug, not the behavior. |
| "The mount() signature is all that matters" | The signature is necessary but not sufficient. Registration is also required. |
| "It works locally without registering" | It silently fails the protocol check. You won't see the error until an agent spawns. |
Red Flags — STOP and Use the Placeholder Pattern
If you find yourself thinking any of these, STOP:
- "mount() can just log and return None" → NO. It must register a tool.
- "I'll skip the tool class since there's nothing to implement yet" → NO. Create a placeholder class.
- "The test should assert
result is None" → NO. The test should verifycoordinator.mount()was called. - "It's a stub so it should be empty" → NO. Use the placeholder pattern above.
- "Phase 2 will make it real, for now I'll just return None" → NO. Phase 2 doesn't exist yet. The module loads today.
Validation Checklist
After creating a module, verify it will pass protocol compliance:
- Does
mount()callawait coordinator.mount("tools", tool, name=tool.name)? - Does the tool class have a
nameproperty (string)? - Does the tool class have a
descriptionproperty (string)? - Does that description lead with the TRIGGER, carry a DO NOT USE WHEN
clause naming the alternative, and contain zero
<example>blocks? (foundation:context/shared/description-authoring-principles.md -- a tool description is paid on every request whether or not the tool is called) - Does the tool class have an
input_schemaproperty (dict)? - Does the tool class have a callable
execute()method? - Does
mount()return a metadata dict (notNone)? - Does
pyproject.tomldeclare theamplifier.modulesentry point? - Do tests verify
coordinator.mount()was called (not that result isNone)?
All nine boxes must be checked before committing.
Quick Reference
Minimum viable module __init__.py:
from amplifier_core import ToolResult
class MyTool:
name = "my_tool"
description = "What this tool does"
input_schema = {"type": "object", "properties": {}}
async def execute(self, input_data):
return ToolResult(success=False, output="Not yet implemented")
async def mount(coordinator, config=None):
tool = MyTool()
await coordinator.mount("tools", tool, name=tool.name)
return {"name": "tool-my-tool", "version": "0.1.0", "provides": ["my_tool"]}This is the minimum. Every line is required. Nothing can be removed.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

