Skip to navigation

Creating Framework Integrations

Build custom adapters for any LLM framework
Multiple agents building large rocket in hangar

This guide explains how to create a new framework adapter for the Band SDK using the composition-based architecture.

Architecture Overview

The composition pattern separates concerns:

Agent.create(adapter=MyAdapter(...), agent_id="...", api_key="...")
  • Agent: Manages platform connection, event loop, room lifecycle
  • Adapter: Handles LLM interaction for your framework
  • Tools: Platform capabilities exposed to the LLM (band_send_message, band_add_participant, etc.)

Critical Concept: Platform tools like band_send_message are called BY THE LLM, not by your adapter. Your adapter’s job is to give tools to the LLM and let it decide when to use them.

Participant Identification: In multi-agent rooms, your LLM needs to know WHO sent each message. The platform provides sender_name in history - use your LLM’s native mechanism for identifying speakers (e.g., OpenAI’s name field) rather than embedding names in message content.

What the Platform Guarantees

Before diving into implementation, understand what the platform provides:

  • history is already converted by your HistoryConverter (or raw HistoryProvider if none set)
  • participants_msg is only set when the participant list has changed since the last message. It lists each participant’s handle, display name and type, plus their description when they have one, so the LLM can route by role without a tool call
  • is_session_bootstrap means “first message delivery for this room session”, not “first message ever in the room”
  • Adapters should NOT call band_send_message directly for normal responses - let the LLM decide via tool calls. Direct calls are only for emergency/fallback behavior.

History Fields

Each message in the raw history includes:

FieldDescription
role”user” or “assistant”
contentMessage content
sender_nameDisplay name (e.g., “John Doe”, “Weather Agent”)
sender_type”User” or “Agent”
message_type”text”, “tool_call”, “tool_result”, “thought”, or “error”

Multi-agent scenarios: History includes messages from ALL participants - users AND other agents. Your converter needs to handle messages from other agents appropriately (they have role: "assistant" but aren’t YOUR agent’s messages).

Two Patterns for Tool Execution

The real difference (one sentence):

  • Pattern 1: Your framework runs the agent loop and calls tools itself
  • Pattern 2: You run the agent loop and call tools yourself

Everything else is detail.

Pattern 1 (framework-managed) Pattern 2 (adapter-managed)
LLM response LLM response
↓ ↓
Framework (calls tool) Adapter (parses tool calls)
↓ ↓
Adapter (intercepts) Platform (executes)
↓ ↓
Platform (executes) Adapter (feeds results back)
↓ ↓
Framework (gets result) LLM (next turn)
QuestionPattern 1Pattern 2
Who runs the agent loop?FrameworkAdapter
Who executes tools?FrameworkAdapter
Do you see tool calls?NoYes
Do you manage history?Usually noYes
Can you intercept errors mid-loop?LimitedFull control
ComplexityLowHigher
ControlMediumMaximum

Concrete example, band_send_message:

Pattern 1 (LangGraph):
LLM decides to call band_send_message
→ Framework executes it internally
→ You only see: { "event": "on_tool_start", "name": "band_send_message" }
Pattern 2 (Anthropic/OpenAI):
LLM returns: { "tool_calls": [{ "name": "band_send_message", ... }] }
→ YOU execute: await tools.execute_tool_call("band_send_message", {...})

Rule of thumb:

  • Framework already knows how to run agents → Pattern 1
  • Raw LLM API (Anthropic, OpenAI) → Pattern 2
  • Unsure → Pattern 2 (it always works)

Pattern 1: Framework Manages Tools (LangGraph-style)

When your framework has its own tool execution loop (like LangGraph’s ReAct agent):

  1. Convert AgentTools to framework-specific tool format
  2. Pass tools to the framework/graph
  3. Framework calls tools internally as part of its agent loop

Example: LangGraph adapter

