Loom Architecture
Overview
Loom is a Python framework for building agentic applications. It provides the core infrastructure that any agent needs: an iterative LLM loop, tool calling, skill management, streaming, human-in-the-loop approvals, and multi-provider support.
The framework is designed around composition over inheritance. Apps create components, configure them, and wire them together. The framework doesn't dictate your domain model.
Core Concepts
1. Agentic Loop (loom.loop)
The Agent class implements the core agentic pattern:
Key features:
- Configurable max iterations (default 32)
- Progressive disclosure of skills (only names/descriptions in system prompt, full body loaded via
activate_skill) - Pending question tracking across turns (annotates short replies like "yes"/"no" with context)
- Streaming and non-streaming modes
- Hook points:
on_before_turn,on_after_turn,on_tool_result
AgentConfig controls behavior:
max_iterations-- iteration budgetmodel-- default model ID for provider registrysystem_preamble-- static system prompt prefixon_before_turn(messages)-- transform messages before each turnon_after_turn(turn)-- callback after completionon_tool_result(tool_call, result)-- callback per tool execution
2. Tool System (loom.tools)
Tools are the agent's way of acting in the world.
ToolHandler (abstract base class):
language-python ToolRegistry manages handlers:
register(handler)-- add a tooldispatch(name, args)-- invoke by name (catches exceptions, returns error ToolResult)specs()-- get all tool declarations for LLM
ToolResult carries execution output:
text: str-- primary text result (always present)metadata: dict-- ancillary data (exit codes, status, etc.)content_parts: list[ContentPart] | None-- structured media (images, files) alongside textis_error: bool-- error flag
When content_parts is set, the agent loop constructs a multimodal ChatMessage (list of ContentPart) instead of a plain string, forwarding images and other media natively to the model.
Built-in tools:
| Tool | File | Description |
|---|---|---|
http_call | tools/http.py | GET/POST requests with configurable timeout and truncation |
ask_user | tools/hitl.py | HITL: confirm/choice/text questions to the user |
terminal | tools/hitl.py | Approval-gated shell command execution |
vault | tools/vault.py | Search/read/write/list on a vault store |
memory | tools/memory.py | Read/write/search/list/delete on structured memory |
delegate | tools/delegate.py | Inter-agent delegation via AgentRuntime |
edit_identity | tools/profile.py | Edit SOUL/IDENTITY/USER.md within permission bounds |
web_search | tools/search.py | Multi-provider web search (DDGS, Brave, Tavily, Google) with concurrent/fallback strategies |
web_scrape | tools/scrape.py | Scrape web pages — text/markdown/HTML, CSS/XPath, cookie auth |
Search providers (loom.search):
| Provider | Class | Requires API key | Install |
|---|---|---|---|
| DuckDuckGo (default) | DuckDuckGoSearchProvider | No | pip install "loom[search]" |
| Brave | BraveSearchProvider | Yes | — |
| Tavily | TavilySearchProvider | Yes | — |
| Google Custom Search | GoogleSearchProvider | Yes (api_key + cx) | — |
The DDGS provider runs in a background thread via asyncio.to_thread() to avoid blocking the event loop. Multiple providers can be composed with CompositeSearchProvider using CONCURRENT (fire all, merge, deduplicate) or FALLBACK (try sequentially, stop when enough results) strategies.
Web scrape (loom.scrape):
Scrapling-based web scrape provider with cascade fetching and cookie auth fallback.
| Mode | Class | Description |
|---|---|---|
fetcher | Fetcher | Plain HTTP GET |
dynamic | DynamicFetcher | Headless browser (needs scrapling install) |
stealthy | StealthyFetcher | Anti-detection browser |
auto (default) | cascade | Tries fetcher → dynamic → stealthy on block detection |
ScraplingProvider detects anti-bot blocks (Cloudflare) and auth failures (login wall). When a cookie store is configured and auth failure is detected, it retries with stored cookies for that domain. Output formats: text, markdown, html. Supports CSS selectors and XPath for element extraction.
Cookie store (loom.store.cookies):
CookieStore protocol + FilesystemCookieStore (Netscape cookies.txt format, one file per domain). Used by ScraplingProvider for cookie-based auth retry across requests.
Adding custom tools -- subclass ToolHandler, implement tool and invoke, register with ToolRegistry.
3. Skill System (loom.skills)
Skills are Markdown documents (SKILL.md) that teach the agent new procedures.
Format:
language-markdown Progressive disclosure:
- System prompt lists only
(name, description)pairs - Agent calls
activate_skill(name)-> full body injected as tool result - Agent follows the instructions using its available tools
SkillRegistry -- discovers SKILL.md files, maintains in-memory index:
scan()-- discover all skillsdescriptions()->(name, description)pairs for system promptget(name)-> full Skill object
SkillManager -- 6-op lifecycle (create/edit/patch/delete/write_file/remove_file):
- All writes are atomic (tempfile + rename)
- All writes are guard-scanned before persisting
- Validation: re-parses after write, rolls back on failure
- Path safety: prevents traversal outside skill directory
SkillGuard -- regex security scanner:
dangerous(blocked): credential exfiltration, destructive commands, prompt injectioncaution(logged): persistence mechanisms (cron, launchd, systemd)safe: no findings
Trust tiers: builtin (seed skills), user (operator-authored), agent (LLM-authored)
4. Multimodal Content (loom.types, loom.media)
ChatMessage.content accepts three forms:
str— plain text (backward compatible, no change to existing code)list[ContentPart]— structured content with typed partsNone— empty content
Content part types:
| Type | Fields | Use case |
|---|---|---|
TextPart | text: str | Inline text alongside other parts |
ImagePart | source: str, media_type: str | PNG, JPEG, GIF, WebP images |
VideoPart | source: str, media_type: str | MP4, WebM video clips |
FilePart | source: str, media_type: str | PDFs, documents, arbitrary files |
Files are referenced by path or URL. They are loaded from disk at send-time by the provider layer — never stored as base64 blobs in memory or the database. MIME types are inferred from file extensions when media_type is omitted.
loom.media provides file I/O utilities:
infer_media_type(source)— MIME type from extensionload_file_bytes(source)— read file from disk or fetch from URLencode_to_data_url(source)— base64 data URL (for OpenAI)encode_to_base64(source)— raw base64 + media type (for Anthropic)
Provider integration:
OpenAICompatibleProvider._convert_content_part()maps parts to OpenAI'simage_urlformat with base64 data URLsAnthropicProvider._convert_content_part()maps parts to Anthropic'simagesource blocks with base64 data
Backward compatibility: The ChatMessage.text_content property extracts text from any content format. All existing code that accesses .content as a string continues to work when content is str. The agent loop uses text_content internally for string operations like short-reply annotation and GraphRAG enrichment.
5. LLM Provider Layer (loom.llm)
LLMProvider (abstract base class):
OpenAICompatibleProvider -- raw httpx, no SDK:
- Works with OpenAI, Ollama, LM Studio, vLLM, Together, Groq, etc.
- Full streaming with index-based tool call assembly
- Maps to framework types (ChatResponse, StreamEvent)
AnthropicProvider -- uses anthropic SDK:
- Maps between framework types and Anthropic content blocks
- System message extraction (Anthropic requires separate system param)
- Streaming via
messages.stream()
ProviderRegistry -- maps model IDs to (provider, upstream_model_name):
language-python Secret redaction (llm/redact.py):
- 30+ patterns for API keys, tokens, connection strings, JWTs, etc.
- Idempotent (already-redacted tokens pass through)
- Applied to outbound LLM payloads
6. Streaming
run_turn_stream() yields StreamEvent objects:
ContentDeltaEvent-- text deltasToolCallDeltaEvent-- tool call fragments (index-based assembly)UsageEvent-- token countsStopEvent-- stop reason
The loop handles tool call assembly: collects fragments, dispatches completed tools, continues iteration.
7. Human-in-the-Loop (HITL)
ask_user tool:
confirm-- yes/no questionchoice-- pick from optionstext-- free-form input- The handler is a callback:
async (kind, message, choices) -> str - In the TUI, this prompts the user in the terminal
- In the server, this parks on an asyncio.Future and emits SSE events
terminal tool:
- Composes on top of ask_user for approval
- Configurable timeout, max output truncation
- Runs via
asyncio.create_subprocess_shell
8. Model Routing (loom.routing)
Message classification:
coding-- regex: def/class/import/SELECT/traceback/bug/fix/debugreasoning-- regex: why/explain/analyze/compare/plan/design + length > 40 charstrivial-- short messages < 80 charsbalanced-- everything else
Model selection:
- Each model has
ModelStrengths(speed/cost/reasoning/coding scores 1-10) - Primary strength based on category + cost tiebreaker
- Returns best model ID from the registry
9. Store Layer (loom.store)
SessionStore -- SQLite at ~/.loom/agents/<name>/sessions.sqlite (per-agent):
- Message persistence with tool_calls serialization
- Usage tracking (tokens, tool calls)
- Session metadata (title, model, context)
- Search (LIKE-based, can upgrade to FTS5)
VaultStore -- filesystem + FTS5:
- Markdown documents with optional YAML frontmatter
- FTS5 full-text search with BM25 ranking and snippets
- Tag extraction from frontmatter and #hashtags
- Atomic writes, path traversal prevention
- Auto-reindexing on write
SecretsStore -- plaintext JSON at ~/.loom/secrets.json (0600):
- Simple key-value secrets; kept for backward compatibility
- Deprecated — prefer
SecretStorefor new code
SecretStore -- Fernet-encrypted JSON at a caller-supplied path (RFC 0002):
- 8 typed secrets:
password,api_key,basic_auth,bearer_token,oauth2_client_credentials,ssh_private_key,aws_sigv4,jwt_signing_key - Key auto-generated at
<store_dir>/keys/secrets.key(mode 0600); override withLOOM_SECRET_KEYenv var - All reads decrypt from disk on every call (no in-process secret cache)
put / get / get_metadata / list / revoke / rotate
KeychainStore -- OS keychain backend for SecretStore-compatible access (loom[keychain]):
- Same protocol as
SecretStore; backed by macOS Keychain / Linux Secret Service / Windows Credential Manager viakeyring
Atomic writes (store/atomic.py):
tempfile.mkstemp+os.replace- Cleanup on exception
10. Server (loom.server)
create_app() factory:
language-python Endpoints:
| Method | Path | Description |
|---|---|---|
| GET | /health | Health check |
| POST | /chat | Non-streaming chat |
| POST | /chat/stream | SSE streaming chat |
| GET | /sessions | List sessions |
| DELETE | /sessions/{id} | Delete session |
| GET | /skills | List skills |
Apps extend with domain routes.
11. Configuration (loom.config)
LoomConfig -- JSON-based:
default_model,max_iterations,system_preamble,routing_modeproviders: dict[str, ProviderConfig]-- base_url, api_key, typemodels: list[dict]-- model entries with tags and strengths
Config resolution precedence: CLI flags > env vars > config file > defaults
Environment variables:
LOOM_LLM_BASE_URL-- LLM API endpointLOOM_LLM_API_KEY-- API keyLOOM_LLM_MODEL-- model name
12. Error Handling (loom.errors, loom.retry)
Error classification:
LLMTransportError-- network/HTTP errors (retryable)MalformedOutputError-- parse failures (never retryable)ClassifiedError-- reason + recovery action (retry/backoff/rotate/compress/abort)
Retry:
- Jittered exponential backoff (base 2s, max 60s)
- Only retries
LLMTransportErrorwith retryable classification - Monotonic counter for decorrelation
13. Agent Communication Protocol (loom.acp)
ACP enables agents to call external agents over WebSocket with Ed25519 authentication.
DeviceKeypair -- Ed25519 keypair stored at ~/.loom/device.key:
sign_challenge(challenge)-- produce a base64 signaturepublic_key_b64-- base64-encoded public key
AcpCallTool -- tool handler for calling remote agents:
- Takes
url,message, and optionalagent_name - Opens a WebSocket connection, authenticates via challenge-response
- Returns the remote agent's response
AcpConfig -- connection configuration (URL, timeout, retries).
14. MCP Client (loom.mcp)
MCP (Model Context Protocol) client integration -- connect to external MCP servers and register their tools with a Loom ToolRegistry. Optional subpackage (requires pip install "loom[mcp]").
McpServerConfig -- Pydantic model describing one server:
transport: "stdio" | "sse"- stdio:
command: list[str],env: dict - sse:
url: str,headers: dict
McpClient -- async context manager that owns the session lifecycle:
__aenter__launches the subprocess (stdio) or opens SSE, then callsinitialize()list_tools()discovers remote tools viatools/listand returnsMcpToolHandlerinstancescall_tool(name, args)proxies totools/call, returning aToolResultwith optionalcontent_partsfor native image forwarding
When an MCP server returns ImageContent blocks, the client saves them to temporary files and returns ImagePart references in ToolResult.content_parts. The agent loop then constructs multimodal tool-result messages, forwarding images natively to the model instead of embedding raw base64 as text.
McpToolHandler -- a ToolHandler wrapping one remote MCP tool. Constructed with a call_fn callable (bound to the parent McpClient.call_tool) to avoid circular coupling.
Lifecycle: the McpClient context manager must stay open while tools are in use -- register the handlers inside the async with block.
15. HITL Broker (loom.hitl)
For web/SSE integrations where the agent can't directly prompt a terminal user.
HitlBroker -- session-scoped Future registry + pub/sub event bus:
ask(session_id, kind, message, choices, timeout)-- parks on anasyncio.Futureanswer(session_id, request_id, answer)-- resolves the Futuresubscribe(session_id)-- async iterator ofHitlEventfor SSE streaming
BrokerAskUserTool -- wraps HitlBroker.ask() as a ToolHandler, scoped to a session ID.
Use case: The FastAPI server creates a HitlBroker, wires BrokerAskUserTool into the agent's tool registry, and exposes an HTTP endpoint so frontends can resolve pending questions.
16. Memory Recall (loom.store.memory)
Beyond simple search, MemoryStore.recall() provides hybrid retrieval:
Scoring: BM25 (FTS5) + salience (pinned/importance/access_count) + recency + optional vector similarity, blended into a single rank score.
Salience signals:
pinned-- always promoted to top resultsimportance-- user-set priority (1-5)access_count-- frequently accessed memories rank higherrecency-- decayed by age
EmbeddingProvider (optional protocol):
language-python When wired in, vector similarity is blended into the hybrid score (weight 0.30). Weights with embeddings: BM25 0.35, salience 0.25, recency 0.10, vector 0.30. Without an embedder: BM25 0.55, salience 0.30, recency 0.15.
Memory preview -- top 5 recent memories auto-injected into system prompt (1500 char budget).
17. Credential Subsystem (loom.auth, loom.store.secrets)
Implements RFC 0002 (credentials + appliers + policies) and RFC 0003 (SSH tool). Three decoupled layers; each is independently usable.
Layer 1 — Secret storage
SecretStore is the default backend: typed, Fernet-encrypted, scope-keyed JSON file. KeychainStore is the OS-backed alternative (loom[keychain]). Both expose the same async protocol: put / get / get_metadata / list / revoke / rotate.
Scopes are opaque strings (e.g. "prod-oic-us-east", "agent:coder:openai"). Loom imposes no structure.
Layer 2 — Auth appliers
An applier converts a Secret into transport-ready material. Each applier handles exactly one (secret_type, transport) pair.
| Applier | Secret type | Transport | Output |
|---|---|---|---|
BasicHttpApplier | basic_auth | http | {"Authorization": "Basic ..."} |
BearerHttpApplier | bearer_token | http | {"Authorization": "Bearer ..."} |
OAuth2CCHttpApplier | oauth2_client_credentials | http | {"Authorization": "Bearer ..."} (token cached in-process) |
ApiKeyHeaderApplier | api_key | http | {header_name: value} (configurable header) |
ApiKeyStringApplier | api_key | llm_api_key | raw str |
SshPasswordApplier | password | ssh | SshConnectArgs |
SshKeyApplier | ssh_private_key | ssh | SshConnectArgs |
SigV4Applier | aws_sigv4 | http | full headers dict including Authorization (loom[aws]) |
JwtBearerApplier | jwt_signing_key | http | {"Authorization": "Bearer <signed-jwt>"} (loom[jwt]) |
Extras groups: loom[ssh] (asyncssh), loom[aws] (botocore), loom[jwt] (PyJWT), loom[keychain] (keyring).
Layer 3 — Policy enforcement (HITL gating)
PolicyEnforcer.gate(scope, context) runs before the secret is fetched. Five modes:
| Mode | Behaviour |
|---|---|
AUTONOMOUS | No gate — agent uses credential freely |
NOTIFY_BEFORE | Blocks; human must approve via HitlBroker before secret is released |
NOTIFY_AFTER | Fire-and-log — secret released immediately, event emitted for audit |
TIME_BOXED | Autonomous inside [window_start, window_end); denied outside |
ONE_SHOT | Allowed once; uses_remaining decremented to 0 and secret auto-revoked |
No policy configured for a scope → implicit AUTONOMOUS (backward compatible).
PolicyStore persists CredentialPolicy objects as 0600 JSON (not encrypted — policies are metadata, not secrets).
The resolution pipeline
language-text CredentialResolver wires these three layers together. Consumers register appliers with resolver.register(applier, transport=...), then call await resolver.resolve_for(scope, transport).
Pre-request hook integration (RFC 0001)
HttpCallTool accepts an optional pre_request_hook: async (dict) -> dict that runs after argument parsing and before the HTTP request is dispatched. The hook receives and returns {method, url, headers, body}. This is the canonical way to feed resolver output into an HTTP tool call.
SshCallTool (RFC 0003)
loom.tools.ssh.SshCallTool runs one-shot commands on remote hosts via asyncssh. It calls resolver.resolve_for(scope, "ssh") to get SshConnectArgs (host, port, username, auth material) and never exposes connection details to the agent. Returns ToolResult with exit_code, stderr, truncated_stdout, truncated_stderr, duration_ms in metadata. Errors are classified as auth | timeout | transport | unknown.
Requires loom[ssh].
18. Heartbeat Scheduler (loom.heartbeat)
Heartbeats are recurring scheduled tasks. Each one consists of two files in its own directory: HEARTBEAT.md (YAML frontmatter with name, description, schedule, enabled + a Markdown body used as the agent's system prompt) and driver.py (a class Driver(HeartbeatDriver) that implements check(state) -> (events, new_state)).
Key design choices:
- Drivers are stateless pure functions. They receive state-in and return state-out; the runtime persists state between ticks via
HeartbeatStore(SQLite, WAL mode). - Events drive agent invocations. When
driver.check()returns a non-empty events list, the scheduler callsrun_fn(instructions, [event_message])once per event. The agent runs with the heartbeat's instructions as its system prompt and the event summary as the user message. - Multi-instance support. State is keyed by
(heartbeat_id, instance_id), so the same driver package can run as independent instances without shared state. - SessionStore integration (optional). When a
SessionStoreis provided, each agent invocation is persisted as a titled session for observability.
Components:
| Class | Role |
|---|---|
HeartbeatDriver | ABC — implement check(state) -> (events, new_state) |
HeartbeatRegistry | In-memory index; scans directories for HEARTBEAT.md files |
HeartbeatStore | SQLite state persistence keyed by (heartbeat_id, instance_id) |
HeartbeatScheduler | Asyncio background loop; ticks, fires, invokes agent |
HeartbeatManager | Disk CRUD (create/delete/enable/disable/list) + registry sync |
HeartbeatToolHandler | manage_heartbeat tool — lets the agent manage heartbeats at runtime |
Schedule formats: natural language ("every 5 minutes"), cron shorthands (@daily, @hourly), or 5-field cron ("0 9 * * 1-5").
Wire-up example:
language-python 19. GraphRAG (loom.store.graphrag)
Graph-based Retrieval-Augmented Generation. Fully opt-in — pass a GraphRAGEngine to Agent(graphrag=...) or leave it None (default).
Components:
| Module | Class | Role |
|---|---|---|
store.vector | VectorStore | SQLite-backed vector store; float32 BLOBs, brute-force cosine search |
store.graph | EntityGraph | SQLite-backed entity-relationship graph; multi-hop BFS traversal, paginated listing, subgraph extraction, connected components, degree counts |
store.embeddings | OllamaEmbeddingProvider / OpenAIEmbeddingProvider | Async embedding API clients |
store.graphrag | GraphRAGEngine | Orchestrator: chunking, indexing, extraction, retrieval, context formatting |
Pipeline:
- Chunking —
chunk_markdown()splits text on headings, merges small sections, and splits large ones with overlap. Deterministic chunk IDs via SHA-256. - Embedding + indexing — chunks are embedded and stored in
VectorStore. Source-level replacement (re-indexing a path removes old chunks first). - Entity extraction (optional, requires
llm_provider) — an LLM extracts entities and relationships from each chunk using a structured JSON prompt. Supports gleaning (re-prompting for missed entities). Results are stored inEntityGraphwith mention tracking and alias resolution. - Hybrid retrieval — vector similarity search finds top chunks; graph expansion adds related chunks via multi-hop entity neighbors. Results are scored and deduplicated.
- Context injection —
format_context()assembles results into a Markdown block within a configurable character budget. The agent loop appends this to the system message once perrun()/run_stream()call.
Usage levels (all opt-in):
- No GraphRAG — default, no overhead.
- Vector search only — embedder without LLM provider.
- Full GraphRAG — embedder + LLM for entity extraction.
New optional extra: pip install "loom[graphrag]" (numpy>=1.26 for accelerated batch cosine similarity; pure-Python fallback when absent).
Data Flow
Design Principles
- Composition over inheritance -- Components are created and wired, not subclassed
- Optional everything -- Streaming, HITL, multi-provider, skills are all opt-in
- No framework lock-in -- Use what you need, extend what you want
- Crash safety -- All disk mutations are atomic
- Security first -- Guard scanner, secret redaction, path traversal prevention
- Type safe -- Pydantic v2 models throughout