Custom architectures
If none of the built-in architectures fits your loop, write your own:
implement the Architecture protocol (three members) and pass an
instance to Agent(architecture=...).
The protocol
from collections.abc import AsyncIterator
from typing import Protocol, runtime_checkable
from loomflow import Agent
from loomflow.architecture.base import AgentSession, Dependencies
from loomflow.core.types import Event
@runtime_checkable
class Architecture(Protocol):
name: str
def declared_workers(self) -> dict[str, Agent]: ...
def run(
self,
session: AgentSession,
deps: Dependencies,
prompt: str,
) -> AsyncIterator[Event]:
...name. Short identifier for telemetry / logging (e.g."react","my-strategy").declared_workers(). The sub-agents the architecture delegates to, keyed by role name. Used for graph visualization. Return{}for single-agent loops.run(session, deps, prompt). Async generator (async defwithyield) yieldingEvents as iteration progresses.promptis the user’s message for this pass. TheAgentmay callrunagain on the same session when a stop hook asks it to continue; thensession.messagesis not empty andpromptis a new user turn to append. See Architecture protocols.
What AgentSession and Dependencies give you
AgentSession is the mutable per-run state:
| Field | Purpose |
|---|---|
id | Run id (ULID) |
instructions | The agent’s system instructions |
messages | The model conversation so far |
turns | Increment as each iteration completes |
output | Final answer (write at termination) |
cumulative_usage | Aggregate Usage |
interrupted, interruption_reason | Set when terminating early |
metadata | Free-form dict for architecture-specific state |
Dependencies is the read-only protocol bundle:
| Field | Use |
|---|---|
model | The Model to call |
memory | Recall episodes / facts; remember new ones |
runtime | runtime.step(name, fn, *args) for replay-correct calls |
tools | The ToolHost to dispatch tools through |
budget | budget.allows_step(...) before expensive work, budget.consume(...) after |
permissions | permissions.check(call) before tool dispatch |
hooks | hooks.pre_tool(...) / hooks.post_tool(...) |
telemetry | async with telemetry.trace("span_name"): |
audit_log | await audit_log.append(entry) |
max_turns | Hard cap on iterations |
approval_handler | Resolves PermissionDecision.ask_(...) |
Setup events (Event.started) and teardown events (Event.completed)
are emitted by Agent, NOT the architecture. Architectures yield the
events that happen during iteration. Per-turn, per-tool, per-step,
budget warnings, errors.
Minimal example: a “single-shot” architecture
from collections.abc import AsyncIterator
from loomflow import Agent
from loomflow.architecture.base import AgentSession, Dependencies
from loomflow.core.types import Event, Message, Role
class SingleShot:
"""One model call, no tools, return the answer. For testing."""
name: str = "single-shot"
def declared_workers(self) -> dict[str, Agent]:
return {}
async def run(
self,
session: AgentSession,
deps: Dependencies,
prompt: str,
) -> AsyncIterator[Event]:
if not session.messages:
session.messages.append(
Message(role=Role.SYSTEM, content=session.instructions)
)
session.messages.append(Message(role=Role.USER, content=prompt))
text = ""
async for chunk in deps.model.stream(session.messages):
if chunk.kind == "text":
text += chunk.text or ""
yield Event.model_chunk(session.id, chunk)
session.messages.append(Message(role=Role.ASSISTANT, content=text))
session.output = text
session.turns += 1
# No need to yield Event.completed — Agent emits that.
agent = Agent("...", model="claude-opus-4-7", architecture=SingleShot())This sketch skips the budget checks and usage accounting the built-in
architectures do (through budget_gate and consume_usage in
loomflow.architecture.helpers), and it doesn’t recall memory.
Replay-correctness contract
If you make external calls inside run, wrap them with
runtime.step(...) so they’re cached on the journal:
result = await deps.runtime.step(
f"my_call_{session.turns}",
my_async_callable,
arg1, arg2,
)Without this, a crashed run can’t resume cleanly. The call would re-execute and produce a different result. See Runtime.
Telemetry-correctness contract
Open a span per logical “turn” so the OTel trace matches the iteration:
async with deps.telemetry.trace(
"myarch.turn",
turn=session.turns,
session_id=session.id,
):
...ReAct opens loom.turn per iteration, loom.model.complete (or
loom.model.stream when streaming) per model call and loom.tool per
tool call. Match that pattern for compatible dashboards. Give
model-call spans the gen_ai.* attributes from
loomflow.observability.semconv.chat_attrs(deps.model), and
tool spans tool_attrs(name, call_id=...): OTelTelemetry then
exports them under the GenAI spec names (chat {model},
execute_tool {tool}) like the built-in spans, keeping your name in
the loom.span.name attribute. See
GenAI semantic conventions.
Reading existing architectures. loomflow/architecture/react.py
is the canonical reference and exercises every protocol member. For
multi-agent shapes, read supervisor.py next.