nsorros .com
online
← back to writing

Clawdbot in 200 lines of code

Clawdbot (or openclaw in the last few days) has taken the world by storm, in some ways this feels like the ChatGPT moment for agentic AI i.e. ai that acts not just responds to your questions.

How does clawdbot, or even better AI that acts, works? Lets implement it in as few lines of code as possible to uncover the mystery 🎩 Here are the ingredients

🧠 AI (LLM)

⚒️ Tools

🔁 Agentic loop

🗒️ Memory

🪖 Rules aka prompt

AI 🧠

Let’s start with the easy part, calling the AI, the one that responds but does not act.

def call_llm(messages, tools, api_key):
    req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
        data=json.dumps({"model": "openrouter/free", "messages": messages, "tools": tools}).encode(),
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
        method="POST"
)
with urllib.request.urlopen(req) as resp:
        data = json.loads(resp.read().decode())
if "choices" not in data:
return f"Error: {data.get('error', 'No choices')}", []
    msg = data["choices"][0]["message"]
return msg.get("content", ""), msg.get("tool_calls", [])

this is the least exciting part but its the one doing most of the heavy lifting lets not forget, we are sending our conversation in messages , the tools available and an API KEY to our provide of choice, in this case openrouter which gives us access to any model we want and get back a response together with tools the AI would like to use.

Tools ⚒️

The tools are what allows the AI to act 🪄 so its part of the magic for sure. Tools are not new, in fact they date back to GPT4 which launched a few months after ChatGPT, but it has taken some time for the dust to settle in making the AI use tools more reliably. Introducing tools is a double edge sword ⚔️ On the one hand, adding more tools makes the AI more able to do more things, so the more the better in some sense, but on the other hand as we give it more tools we make it harder for the AI to pick the right one and we experience that with worse performance after a point. So there is a minimalist philosophy that has prevailed in regards to tools where we add the minimum tools needed to unlock AI abilities. In our case, these are read, write and edit files as well as run terminal commands and search the web through exa.

def read(path: str) -> str:
return Path(path).read_text()
def write(path: str, content: str) -> str:
    Path(path).parent.mkdir(parents=True, exist_ok=True)
    Path(path).write_text(content)
return f"Written to {path}"
def edit(path: str, old: str, new: str) -> str:
    content = Path(path).read_text()
    Path(path).write_text(content.replace(old, new, 1))
return f"Edited {path}"
def run(command: str) -> str:
    result = subprocess.run(command, shell=True, capture_output=True, text=True)
return result.stdout or result.stderr

def web_search(query: str) -> str:
import urllib.request, urllib.parse, json, os
    api_key = os.environ.get("EXA_API_KEY")