async def on_message(
self, msg, tools, history, participants_msg, contacts_msg,
*, is_session_bootstrap: bool, room_id: str,
):
# Convert platform tools to LangChain format
langchain_tools = agent_tools_to_langchain(tools)
# Create graph with tools - graph handles tool execution internally
graph = create_react_agent(llm, langchain_tools, checkpointer)
# Stream events - LLM decides when to call band_send_message
async for event in graph.astream_events({"messages": messages}, ...):
await self._handle_stream_event(event, room_id, tools)

Pattern 2: Adapter Manages Tool Loop (Anthropic-style)

When you need to manage the tool execution loop yourself:

  1. Get tool schemas via tools.get_tool_schemas("openai") or tools.get_tool_schemas("anthropic"), passing capabilities for the optional tool categories you want advertised
  2. Pass schemas to LLM along with messages
  3. When LLM returns tool calls, execute via tools.execute_tool_call(name, args)
  4. Append both the assistant’s tool call AND the tool result to history
  5. Loop until LLM stops calling tools

Note: Some LLM APIs return arguments as a JSON string instead of a dict. Parse with json.loads() if needed.

Example: Anthropic adapter

async def on_message(
self, msg, tools, history, participants_msg, contacts_msg,
*, is_session_bootstrap: bool, room_id: str,
):
# Get tool schemas in Anthropic format (sync method)
tool_schemas = tools.get_tool_schemas(
"anthropic",
capabilities=self.features.capabilities,
)
# Tool execution loop
while True:
# Call LLM with tools
response = await self.client.messages.create(
model=self.model,
messages=messages,
tools=tool_schemas,
)
# Check if LLM wants to use tools
if response.stop_reason != "tool_use":
break # LLM is done
# IMPORTANT: Append assistant response (with tool_use blocks) to history
messages.append({
"role": "assistant",
"content": response.content, # Contains ToolUseBlock(s)
})
# Execute tool calls and collect results
tool_results = []
for block in response.content:
if isinstance(block, ToolUseBlock):
result = await tools.execute_tool_call(
block.name, # e.g., "band_send_message"
block.input # e.g., {"content": "Hello!", "mentions": ["User"]}
)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(result),
})
# IMPORTANT: Append tool results to history
messages.append({
"role": "user",
"content": tool_results,
})

Note: Tool-result injection is provider-specific; use whatever your client expects (Anthropic uses role=user with content blocks; OpenAI uses role=tool).

Sending Events

Events report execution status to the platform. There are two ways events get sent:

LLM-Initiated Events (via tool)

The band_send_event tool is exposed to the LLM for sharing thoughts, errors, and task progress. The LLM decides when to use it:

TypePurposeExample
thoughtShare reasoning before actions”I’ll first look up available agents, then add the most relevant one”
errorReasoning/task failure”I couldn’t find any agents matching that criteria”
taskReport progress on long operations”Processed 50 of 100 items”

The LLM calls this just like any other tool:

LLM → band_send_event(content="Let me analyze this request...", message_type="thought")

This is already handled when you convert tools via agent_tools_to_langchain() or pass schemas via get_tool_schemas().

Adapter-Initiated Events (direct call)

Your adapter calls tools.send_event() directly to report tool execution status:

TypePurposeWhen to Send
tool_callReport tool invocationWhen LLM requests a tool call
tool_resultReport tool outputAfter tool execution completes
errorInfrastructure/runtime failureOn exceptions in your adapter

These events are NOT available to the LLM - they’re for your adapter to report what’s happening during execution.

Distinguishing errors: LLM error events represent reasoning failures (“I couldn’t find X”). Adapter error events represent infrastructure failures (exceptions, timeouts, API errors).

Pattern 1: Streaming Events (LangGraph-style)

When your framework emits streaming events, forward them to the platform:

import json
from band.core import AgentToolsProtocol
async def _handle_stream_event(
self,
event: dict,
room_id: str,
tools: AgentToolsProtocol,
) -> None:
"""Handle streaming events from framework."""
event_type = event.get("event")
if event_type == "on_tool_start":
tool_name = event.get("name", "unknown")
await tools.send_event(
content=json.dumps(event, default=str),
message_type="tool_call",
)
elif event_type == "on_tool_end":
tool_name = event.get("name", "unknown")
await tools.send_event(
content=json.dumps(event, default=str),
message_type="tool_result",
)

