An AI agent that fits into your stack — not the other way around.
You have an app. Wire in exoclaw and it gains tool use, conversation memory, and any LLM. You own every piece. Nothing baked in, nothing surprising.
pip install exoclaw
One runtime dependency: structlog. Around 2,000 lines of Python you can read in an afternoon.
pip install exoclaw-nanobot
exoclaw-nanobot
That bundles a sensible default stack — provider, conversation, tools, and an interactive CLI channel — into a working agent you can talk to in the terminal. The full plugin catalog (Slack, Telegram, Discord, Matrix, … and many more tools) lives at exoclaw-plugins.
This repo is the protocol-only core that everything else builds on. Read on if you want to wire it into something you already have.
A Go implementation lives at exoclaw-go. This Python repo is the reference implementation; exoclaw-go is forward-ported periodically from tagged Python releases (file-by-file, with a parity table pinning each Go module to the Python version it tracks). Use Python if you want the freshest features; use Go if your stack is Go and you're willing to lag behind by a release or two.
exoclaw is a fork of nanobot, stripped down to the agent loop and the protocols around it.
The original ships with batteries — provider, memory, cron, MCP, Telegram, Discord. Convenient to start. But every baked-in feature becomes a release-blocker: a Telegram API change holds up a cron bug fix, an MCP upgrade pulls in conflicts for users who don't even use MCP.
exoclaw cuts the knot. Seven protocols, one loop. Storage, channels, tools, providers — they all live in separate packages you opt into. The core never changes because it has nothing to change.
- Auditable. ~2,000 lines, mypy strict, 95% test coverage.
- No dependency drag. Your tree contains exactly what you chose.
- No surprise breakage. A bug in someone else's Telegram plugin can't break your app.
- Composable. Swap providers, storage, or channels without touching the loop.
InboundMessage → Bus → AgentLoop → LLM → Tools → Bus → OutboundMessage → Channel
- A Channel receives a message and puts it on the Bus.
- The AgentLoop picks it up, asks the Conversation to build a prompt.
- The prompt goes to the LLMProvider, which returns a response.
- If the response calls Tools, the loop runs them and feeds the results back.
- The final response goes back on the bus, and the Channel delivers it.
Everything underlined is a Python protocol. Pick the implementations you want from exoclaw-plugins, or write your own.
exoclaw doesn't own your event loop — it runs as a background task while your routes act as producers and consumers on the bus.
from fastapi import FastAPI
from exoclaw.agent.loop import AgentLoop
from exoclaw.bus.queue import MessageBus
from exoclaw.bus.events import InboundMessage, OutboundMessage
# These come from plugin packages — see exoclaw-plugins
from exoclaw_provider_litellm.provider import LiteLLMProvider
from exoclaw_conversation.conversation import DefaultConversation
app = FastAPI()
bus = MessageBus()
provider = LiteLLMProvider(default_model="claude-sonnet-4-6")
conversation = DefaultConversation.create(workspace="~/.mybot", provider=provider)
agent = AgentLoop(bus=bus, provider=provider, conversation=conversation)
@app.on_event("startup")
async def _start():
import asyncio
asyncio.create_task(agent.run())
@app.post("/chat")
async def chat(user_id: str, message: str):
await bus.publish_inbound(InboundMessage(
channel="api", sender_id=user_id, chat_id=user_id, content=message,
))
response: OutboundMessage = await bus.consume_outbound()
return {"reply": response.content}import asyncio
from exoclaw.agent.loop import AgentLoop
from exoclaw.bus.queue import MessageBus
from exoclaw.bus.events import InboundMessage
from exoclaw_provider_litellm.provider import LiteLLMProvider
from exoclaw_conversation.conversation import DefaultConversation
async def main():
bus = MessageBus()
provider = LiteLLMProvider(default_model="claude-sonnet-4-6")
conversation = DefaultConversation.create(workspace="~/.mybot", provider=provider)
loop = AgentLoop(bus=bus, provider=provider, conversation=conversation)
asyncio.create_task(loop.run())
await bus.publish_inbound(InboundMessage(
channel="cli", sender_id="me", chat_id="main", content="Hello!",
))
print((await bus.consume_outbound()).content)
asyncio.run(main())Zero infra. The bot replies to issues and PR comments using your GITHUB_TOKEN — no extra secrets needed. See exoclaw-github and the live demo.
# .github/workflows/bot.yml
- uses: Clause-Logic/exoclaw-github@main
with:
trigger: "@exoclawbot"
tools: github_pr_diff, github_file, github_checks, github_review, github_labelEvery component sits behind a protocol. Change one without changing anything else:
# File-backed sessions (default)
conversation = DefaultConversation.create(workspace="~/.mybot", ...)
# → swap for Redis without changing your AgentLoop, channels, or tools
from exoclaw_conversation_redis import RedisConversation
conversation = RedisConversation(url="redis://localhost", ...)Same for providers (LiteLLM ↔ direct Anthropic ↔ local Ollama), the bus (asyncio queue ↔ Redis pub/sub), and the executor (inline ↔ Temporal ↔ Celery for durable execution and retries).
For durable execution under Temporal — every LLM call and tool execution checkpointed, survives worker death — see exoclaw-temporal.
class WeatherTool:
name = "get_weather"
description = "Get the current weather for a city."
parameters = {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
}
async def execute(self, city: str) -> str:
return f"It's sunny in {city}, 22°C."Pass it to AgentLoop(tools=[WeatherTool()]). That's it. (Use ToolBase from exoclaw.agent.tools.protocol if you want parameter casting and JSON schema generation for free.)
Tools can also inject context into the system prompt each turn — useful for reminding the model about scheduled jobs, recent events, or anything else the agent should always know:
class CronTool:
name = "cron"
# ... rest of the tool
def system_context(self) -> str:
return f"# Scheduled jobs\n\n{self._list_active_jobs()}"class WebhookChannel:
name = "webhook"
async def start(self, bus):
self._bus = bus
# start your web server, register routes, etc.
async def stop(self): ...
async def send(self, msg):
# deliver msg.content to the webhook target
...
async def _on_request(self, payload):
await self._bus.publish_inbound(InboundMessage(
channel=self.name,
sender_id=payload["user_id"],
chat_id=payload["chat_id"],
content=payload["text"],
))exoclaw core runs on CPython and MicroPython out of the same source tree. Every PR runs the test suite on both runtimes; both must hit ≥95% coverage on their own reachable lines. A change that breaks MicroPython compat fails CI even if CPython stays green.
In practice that means you can run a single-tenant agent on an ESP32-S3 (8MB) — per-active-turn working set is ~100 KiB, per-session baseline ~5 KiB.
# Install the modules exoclaw imports onto your device
mpremote mip install asyncio dataclasses datetime typing __future__
# Copy the package over and run it
mpremote cp -r exoclaw :exoclaw/
mpremote run main.pyPlugins haven't been ported yet — bring your own LLM provider and lightweight tools.
exoclaw is published to:
- PyPI — the default
pip install exoclawsource. - clause-logic.github.io/registry — our self-hosted PEP 503 index. Each release lands on both within the same workflow run; the registry is useful as a fallback when PyPI's new-project creation rate limit holds up a first-time publish.
PyPI is fine for most users. To prefer the registry, add to your project's pyproject.toml:
[[tool.uv.index]]
name = "clause-logic"
url = "https://clause-logic.github.io/registry/pypi/simple/"uv checks clause-logic first for every package and falls through to PyPI for anything not there. (Transitive deps like structlog still come from PyPI.)
Pip users: pip install --extra-index-url https://clause-logic.github.io/registry/pypi/simple/ exoclaw.
MIT