if not api_key:
return "Error: EXA_API_KEY not set"
    data = json.dumps({"query": query, "num_results": 5}).encode()
    req = urllib.request.Request(
"https://api.exa.ai/search",
        data=data,
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as resp:
        results = json.loads(resp.read().decode()).get("results", [])
if not results:
return "No results found."
return "\n\n".join(f"- {r['title']}\n  {r['url']}" for r in results)

TOOLS = {"read": read, "write": write, "edit": edit, "run": run, "web_search": web_search}

TOOL_SCHEMAS = [
{"type": "function", "function": {"name": "read", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
{"type": "function", "function": {"name": "write", "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["pa
th", "content"]}}},
{"type": "function", "function": {"name": "edit", "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "old": {"type": "string"}, "new": {"type": "strin
g"}}, "required": ["path", "old", "new"]}}},
{"type": "function", "function": {"name": "run", "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}},
{"type": "function", "function": {"name": "web_search", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
]

Choosing the right tools is a bit of an art 🎨, you can argue the only tool you need is run and the AI should be able to figure out the rest and this might be true given the right prompt or model but the set above seems to be the minimum set that seems to work relatively reliably and it is not very disimilar to the toolset claude code, clawdbot and others provide.

Agentic loop 🔁

If AI is the heavy lifting 💪 and tools a big part of the magic 🪄, the agentic loop is what puts them in practice to unleash the power 🚀 The loop is as simple as it sounds and it shows up under many different names like effort level, deep versions of models, time spent thinking, test time compute, search etc. Irrespective of name though, the implementation is a loop where the model, knowing the task from the user and the tools available works step by step to answer. Here is how

while True:
	  task = input("\n> ").strip()
if task.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
if not task:
continue
	
	  conversation.append({"role": "user", "content": task})
	  run_agent(conversation, api_key)
def compact(conversation: list) -> list:
if len(conversation) <= 8:
return conversation

    prompt = conversation[:2] # system + memory
    recent = conversation[-4:] # last 4 messages
    middle = conversation[2:-4]
print(f"\n[Compacting {len(middle)} messages...]")
return prompt + [{"role": "user", "content": f"[Summary of {len(middle)} previous messages]"}] + recent
	  
def run_agent(conversation, api_key, max_iterations=5):
for iteration in range(max_iterations):
print(f"\n[Step {iteration + 1}/{max_iterations}]")
try:
            response, tool_calls = call_llm(conversation, TOOL_SCHEMAS, api_key)
except Exception as e:
if "context" in str(e).lower():
print(f"\n[Context limit hit, compacting...]")
                conversation = compact(conversation)
continue
raise
if response:
print(f"Thinking: {response[:200].replace(chr(10), ' ')}...")
if "context" in response.lower():
print(f"\n[Context limit hit, compacting...]")
            conversation = compact(conversation)
continue
if not tool_calls:
print(f"Final: {response}")
return

        conversation.append({"role": "assistant", "content": response, "tool_calls": tool_calls})
for tc in tool_calls:
            name = tc["function"]["name"]
            args = json.loads(tc["function"].get("arguments") or "{}")
            tc_id = tc.get("id", "")
print(f"  → {name}({args})")
try:
                result = TOOLS[name](**args)
except Exception as e:
                result = f"Error: {e}"
print(f"  ← {result[:100]}...")
            conversation.append({"role": "tool", "content": result, "tool_call_id": tc_id})

Nothing fancy, we call the AI repeatedly a number of times, some of those times the AI decides it wants to use tools, something it has been trained to do it is worth keeping in mind. Lets call the reasoning around those tool uses thinking and return if there is nothing else to act on.

☝️The only important thing to call out is that at some point this back and forth contains more tokens than AI can handle and in these cases we need to summarise whats going on while keeping the initial directions and last few messages for continuity. This is what the function compact does.

Memory 🗒️

This brings us nicely to memory as the number of tokens AI can process can be though of as some form of short term memory. This number has increased substantially in the last years. When ChatGPT launched this was closer to the low thousands whereas now we are in the low millions. After a point though it makes little sense keeping so much information in context and here is where some form of external memory comes into play. Memory also shows up with different names, the latest of which is skills. Irrespective of name, what we want is some way to save notes for future reference or pass in our knowledge to AI and the AI using it. No need to reinvent the wheel every time from first principles if it can save instructions how it achieved it the first time. Simple concept but quite powerful 💡

memory = get_memory(MEMORY_PATH)
print(f"[Memory loaded: {len(memory)} chars]")

conversation = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "system", "content": f"Memory:\n{memory}"},
]
def get_memory(memory_path: str = "memory/") -> str:
    path = Path(memory_path)
if not path.exists():
return "No memory found."

    memory_files = []
for f in sorted(path.glob("*.md")):
        memory_files.append(f"=== {f.name} ===\n{f.read_text()}")
return "\n\n".join(memory_files) if memory_files else "No memory found."

There are a couple of ways to implement memory but one way is simply to keep a folder, lets call it memory, where we save md files with useful instructions. Then we pass it as is, or summarised, to the model first thing after the system prompt while also mentioning in the system prompt to save information to memory when it makes mistakes or long efforts to respond in order to improve over time.

Rules 🪖

The final touch is how we prompt the model. This is important because the model has not been trained to explicitly act as the agent we designed. It has been trained to use tools so no much prompting needed there but has no concept of memory and is cautious when responding instead of being brave 😂 things will be alright so needs a bit of nudging.

SYSTEM_PROMPT = """You are a helpful assistant with access to tools: read, write, edit, run, web_search.

User memory is provided below in the conversation. USE that info directly - don't re-read files.

RULES:
1. The memory system message contains everything you need - use it
2. Try 3+ approaches before giving up
3. Never ask for clarification - figure it out
4. If you make mistakes or spend long time exploring, SAVE what you learned to memory/ for next session
5. Keep memory organized - one file per tool/task (e.g., memory/gog.md for email, memory/gh.md for GitHub, memory/code.md for coding, memory/user.md for preferences). Overlap is f
ine.
6. You can read/research freely but ALWAYS ask permission before taking actions (sending emails, creating tasks/meetings, editing files, running destructive commands)"""

And just like that you can have AI that acts whether you call it Clawdbot 🦞 Claude Code or otherwise. I chose to call it bot to avoid issues with the usual suspects 😂 Here is the whole code https://github.com/nsorros/bot/blob/main/blog_agent.py which you can use as is with just python. Note that in order for it to be more useful from the starts you might want to populate memory with one or two files which you can take straight from the skills of openclaw or ask the model to pick it up via search. Use at your own risk ⚠️ with the tools in your laptop, the more tools, the more useful and the more risky…