Pattern 2: Manual Event Reporting (Anthropic-style)

When you manage the tool loop, report events as you execute:

async def _process_tool_calls(
self,
response: "Message", # anthropic.types.Message
tools: AgentToolsProtocol,
) -> list[dict]:
"""Execute tool calls and report events."""
results = []
for block in response.content:
if not isinstance(block, ToolUseBlock):
continue
# Report tool call
await tools.send_event(
content=f"Calling {block.name}",
message_type="tool_call",
metadata={"tool": block.name, "input": block.input},
)
# Execute tool
try:
result = await tools.execute_tool_call(block.name, block.input)
is_error = False
except Exception as e:
result = f"Error: {e}"
is_error = True
# Report result
await tools.send_event(
content=f"Result: {result}",
message_type="tool_result",
metadata={"tool": block.name, "is_error": is_error},
)
results.append({"tool_use_id": block.id, "content": str(result)})
return results

Error Reporting

Always wrap LLM calls and report errors:

async def on_message(
self, msg, tools, history, participants_msg, contacts_msg,
*, is_session_bootstrap: bool, room_id: str,
):
try:
response = await self._call_llm(messages, tool_schemas)
# ... process response ...
except Exception as e:
# Report error to platform
await tools.send_event(
content=f"Error: {e}",
message_type="error",
)
raise # Re-raise so message is marked as failed

Advanced: Complete Tool Loop with Error Handling

When a tool fails, you have three choices:

  1. Recoverable error → feed to LLM so it can retry, pick another tool, or ask the user
  2. Infrastructure error → fail the run so the platform marks the message as failed
  3. Infinite-loop prevention → hard stop after max iterations (schemas/outputs can be wrong)
import json
from typing import Any
MAX_TOOL_ITERS = 10
class ToolRecoverableError(Exception):
"""Errors the LLM can reasonably react to (bad args, not found, permission, etc)."""
class ToolInfraError(Exception):
"""Errors that indicate runtime/infrastructure problems (timeouts, 5xx, auth, etc)."""
def _maybe_json_loads(x: Any) -> Any:
"""Parse JSON string if needed; some LLM APIs return arguments as strings."""
if isinstance(x, str):
try:
return json.loads(x)
except json.JSONDecodeError:
return x
return x
def _tool_result_message(*, tool_call_id: str, content: str, is_error: bool) -> dict[str, Any]:
"""Build OpenAI-style tool result message."""
prefix = "ERROR: " if is_error else ""
return {
"role": "tool",
"tool_call_id": tool_call_id,
"content": f"{prefix}{content}",
}
class ManualToolLoopAdapter:
def __init__(self, client, model: str):
self.client = client
self.model = model
async def on_message(
self, msg, tools, history, participants_msg, contacts_msg, *, is_session_bootstrap: bool, room_id: str
) -> None:
messages: list[dict[str, Any]] = list(history)
messages.append({"role": "user", "content": msg.format_for_llm()})
tool_schemas = tools.get_tool_schemas("openai")
for i in range(MAX_TOOL_ITERS):
# 1) Call the LLM
try:
resp = await self.client.responses.create(
model=self.model,
input=messages,
tools=tool_schemas,
)
except Exception as e:
await tools.send_event(content=f"LLM call failed: {e}", message_type="error")
raise
# 2) Extract assistant content + tool calls
assistant_content = getattr(resp, "output_text", None) or ""
tool_calls = getattr(resp, "tool_calls", None) or []
# 3) No tool calls = done
if not tool_calls:
if assistant_content:
messages.append({"role": "assistant", "content": assistant_content})
return
# 4) Append assistant message with tool calls
messages.append({
"role": "assistant",
"content": assistant_content,
"tool_calls": tool_calls,
})
# 5) Execute tool calls
for tc in tool_calls:
name = tc["name"]
tool_call_id = tc["id"]
args = _maybe_json_loads(tc.get("arguments", {}))
await tools.send_event(
content=f"Calling {name}",
message_type="tool_call",
metadata={"tool": name, "input": args},
)
try:
result = await tools.execute_tool_call(name, args)
await tools.send_event(
content=f"{name} OK",
message_type="tool_result",
metadata={"tool": name, "is_error": False},
)
messages.append(_tool_result_message(
tool_call_id=tool_call_id,
content=str(result),
is_error=False,
))
except ToolRecoverableError as e:
# Recoverable: give LLM the error so it can decide what to do
await tools.send_event(
content=f"{name} recoverable error: {e}",
message_type="tool_result",
metadata={"tool": name, "is_error": True, "class": "recoverable"},
)
messages.append(_tool_result_message(
tool_call_id=tool_call_id,
content=str(e),
is_error=True,
))
# Do NOT raise — let the loop continue so LLM can react
except Exception as e:
# Infra failure: append result for context, then fail
messages.append(_tool_result_message(
tool_call_id=tool_call_id,
content=f"INFRA_ERROR: {e}",
is_error=True,
))
await tools.send_event(
content=f"{name} infra error: {e}",
message_type="error",
metadata={"tool": name, "class": "infra"},
)
raise
# Max iterations exceeded
await tools.send_event(
content=f"Exceeded max tool iterations ({MAX_TOOL_ITERS})",
message_type="error",
)
raise RuntimeError("Tool loop exceeded max iterations")

What counts as recoverable? Define in your tool layer:

  • Invalid args / schema mismatch
  • Permission denied
  • Resource not found
  • Business rule violation

These should raise ToolRecoverableError. Everything else is treated as infrastructure failure.

Step-by-Step Implementation

Step 1: Create Your Adapter Class

from typing import ClassVar, Unpack
from band import Capability, Emit, FeatureKwargs
from band.core import AgentToolsProtocol, SimpleAdapter
from band.core.types import PlatformMessage
class MyFrameworkAdapter(SimpleAdapter[MyHistoryType]):
"""Adapter for MyFramework."""
# Declare what this adapter actually implements. The base constructor
# raises BandConfigError for any emit or capability value outside these
# sets, so a caller learns immediately rather than at run time.
SUPPORTED_EMIT: ClassVar[frozenset[Emit]] = frozenset({Emit.TOOL_CALLS})
# Capability.MEMORY, Capability.CONTACTS and Capability.FILES are the
# declarable capabilities. Name only the ones you have wired: FILES needs
# the room file tools, and reading a file can return an image, so an
# adapter declaring it has to pass image content through to the model.
SUPPORTED_CAPABILITIES: ClassVar[frozenset[Capability]] = frozenset()
def __init__(
self,
model: str = "gpt-4o",
custom_section: str = "",
history_converter: MyHistoryConverter | None = None,
**features: Unpack[FeatureKwargs],
):
super().__init__(
history_converter=history_converter or MyHistoryConverter(),
**features,
)
self.model = model
self.custom_section = custom_section
self._system_prompt = ""
async def on_started(self, agent_name: str, agent_description: str) -> None:
"""Called after agent metadata is fetched, before the WebSocket connects."""
await super().on_started(agent_name, agent_description)
self._system_prompt = render_system_prompt(
agent_name=agent_name,
agent_description=agent_description,
custom_section=self.custom_section,
)
async def on_message(
self,
msg: PlatformMessage,
tools: AgentToolsProtocol,
history: MyHistoryType,
participants_msg: str | None,
contacts_msg: str | None,
*,
is_session_bootstrap: bool,
room_id: str,
) -> None:
"""Handle incoming message - implement your LLM interaction here."""
# Gate reporting on what the caller actually asked for, e.g.:
# if Emit.TOOL_CALLS in self.features.emit: await tools.send_event(...)
# See patterns above
...
async def on_cleanup(self, room_id: str) -> None:
"""Clean up when leaving a room."""
...

emit defaults to everything SUPPORTED_EMIT declares when the caller omits it; pass emit=() for silence. capabilities defaults to empty (opt-in), since enabling one puts extra tool schemas in front of the model on every turn. See Adapter features in the SDK Reference.

Step 2: Create a History Converter

Convert platform history to your framework’s message format:

from band.core import HistoryConverter
# Define your history type
MyMessages = list[dict[str, Any]] # or your framework's message type
class MyHistoryConverter(HistoryConverter[MyMessages]):
"""Convert platform history to MyFramework format."""
def convert(self, raw: list[dict[str, Any]]) -> MyMessages:
"""
Convert raw platform history.
Each dict in raw has:
- role: "user" or "assistant"
- content: message content
- sender_name: who sent it
- sender_type: "User" or "Agent"
- message_type: "text", "tool_call", "tool_result", etc.
"""
messages = []
for msg in raw:
# Convert to your framework's format
messages.append({
"role": msg["role"],
"content": msg["content"],
# Add framework-specific fields...
})
return messages

Key Points for History Converters:

  1. Use native name field - If your LLM supports a name field (OpenAI does), use it instead of embedding sender names in content. This gives the LLM cleaner context about who sent each message.

  2. Sanitize names - OpenAI’s name field has pattern restrictions (no spaces, <, |, \, /, >). Sanitize with: re.sub(r'[\s<|\\/>]+', '_', name)

  3. Handle multi-agent rooms - Messages from other agents have role: "assistant". Don’t skip all assistant messages - only skip YOUR agent’s text messages (which are redundant with tool calls). Other agents’ messages are valuable context.

  4. Track your agent name - Store the agent name in on_started() so your converter knows which messages to skip:

    async def on_started(self, agent_name: str, agent_description: str) -> None:
    await super().on_started(agent_name, agent_description)
    self._converter.set_agent_name(agent_name)

Step 3: Use Centralized Tool Definitions

The SDK provides centralized tool definitions in runtime/tools.py. Use these instead of defining your own descriptions to ensure consistent LLM behavior across all adapters.

For Pattern 2 (adapter manages tool loop):

from band import Capability
# Get schemas in provider format - descriptions included automatically
tool_schemas = tools.get_tool_schemas("openai") # or "anthropic"
# The same call with the memory tools advertised as well. `capabilities`
# replaces the default instead of adding to it, so name contacts again to keep
# the contact tools the default would have given you.
tool_schemas_with_memory = tools.get_tool_schemas(
"openai",
capabilities=frozenset({Capability.MEMORY, Capability.CONTACTS}),
)

In your own adapter, pass the set the caller configured, self.features.capabilities, so a caller who opted into memory or files actually sees those tools. frozenset() advertises the base room tools and nothing else. get_anthropic_tool_schemas() and get_openai_tool_schemas() are the typed equivalents; they take the same capabilities keyword and no format argument.

For Pattern 1 (framework manages tools):

from band.runtime.tools import get_tool_description
def convert_tools_to_my_framework(tools: AgentToolsProtocol) -> list[MyToolType]:
"""Convert AgentTools to MyFramework tool format."""
# Create wrapper functions
async def send_message_wrapper(content: str, mentions: list[str]) -> dict:
return await tools.send_message(content, mentions)
# Use centralized descriptions
return [
MyTool(
name="band_send_message",
description=get_tool_description("band_send_message"),
func=send_message_wrapper,
),
# ... other tools ...
]

Why centralized?

  • Consistent LLM behavior across all adapters
  • Single place to update tool guidance
  • Descriptions are LLM-optimized (e.g., “Use lookup_peers() first…”)

Step 4: Register Your Adapter (Optional)

Register the adapter in band/adapters/__init__.py. Exports are lazy so importing band.adapters never pulls in an optional extra, and the TYPE_CHECKING block keeps the name visible to type checkers:

band/adapters/__init__.py
from typing import TYPE_CHECKING
from band.exports import lazy_exports
if TYPE_CHECKING:
from band.adapters.my_framework import MyFrameworkAdapter as MyFrameworkAdapter
__all__, __getattr__ = lazy_exports(
__name__,
# ... existing adapters ...
my_framework=["MyFrameworkAdapter"],
)

Add the matching extra to pyproject.toml if the adapter needs third-party dependencies.

Available Platform Tools

Your adapter exposes these tools to the LLM via AgentToolsProtocol:

ToolDescription
band_send_message(content, mentions)Send a message to the chat room
band_send_event(content, message_type, metadata)Send events (thought, error, task, tool_call, tool_result)
band_add_participant(name, role)Add agent/user to room
band_remove_participant(name)Remove participant from room
band_get_participants()List room participants
band_lookup_peers(page, page_size)Find available agents/users on platform
band_create_chatroom(task_id)Create a new chat room
get_tool_schemas(format, *, capabilities=None)Get tool schemas (“openai” or “anthropic” format); capabilities selects the optional tool categories
execute_tool_call(name, args)Execute a tool by name (for Pattern 2)

Tool descriptions are centralized in runtime/tools.py. Use get_tool_description(name) to get the LLM-optimized description for any tool. This ensures consistent behavior across all adapters.

SimpleAdapter Lifecycle

Agent.run()
│
├─► on_started(agent_name, agent_description)
│ Called once after platform connection
│
├─► [event loop]
│ │
│ └─► on_message(msg, tools, history, participants_msg, contacts_msg, *, is_session_bootstrap, room_id)
│ Called for each user/agent message
│ history: Already converted by your HistoryConverter
│ participants_msg: Set when participants changed
│ contacts_msg: Set when the agent's contacts changed
│ is_session_bootstrap: True on first message per room
│
└─► on_cleanup(room_id)
Called when leaving a room

Example: Complete Minimal Adapter

"""Minimal adapter using Pattern 2 (adapter manages tool loop)."""
from band.core import AgentToolsProtocol, SimpleAdapter
from band.core.types import PlatformMessage
from band.runtime.prompts import render_system_prompt
class MinimalAdapter(SimpleAdapter[list[dict]]):
"""
Minimal adapter that manages its own message history.
Uses history_converter=None to bypass platform history conversion,
maintaining per-room state internally instead.
"""
def __init__(self, api_key: str, model: str = "gpt-4o"):
# No history converter - we manage history ourselves
super().__init__(history_converter=None)
self.api_key = api_key
self.model = model
self._system_prompt = ""
self._room_messages: dict[str, list] = {} # Per-room message history
async def on_started(self, agent_name: str, agent_description: str) -> None:
await super().on_started(agent_name, agent_description)
self._system_prompt = render_system_prompt(
agent_name=agent_name,
agent_description=agent_description,
)
async def on_message(
self,
msg: PlatformMessage,
tools: AgentToolsProtocol,
history, # Ignored - we manage our own history
participants_msg: str | None,
contacts_msg: str | None,
*,
is_session_bootstrap: bool,
room_id: str,
) -> None:
# Initialize room on first message
if is_session_bootstrap:
self._room_messages[room_id] = [
{"role": "system", "content": self._system_prompt}
]
messages = self._room_messages[room_id]
# Add user message
messages.append({"role": "user", "content": msg.format_for_llm()})
# Get tool schemas (sync method)
tool_schemas = tools.get_tool_schemas(
"openai",
capabilities=self.features.capabilities,
)
# Tool execution loop
while True:
response = await self._call_llm(messages, tool_schemas)
# Check if LLM wants to use tools
if not response.get("tool_calls"):
# No tools - add final assistant message and exit
if response.get("content"):
messages.append({
"role": "assistant",
"content": response["content"],
})
break
# Append assistant response with tool calls
messages.append({
"role": "assistant",
"content": response.get("content", ""),
"tool_calls": response["tool_calls"],
})
# Execute tools and collect results
for tool_call in response["tool_calls"]:
result = await tools.execute_tool_call(
tool_call["name"],
tool_call["arguments"],
)
# Append tool result
messages.append({
"role": "tool",
"tool_call_id": tool_call["id"],
"content": str(result),
})
async def on_cleanup(self, room_id: str) -> None:
"""Clean up room state when session ends."""
self._room_messages.pop(room_id, None)

Key Points

  1. Use the name field - OpenAI messages support a name field to identify participants. Use it instead of embedding names in content. Sanitize names (no spaces/special chars).

  2. Store tool calls as-is - Just serialize the tool call object from the LLM response. The converter wraps it in an assistant message when loading.

  3. Store tool results as-is - The OpenAI tool message format (role: tool, tool_call_id, content). Loads directly.

  4. Include other agents’ messages - Messages from other agents (like Weather Agent) are essential context. Only skip THIS agent’s text messages (redundant with tool calls).

  5. is_session_bootstrap - True on first message after agent starts. Load platform history here to restore context.

  6. participants_msg - Contains participant names. Include it so the LLM uses correct @mentions.

Common Pitfalls

Avoid these common mistakes when building adapters:

PitfallSymptomSolution
Not sanitizing namesOpenAI 400 error: “string does not match pattern”Use re.sub(r'[\s<|\\/>]+', '_', name) for the name field
Skipping all assistant messagesAgent repeats questions other agents already answeredOnly skip YOUR agent’s text; include other agents’ messages
Not loading history on bootstrapAgent loses context after restartCheck is_session_bootstrap and extend messages with history
Missing pytest-asyncioTests don’t run or hangInstall with uv add pytest-asyncio and use @pytest.mark.asyncio(loop_scope="function")
Embedding names in contentLLM can’t distinguish speakers cleanlyUse the LLM’s native name field (if available)

Testing Your Adapter

Use FakeAgentTools for unit testing:

from datetime import datetime, timezone
from band.core.types import PlatformMessage
from band.testing import FakeAgentTools
async def test_my_adapter():
adapter = MyFrameworkAdapter(model="gpt-4o")
tools = FakeAgentTools()
# Simulate a message
await adapter.on_message(
msg=PlatformMessage(
id="1",
room_id="room-1",
content="Hello",
sender_id="user-1",
sender_type="User",
sender_name="User",
message_type="text",
metadata={},
created_at=datetime.now(timezone.utc),
),
tools=tools,
history=[],
participants_msg=None,
contacts_msg=None,
is_session_bootstrap=True,
room_id="room-1",
)
# Assert on tool calls
assert tools.messages_sent # LLM called band_send_message

For comprehensive testing patterns including LLM mocking, history testing, and integration tests, see the Testing Agents guide.

Conformance Testing

When creating a new adapter, verify it works correctly with these tests.

Required Tests

TestWhat to Verify
Basic message handlingon_message() processes a message and the LLM calls band_send_message
Tool executionTools are passed to the LLM and executed correctly
History conversionHistoryConverter.convert() transforms raw history into your framework’s format
Multi-agent roomsMessages from other agents are included in history, not skipped
Session bootstrapis_session_bootstrap=True triggers proper initialization
Error propagationLLM failures are reported via send_event and re-raised
Cleanupon_cleanup() releases room-specific resources

Submission Checklist

Before submitting a new adapter:

  1. All required tests pass
  2. History converter handles empty history, single messages, and multi-agent conversations
  3. Tool schemas are fetched via get_tool_schemas(), not hardcoded, and the call forwards self.features.capabilities
  4. Error events are sent to the platform for LLM and infrastructure failures
  5. on_cleanup() frees any per-room state
  6. No mutable state shared across rooms without synchronization

Reference Implementations

  • band/adapters/langgraph.py - Pattern 1 (framework manages tools)
  • band/adapters/pydantic_ai.py - Pattern 1 (framework manages tools)
  • band/adapters/anthropic.py - Pattern 2 (adapter manages tool loop)
  • band/adapters/claude_sdk.py - Pattern 1 with Claude Agent SDK