Building Autonomous AI Agents: From Zero to Production

A complete tutorial on building autonomous AI agents from scratch, drawing on architectural insights from Hermes, OpenClaw, PicoClaw, ZeroClaw, gptme, and IronClaw.

Target audience: Intermediate developers who know Python and have used LLM APIs.
What you'll build: A working agent framework, incrementally, chapter by chapter.
Prerequisites: Python 3.10+, an LLM API key (OpenAI, Anthropic, or compatible provider), and a terminal.


Table of Contents

Part I: Foundations

Part II: Core Capabilities

Part III: Going Deeper

Part IV: Advanced Topics

Part V: Production & Ecosystem

Appendices


Chapter 1: What Is an AI Agent?

A Conversation, Not a Question

A chatbot answers your question and stops. An AI agent loops — it reasons, acts, observes the results, then reasons again. It's the difference between asking "What's the weather?" and saying "Email the weather report to the team every morning at 8 AM."

Here's the fundamental difference:

Chatbot:    User  LLM  Answer  (one shot)
AI Agent:   User  LLM  Tool Call  Tool Result  LLM  Tool Call  ...  Answer  (loop)

What problem are we solving? A chatbot is stateless and single-shot. An agent must maintain state across multiple reasoning steps, decide when to act vs. when to answer, and recover when tools fail.

Think about it: You have an LLM that can call functions. How would you build a loop that repeatedly calls the LLM, executes the functions it requests, feeds results back, and knows when to stop? What would go wrong if the LLM calls a non-existent function? What if it loops forever? Take a moment before reading on.

How the frameworks solve it: Every production agent implements a bounded iteration loop with error classification and recovery. The loop is the same across all frameworks — what differs is the sophistication of error handling, budget tracking, and termination detection.

The Three Pillars

Every AI agent, regardless of framework or language, rests on three foundations:

  1. Reasoning — The LLM plans, decides, and adapts. It's the brain.
  2. Tools — The hands. The agent can execute code, read files, browse the web, send messages.
  3. Memory — The notebook. The agent remembers past conversations, user preferences, learned facts.
┌──────────────────────────────────────────┐
│              THE AI AGENT                  │
│                                           │
│  ┌─────────┐   ┌─────────┐   ┌────────┐  │
│  │REASONING│◄─►│  TOOLS  │◄─►│ MEMORY │  │
│  │   LLM   │   │ Shell   │   │ Facts  │  │
│  │  Plans  │   │ Files   │   │ History│  │
│  │ Decides │   │ Web     │   │ Profile│  │
│  └─────────┘   └─────────┘   └────────┘  │
│       │             │              │       │
│       └─────────────┼──────────────┘       │
│                     ▼                      │
│              SYSTEM PROMPT                 │
│         (Identity, Rules, Context)          │
└──────────────────────────────────────────┘

How All Six Frameworks Implement This

Despite being written in Python, TypeScript, Go, and Rust — and ranging from 3,000 to 400,000 lines — every agent framework we studied implements the same core pattern:

Framework Language Lines Key Innovation
Hermes Python ~50K Prompt caching obsession, skill self-improvement
OpenClaw TypeScript ~400K Plugin SDK boundary, multi-tenant isolation
PicoClaw Go ~30K Ultra-lightweight (<10MB RAM), runs on $10 hardware
ZeroClaw Rust ~126K Trait-driven microkernel, WASM plugins, verifiable intent
gptme Python ~15K Keep-it-tiny philosophy, lessons system, auto-discovery
IronClaw Security N/A Five-layer defense model, OS-level isolation

All six use the same pattern — the agent loop:

FUNCTION agent(task):
    messages = [system_prompt, user_message]
    FOR iteration = 1 TO max_iterations:
        response = LLM.chat(messages, tools)
        IF response has tool_calls:
            FOR each tool_call:
                result = execute_tool(tool_call)
                ADD tool_result TO messages
        ELSE:
            RETURN response.text
    RETURN "max iterations exceeded"

What We're Building

By the end of this book, you'll have built a complete, production-capable agent framework. But we start small — 40 lines of code that can already do useful work. Each chapter adds one capability, always with working code you can run.

Pitfalls to avoid:
- Don't start with architecture planning. Build the smallest thing that works.
- Don't treat the LLM as an oracle. It's a reasoning engine that makes mistakes.
- Don't skip error handling. Agents run in loops; one unhandled error cascades.

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 2: Your First Agent (40 Lines)

The Minimum Viable Agent

What problem are we solving? We need the smallest possible program that demonstrates the agent pattern: call an LLM with tools, execute the tool it requests, feed the result back, and return a final answer.

Think about it: You have an LLM API that accepts a list of messages and a list of tool definitions. The LLM can return either text or a tool call. How would you connect these? What's the simplest loop that handles both cases? What happens if the LLM keeps calling tools forever? Take a moment before reading on.

How the frameworks solve it: Every framework starts with exactly this minimal loop. Hermes began as a single run_conversation() function. gptme's first commit was essentially the same pattern. The differences — streaming, parallel tool execution, error recovery — come later.

Here is the minimum viable agent in pseudocode:

FUNCTION minimal_agent(task):
    LLM = connect_to_model("gpt-4o")

    tools = [{
        name: "run_command",
        description: "Execute a shell command",
        parameters: {
            cmd: {type: string, description: "Shell command"}
        }
    }]

    messages = [
        {role: "system", content: system_prompt},
        {role: "user", content: task}
    ]

    FOR i = 1 TO 10:
        response = LLM.chat(
            model = "gpt-4o",
            messages = messages,
            tools = tools
        )

        IF response has tool_calls:
            ADD assistant_message TO messages
            FOR each tool_call:
                args = parse_arguments(tool_call)
                result = execute_shell(args.cmd)
                TRUNCATE result TO 4000 chars
                ADD tool_result TO messages
        ELSE:
            RETURN response.text

    RETURN "Agent exceeded maximum iterations."

What Just Happened?

Let's trace the execution:

  1. System prompt tells the LLM who it is and what it can do
  2. User message enters the loop
  3. LLM call — the model decides: "I need to run ls"
  4. Tool call extraction — we parse the function call from the response
  5. Tool execution — we run ls via the shell
  6. Tool result injection — the output goes back as a tool role message
  7. LLM call again — the model sees the result and formulates its answer
  8. Final response — no more tool calls needed, loop exits

This is the pattern every framework uses. The differences are in sophistication, not structure.

The Anatomy of a Tool Call

The tool calling protocol works through three message types:

Request (what we send):

{
  model: "gpt-4o",
  messages: [...],
  tools: [{
    type: "function",
    function: {
      name: "run_command",
      description: "Execute a shell command...",
      parameters: { type: "object", properties: { cmd: { type: "string" } } }
    }
  }]
}

Response (what we get back):

{
  choices: [{
    message: {
      role: "assistant",
      tool_calls: [{
        id: "call_abc123",
        function: { name: "run_command", arguments: '{"cmd": "ls -la"}' }
      }]
    }
  }]
}

Tool result (what we send back):

{
  role: "tool",
  tool_call_id: "call_abc123",
  content: "total 24\ndrwxr-xr-x ..."
}

PicoClaw's Equivalent in Go

The same pattern in Go is remarkably similar. From PicoClaw's toolloop.go:

FUNCTION RunToolLoop(context, config, messages):
    FOR i = 0 TO config.MaxIterations - 1:
        toolDefs = buildToolDefinitions(config.Registry)
        response = config.Provider.Chat(context, messages, toolDefs)

        IF response has NO tool_calls:
            RETURN response.Content

        // Execute all tool calls in parallel using goroutines
        results = PARALLEL_EXECUTE(response.ToolCalls, config.Registry)

        FOR each result IN results:
            APPEND result TO messages

Notice the parallel execution of tool calls — a key optimization present in every mature framework.

What problem are we solving? Multiple independent tool calls shouldn't execute sequentially when they could run in parallel.

Think about it: If the LLM calls read_file("a.txt") and read_file("b.txt") in the same response, why wait for the first to finish before starting the second? How would you implement this in your language — threads, async, goroutines? What are the safety concerns with parallel file access?

How the frameworks solve it: PicoClaw uses goroutines + WaitGroup. ZeroClaw uses Tokio async. Hermes supports both sequential and parallel execution based on tool dependency annotations. The key is recognizing that tools without shared state can safely run concurrently.

What We Built

A minimal agent that can execute shell commands, read files, and answer questions using real tool output. It's primitive but complete. From here, every chapter adds capability without changing the fundamental loop.

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 3: The Agent Loop Deep Dive

Formalizing the Loop

Our minimal agent has a simple loop. Real agents need more: iteration budgets, error handling, recovery from hallucinations, and graceful termination. Let's formalize.

┌──────────────────────────────────────────────────────────┐
                    THE AGENT LOOP                          
                                                           
  START                                                    
                                                          
                                                          
  ┌─────────────────┐                                      
   Build Context      System prompt + history + memory   
  └────────┬────────┘                                      
                                                          
  ┌─────────────────┐     ┌─────────────────────────────┐ 
   LLM API Call     │────▶│ Error? Classify & Recover     
   (with retry)            Rate limit  backoff        
  └────────┬────────┘       Context overflow  compress  
                            Content policy  report      
                            Network  retry              
  ┌─────────────────┐     └─────────────────────────────┘ 
   Parse Response                                         
    Text?  Return                                       
    Tool calls?                                         
  └────────┬────────┘                                      
                                                          
  ┌─────────────────┐     ┌─────────────────────────────┐ 
   Validate Tools   │────▶│ Hallucinated name?            
    Name check            Levenshtein repair           
    JSON parse            Inject error  model fixes   
    Guardrails            Retry (up to 3x)             
  └────────┬────────┘     └─────────────────────────────┘ 
                                                          
  ┌─────────────────┐                                      
   Execute Tools      Parallel execution, timeouts       
  └────────┬────────┘                                      
                                                          
  ┌─────────────────┐                                      
   Check Budgets      Iterations left? Tokens left?      
  └────────┬────────┘                                      
                                                          
     ┌─────▼─────┐                                         
      Continue?  │──Yes──▶ Back to LLM API Call           
     └─────┬─────┘                                         
            No                                            
                                                          
  ┌─────────────────┐                                      
   Finalize Turn      Persist, memory review, cleanup    
  └─────────────────┘                                      
                                                          
                                                          
          END                                              
└──────────────────────────────────────────────────────────┘

The Production Loop

What problem are we solving? A naive loop crashes on the first error. Real APIs have rate limits, network failures, context overflows, and content policy rejections. The agent must classify each error and apply the right recovery strategy.

Think about it: You call the LLM API and get back a 429 (rate limit). Do you retry immediately? Wait? How long? What about a 400 (context too long) — retrying with the same input will fail again. What if the model hallucinates a tool name that doesn't exist? Take a moment before reading on.

How the frameworks solve it: Hermes classifies 15+ distinct error types with dedicated recovery paths. PicoClaw uses a simple retry loop with configurable max iterations. ZeroClaw detects malformed tool protocols and retries with corrected prompts. All frameworks implement jittered backoff (random delay to avoid thundering herd).

Here's the production-grade loop in pseudocode:

CLASS AgentConfig:
    max_iterations = 30
    max_tool_retries = 3
    base_backoff = 2.0 seconds
    max_backoff = 60.0 seconds
    result_truncation = 8000 chars

CLASS TurnResult:
    response = ""
    tool_calls_made = 0
    iterations_used = 0
    errors_recovered = 0

FUNCTION classify_error(error_message):
    IF "rate" or "429" IN error_message:
        RETURN "rate_limit"
    IF "context" or "token" IN error_message:
        RETURN "context_overflow"
    IF "policy" or "safety" IN error_message:
        RETURN "content_policy"
    IF "overloaded" or "503" IN error_message:
        RETURN "overloaded"
    RETURN "network_error"

FUNCTION backoff_delay(attempt):
    delay = base_backoff * (2 ^ attempt) + RANDOM(0, 1)
    RETURN MIN(delay, max_backoff)

FUNCTION production_loop(messages, system_prompt, tools):
    result = TurnResult()
    full_messages = [system_prompt] + messages

    FOR iteration = 1 TO max_iterations:
        result.iterations_used = iteration

        // --- API Call with Retry ---
        response = NULL
        FOR retry = 1 TO 3:
            TRY:
                response = LLM.chat(full_messages, tools)
                BREAK
            CATCH error:
                reason = classify_error(error)

                IF reason == "context_overflow":
                    RAISE  // Cannot retry  need compression
                IF reason == "content_policy":
                    RETURN BlockedResult
                IF retry == 3:
                    RAISE

                wait = backoff_delay(retry)
                result.errors_recovered += 1
                SLEEP(wait)

        // --- Handle Truncation ---
        IF response.finish_reason == "length":
            ADD "Continue where you left off" TO full_messages
            CONTINUE

        // --- No tool calls = final ---
        IF response has NO tool_calls:
            result.response = response.text
            RETURN result

        // --- Validate and Execute ---
        ADD assistant_message TO full_messages

        FOR each tool_call IN response.tool_calls:
            tool_name = tool_call.name

            // Hallucination repair
            IF tool_name NOT IN tools.registry:
                REPAIR ATTEMPT: find closest match (Levenshtein)
                IF no match:
                    ADD error TO tool_errors
                    CONTINUE

            // Parse arguments
            TRY:
                args = parse_json(tool_call.arguments)
            CATCH:
                ADD "Invalid JSON" error TO tool_errors
                CONTINUE

            // Execute
            TRY:
                tool_result = tools.execute(tool_name, args)
                result.tool_calls_made += 1
                content = TRUNCATE(tool_result, result_truncation)
                ADD tool_result TO full_messages
            CATCH error:
                ADD error TO tool_errors

        // Inject errors for self-correction
        FOR each error IN tool_errors:
            ADD error TO full_messages

    result.response = "Max iterations exceeded"
    RETURN result

How Frameworks Handle the Loop

Hermes (conversation_loop.py, ~4,000 lines):
- 15+ distinct error classifications with dedicated recovery
- Streaming-first API strategy with stale-stream detection at 90s
- Complex empty-response recovery: prefill → retry → fallback → terminal
- Context overflow auto-detection and compression retry (up to 3 compression attempts)

PicoClaw (toolloop.go, ~300 lines):
- Elegantly simple: ToolLoop is a reusable function shared by main agent and sub-agents
- Parallel tool execution via goroutines + WaitGroup
- Single retry loop with configurable max iterations
- No streaming (simplicity trade-off for <10MB RAM)

gptme (chat.py, ~400 lines):
- Step-based: each "step" = one LLM generation + tool execution
- Hook-driven: lifecycle hooks at every stage (SESSION_START, TURN_PRE, STEP_PRE, etc.)
- Auto-compression via token counting and context trimming
- Auto-continue for non-interactive agent mode

ZeroClaw (loop_.rs, ~13K lines):
- Multi-dispatcher: NativeToolDispatcher, XmlToolDispatcher, PromptGuidedDispatcher
- XML tool protocol for providers without native function calling
- Malformed tool protocol detection with retry (up to 2 attempts)
- Loop detection via pattern matching for repeated tool calls

Iteration Budgets and Why They Matter

What problem are we solving? Without limits, a confused model could call tools forever, burning through API costs indefinitely.

Think about it: How many iterations is reasonable? What if the model calls the same tool with the same arguments repeatedly — how would you detect that? What about token budgets vs. iteration budgets — which is the better limit? Take a moment.

How the frameworks solve it: Every framework implements a hard cap, but the best ones add stall detection. If the model repeats the same tool call 3+ times without progress, terminate or warn the user.

Every framework implements some form of iteration budget:

Hermes:    max_iterations=90, iteration_budget with grace calls
OpenClaw:  per-session queue serialization, global lane
PicoClaw:  MaxIterations config (default ~10)
ZeroClaw:  MAX_MALFORMED_TOOL_PROTOCOL_RETRIES=2, budget checks
gptme:     GPTME_MAX_STEPS env var

Pattern:  WHILE iterations < max AND budget > 0 AND NOT stalled

Production agents always implement:
1. Hard iteration cap — absolute maximum turns per request
2. Token budget — total tokens across all iterations
3. Cost budget — monetary limit on API spend
4. Stall detection — repeated identical tool calls with no progress

Pitfalls

  1. The empty-response trap: Models sometimes return content: null after tool execution. Hermes has a multi-stage recovery (nudge → prefill → retry → fallback). Your agent should at minimum retry once.

  2. The role-alternation trap: OpenAI requires alternating user/assistant roles. Injecting tool errors as role: "user" breaks this. Hermes always injects error results as role: "tool" to preserve alternation.

  3. The truncation trap: When finish_reason="length", the model may have been mid-tool-call. Hermes detects truncated JSON and retries without appending the broken response.

What We Built

A production-quality agent loop with:
- Error classification and jittered backoff
- Tool validation with hallucination repair
- Budget tracking and stall protection
- Structured TurnResult with metrics

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 4: System Prompts and Personality

The Prompt Is the Soul

The system prompt defines everything about your agent: its identity, its capabilities, its constraints, and its behavior. It's the most impactful piece of text in your entire system. A good prompt makes an agent helpful and safe; a bad one makes it verbose, confused, or dangerous.

What problem are we solving? A system prompt is not one monolithic text block — it has distinct sections that serve different purposes. How do you organize them? Which parts change every turn and which stay the same?

Think about it: If your system prompt includes the current time down to the second, then every API call gets a different prompt — destroying prefix caching and doubling your costs. What should be in the "stable" section vs. the "volatile" section? Where do user preferences go? How do you handle different output formats for different messaging platforms? Take a moment before reading on.

How the frameworks solve it: All mature framewsorks use a multi-section prompt architecture with explicit separation between stable (cacheable) and volatile (per-turn) content. The canonical structure has 7 sections, each serving a distinct role.

Multi-Section Prompt Architecture

Every framework we studied uses a multi-section prompt. Here's the canonical structure:

┌──────────────────────────────────────────┐
 SECTION 1: IDENTITY                       
 "You are X, created by Y. You help with Z"
├──────────────────────────────────────────┤
 SECTION 2: CORE RULES                     
 Tool-use enforcement, task completion,    
 anti-hallucination, behavioral constraints
├──────────────────────────────────────────┤
 SECTION 3: TOOL CATALOG                   
 Available tools with descriptions and     
 parameter schemas                         
├──────────────────────────────────────────┤
 SECTION 4: CONTEXT & ENVIRONMENT          
 Working directory, OS, Python version,    
 active session info, platform quirks      
├──────────────────────────────────────────┤
 SECTION 5: MEMORY & PREFERENCES           
 User profile, learned facts, conventions  
├──────────────────────────────────────────┤
 SECTION 6: SKILLS & CAPABILITIES          
 Loadable skill modules, plugin context    
├──────────────────────────────────────────┤
 SECTION 7: FORMATTING DIRECTIVES          
 Output format, media handling, platform   
 hints (e.g., "no markdown tables on WA")  
└──────────────────────────────────────────┘

Building the Prompt Engine

What problem are we solving? We need a systematic way to assemble prompts from multiple sources, with caching awareness built in from the start.

Think about it: If the agent runs on Discord vs. Telegram vs. CLI, the formatting instructions differ. If the user has a profile stored, it should be included. How do you build a prompt builder that handles all these cases without becoming a tangled mess of string concatenation? Take a moment.

How the frameworks solve it: A PromptBuilder class that assembles sections from configuration, with each section being a pure function. The key insight: separate what's stable from what's volatile.

CLASS PromptBuilder:
    agent_name = "MyAgent"
    agent_creator = "Me"
    agent_description = "A helpful AI assistant."
    workspace_dir = NULL
    user_profile = NULL
    memory_facts = []
    platform = "cli"  // cli, telegram, discord, etc.

    FUNCTION build(tools_description):
        sections = [
            build_identity(),
            build_core_rules(),
        ]
        IF tools_description:
            ADD build_tool_section(tools_description) TO sections
        ADD build_context() TO sections
        ADD build_memory() TO sections
        ADD build_formatting() TO sections
        RETURN JOIN sections WITH "\n\n"

    FUNCTION build_identity():
        RETURN "You are {agent_name}, an AI assistant created by {agent_creator}.\n" +
               "{agent_description}\n" +
               "Communicate clearly, admit uncertainty, and prioritize being useful."

    FUNCTION build_core_rules():
        RETURN """
            CORE RULES:
            1. TOOL-USE: You MUST use tools to take action — don't describe what you'd do.
            2. TASK COMPLETION: Deliver working artifacts backed by real tool output.
            3. NO FABRICATION: Never invent tool results. Report blockers honestly.
            4. CONCISENESS: Be targeted and efficient.
        """

    FUNCTION build_context():
        RETURN """
            ENVIRONMENT:
            Host: {hostname}
            OS: {os_version}
            Python: {python_version}
            Working directory: {workspace}
            Date: {current_date}  // Date only, NO time — preserves caching
        """

    FUNCTION build_memory():
        IF no memories AND no profile:
            RETURN "No persistent memory."
        RETURN "MEMORY:\n" + user_profile + memory_facts

    FUNCTION build_formatting():
        hints = {
            "cli": "Use markdown. Code blocks with language tags.",
            "telegram": "Telegram markdown. No tables. MEDIA: prefix for images.",
            "discord": "Discord markdown. Messages limited to 2000 chars.",
            "whatsapp": "Plain text only. No markdown."
        }
        RETURN "OUTPUT FORMAT:\n" + hints[platform]

Comparing Framework Prompt Architectures

Hermes — Three-Tier Cache-Aware Prompt:

┌───────────────────────────────────┐
│ STABLE TIER (byte-identical)       │
│ • Identity (SOUL.md or default)    │
│ • Task completion guidance         │
│ • Tool-use enforcement             │
│ • Skills index                     │
│ • Environment hints                │
│ • Model-specific guidance          │
│ • Coding workspace blocks          │
├───────────────────────────────────┤
│ CONTEXT TIER (cwd-dependent)       │
│ • AGENTS.md from workspace         │
│ • .hermes.md, .cursorrules etc.   │
├───────────────────────────────────┤
│ VOLATILE TIER (per-session)        │
│ • MEMORY.md snapshot               │
│ • USER.md profile                  │
│ • Timestamp line                   │
│ • Session metadata                 │
└───────────────────────────────────┘

The stable tier is built ONCE per session, persisted to SQLite, and reused across turns for prefix caching. Hermes's architecture document explicitly states: "Per-conversation prompt caching is sacred. Anything that mutates past context, swaps toolsets, or rebuilds the system prompt mid-conversation invalidates that cache and multiplies cost."

OpenClaw — Cache-Optimized Sections:

ABOVE CACHE BOUNDARY (stable):
  • Tooling guidance
  • Execution bias
  • Safety
  • Skills
  • Workspace files (AGENTS.md, SOUL.md, etc.)
  • Sandbox info
  • Current date (date only, no time)

BELOW CACHE BOUNDARY (volatile):
  • Messaging surface details
  • Voice/TTS hints
  • Group chat rules
  • Heartbeat behavior
  • Runtime info (host, model, thinking)
  • Provider-specific contributions

gptme — Layered with Lessons:

Layer 1: Core prompt (identity + instructions + tools + skills)
Layer 2: Workspace context (AGENTS.md, CLAUDE.md, project files)
Layer 3: Agent config (agent-specific overrides)
--- CACHE BOUNDARY ---
Layer 4: Dynamic context (context_cmd output, chat history)

PicoClaw — Explicit Slot System:

kernel layer:    identity, hierarchy
instruction:     (reserved for extensibility)
capability:      tooling, MCP, skill catalog, active skill
context:         workspace, memory, runtime, summary
turn:            message, steering, subturn, interrupt, output

Prompt Engineering Principles

From studying all five frameworks, these principles emerge:

  1. Separate by stability. Stable content goes first (for prefix caching). Volatile content goes last.

  2. Be explicit about tool use. Every framework tells the model "you MUST use tools" in the strongest possible language. Hermes: "You MUST use your tools to take action — do not describe what you would do or plan to do without actually doing it."

  3. Date precision matters. Use date-only (not second-precision) timestamps in cacheable sections. Hermes uses "Thursday, June 11, 2026" — not "2026-06-11T14:32:17Z".

  4. Platform hints prevent format confusion. Different messaging platforms have different capabilities. Hermes injects: "Telegram: no table syntax. WhatsApp: no markdown."

  5. Anti-fabrication is explicit. Hermes: "NEVER substitute plausible-looking fabricated output for results you couldn't actually produce." ZeroClaw: "NEVER fabricate, invent, or guess tool results."

  6. Skills should be loaded on demand. Loading every skill into every prompt burns tokens. Instead, include a skills INDEX and tell the model to call skill_view(name) to load full content when relevant.

Pitfalls

  1. Over-prompting. A 50-page system prompt may sound thorough, but the model will skim it. Be concise. Hermes's ~4,000-line codebase has a core identity block of just 6 sentences.

  2. Cache-hostile timestamps. If your prompt includes the exact second, every request gets a different cache key. Use date-only timestamps in cacheable sections.

  3. Conflicting instructions. "Be concise" and "Be thorough" in the same prompt confuse models. Pick one primary directive.

What We Built

A multi-section prompt builder with:
- Identity, core rules, tool catalog, context, memory, and formatting sections
- Platform-specific formatting hints
- Foundation for prompt caching (stable vs. volatile separation)

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 5: Building a Tool System

The Three Patterns

Every agent framework needs a way to define tools, discover them, and dispatch calls. Three patterns dominate:

Pattern Example How It Works
Self-Registration Hermes Each tool file calls registry.register() at module level. AST-based discovery scans for register calls.
Auto-Discovery gptme Scan modules for ToolSpec instances. No explicit registration.
Trait/Interface ZeroClaw, PicoClaw Each tool implements a Tool trait/interface. Collected at build time.

What problem are we solving? Adding a new tool shouldn't require editing core framework files. The tool system must support discovery, schema generation, execution, and error recovery — all without the agent author needing to understand the internals.

Think about it: When the LLM calls read_file, how does the framework know what function to execute? What happens when the LLM hallucinates a name like read_fiel? How do you convert a Python function signature into the JSON schema format that OpenAI expects? How do you handle async tools vs. sync tools? Take a moment before reading on.

How the frameworks solve it: Three distinct patterns — each with trade-offs. Self-registration gives control, auto-discovery gives simplicity, and traits give compile-time safety.

Pattern 1: Simple Registry

CLASS ToolDefinition:
    name: string
    description: string
    parameters: dictionary  // JSON Schema properties
    handler: function       // The actual implementation
    category: string        // "file", "shell", "web", etc.

    FUNCTION to_llm_schema():
        RETURN {
            type: "function",
            function: {
                name: self.name,
                description: self.description,
                parameters: {
                    type: "object",
                    properties: self.parameters,
                    required: keys(self.parameters)
                }
            }
        }

CLASS ToolRegistry:
    tools = {}  // name  ToolDefinition

    FUNCTION register(name, description, parameters, handler, category):
        tools[name] = ToolDefinition(name, description, parameters, handler, category)

    FUNCTION get_definitions(filter_names = NULL):
        filtered = tools matching filter_names (or all)
        RETURN [tool.to_llm_schema() for tool in filtered]

    FUNCTION execute(name, args):
        IF name NOT IN tools:
            RETURN "Error: Tool '{name}' not found. Available: {available_names}"
        TRY:
            RETURN STRING(tools[name].handler(**args))
        CATCH error:
            RETURN "Error executing {name}: {error}"

    FUNCTION closest_match(hallucinated_name, threshold = 0.6):
        best_match = NULL
        best_score = 0.0
        FOR each tool_name IN tools:
            score = bigram_similarity(hallucinated_name, tool_name)
            IF score > best_score:
                best_score = score
                best_match = tool_name
        IF best_score >= threshold:
            RETURN best_match
        RETURN NULL

    FUNCTION bigram_similarity(a, b):
        // Compare character bigram overlaps
        a_bigrams = SET of 2-char sequences in a
        b_bigrams = SET of 2-char sequences in b
        RETURN intersection_size / union_size

// --- Define Tools ---

registry = ToolRegistry()

registry.register(
    name = "read_file",
    description = "Read a file with line numbers and pagination.",
    parameters = {
        "path": {type: "string", description: "Path to the file"},
        "offset": {type: "integer", description: "Start line (default: 1)"},
        "limit": {type: "integer", description: "Max lines (default: 500)"}
    },
    handler = read_file_implementation,
    category = "file"
)

registry.register(
    name = "write_file",
    description = "Write content to a file, creating directories as needed.",
    parameters = {
        "path": {type: "string", description: "Path to write to"},
        "content": {type: "string", description: "Content to write"}
    },
    handler = write_file_implementation,
    category = "file"
)

registry.register(
    name = "shell",
    description = "Execute a shell command with timeout.",
    parameters = {
        "command": {type: "string", description: "Shell command"},
        "timeout": {type: "integer", description: "Timeout seconds (default: 60)"},
        "workdir": {type: "string", description: "Working directory"}
    },
    handler = shell_implementation,
    category = "shell"
)

registry.register(
    name = "web_search",
    description = "Search the web for information.",
    parameters = {
        "query": {type: "string", description: "Search query"}
    },
    handler = web_search_implementation,
    category = "web"
)

Pattern 2: Auto-Discovery (gptme Style)

gptme's tool system uses Python module auto-discovery. No explicit registration — just define a ToolSpec in a module and it's found:

CLASS ToolSpec:
    name: string
    description: string
    parameters: dictionary
    execute: function
    available: boolean = true

FUNCTION discover_tools(package):
    tools = []
    FOR each module IN walk_package(package):
        FOR each attribute IN module:
            IF attribute IS ToolSpec:
                ADD attribute TO tools
    RETURN tools SORTED BY name

The key insight: no boilerplate registration code. Each tool module just needs a module-level ToolSpec. The framework does the rest.

Pattern 3: Trait-Based Tools (ZeroClaw/PicoClaw Style)

In compiled languages, tools implement an interface:

// ZeroClaw-style trait (Rust)
TRAIT Tool:
    FUNCTION name() -> string
    FUNCTION description() -> string
    FUNCTION parameters_schema() -> JSON
    FUNCTION execute(args: JSON) -> Result<ToolResult>
// PicoClaw-style interface (Go)
INTERFACE Tool:
    FUNCTION Name() -> string
    FUNCTION Description() -> string
    FUNCTION Parameters() -> map[string]any
    FUNCTION Execute(context, args) -> *ToolResult

In Python, the equivalent is duck typing — any object with name, description, parameters, and execute works.

Hermes's Self-Registration Pattern

Hermes uses the most sophisticated approach: tools call registry.register() at module top-level, and discovery uses AST scanning:

// In tools/my_tool.py:

registry.register(
    name = "my_tool",
    toolset = "custom",
    schema = {
        "name": "my_tool",
        "description": "Does something useful",
        "parameters": {
            "type": "object",
            "properties": {
                "input": {type: "string", description: "The input"}
            },
            "required": ["input"]
        }
    },
    handler = handle_my_tool,
    is_async = false
)

FUNCTION handle_my_tool(input):
    RETURN "Processed: {input}"

Discovery scans tools/*.py for files containing registry.register( (AST-based, not importing every file). Only matching files are imported, which triggers their register() call.

What problem are we solving? How do you discover tools without importing every Python file (which could have side effects)?

Think about it: If you import every file in a tools directory, any module-level code runs. A tool file that connects to a database would connect during discovery. How can you find tools without executing their code? Take a moment.

How the frameworks solve it: Hermes uses AST scanning — it parses the Python source into an abstract syntax tree and looks for registry.register( calls without executing anything. Only files that contain the pattern are imported. gptme takes the simpler approach of just importing everything (tools are assumed to be safe to import).

Tool Result Formatting

All frameworks converge on a similar result format:

Successful:  plain text result
Error:       "Error: {message}" (model can self-correct)
Timeout:     "Command timed out after {N}s"
Not found:   "Error: Tool '{name}' not found. Available: ..."

Hermes adds contextual metadata:

Tool: read_file
Result: 200 lines read from /path/to/file
Args: {"path": "/path/to/file", "offset": 1, "limit": 200}
Duration: 0.03s

Pitfalls

  1. Tool description inflation. Long descriptions consume context tokens. Hermes has ~50 tools, which alone can consume 20-30K tokens. Be terse.

  2. JSON schema complexity. Models struggle with deeply nested schemas. Flat parameter structures with simple types (string, integer, boolean) work best.

  3. Missing required fields. Always include "required" in your schemas. Models sometimes omit parameters when they're not marked required.

  4. Tool result size. A read_file of a 10MB log file will overflow the context window. Always truncate.

What We Built

A complete tool system with:
- Self-registering tools with automatic schema generation
- Levenshtein-based hallucination repair
- Three implementation patterns (simple registry, auto-discovery, trait-based)
- Error formatting that allows model self-correction

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 6: Working with Files and the Terminal

The Two Essential Tools

Ask any agent framework what tools get used most, and the answer is universal: files and the terminal. These two capabilities turn an LLM from a chatbot into a useful developer tool.

What problem are we solving? Reading and writing files, and executing shell commands, seem simple — but edge cases abound. What happens with binary files? Symlinks? Files larger than the context window? Commands that hang forever?

Think about it: If you let an LLM rewrite an entire 1,000-line file, it will likely introduce bugs, strip comments, and change formatting. How do you restrict edits to targeted changes? For shell commands, how do you prevent rm -rf /? What about tail -f running forever? How do you handle interactive CLI tools that need a terminal? Take a moment before reading on.

How the frameworks solve it: All frameworks provide targeted edit operations (never full-file rewrites), enforce size limits and timeouts, and use PTY support for interactive commands. The key patterns are: check before read, truncate aggressively, prefer patches over rewrites, and always set timeouts.

Safe File Reading

FUNCTION safe_read_file(path, offset = 1, limit = 500, max_size_mb = 50):
    filepath = RESOLVE(expand_user(path))

    // Existence checks
    IF NOT filepath EXISTS:
        RETURN "Error: File not found: {path}"
    IF filepath IS DIRECTORY:
        RETURN "Error: '{path}' is a directory. Contents:\n" + list_directory(filepath)

    // Size check
    size_mb = filepath.size / 1MB
    IF size_mb > max_size_mb:
        RETURN "Error: File is {size_mb}MB (max {max_size_mb}MB). Use offset/limit."

    // Binary detection
    TRY:
        READ first 1KB as UTF-8
    CATCH UnicodeDecodeError:
        RETURN "Error: File appears to be binary. Size: {size_mb}MB"

    // Read with line numbers
    lines = READ_ALL_LINES(filepath)
    total_lines = LENGTH(lines)

    IF offset > total_lines:
        RETURN "Error: offset exceeds file length"

    selected = lines[offset-1 TO offset-1+limit]

    output = ""
    FOR i, line IN ENUMERATE(selected):
        line_num = i + offset
        output += "{line_num:6d}|{line}"

    header = "File: {path} ({total_lines} lines, {size_mb}MB)"
    IF more lines remain:
        header += " [showing lines {offset}-{offset+limit-1}]"
        output += "\n... ({remaining} more lines)"

    RETURN header + "\n" + output

Targeted File Editing

Never let an LLM rewrite entire files. Use targeted edits:

FUNCTION patch_file(path, old_string, new_string, replace_all = false):
    filepath = RESOLVE(expand_user(path))

    IF NOT filepath EXISTS:
        RETURN "Error: File not found: {path}"

    content = READ_TEXT(filepath)

    // Find old_string (with fuzzy fallback)
    IF old_string IN content:
        count = COUNT_OCCURRENCES(old_string) IF replace_all ELSE 1

        IF NOT replace_all AND count > 1:
            RETURN "Error: old_string appears {count} times. Add context to make unique or set replace_all=true."

        new_content = REPLACE(content, old_string, new_string)

        // Show diff
        diff = COMPUTE_UNIFIED_DIFF(content, new_content, path)

        WRITE_TEXT(filepath, new_content)
        RETURN "Applied edit to {path}:\n" + diff
    ELSE:
        RETURN "Error: Could not find the specified text in {path}. Check whitespace."

Shell Command Execution

Shell commands need timeouts, background support, PTY support, and security:

CLASS ShellExecutor:
    workspace: string
    denied_commands: list = ["rm -rf /", "mkfs.", "dd if=", "fork_bomb_pattern"]
    allowed_commands: list = NULL  // NULL = allow all

    FUNCTION execute(command, timeout = 60, workdir = NULL, background = false):
        // Security check
        IF NOT is_safe(command):
            RETURN "Error: Command rejected by security policy."

        cwd = workdir OR workspace

        // Start process
        process = START_PROCESS(command, cwd, capture_output = true)

        IF background:
            RETURN "Started background process [PID {process.pid}]"

        // Wait with timeout
        TRY:
            stdout, stderr = process.communicate(timeout)
            output = stdout
            IF stderr:
                output += "\n[stderr]\n" + stderr
            IF output IS empty:
                output = "Command completed (exit code {process.exit_code})"
            RETURN output
        CATCH TimeoutError:
            KILL_PROCESS_GROUP(process)
            RETURN "Error: Command timed out after {timeout}s"

    FUNCTION is_safe(command):
        cmd_lower = LOWERCASE(command)

        // Check deny list
        FOR pattern IN denied_commands:
            IF pattern IN cmd_lower:
                RETURN false

        // Check allow list (if configured)
        IF allowed_commands IS SET:
            cmd_base = FIRST_WORD(command)
            RETURN cmd_base IN allowed_commands

        RETURN true

PTY Support for Interactive Commands

For interactive CLI tools (Python REPL, Vim, etc.), you need a pseudo-terminal:

FUNCTION execute_interactive(command, timeout = 30):
    CREATE pseudo-terminal (master_fd, slave_fd)

    TRY:
        process = START_PROCESS(command, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd)
        CLOSE(slave_fd)

        output = []
        deadline = NOW + timeout

        WHILE NOW < deadline:
            IF process HAS EXITED:
                BREAK

            WAIT for data on master_fd (1 second timeout)

            IF data available:
                TRY:
                    data = READ(master_fd, 4096 bytes)
                    IF data IS empty:
                        BREAK
                    ADD decoded data TO output
                CATCH:
                    BREAK

        // Read remaining
        DRAIN remaining data from master_fd

        // Kill if still running
        IF process IS still running:
            KILL_PROCESS_GROUP(process)
            output += "\n[Command timed out after {timeout}s]"

        RETURN JOIN(output)
    FINALLY:
        CLOSE(master_fd)

How Frameworks Handle File Operations

Hermes:
- read_file with line numbers, pagination (offset/limit), and max size rejection
- write_file with auto mkdir and syntax validation (checks .py/.json/.yaml after write)
- patch with fuzzy matching and 9 matching strategies
- search_files with ripgrep integration for content search and file globbing

PicoClaw:
- read_file with bytes OR lines mode
- write_file for create/overwrite
- edit_file for targeted find-and-replace
- append_file for appending
- list_dir for directory listing
- All tools restricted to agent workspace

gptme:
- read for files and directories
- save / append for creating/updating
- patch / morph for incremental edits (morph uses AST-level changes for Python)
- shell for command execution

Pitfalls

  1. Writing entire files. LLMs love to rewrite entire files. This causes: (a) lost formatting, (b) lost comments, (c) merge conflicts. Always prefer targeted edits.

  2. No timeouts on shell commands. A hung command (tail -f, yes, etc.) will block the agent loop forever. Every shell command needs a timeout.

  3. Unbounded file reads. Reading a 2GB log file into context will crash everything. Check file size before reading and enforce limits.

  4. Symlink traversal. Following symlinks can escape the workspace. Resolve paths and check boundaries.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 7: Web Access and Browser Automation

The Web as a Tool

Agents need to search the web, fetch pages, and sometimes interact with web applications. This chapter covers the spectrum from simple HTTP fetch to full headless browser control.

What problem are we solving? The web is the largest knowledge source available to an agent. But web access has unique challenges: rate limiting, JavaScript-only pages, bot detection, and unpredictable content sizes.

Think about it: If you let an agent search the web for every query, you'll quickly hit API rate limits. How should you cache results? What if the page is a React SPA that returns empty HTML — how do you detect that and fall back to a headless browser? How do you extract readable text from the chaos of HTML with navigation, ads, and scripts? Take a moment before reading on.

How the frameworks solve it: Aggressive caching (Hermes caches search results for 7 days), multi-backend search with automatic fallback, and layered content extraction (simple HTML text → readability algorithms → headless browser).

Web Search Architecture

CLASS WebSearcher:
    cache_dir: string
    backends: dictionary = {
        "duckduckgo": {needs_key: false, handler: duckduckgo_search},
        "brave":      {needs_key: true,  handler: brave_search, env_key: "BRAVE_API_KEY"},
        "google":     {needs_key: true,  handler: google_search, env_key: "GOOGLE_API_KEY"},
        "searxng":    {needs_key: false, handler: searxng_search}
    }

    FUNCTION search(query, count = 5, backend = "duckduckgo"):
        // Check cache first (1 hour TTL)
        cache_key = HASH("{backend}:{query}:{count}")
        IF cache_key EXISTS AND age < 1 hour:
            RETURN READ_CACHE(cache_key)

        // Execute search
        results = backends[backend].handler(query, count)

        // Cache for future
        WRITE_CACHE(cache_key, results)
        RETURN results

    FUNCTION duckduckgo_search(query, count):
        url = "https://api.duckduckgo.com/?q={ENCODE(query)}&format=json"

        TRY:
            data = FETCH_JSON(url, timeout = 10s)

            output = ["Search results for: {query}"]

            IF data has AbstractText:
                ADD "Summary: {data.AbstractText}" TO output

            FOR topic IN data.RelatedTopics[0:count]:
                IF topic has Text:
                    ADD "- {topic.Text (truncated to 300 chars)}" TO output
                    IF topic has FirstURL:
                        ADD "  URL: {topic.FirstURL}" TO output

            RETURN JOIN output WITH newlines
        CATCH error:
            RETURN "Search error: {error}"

Web Page Fetching

Extracting readable content from HTML:

FUNCTION fetch_webpage(url, timeout = 15, max_size_mb = 5):
    TRY:
        // Validate URL
        parsed = PARSE_URL(url)
        IF scheme NOT IN ["http", "https"]:
            RETURN "Error: Unsupported URL scheme"

        // Fetch with reasonable user agent
        response = HTTP_GET(url, headers = {
            "User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)",
            "Accept": "text/html"
        }, timeout = timeout)

        // Check content type
        IF "text/html" NOT IN response.content_type:
            RETURN "Error: Not an HTML page"

        // Check size
        IF response.content_length > max_size_mb * 1MB:
            RETURN "Error: Page too large"

        // Extract readable text
        html = response.text
        text = strip_html_tags(html)
        text = collapse_whitespace(text)
        text = truncate(text, 15000 chars)

        RETURN "URL: {url}\nStatus: {response.status}\n\n{text}"
    CATCH error:
        RETURN "Error fetching {url}: {error}"

FUNCTION strip_html_tags(html):
    // Skip these tags entirely
    SKIP_TAGS = {"script", "style", "nav", "footer", "header", "iframe", "svg"}

    text_parts = []
    skip_depth = 0

    FOR each token IN PARSE_HTML(html):
        IF token IS start_tag AND tag IN SKIP_TAGS:
            skip_depth += 1
        ELSE IF token IS end_tag AND tag IN SKIP_TAGS:
            skip_depth -= 1
        ELSE IF token IS data AND skip_depth == 0:
            ADD trimmed text TO text_parts
        ELSE IF token IS block_tag (p, br, li, h1-h6, div):
            ADD newline TO text_parts

    RETURN JOIN text_parts

Headless Browser Control

For JavaScript-heavy sites, you need a real browser:

CLASS HeadlessBrowser:
    browser = NULL
    page = NULL

    ASYNC FUNCTION start():
        TRY:
            playwright = await START_PLAYWRIGHT()
            browser = await playwright.chromium.launch(
                headless = true,
                args = ["--no-sandbox", "--disable-dev-shm-usage"]
            )
            page = await browser.new_page()
            RETURN "Browser started."
        CATCH ImportError:
            RETURN "Error: playwright not installed."

    ASYNC FUNCTION navigate(url, wait_until = "domcontentloaded"):
        IF NOT page:
            RETURN "Browser not started."

        await page.goto(url, wait_until, timeout = 30s)
        text = await page.inner_text("body")
        title = await page.title()

        RETURN "URL: {url}\nTitle: {title}\n\n{text (truncated to 8000)}"

    ASYNC FUNCTION screenshot(path = "/tmp/screenshot.png"):
        IF NOT page:
            RETURN "Browser not started."
        await page.screenshot(path)
        RETURN "Screenshot saved to {path}"

    ASYNC FUNCTION stop():
        IF browser:
            await browser.close()
        await playwright.stop()

How Frameworks Handle Web Access

Hermes provides:
- web_search via Brave Search API (cached for 7 days)
- web_extract via urlopen + BeautifulSoup or headless browser
- browser_navigate, browser_snapshot, browser_click, browser_type, browser_scroll for full browser automation

PicoClaw supports 9+ search backends:
- DuckDuckGo, Google, Brave, Kagi, SearXNG, etc.
- web_search and web_fetch tools
- Swappable search backends via config

gptme uses:
- Playwright for full browser automation
- browser tool for search, navigation, and screenshots
- Content extraction with readability algorithms

Content Extraction Strategies

Strategy Use Case Implementation
HTML text extraction News articles, documentation Strip tags, extract body text
Readability algorithm Blog posts, articles Mozilla Readability or newspaper3k
Headless browser JS-heavy SPAs, interactive pages Playwright/Puppeteer
Markdown conversion Technical docs html2text or turndown
Structured extraction Tables, lists, data BeautifulSoup selectors

Pitfalls

  1. Rate limiting. Web search APIs have rate limits. Cache aggressively. Hermes caches search results for 7 days.

  2. JavaScript-only pages. urlopen returns empty pages from React SPAs. Fall back to headless browser when needed.

  3. Infinite scroll. page.inner_text("body") may not capture dynamically loaded content. Scroll the page first.

  4. Bot detection. Many sites block automated access. Use realistic user agents and respect robots.txt.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 8: Multi-Turn Conversations and History

Sessions and State

Single-turn agents are toys. Real agents maintain conversation history across turns, remember context, and can resume conversations days later.

What problem are we solving? Each turn of conversation must build on previous turns. The agent needs to store messages, retrieve them, manage context window limits, and handle the role alternation rules that LLM APIs enforce.

Think about it: If you store messages in memory, what happens when the server restarts? If you use a database, how do you handle concurrent reads and writes? How do you know when you're approaching the context window limit? What happens if the API rejects your message sequence because of role ordering rules? Take a moment before reading on.

How the frameworks solve it: SQLite with WAL mode for concurrent access, proactive token counting before API calls, and message normalization to fix role alternation violations before they become errors.

Session Storage

CLASS Message:
    role: string       // "system", "user", "assistant", "tool"
    content: string
    tool_calls: list   // Only for assistant messages with tool calls
    tool_call_id: string // Only for tool result messages
    name: string       // Optional
    timestamp: float

    FUNCTION to_api_format():
        msg = {role: self.role, content: self.content}
        IF self.tool_calls:
            msg.tool_calls = self.tool_calls
        IF self.tool_call_id:
            msg.tool_call_id = self.tool_call_id
        RETURN msg

CLASS SessionStore:
    db_path: string

    FUNCTION __init__(db_path):
        CREATE directory if needed
        INITIALIZE database:
            CREATE TABLE sessions (
                id TEXT PRIMARY KEY,
                title TEXT,
                created_at REAL,
                updated_at REAL,
                model TEXT
            )
            CREATE TABLE messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                session_id TEXT NOT NULL,
                role TEXT NOT NULL,
                content TEXT,
                tool_calls TEXT DEFAULT '[]',
                tool_call_id TEXT,
                name TEXT,
                timestamp REAL
            )

    FUNCTION create_session(session_id, title, model):
        INSERT INTO sessions VALUES (...)

    FUNCTION add_message(session_id, message):
        INSERT INTO messages (...)
        UPDATE sessions SET updated_at = NOW

    FUNCTION get_messages(session_id, limit = 100):
        rows = SELECT ... FROM messages
              WHERE session_id = ?
              ORDER BY id ASC LIMIT ?
        RETURN [Message.from_row(r) for r in rows]

    FUNCTION list_sessions(limit = 20):
        RETURN SELECT ... FROM sessions ORDER BY updated_at DESC LIMIT ?

    FUNCTION delete_session(session_id):
        DELETE FROM messages WHERE session_id = ?
        DELETE FROM sessions WHERE id = ?

Context Window Management

All LLMs have context window limits. Your agent needs to stay within them:

CLASS ContextManager:
    model: string
    max_tokens: integer = 128000
    safety_margin: float = 0.9
    encoder: TokenEncoder

    FUNCTION __init__(model):
        self.model = model
        self.target_max = max_tokens * safety_margin
        self.encoder = GET_TOKENIZER_FOR_MODEL(model)

    FUNCTION count_tokens(messages):
        total = 0
        FOR each msg IN messages:
            total += 4  // Role + formatting overhead
            total += encoder.count(msg.content)
            IF msg has tool_calls:
                total += encoder.count(SERIALIZE(msg.tool_calls))
            IF msg has name:
                total += encoder.count(msg.name)
        total += 2  // Reply priming
        RETURN total

    FUNCTION needs_compression(messages, system_prompt):
        system_tokens = encoder.count(system_prompt)
        message_tokens = count_tokens(messages)
        // Reserve ~4000 tokens for the response
        RETURN (system_tokens + message_tokens) > (target_max - 4000)

    FUNCTION get_usage_ratio(messages, system_prompt):
        total = encoder.count(system_prompt) + count_tokens(messages)
        RETURN total / max_tokens

Role Alternation Rules

Different providers have different rules about message ordering:

What problem are we solving? LLM APIs enforce strict message ordering: user → assistant → user → assistant, with tool messages allowed between certain roles. A naive message list can easily violate these rules.

Think about it: If you inject a tool error as a user message, the API rejects it. If you have consecutive user messages (from different turns), the API rejects it. If the conversation ends with an assistant message, the next turn starts with... a user message? But the API sees assistant → user which is valid. What if there are multiple system messages? Take a moment.

How the frameworks solve it: Message normalization before every API call. The normalizer merges consecutive same-role messages, removes duplicate system messages, and ensures the sequence doesn't end with an assistant role.

FUNCTION normalize_messages(messages):
    IF messages IS empty:
        RETURN messages

    normalized = []
    seen_system = false

    FOR each msg IN messages:
        // Only keep first system message
        IF msg.role == "system":
            IF NOT seen_system:
                ADD msg TO normalized
                seen_system = true
            CONTINUE

        // Merge consecutive same-role messages
        IF normalized NOT empty AND msg.role == LAST(normalized).role
           AND msg.role IN ["user", "assistant"]:
            LAST(normalized).content += "\n\n" + msg.content
            CONTINUE

        ADD msg TO normalized

    // Ensure doesn't end with assistant
    IF normalized NOT empty AND LAST(normalized).role == "assistant":
        ADD {role: "user", content: "[Please provide your final response.]"} TO normalized

    RETURN normalized

How Frameworks Handle Sessions

Framework Storage Key Features
Hermes SQLite + FTS5 Full-text search across sessions, parent session chains for compression splits
OpenClaw JSONL transcripts Per-agent session isolation, transcript replay
PicoClaw JSONL files in workspace Per-session history, context-based session propagation
ZeroClaw Memory backend abstraction Multiple backends (file, SQLite, vector)
gptme JSONL via LogManager Tree-based conversation structure planned

Pitfalls

  1. Role alternation violations. OpenAI rejects user → user sequences. Always normalize before sending.

  2. Session ID collisions. Use UUIDs or timestamp+hash combos to prevent accidental session merges.

  3. Silent context overflow. Some providers silently truncate old messages. Track your own token counts and compress proactively.

  4. Thread safety. SQLite in WAL mode supports concurrent readers but only one writer. Queue writes in multi-threaded servers.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 9: Memory Across Sessions

Beyond the Conversation

An agent that forgets everything between sessions is barely useful. Persistent memory — user preferences, learned facts, environmental quirks — turns an agent from a tool into a companion.

What problem are we solving? Conversations are ephemeral. The agent needs to remember facts across sessions: "user prefers Python over JavaScript," "the build always fails on the CI server because of a missing env var," "the deployment script is in ~/scripts/deploy.sh."

Think about it: Where do you store memories? A database? Files? Vector embeddings? How do you decide WHAT to remember vs. what's just conversation noise? How do you prevent memory bloat over months of conversations? What if the agent remembers contradictory facts? Take a moment before reading on.

How the frameworks solve it: Markdown files for simple facts (MEMORY.md, USER.md), vector databases for semantic search, and background review subagents that decide what's worth saving. The key guidance: "save durable facts that will still matter later — not task progress."

The Memory Architecture

┌──────────────────────────────────────────────────────────┐
│                    MEMORY SYSTEM                           │
│                                                           │
│  ┌─────────────┐  ┌─────────────┐  ┌──────────────────┐  │
│  │ USER.md      │  │ MEMORY.md   │  │ Vector DB         │  │
│  │ Profile      │  │ Facts       │  │ Semantic Memory   │  │
│  │ • Name       │  │ • Prefs     │  │ • Embeddings      │  │
│  │ • OS         │  │ • Conventions│  │ • Similarity      │  │
│  │ • Languages  │  │ • Quirks    │  │ • Clustering      │  │
│  └──────┬───────┘  └──────┬──────┘  └────────┬─────────┘  │
│         │                 │                   │            │
│         └─────────────────┼───────────────────┘            │
│                           ▼                                │
│              ┌────────────────────────┐                    │
│              │ Memory Injection        │                    │
│              │ Into system prompt      │                    │
│              │ (every turn)            │                    │
│              └────────────────────────┘                    │
│                                                           │
│  Memory Operations:                                       │
│  • auto-save (after significant turns)                    │
│  • recall (keyword + semantic search)                     │
│  • forget (remove stale facts)                            │
│  • review (background agent suggests updates)             │
└──────────────────────────────────────────────────────────┘

Simple Memory (Markdown Files)

The simplest persistent memory: markdown files. Used by Hermes, OpenClaw, and PicoClaw:

CLASS SimpleMemory:
    memory_dir: string

    FUNCTION __init__(memory_dir = "~/.agent-memory"):
        CREATE memory_dir if needed
        self.memory_file = memory_dir / "MEMORY.md"
        self.user_file = memory_dir / "USER.md"

    FUNCTION get_user_profile():
        IF user_file EXISTS:
            RETURN READ_TEXT(user_file)
        RETURN ""

    FUNCTION get_facts():
        IF memory_file EXISTS:
            RETURN READ_TEXT(memory_file)
        RETURN ""

    FUNCTION add_fact(fact, category = "general"):
        existing = get_facts()

        // Deduplicate
        IF fact IN existing:
            RETURN  // Already stored

        timestamp = FORMAT_DATETIME(NOW, "%Y-%m-%d %H:%M")
        entry = "\n## {timestamp} [{category}]\n{fact}\n"

        WRITE_TEXT(memory_file, existing + entry)

    FUNCTION search_facts(query):
        content = get_facts()
        IF NOT content:
            RETURN "No memories stored."

        // Simple keyword search with context
        lines = SPLIT(content, "\n")
        results = []
        query_lower = LOWERCASE(query)

        FOR i, line IN ENUMERATE(lines):
            IF query_lower IN LOWERCASE(line):
                // Include surrounding context (±2 lines)
                start = MAX(0, i - 2)
                end = MIN(LENGTH(lines), i + 3)
                ADD JOIN(lines[start:end], "\n") TO results
                ADD "---" TO results

        IF results:
            RETURN "Found {LENGTH(results)} matches:\n\n" + JOIN(results, "\n")
        RETURN "No memories match '{query}'."

    FUNCTION to_context_block():
        profile = get_user_profile()
        facts = get_facts()

        IF NOT profile AND NOT facts:
            RETURN ""

        parts = ["## Memory\n"]
        IF profile:
            ADD "### User Profile\n{profile}" TO parts
        IF facts:
            ADD "### Persistent Facts\n{facts}" TO parts

        RETURN JOIN(parts, "\n")

Hermes's Three-Target Memory

Hermes uses a more sophisticated approach with three memory targets:

1. BUILT-IN MEMORY (~/.hermes/memories/MEMORY.md, USER.md)
   - Injected into VOLATILE tier of every system prompt
   - Updated by the agent during conversations
   - Guidance: "save durable facts, not task progress"
   - Format: declarative facts, not imperatives

2. EXTERNAL MEMORY PROVIDERS (plugins)
   - Vector databases (Chroma, Pinecone, etc.)
   - Long-term semantic memory
   - Loaded at turn start via memory_manager.py

3. BACKGROUND MEMORY REVIEW
   - After each turn, a lightweight subagent reviews the conversation
   - Nudges the model: "Should any of this be saved to memory?"
   - Suggests creating skills for reusable workflows

The memory guidance prompt used by Hermes:

You have persistent memory across sessions. Save durable facts using the memory
tool: user preferences, environment details, tool quirks, and stable conventions.

Prioritize what reduces future user steering  the most valuable memory is one
that prevents the user from having to correct or remind you again.

Write memories as declarative facts, not instructions to yourself.
'User prefers concise responses'   'Always respond concisely' .

Vector-Based Semantic Memory

For large memory collections, keyword search isn't enough:

CLASS VectorMemory:
    db_path: string
    embedder: EmbeddingModel  // Lazy-loaded

    FUNCTION __init__(db_path = "~/.agent-memory/vector.db"):
        CREATE database with table:
            memory_vectors (
                id INTEGER PRIMARY KEY,
                content TEXT,
                embedding BLOB,
                category TEXT,
                created_at REAL
            )

    FUNCTION embed(text):
        // Load embedding model on first use
        // Options: OpenAI embeddings API, sentence-transformers (local)
        IF embedder IS NULL:
            embedder = LOAD_MODEL("all-MiniLM-L6-v2")
        RETURN embedder.encode(text)

    FUNCTION add(content, category = "general"):
        embedding = embed(content)
        INSERT INTO memory_vectors (content, embedding, category, created_at)
        VALUES (content, embedding_bytes, category, NOW)

    FUNCTION search(query, top_k = 5, threshold = 0.5):
        query_vec = embed(query)

        // Load all stored vectors
        // (Production: use vector index like FAISS or pgvector)
        rows = SELECT * FROM memory_vectors

        results = []
        FOR each row:
            stored_vec = DESERIALIZE(row.embedding)

            // Cosine similarity
            similarity = DOT_PRODUCT(query_vec, stored_vec) /
                         (NORM(query_vec) * NORM(stored_vec))

            IF similarity >= threshold:
                ADD {id: row.id, content: row.content, similarity: similarity}
                   TO results

        SORT results BY similarity DESCENDING
        RETURN results[0:top_k]

    FUNCTION recall(query, top_k = 5):
        results = search(query, top_k)
        IF NOT results:
            RETURN "No relevant memories found."

        output = ["Memories relevant to '{query}':\n"]
        FOR r IN results:
            ADD "[{r.category}] ({r.similarity:.2f}) {r.content}" TO output
        RETURN JOIN(output, "\n")

Memory Injection Timing

When does memory get injected? The frameworks differ:

Pitfalls

  1. Memory bloat. Unchecked memory growth consumes context tokens. Implement pruning: forget old/irrelevant facts, summarize clusters of related memories.

  2. Memory contradictions. "User prefers Python" and "User prefers Rust" both stored. Implement conflict detection or just let the LLM ask for clarification.

  3. PII in memory. Memories may contain personal information. Consider encryption at rest and scrubbing before external storage.

  4. Over-saving. Without guidance, agents save every interaction. Hermes's guidance is key: "save durable facts that will still matter later," not task progress.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 10: Skills and Self-Improvement

The Agent That Gets Better

The most powerful pattern in agent design: the agent saves successful workflows and learns from experience. This chapter implements a skill system where the agent itself creates, refines, and loads reusable instructions.

What problem are we solving? When an agent successfully completes a complex multi-step task (like deploying an app), that knowledge is lost when the conversation ends. Next time the user asks for the same thing, the agent starts from scratch.

Think about it: How would you design a system where the agent can save its own successful workflows? What format should skills use — code, markdown, structured data? How does the agent discover that a relevant skill exists without loading every skill into every prompt? Who creates skills — the agent autonomously, or only when the user asks? Take a moment before reading on.

How the frameworks solve it: Skills are markdown files with YAML frontmatter, organized by category in a directory tree. An index is injected into the system prompt, and the agent loads full skill content on demand. Critically, the agent creates skills autonomously after completing complex tasks.

How Skills Work

┌──────────────────────────────────────────────────────────┐
                    SKILL LIFECYCLE                         
                                                           
  1. AGENT PERFORMS TASK                                   
     User: "Deploy my React app to Vercel"                 
     Agent: [complex sequence of git, npm, vercel CLI]     
                                                           
  2. AGENT SAVES AS SKILL                                 
     "This workflow was complex and worked well.           │
      Saving as a skill for future use."                   │
                                                           
  3. SKILL STORED AS SKILL.md                             
     ~/.agent-skills/deployment/vercel-deploy/SKILL.md     
                                                           
  4. FUTURE SESSIONS: SKILL INJECTED                       
     User: "Deploy my new app"                             
     Agent: [loads vercel-deploy skill  follows steps]    
                                                           
  5. SKILL REFINED OVER TIME                               
     Agent patches skill with edge cases discovered        
└──────────────────────────────────────────────────────────┘

Simple Skill System

CLASS SkillSystem:
    skills_dir: string

    FUNCTION __init__(skills_dir = "~/.agent-skills"):
        self.dir = CREATE_DIRECTORY(skills_dir)

    FUNCTION discover():
        skills = []
        FOR each SKILL.md file IN recursive_glob(self.dir, "SKILL.md"):
            TRY:
                meta = parse_yaml_frontmatter(file)
                IF meta:
                    ADD {
                        name: meta.name OR file.parent.name,
                        description: meta.description,
                        category: file.parent.parent.name,
                        path: file.path,
                        conditions: meta.conditions OR {}
                    } TO skills
            CATCH:
                SKIP
        RETURN skills

    FUNCTION load(name):
        FOR each SKILL.md file IN recursive_glob(self.dir, "SKILL.md"):
            meta = parse_yaml_frontmatter(file)
            IF meta.name == name:
                RETURN READ_TEXT(file)
        RETURN NULL

    FUNCTION create(name, description, content, category = "general"):
        skill_dir = self.dir / category / name
        CREATE_DIRECTORY(skill_dir)

        frontmatter = {
            "name": name,
            "description": description,
            "category": category,
            "created": NOW_ISO,
            "updated": NOW_ISO
        }

        skill_md = "---\n" + TO_YAML(frontmatter) + "---\n\n" + content
        WRITE_TEXT(skill_dir / "SKILL.md", skill_md)

        RETURN skill_dir.path

    FUNCTION update(name, new_content):
        FOR each SKILL.md file:
            IF meta.name == name:
                meta.updated = NOW_ISO
                new_frontmatter = TO_YAML(meta)
                WRITE_TEXT(file, "---\n{new_frontmatter}---\n\n{new_content}")
                RETURN file.path
        RETURN "Skill '{name}' not found."

    FUNCTION get_index():
        skills = discover()
        IF NOT skills:
            RETURN "No skills available."

        // Group by category
        by_category = GROUP_BY(skills, "category")

        lines = ["## Available Skills\n"]
        lines.append("Load a skill with: skill_view(name=\"skill-name\")\n")

        FOR cat, cat_skills IN SORTED(by_category):
            lines.append("### {cat}")
            FOR s IN SORTED(cat_skills, by name):
                lines.append("- **{s.name}**: {s.description}")

        RETURN JOIN(lines, "\n")

    FUNCTION parse_yaml_frontmatter(file):
        text = READ_TEXT(file)
        IF text STARTS WITH "---":
            parts = SPLIT(text, "---\n", 2)
            IF LENGTH(parts) >= 3:
                RETURN PARSE_YAML(parts[1])
        RETURN NULL

The Self-Improvement Loop

The magic happens when the agent creates skills autonomously:

TURN COMPLETE  Background Review
    
    ├─ "This was a complex multi-step task that I completed successfully"
    ├─ "The user might need this again"
    ├─ "I'll save it as a skill"
    
    └─ Calls skill_create() autonomously

NEXT SESSION:
    User: "Do that deployment thing again"
    Agent: [scans skills index  finds "vercel-deploy"  loads it]
    Agent: [follows saved steps exactly, handles edge cases from last time]

How Frameworks Implement Skills

Hermes:
- Skills are markdown in ~/.hermes/skills/<category>/<name>/SKILL.md
- YAML frontmatter with conditions (required tools, platform, environment)
- skill_view(name) loads full content; skill_manage(action, name, ...) creates/edits/deletes
- Snapshot caching for fast reload
- "Compact categories" for coding posture (non-coding skills shown as names only)
- Post-turn nudge: "You did complex work without skills — save it!"

OpenClaw:
- Skills loaded from workspace, project, personal, managed, bundled locations (6 levels)
- SKILL.md files with nested folder support
- Plugins can register skills and tools
- Gated by config allowlists

gptme:
- Lessons system — contextual guidance auto-injected by keyword matching
- Triggered by keywords, tools in use, or file patterns
- Different guidance for interactive vs autonomous modes
- Community extensions via gptme-contrib

PicoClaw:
- Skills in workspace folders
- Active skill instructions injected into capability layer
- Skill catalog listed in prompt

Pitfalls

  1. Skill bloat. Too many skills consume context tokens. Hermes uses "compact categories" — show description for relevant categories, names only for others.

  2. Stale skills. Skills created for old versions of tools/APIs become wrong. Include version/date metadata and let the agent update skills.

  3. Skill quality. Agent-created skills may contain mistakes. Human review is ideal; failing that, track skill success rates.

  4. Circular dependencies. A skill that says "load this other skill first" creates infinite loops. Detect and prevent.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 11: Multi-Platform Messaging

The Gateway Pattern

All five frameworks use the same architecture for multi-platform support: the gateway pattern. A central gateway process manages connections to messaging platforms, normalizes messages, and routes them to the agent.

What problem are we solving? Each messaging platform (Telegram, Discord, Slack, WhatsApp) has its own API, message format, markup rules, and message length limits. The agent shouldn't need to know about these differences.

Think about it: How do you convert a markdown-formatted agent response into something Telegram can display (which doesn't support tables)? What about WhatsApp, which supports no formatting at all? How do you handle Discord's 2000-character message limit vs. Telegram's 4096? How do you route incoming messages from 20+ platforms to the right agent session? Take a moment before reading on.

How the frameworks solve it: A gateway daemon with per-platform adapters. Each adapter normalizes incoming messages into a common format, and formats outgoing agent responses for its platform's constraints. Message splitting at paragraph boundaries handles length limits.

┌──────────────────────────────────────────────────────────┐
│                     GATEWAY PATTERN                        │
│                                                           │
│  ┌──────────────────────────────────────────────────────┐ │
│  │                    GATEWAY (Daemon)                    │ │
│  │  • Message normalization                              │ │
│  │  • Session management                                 │ │
│  │  • Platform routing                                   │ │
│  │  • Media/file handling                                │ │
│  └───┬──────────┬──────────┬──────────┬─────────────────┘ │
│      │          │          │          │                    │
│  ┌───▼───┐ ┌───▼───┐ ┌───▼───┐ ┌───▼───┐                │
│  │Telegram│ │Discord│ │ Slack │ │WhatsApp│  ... 20+ more  │
│  │Adapter │ │Adapter│ │Adapter│ │Adapter │                │
│  └───────┘ └───────┘ └───────┘ └───────┘                │
│      │          │          │          │                    │
│      └──────────┴──────────┴──────────┘                    │
│                     │                                      │
│              ┌──────▼──────┐                              │
│              │ AGENT CORE  │                              │
│              │ (unchanged) │                              │
│              └─────────────┘                              │
└──────────────────────────────────────────────────────────┘

Message Normalization

Every platform has its own message format. The gateway normalizes them:

CLASS NormalizedMessage:
    platform: string       // "telegram", "discord", etc.
    chat_id: string        // Unique chat/channel identifier
    sender_id: string      // User identifier
    sender_name: string    // Display name
    text: string           // Message text (plain, no markup)
    message_id: string     // Platform-specific message ID
    thread_id: string      // Reply thread (optional)
    attachments: list      // [{type, url, data}]
    timestamp: datetime
    is_group: boolean      // Group chat vs DM
    is_command: boolean    // Is this a slash command?

CLASS PlatformAdapter(ABC):
    FUNCTION normalize(raw_message):
        // Convert platform-specific format → NormalizedMessage

    FUNCTION send(chat_id, text, attachments = NULL):
        // Send to platform

    FUNCTION format_for_platform(text):
        // Convert agent markdown → platform-specific formatting

Platform-Specific Formatting

Different platforms have different capabilities:

CLASS TelegramFormatter:
    // Supports MarkdownV2 with limitations:
    // - No HTML tables
    // - No headings (use **bold** instead)
    // - Code blocks supported
    // - 4096 character limit per message

    STATIC FUNCTION format(text):
        // Strip HTML tables
        text = REMOVE_HTML_TABLES(text)
        // Convert markdown headings to bold
        text = REPLACE_REGEX(text, "^### (.+)$", "**\\1**")
        RETURN text

    STATIC FUNCTION split_long_message(text, max_len = 4096):
        IF LENGTH(text) <= max_len:
            RETURN [text]
        parts = []
        WHILE LENGTH(text) > max_len:
            // Split at nearest paragraph break
            split_at = FIND_LAST(text, "\n\n", max_len)
            IF split_at == -1:
                split_at = FIND_LAST(text, "\n", max_len)
            IF split_at == -1:
                split_at = max_len
            ADD text[0:split_at] TO parts
            text = TRIM_LEFT(text[split_at:])
        ADD text TO parts
        RETURN parts


CLASS DiscordFormatter:
    // Supports Markdown
    // 2000 character limit per message

    STATIC FUNCTION format(text):
        RETURN text  // Discord natively supports markdown

    STATIC FUNCTION split_long_message(text, max_len = 2000):
        // Same as Telegram but with 2000 char limit


CLASS WhatsAppFormatter:
    // NO markdown support. Plain text only.
    // *text* for emphasis, ``` for code

    STATIC FUNCTION format(text):
        // Remove markdown syntax
        text = REPLACE_REGEX(text, "\\*\\*(.+?)\\*\\*", "*\\1*")  // Bold  WhatsApp bold
        text = REPLACE_REGEX(text, "\\[(.+?)\\]\\(.+?\\)", "\\1")  // Remove links
        text = REPLACE_REGEX(text, "^#+ (.+)$", "*\\1*")           // Headings  bold
        RETURN text


FORMATTERS = {
    "telegram": TelegramFormatter,
    "discord": DiscordFormatter,
    "whatsapp": WhatsAppFormatter,
    "cli": DiscordFormatter  // CLI uses full markdown
}

Gateway Architecture

CLASS Gateway:
    agent_loop: AgentLoop
    sessions: SessionStore
    adapters: dictionary  // platform  PlatformAdapter
    active_sessions: dictionary  // chat_id  session_context

    FUNCTION register_adapter(platform, adapter):
        adapters[platform] = adapter

    ASYNC FUNCTION handle_message(platform, raw_message):
        adapter = adapters[platform]

        // 1. Normalize incoming message
        msg = adapter.normalize(raw_message)

        // 2. Get or create session
        session_key = "{platform}:{msg.chat_id}"
        IF session_key NOT IN active_sessions:
            history = sessions.get_messages(session_key, limit=50)
        ELSE:
            history = active_sessions[session_key].history

        // 3. Build context with platform hints
        system_prompt = agent_loop.build_system_prompt(
            platform_hint = platform
        )

        // 4. Run agent
        result = agent_loop.run(
            messages = history + [msg.to_api_format()],
            system_prompt = system_prompt
        )

        // 5. Format for platform
        formatter = FORMATTERS[platform]
        formatted = formatter.format(result.response)

        // 6. Split long messages
        parts = formatter.split_long_message(formatted)

        // 7. Send via adapter
        FOR each part IN parts:
            adapter.send(msg.chat_id, part)

        // 8. Persist session
        update_session(session_key, msg, result)

        RETURN result.response

How Frameworks Handle Messaging

Framework Platforms Architecture
Hermes 20+ (Telegram, Discord, Slack, WhatsApp, Signal, Matrix, iMessage, SMS, Email, Mattermost, Feishu/WeChat, etc.) Single gateway process, per-session agent cache, MEDIA: prefix for attachments
OpenClaw 25+ channels WebSocket gateway daemon, typed JSON-RPC, per-platform plugins
PicoClaw 19+ platforms Channel adapters in pkg/channels/, context-based session propagation
ZeroClaw 30+ channels Channel crate, late-bound channel registration
gptme CLI + ACP (editor integration) Server mode for REST API access

Pitfalls

  1. Message length limits. Telegram: 4096 chars. Discord: 2000 chars. WhatsApp: unlimited but ugly. Always split long messages at paragraph boundaries.

  2. Formatting mismatches. Sending Markdown to WhatsApp results in visible syntax characters. Always strip formatting for plain-text platforms.

  3. Media delivery. Images on CLI need file paths. Images on Telegram need file uploads. Hermes uses the MEDIA:/path/to/file convention that platform adapters translate.

  4. Rate limiting. Messaging platforms aggressively rate-limit bot messages. Batch sends with delays.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 12: Context Compression

The Token Wall

GPT-4o has a 128K context window. Claude has 200K. Sounds like plenty — until you realize the system prompt alone can consume 30K tokens, and a long conversation with file reads and web searches adds up fast. Eventually, you hit the wall:

[Context Overflow]  API Error  Agent Crashes  User Frustrated

Compression fixes this by summarizing old conversation turns while preserving the essential thread.

What problem are we solving? Long conversations eventually exceed the LLM's context window. You need to compress old turns without losing critical information about what was discussed, decided, and left pending.

Think about it: If you just truncate old messages, the agent forgets what it was doing. If you summarize with an LLM, the summary might tell the agent "the user asked for a weather report" — and the agent might answer it again. How do you prevent that? What information MUST be preserved: completed tasks, pending work, decisions made, facts learned? Take a moment before reading on.

How the frameworks solve it: LLM-powered summarization with structured output (resolved questions, pending tasks, decisions, facts learned). A SUMMARY_PREFIX warns the model not to re-answer questions from the summary. Tool outputs are pruned as cheap pre-compression. Compression only triggers when approaching token limits.

The Compression Algorithm

┌──────────────────────────────────────────────────────────┐
                CONTEXT COMPRESSION                         
                                                           
  BEFORE: [SYS][T1][T2][T3][T4][T5][T6][T7][T8][T9]      
  (10 turns, approaching token limit)                      
                                                           
  DURING: Summarize T1-T7 into a compact summary block     
                                                           
  AFTER:  [SYS][SUMMARY][T8][T9]                           
  (Summary + last 2-3 turns intact)                        
                                                           
  PROTECTED:                                               
   Head: System prompt (identity, rules)                  
   Tail: Last 2-3 turns (recent context)                  
                                                           
  COMPRESSED (middle):                                     
   Tool outputs  pruned (old results irrelevant)         
   Conversation  summarized by auxiliary LLM             
└──────────────────────────────────────────────────────────┘

Implementation

SUMMARY_PREFIX = """
[CONTEXT COMPACTION — REFERENCE ONLY]
Earlier turns were compacted into the summary below. This is a handoff from
a previous context window — treat it as background reference, NOT as active
instructions. Do NOT answer questions or fulfill requests mentioned in this
summary; they were already addressed. Respond ONLY to the latest user message
that appears AFTER this summary.
"""

CLASS ContextCompressor:
    llm: LLMProvider        // Cheap model for summaries
    token_counter: function
    keep_last: integer = 3  // Keep last N turns intact
    summary_ratio: float = 0.2  // Summary budget as fraction of compressed

    FUNCTION compress(messages, system_prompt = ""):
        total_tokens = token_counter(messages)
        system_tokens = token_counter([system_prompt])

        // Only compress if approaching limit
        IF total_tokens + system_tokens < 100000:
            RETURN messages  // Not needed yet

        // Split: protect head and tail
        non_system = FILTER(messages, role != "system")
        IF LENGTH(non_system) <= keep_last * 2:
            RETURN messages  // Not enough to compress

        compress_these = non_system[0 : -keep_last * 2]
        keep_these = non_system[-keep_last * 2 :]

        // Step 1: Prune old tool outputs (cheap, no LLM call)
        pruned = prune_tool_outputs(compress_these)

        // Step 2: Summarize with cheap LLM
        summary = summarize(pruned)

        // Build compressed message list
        compressed = (
            [{role: "system", content: system_prompt}] +
            [{role: "user", content: SUMMARY_PREFIX + "\n\n" + summary}] +
            keep_these
        )

        RETURN compressed

    FUNCTION prune_tool_outputs(messages):
        pruned = []
        FOR msg IN messages:
            IF msg.role == "tool":
                ADD {role: "tool", tool_call_id: msg.tool_call_id,
                     content: "[Tool output pruned during compression]"} TO pruned
            ELSE:
                ADD msg TO pruned
        RETURN pruned

    FUNCTION summarize(messages):
        turn_text = format_turns(messages)
        token_budget = MIN(12000, MAX(2000, total_tokens * summary_ratio))

        prompt = """
            Summarize this conversation segment. Focus on:
            1. What tasks were attempted and completed
            2. Any decisions made
            3. Any unresolved questions or pending work
            4. Key facts learned about the user or environment

            Keep the summary within {token_budget} tokens. Be concise.

            CONVERSATION:
            {turn_text}
        """

        TRY:
            response = llm.chat(
                model = "gpt-4o-mini",  // Cheaper model
                messages = [{role: "user", content: prompt}],
                max_tokens = token_budget
            )
            RETURN response.text
        CATCH:
            // Fallback: deterministic truncation
            RETURN fallback_summary(messages)

    FUNCTION format_turns(messages):
        lines = []
        FOR msg IN messages:
            role = UPPERCASE(msg.role)
            content = msg.content[0:500]  // Truncate per-message
            IF msg.role == "tool":
                ADD "[{role}] (output trimmed)" TO lines
            ELSE:
                ADD "[{role}] {content}" TO lines
        RETURN JOIN(lines, "\n")

    FUNCTION fallback_summary(messages):
        topics = SET()
        FOR msg IN messages:
            IF msg.role == "user":
                first_sentence = msg.content.SPLIT(".")[0][0:100]
                IF first_sentence:
                    ADD first_sentence TO topics
        RETURN "Earlier conversation topics: " + JOIN(topics, "; ")

Structured Compression (Hermes Style)

Hermes uses a more sophisticated structured summary:

SUMMARY:
**Resolved Questions:**
- "What's the weather in Tokyo?"  Answered: 22°C, sunny

**Pending User Asks:**
- None pending

**Active Task:**
Building a weather dashboard  completed backend API

**Decisions Made:**
- Using Flask instead of FastAPI (user preference)
- SQLite for storage (simplicity)

**Facts Learned:**
- User prefers Python over JavaScript
- Working directory: /home/user/projects
- API key configured for OpenWeatherMap

This format preserves more semantic information than a flat summary.

Incremental Compression

Single-pass compression loses information. Better: compress incrementally:

FUNCTION incremental_compress(messages, existing_summary = NULL):
    IF existing_summary:
        merge_prompt = """
            Below is an existing conversation summary and new
            conversation turns. Merge into a single updated summary.

            EXISTING SUMMARY:
            {existing_summary}

            NEW TURNS:
            {format_turns(messages)}
        """
        // LLM call to merge
        summary = llm.chat(merge_prompt)

    // Proceed with standard compression
    RETURN compress(messages)

How Frameworks Handle Compression

Hermes (context_compressor.py, ~2,182 lines):
- Uses a cheap auxiliary model for structured summarization
- SUMMARY_PREFIX with increasingly defensive wording
- Tool output pruning (cheap pre-pass)
- Scaled summary budget (20% of compressed content, 2,000-12,000 tokens)
- Fallback to deterministic truncation
- Session splitting on compression

OpenClaw:
- Context overflow detection → auto-compaction → retry
- Pluggable context engine for custom compression strategies
- before_compaction / after_compaction plugin hooks

gptme:
- Token-aware trimming with savings tracking
- Automatic summarization of long history
- autocompact tool for on-demand compression
- Deduplication of repeated tool outputs

Pitfalls

  1. The ghost directive problem. If the summary says "the user asked for a weather report," the model might answer it again. Hermes solves this with the SUMMARY_PREFIX: "Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed."

  2. Summary quality varies. A cheap model doing summarization might miss critical information. Use a mid-tier model for summaries (Hermes uses a separate model config).

  3. Compression cost. Each compression costs an LLM call. Balance compression frequency against cost.

  4. Too-aggressive compression. Compressing when you're at 50% context wastes money. Only compress when approaching limits.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 13: Subagents and Delegation

The One-Agent Problem

A single agent can only do one thing at a time. Need to research three topics simultaneously? Wait. Need to run a long build while exploring code? Wait. Subagents solve this by spawning child agents that work in parallel.

What problem are we solving? A single-threaded agent is a bottleneck. Complex tasks often have independent sub-tasks that could run in parallel, saving wall-clock time and allowing the main agent to focus on orchestration.

Think about it: How do you spawn a child agent? Does it inherit the parent's context or start fresh? What tools should it have access to? How do you prevent runaway spawning (agent spawning agents spawning agents...)? What about cost — each subagent makes its own LLM calls? Take a moment before reading on.

How the frameworks solve it: A SubagentManager with concurrency limits, configurable tool sets per subagent, and two context modes: "isolated" (fresh context, cheaper) and "fork" (inherits parent history, more capable). The delegate_task tool lets the main agent spawn subagents naturally.

┌──────────────────────────────────────────────────────────┐
               PARENT AGENT                                
  "Research these three topics and summarize findings"     
                                                           
  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐       
   Subagent 1     Subagent 2     Subagent 3         
   Research       Research       Research           
   Topic A        Topic B        Topic C            
   [working]      [working]      [working]          
  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘       
                                                        
         └────────────────┼────────────────┘               
                                                          
              Results collected, summarized                
              Final answer to user                         
└──────────────────────────────────────────────────────────┘

Subagent Implementation

CLASS SubagentConfig:
    task: string
    tools: list          // Restricted tool set (empty = all tools)
    max_iterations: integer = 15
    context_mode: string = "isolated"  // "isolated" or "fork"
    timeout: integer = 300  // Max seconds

CLASS SubagentResult:
    task: string
    response: string
    success: boolean
    error: string = NULL
    iterations_used: integer = 0
    duration_seconds: float = 0.0

CLASS SubagentManager:
    agent_factory: function  // Creates new AgentLoop instances
    max_concurrent: integer = 5
    semaphore: Semaphore(max_concurrent)
    active_subagents: dictionary = {}
    executor: ThreadPool(max_workers = max_concurrent)

    FUNCTION spawn(task, tools = NULL, context_mode = "isolated", timeout = 300):
        subagent_id = GENERATE_UUID()
        config = SubagentConfig(task, tools, context_mode, timeout)

        // Acquire concurrency slot
        IF NOT semaphore.try_acquire(timeout = 1s):
            RETURN SubagentResult(success = false,
                   error = "Max concurrent subagents ({max_concurrent}) reached")

        TRY:
            active_subagents[subagent_id] = config
            start_time = NOW

            future = executor.submit(run_subagent, subagent_id, config)
            result = future.result(timeout = timeout)
            result.duration_seconds = NOW - start_time
            RETURN result

        CATCH error:
            RETURN SubagentResult(success = false, error = error)
        FINALLY:
            REMOVE subagent_id FROM active_subagents
            semaphore.release()

    FUNCTION run_subagent(subagent_id, config):
        TRY:
            agent = agent_factory(
                tools = config.tools,
                max_iterations = config.max_iterations
            )
            result = agent.run(
                messages = [{role: "user", content: config.task}],
                system_prompt = build_subagent_prompt(config)
            )
            RETURN SubagentResult(
                task = config.task,
                response = result.response,
                success = true,
                iterations_used = result.iterations_used
            )
        CATCH error:
            RETURN SubagentResult(success = false, error = error)

    FUNCTION build_subagent_prompt(config):
        RETURN """
            You are a focused subagent working on a specific delegated task.

            YOUR TASK:
            {config.task}

            CONTEXT:
            You are a subagent with a narrow, focused task. Complete it and return results.
            Do not ask clarifying questions — use your best judgment.
            Be thorough but concise — your response goes back to the parent agent.

            IMPORTANT: Return your complete findings. The parent agent relies on your work.
        """

    FUNCTION spawn_parallel(tasks):
        // tasks: list of {task, tools}
        futures = []
        FOR i, task_spec IN ENUMERATE(tasks):
            future = executor.submit(run_subagent, "parallel_{i}",
                                     SubagentConfig(**task_spec))
            ADD future TO futures

        results = []
        FOR future IN futures:
            TRY:
                ADD future.result(timeout = 600) TO results
            CATCH error:
                ADD SubagentResult(success = false, error = error) TO results

        RETURN results

The Delegation Tool

FUNCTION delegate_task(subagent_manager, task, tools = NULL, context_mode = "isolated"):
    """
    Delegate a task to a subagent.

    Spawns a child agent with a subset of tools to work on a task
    independently. The subagent runs in parallel and returns results.

    Use for:
    - Researching multiple topics simultaneously
    - Running long computations without blocking the main conversation
    - Isolating potentially risky operations
    """
    result = subagent_manager.spawn(task, tools, context_mode)

    IF result.success:
        RETURN """
            Subagent completed in {result.duration_seconds:.1f}s.

            TASK: {task}

            RESULT:
            {result.response}

            ---
            Iterations: {result.iterations_used}
        """
    ELSE:
        RETURN "Subagent failed: {result.error}"

How Frameworks Handle Delegation

Hermes (delegate_task):
- Configurable max_concurrent_children and max_spawn_depth
- Each subagent gets its own AIAgent instance with tool subset
- Guardrails: _cap_delegate_task_calls() limits concurrent calls
- Subagent reasoning displayed to parent

PicoClaw (SubTurn + spawn):
- SubTurn: sub-agent for parallel/isolated tasks
- Each sub-agent has its own workspace, tool set, session
- spawn and spawn_status tools for async execution
- Concurrency control prevents runaway spawning

ZeroClaw (spawn_subagent + delegate):
- Inherits parent's identity and permissions
- Isolated sessions with separate history
- Results reported back to parent

gptme (subagent):
- Sub-agents for parallel or isolated tasks
- Can reference and search past conversations

OpenClaw (sessions_spawn):
- Child agent runs with isolated or forked context
- Push-based completion notification (no polling)
- Subagent announce-loop guard prevents infinite spawning

Parallel Task Execution

For tasks that benefit from parallelism:

FUNCTION parallel_research(questions, subagent_manager):
    tasks = [{task: q, tools: ["web_search", "web_fetch"]}
             FOR q IN questions]

    results = subagent_manager.spawn_parallel(tasks)

    output = ["# Parallel Research Results\n"]
    FOR i, (question, result) IN ENUMERATE(ZIP(questions, results)):
        output.append("## Q{i+1}: {question}")
        IF result.success:
            output.append(result.response[0:500])
        ELSE:
            output.append("Failed: {result.error}")
        output.append("")

    RETURN JOIN(output, "\n")

Pitfalls

  1. Runaway spawning. Without limits, the agent could spawn infinite subagents. Always enforce max_concurrent_children and max_spawn_depth.

  2. Cost multiplication. Subagents amplify API costs. Each subagent makes its own LLM calls. Budget carefully.

  3. Context mode confusion. Forked contexts carry parent history (expensive). Isolated contexts start fresh (but may lack needed context). Choose wisely.

  4. Race conditions. Subagents modifying shared files create conflicts. Use file locks or separate workspaces.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 14: Security and Sandboxing

The Trust Problem

You're giving an LLM the ability to execute shell commands, read/write files, and browse the web. That's powerful — and dangerous. Without security, one prompt injection or model hallucination could destroy data.

What problem are we solving? An agent with shell access can run rm -rf /. An agent reading web pages can encounter prompt injection attacks embedded in HTML. An agent writing files can overwrite critical system configs. How do you build defense in depth?

Think about it: Is a denylist of dangerous commands enough? What about python -c 'import os; os.system("rm -rf /")' — that bypasses any command denylist. How do you detect prompt injection attempts in user input? In tool output? How do you sandbox file access to a workspace? What if the agent needs approval before destructive actions? Take a moment before reading on.

How the frameworks solve it: The IronClaw five-layer defense model provides the canonical framework. Each layer catches what the layers below might miss. No single layer is sufficient alone.

The Five-Layer Defense Model (IronClaw)

The IronClaw security framework proposes five layers:

┌──────────────────────────────────────────────────────────┐
              SECURITY LAYERS                               
                                                           
  LAYER 5: OS ISOLATION                                    
  Docker containers, WASM sandboxes, Landlock, seccomp     
  ─────────────────────────────────────────                 
  LAYER 4: COMMAND APPROVAL                                
  User consent for destructive operations                  
  ─────────────────────────────────────────                 
  LAYER 3: TOOL GUARDRAILS                                 
  Per-tool restrictions, allowlists, denylists             
  ─────────────────────────────────────────                 
  LAYER 2: PROMPT DEFENSE                                  
  Injection detection, trust markers, input sanitization   
  ─────────────────────────────────────────                 
  LAYER 1: AUDIT TRAIL                                     
  Logging, receipts, verifiable intent                     
└──────────────────────────────────────────────────────────┘

Layer 1: Audit Trail

CLASS AuditTrail:
    log_dir: string

    FUNCTION log_tool_execution(tool_name, args, result, session_id):
        timestamp = NOW
        receipt = SHA256("{session_id}:{tool_name}:{JSON(args)}:{timestamp}")[0:16]

        entry = {
            timestamp: timestamp,
            session_id: session_id,
            tool: tool_name,
            args: args,
            result_preview: result[0:200],
            receipt: receipt
        }

        log_file = log_dir / "audit-{FORMAT_DATE(timestamp)}.jsonl"
        APPEND JSON_LINE(entry) TO log_file

        RETURN receipt

    FUNCTION query(tool_name = NULL, session_id = NULL, limit = 100):
        results = []
        FOR log_file IN SORTED(glob("audit-*.jsonl"), REVERSE):
            FOR line IN READ_LINES(log_file):
                entry = PARSE_JSON(line)
                IF tool_name AND entry.tool != tool_name:
                    CONTINUE
                IF session_id AND entry.session_id != session_id:
                    CONTINUE
                ADD entry TO results
                IF LENGTH(results) >= limit:
                    RETURN results
        RETURN results

Layer 2: Prompt Injection Defense

CLASS InjectionDetector:
    // Known injection patterns
    PATTERNS = [
        "ignore (all )?(previous|above) instructions",
        "you are now (DAN|a different|no longer)",
        "system:\\s*override",
        "<\\|im_start\\|>",
        "<\\|im_end\\|>",
        "\\[system\\].*ignore",
        "new system prompt:",
        "forget (everything|all) (you know|above)"
    ]

    // Hermes-style trust markers
    STEER_MARKER_START = "[STEER_CHANNEL]"
    STEER_MARKER_END = "[/STEER_CHANNEL]"

    STATIC FUNCTION detect_injection(text):
        text_lower = LOWERCASE(text)
        FOR pattern IN PATTERNS:
            IF REGEX_MATCH(pattern, text_lower):
                RETURN "WARNING: Potential prompt injection detected: {pattern}"
        RETURN NULL

    STATIC FUNCTION sanitize_user_input(text):
        // Remove surrogate characters
        text = REMOVE_SURROGATES(text)
        // Remove null bytes
        text = REPLACE(text, '\x00', '')
        // Detect injection
        warning = detect_injection(text)
        IF warning:
            text = "[SECURITY NOTE: {warning}]\n\n{text}"
        RETURN text

    STATIC FUNCTION sanitize_tool_output(text, tool_name):
        // Check if tool output contains injection-like instructions
        warning = detect_injection(text)
        IF warning:
            RETURN "[SECURITY: {warning}. Original output suppressed.]"
        RETURN text

Layer 3: Tool Guardrails

CLASS SecurityPolicy:
    workspace: string
    allowed_paths: list
    denied_paths: list
    allowed_commands: list  // NULL = allow all
    denied_commands: list = [
        "rm -rf /", "mkfs.", "dd if=", "shutdown", "reboot",
        ":(){ :|:& };:",  // Fork bomb
        "chmod 777 /", "chown -R"
    ]
    autonomy_level: string = "default"  // "full", "default", "readonly"

    FUNCTION check_file_access(path, operation = "read"):
        filepath = RESOLVE(expand_user(path))

        // Must be within workspace
        IF NOT filepath IS WITHIN workspace:
            // Check allowed paths override
            IF any(allowed IN filepath FOR allowed IN allowed_paths):
                RETURN NULL  // OK
            RETURN "Access denied: {path} is outside workspace"

        // Check denied paths
        FOR denied IN denied_paths:
            IF filepath STARTS WITH denied:
                RETURN "Access denied: {path} is in denied path"

        RETURN NULL  // OK

    FUNCTION check_shell_command(command):
        cmd_lower = LOWERCASE(command)

        // Check denied commands
        FOR pattern IN denied_commands:
            IF pattern IN cmd_lower:
                RETURN "Command blocked: matches denied pattern '{pattern}'"

        // Check allowed commands (if list is set)
        IF allowed_commands IS SET:
            cmd_base = FIRST_WORD(command)
            IF cmd_base NOT IN allowed_commands:
                RETURN "Command '{cmd_base}' not in allowed list"

        // Autonomy level checks
        IF autonomy_level == "readonly":
            destructive = ["rm ", "mv ", ">", "dd ", "format", "mkfs"]
            IF any(d IN cmd_lower FOR d IN destructive):
                RETURN "ReadOnly mode: destructive commands not allowed"

        RETURN NULL  // OK

    FUNCTION needs_approval(tool_name, args):
        IF autonomy_level == "full":
            RETURN false

        // Destructive file ops need approval
        IF tool_name == "write_file" AND FILE_EXISTS(args.path):
            RETURN true  // Overwriting existing file

        // Dangerous shell commands need approval
        IF tool_name == "shell":
            dangerous = ["rm ", "sudo ", "pip uninstall", "git push --force",
                        "docker rm", "kubectl delete"]
            IF any(d IN LOWERCASE(args.command) FOR d IN dangerous):
                RETURN true

        RETURN false

Layer 4: Command Approval

CLASS ApprovalManager:
    auto_approve: boolean = false
    pending_approvals: dictionary = {}

    FUNCTION request_approval(action, details, risk = "medium"):
        IF auto_approve:
            RETURN "APPROVED"

        approval_id = "approval_{HASH(details)[0:8]}"
        pending_approvals[approval_id] = {
            action: action,
            details: details,
            risk: risk,
            status: "pending"
        }

        RETURN """
            ⚠️  **Approval Required** [{UPPERCASE(risk)} RISK]
            Action: {action}
            Details: {details}
            Approve with: /approve {approval_id}
            Deny with: /deny {approval_id}
        """

    FUNCTION approve(approval_id):
        IF approval_id IN pending_approvals:
            pending_approvals[approval_id].status = "approved"
            RETURN "Approved: {approval_id}"
        RETURN "Approval {approval_id} not found."

Layer 5: OS-Level Isolation

CLASS DockerSandbox:
    image: string = "python:3.11-slim"
    workspace: string = "/workspace"

    FUNCTION execute_in_sandbox(command, files = NULL, timeout = 120):
        // Create temp directory with files
        WITH temporary_directory AS tmpdir:
            IF files:
                FOR path, content IN files:
                    WRITE content TO tmpdir/path

            docker_cmd = [
                "docker", "run", "--rm",
                "--network", "none",     // No network access
                "--memory", "256m",      // Memory limit
                "--cpus", "1",           // CPU limit
                "-v", "{tmpdir}:{workspace}:ro",  // Read-only workspace
                "-w", workspace,
                image,
                "bash", "-c", command
            ]

            TRY:
                result = RUN(docker_cmd, timeout = timeout)
                output = result.stdout
                IF result.stderr:
                    output += "\n[stderr]\n{result.stderr}"
                RETURN output
            CATCH TimeoutError:
                RETURN "Sandboxed command timed out after {timeout}s"
            CATCH FileNotFoundError:
                RETURN "Error: Docker not available."


CLASS WASMSandbox:
    // Execute code in WebAssembly sandbox (stronger isolation than Docker)

    FUNCTION execute_python(code, timeout = 30):
        // Requires: wasmtime, python.wasm
        WITH temp_file AS script:
            WRITE code TO script

        TRY:
            result = RUN(["wasmtime", "run", "--dir=.", "python.wasm", script],
                         timeout = timeout)
            RETURN result.stdout OR result.stderr
        CATCH FileNotFoundError:
            RETURN "Error: wasmtime not installed."
        FINALLY:
            DELETE script

How Frameworks Handle Security

Hermes:
- Context file threat scanning
- Secret scrubbing in gateway output
- Steer channel trust markers
- Cross-profile write guards
- Tool guardrails and edit approval for ACP sessions
- Anti-injection patterns in compaction summary

ZeroClaw:
- SecurityPolicy struct with tool allowlists/denylists
- Three autonomy levels: Full, ReadOnly, Default
- Command validation against security policy
- Credential scrubbing from tool output
- Verifiable Intent with Ed25519 signatures

OpenClaw:
- DM pairing for unknown senders
- Sandbox modes: host, non-main, Docker, SSH, OpenShell
- Channel allowlists
- openclaw doctor for security diagnostics
- Gateway device identity + pairing approval

Pitfalls

  1. Security theater. A denylist of dangerous commands doesn't stop python -c 'import os; os.system("rm -rf /")'. Defense in depth is necessary.

  2. Prompt injection via tools. Web pages and files can contain injection instructions. Hermes sanitizes tool output and uses trust markers.

  3. File path traversal. ../../../etc/passwd bypasses workspace checks. Always resolve and canonicalize paths.

  4. Race conditions in approval. Between requesting approval and executing, the state could change. Use receipts and verify before execution.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 15: Scheduled and Autonomous Operation

The Agent That Runs Itself

The ultimate agent doesn't wait for commands — it runs on schedule, monitors events, and pursues goals autonomously. This chapter builds the infrastructure for autonomous operation.

What problem are we solving? An agent that only works when a user sends a message is limited. The agent should be able to run on a schedule ("check for security updates every morning"), react to file system events ("when a new CSV appears in the data directory, process it"), and pursue long-term goals autonomously.

Think about it: How do you design a cron scheduler that persists across restarts? What happens if a scheduled job fails silently? How do you prevent an autonomous agent from creating more cron jobs that create more cron jobs in an infinite loop? How do you debounce file system events so you don't process the same file 50 times? Take a moment before reading on.

How the frameworks solve it: Persistent cron storage (JSON or SQLite), human-readable schedule expressions, debounced file watchers, and autonomous goal tracking with status management.

Cron Scheduler

CLASS CronJob:
    id: string
    name: string
    schedule: string       // Cron expression or "every 2 hours"
    command: string
    enabled: boolean = true
    last_run: float = NULL
    next_run: float = NULL
    run_count: integer = 0
    error_count: integer = 0

CLASS CronScheduler:
    storage_path: string
    agent_callback: function  // Called for "agent:" prefixed jobs
    jobs: dictionary = {}
    running: boolean = false

    FUNCTION __init__(storage_path, agent_callback = NULL):
        CREATE directory for storage_path
        LOAD jobs from storage_path

    FUNCTION add(name, schedule, command):
        job_id = "cron_{NOW_TS}_{HASH(name)}"
        job = CronJob(id=job_id, name=name, schedule=schedule, command=command)
        calculate_next_run(job)
        jobs[job_id] = job
        SAVE jobs to storage
        RETURN job_id

    FUNCTION remove(job_id):
        IF job_id IN jobs:
            DELETE jobs[job_id]
            SAVE jobs to storage
            RETURN true
        RETURN false

    FUNCTION start():
        IF running:
            RETURN
        running = true
        START background thread: scheduler_loop

    FUNCTION scheduler_loop():
        WHILE running:
            now = NOW

            FOR each job IN jobs:
                IF NOT job.enabled:
                    CONTINUE
                IF job.next_run AND job.next_run <= now:
                    execute_job(job)
                    calculate_next_run(job)
                    SAVE to storage

            SLEEP(15 seconds)

    FUNCTION execute_job(job):
        job.last_run = NOW
        job.run_count += 1

        TRY:
            IF job.command STARTS WITH "agent:":
                // Agent-driven job
                task = job.command[6:]  // Strip "agent:"
                IF agent_callback:
                    result = agent_callback(task)
            ELSE:
                // Shell command
                result = RUN_SHELL(job.command, timeout = 300)
                IF result.exit_code != 0:
                    job.error_count += 1
        CATCH error:
            job.error_count += 1

    FUNCTION calculate_next_run(job):
        TRY:
            schedule = job.schedule
            IF schedule STARTS WITH "every ":
                schedule = human_to_cron(schedule)

            now = datetime.NOW()
            cron = PARSE_CRON(schedule)
            job.next_run = cron.get_next_time(now)
        CATCH:
            job.enabled = false

    FUNCTION human_to_cron(human):
        human = LOWERCASE(REPLACE(human, "every ", ""))
        IF "minute" IN human:
            n = EXTRACT_NUMBER(human) OR 1
            RETURN "*/{n} * * * *"
        IF "hour" IN human:
            n = EXTRACT_NUMBER(human) OR 1
            RETURN "0 */{n} * * *"
        IF "day" IN human:
            RETURN "0 9 * * *"   // 9 AM daily
        IF "week" IN human:
            RETURN "0 9 * * 1"   // Monday 9 AM
        RETURN "0 9 * * *"       // Default

Event-Driven Triggers

CLASS FileWatcherTrigger(FileSystemEventHandler):
    agent_callback: function
    cooldown: dictionary = {}  // file_hash  last_trigger_time

    FUNCTION on_created(event):
        IF NOT event.is_directory:
            handle("created", event.src_path)

    FUNCTION on_modified(event):
        IF NOT event.is_directory:
            handle("modified", event.src_path)

    FUNCTION handle(event_type, path):
        file_hash = MD5(path)
        now = NOW

        // Debounce: max 1 trigger per file per 5 seconds
        IF file_hash IN cooldown:
            IF now - cooldown[file_hash] < 5:
                RETURN

        cooldown[file_hash] = now
        agent_callback("File {event_type}: {path}")

Autonomous Goal Pursuit

ENUM GoalStatus:
    PENDING = "pending"
    IN_PROGRESS = "in_progress"
    COMPLETED = "completed"
    FAILED = "failed"
    BLOCKED = "blocked"

CLASS Goal:
    id: string
    description: string
    status: GoalStatus = PENDING
    subgoals: list = []
    progress_notes: list = []
    created_at: float = NOW

CLASS AutonomousAgent:
    agent_loop: AgentLoop
    cron: CronScheduler
    goals: list = []
    max_concurrent_goals: integer = 3

    FUNCTION add_goal(description):
        goal = Goal(id = "goal_{NOW_TS}", description = description)
        ADD goal TO goals
        RETURN goal.id

    FUNCTION run_pending_goals():
        in_progress = COUNT(goals WHERE status == IN_PROGRESS)
        available_slots = max_concurrent_goals - in_progress

        pending = FILTER(goals, status == PENDING)
        results = []

        FOR goal IN pending[0:available_slots]:
            goal.status = IN_PROGRESS
            TRY:
                result = agent_loop.run(messages = [{
                    role: "user",
                    content: """
                        Goal: {goal.description}

                        Work on this goal and report progress.
                        If the goal requires multiple steps, complete what
                        you can now and note what remains.
                    """
                }])

                ADD result.response TO goal.progress_notes

                IF "GOAL COMPLETE" IN result.response:
                    goal.status = COMPLETED
                ELSE IF "BLOCKED" IN result.response:
                    goal.status = BLOCKED
                ELSE:
                    goal.status = PENDING  // Continue next cycle

                ADD "Goal '{goal.description[0:50]}...': {goal.status}" TO results

            CATCH error:
                goal.status = FAILED
                ADD "Goal FAILED: {error}" TO results

        RETURN results

    FUNCTION get_status():
        IF NOT goals:
            RETURN "No goals."

        emojis = {
            PENDING: "⏳", IN_PROGRESS: "🔄",
            COMPLETED: "✅", FAILED: "❌", BLOCKED: "🚫"
        }

        output = ["# Goals\n"]
        FOR goal IN goals:
            emoji = emojis[goal.status]
            output.append("{emoji} {goal.description}")
            IF goal.progress_notes:
                output.append("   Last: {goal.progress_notes[-1][0:100]}")

        RETURN JOIN(output, "\n")

How Frameworks Handle Autonomy

Hermes:
- cronjob tool for create/list/update/pause/resume/remove/trigger
- Persistent cron scheduler with headless execution
- Background review subagent after each turn

ZeroClaw:
- SOP (Standard Operating Procedures) engine for multi-step workflows
- Sequential and parallel step execution with conditional branching
- Cron scheduler with human-readable schedules
- Missed run handling and rescheduling

gptme:
- Agent template for persistent autonomous agents
- Scheduled runs via systemd or launchd
- Git-tracked "brain" with journal, tasks, knowledge base
- Multi-agent coordination via file leases and message bus

PicoClaw:
- cron_add/list/remove tools
- Scheduled tasks and reminders
- Async execution for non-blocking operations

Pitfalls

  1. Runaway automation. An agent autonomously creating more cron jobs that create more cron jobs... Always limit nested automation.

  2. Missed runs. If the scheduler is down, jobs are missed. Implement catch-up logic: "run immediately if last scheduled run was missed."

  3. Silent failures. Cron jobs that fail silently accumulate errors without anyone noticing. Implement health checks and alerting.

  4. Cost accumulation. Autonomous agents running 24/7 can rack up significant API costs. Implement daily/monthly cost budgets.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 16: Multi-Agent Orchestration

Beyond Single Agents

What's better than one agent? Multiple agents working together. This chapter covers patterns for multi-agent coordination: swarms, queues, kanban boards, and coordinator patterns.

What problem are we solving? Complex projects require multiple agents with different specialties working on interdependent tasks. How do you assign tasks, track progress, handle dependencies, and prevent conflicts?

Think about it: If Agent A is working on Task X and Agent B claims Task X at the same time, who wins? How do agents communicate results to each other? How do you handle a task that blocks another task? What if an agent crashes mid-task — how do you reassign its work? Take a moment before reading on.

How the frameworks solve it: A kanban board with SQLite persistence for task state, heartbeats for liveness detection, and a coordinator pattern for workflows with explicit dependencies. The swarm pattern handles embarrassingly parallel work with map-reduce.

The Kanban Pattern (Hermes)

┌──────────────────────────────────────────────────────────┐
                    KANBAN BOARD                           
                                                           
  BACKLOG        IN PROGRESS        DONE                  
  ┌─────────┐   ┌─────────┐        ┌─────────┐           
   Task A      Task C    ──▶    Task B             
   Task D       [Agent1]                            
   Task E                      └─────────┘           
  └─────────┘   └─────────┘                               
                ┌─────────┐                                
       ┌───────▶│ Task F                                  
                 [Agent2]                                
               └─────────┘                                
  ┌────┴────┐                                              
  Dispatcher  Assigns tasks to available agents          
  └─────────┘                                              
                                                           
  Workers: orient  work  heartbeat  complete           
           create follow-ups                               
└──────────────────────────────────────────────────────────┘

Kanban Implementation

ENUM TaskStatus:
    BACKLOG = "backlog"
    IN_PROGRESS = "in_progress"
    BLOCKED = "blocked"
    DONE = "done"

CLASS KanbanBoard:
    db_path: string

    FUNCTION __init__(db_path):
        CREATE database with tables:
            tasks (id, title, description, status, assigned_to,
                   created_at, completed_at, parent_task, heartbeats)
            comments (id, task_id, agent_id, content, timestamp)

    FUNCTION create_task(title, description = "", parent = NULL):
        task_id = "task_{UUID_SHORT}"
        INSERT task INTO tasks
        RETURN task_id

    FUNCTION claim_task(agent_id):
        // Find oldest backlog task
        task = SELECT FROM tasks
               WHERE status = 'backlog'
               ORDER BY created_at ASC LIMIT 1

        IF NOT task:
            RETURN NULL

        UPDATE tasks SET status = 'in_progress', assigned_to = agent_id
        WHERE id = task.id

        RETURN {id: task.id, title: task.title, description: task.description}

    FUNCTION complete_task(task_id, agent_id):
        UPDATE tasks SET status = 'done', completed_at = NOW
        WHERE id = task_id AND assigned_to = agent_id
        RETURN rows_affected > 0

    FUNCTION block_task(task_id, agent_id, reason):
        UPDATE tasks SET status = 'blocked'
        WHERE id = task_id AND assigned_to = agent_id
        IF rows_affected > 0:
            add_comment(task_id, agent_id, "BLOCKED: {reason}")
        RETURN rows_affected > 0

    FUNCTION heartbeat(task_id, agent_id):
        UPDATE tasks: APPEND NOW to heartbeats array
        RETURN success

    FUNCTION add_comment(task_id, agent_id, content):
        INSERT INTO comments (task_id, agent_id, content, timestamp)
        VALUES (task_id, agent_id, content, NOW)

    FUNCTION get_board_status():
        output = ["# Kanban Board\n"]

        emojis = {BACKLOG: "📋", IN_PROGRESS: "🔄",
                  BLOCKED: "🚫", DONE: "✅"}

        FOR status IN ["backlog", "in_progress", "blocked", "done"]:
            rows = SELECT id, title, assigned_to FROM tasks
                   WHERE status = ?
            output.append("## {emojis[status]} {UPPERCASE(status)}")

            IF NOT rows:
                output.append("  (empty)\n")
            FOR row IN rows:
                agent = " [{row.assigned_to}]" IF row.assigned_to ELSE ""
                output.append("  - {row.title}{agent}")

        RETURN JOIN(output, "\n")

Agent Swarms (Map-Reduce)

CLASS AgentSwarm:
    agent_factory: function
    num_workers: integer = 3

    FUNCTION map_reduce(task, items, map_prompt, reduce_prompt):
        // MAP phase  parallel processing
        map_results = []
        WITH ThreadPool(max_workers = num_workers) AS executor:
            futures = {}
            FOR item IN items:
                future = executor.submit(run_map, map_prompt, item)
                futures[future] = item

            FOR future IN AS_COMPLETED(futures):
                item = futures[future]
                TRY:
                    result = future.result()
                    ADD {item: item, result: result} TO map_results
                CATCH error:
                    ADD {item: item, error: error} TO map_results

        // REDUCE phase  synthesize results
        reduce_input = JOIN(
            "ITEM: {r.item}\nRESULT: {r.result OR r.error}"
            FOR r IN map_results
        , "\n\n")

        agent = agent_factory()
        RETURN agent.run(messages = [{
            role: "user",
            content: "{reduce_prompt}\n\nMAP RESULTS:\n{reduce_input}"
        }])

    FUNCTION run_map(prompt_template, item):
        agent = agent_factory()
        return agent.run(messages = [{
            role: "user",
            content: "{prompt_template}\n\nTASK ITEM: {item}"
        }]).response

Coordinator Pattern

CLASS Coordinator:
    agent_factory: function
    kanban: KanbanBoard

    FUNCTION execute_workflow(workflow):
        // workflow: list of steps [{task, agent_type, depends_on}]
        results = {}

        FOR i, step IN ENUMERATE(workflow):
            // Wait for dependencies
            FOR dep_idx IN step.depends_on:
                WAIT UNTIL dep_idx IN results

            // Create kanban task
            task_id = kanban.create_task(
                title = step.task,
                description = "Step {i+1}/{LENGTH(workflow)}"
            )

            // Build context from dependencies
            context = ""
            IF step.depends_on:
                context = "Context from previous steps:\n"
                FOR dep_idx IN step.depends_on:
                    IF dep_idx IN results:
                        context += "Step {dep_idx+1} result:\n"
                        context += results[dep_idx][0:1000] + "\n\n"

            // Execute
            agent = agent_factory(agent_type = step.agent_type)
            result = agent.run(messages = [{
                role: "user",
                content: context + "Step {i+1}: {step.task}"
            }])

            results[i] = result.response
            kanban.complete_task(task_id, "coordinator")

        RETURN synthesize_results(workflow, results)

How Frameworks Handle Multi-Agent

Hermes (Kanban):
- SQLite-backed shared task board
- Dispatcher spawns agent processes per task
- Workers use kanban tools: kanban_show, kanban_complete, kanban_block, kanban_create, kanban_heartbeat, kanban_comment
- Full worker lifecycle protocol (orient → work → heartbeat → complete → create follow-ups)

OpenClaw (Multi-Agent Routing):
- Each agent has own workspace, auth profiles, session store
- Deterministic binding routing (most-specific-wins)
- Subagents with isolated or forked context modes

gptme (Multi-Agent Coordination):
- File leases for work claiming
- Message bus for inter-agent communication
- GTD-style task workflows

Pitfalls

  1. Orchestration overhead. A coordinator agent that delegates everything adds latency and cost. Direct agents are faster for simple tasks.

  2. Task starvation. Without fair scheduling, some tasks may never get picked up. Implement timeouts and reassignment.

  3. Communication complexity. Agents passing partial results need a shared schema. JSON is universal but unstructured. Consider protobuf or typed schemas.

  4. Deadlocks. Agent A waits for Agent B, which waits for Agent A. Implement cycle detection and timeouts.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 17: Provider Abstraction

One Interface, Many Models

Your agent shouldn't be locked to OpenAI. It should work with Anthropic, Google, local models, and any future provider — ideally without changing agent code.

What problem are we solving? Each LLM provider has different API formats, tool schemas, token counting methods, and streaming protocols. Hard-coding to one provider means rewriting everything when you switch.

Think about it: OpenAI uses tools: [{type: "function", function: {...}}]. Anthropic uses tools: [{name: "...", input_schema: {...}}]. How do you normalize these? How do you handle fallback when the primary provider is down? How do you route simple queries to cheaper models? Take a moment before reading on.

How the frameworks solve it: An abstract LLMProvider interface with concrete implementations for each provider. A FallbackChain tries providers in order with cooldown on failures. Model routing sends simple queries to cheap models.

The Provider Interface

CLASS ModelResponse:
    content: string
    tool_calls: list = []
    finish_reason: string = "stop"
    model: string = ""
    usage: dictionary = {}  // {input_tokens, output_tokens}

CLASS LLMProvider(ABC):
    FUNCTION chat(messages, tools = NULL, model = NULL,
                  max_tokens = 4096, temperature = 0.7) -> ModelResponse

    FUNCTION stream_chat(messages, tools = NULL, model = NULL,
                         max_tokens = 4096, temperature = 0.7) -> StreamIterator

    FUNCTION count_tokens(messages) -> integer

    FUNCTION list_models() -> list
    FUNCTION provider_name() -> string

OpenAI Provider

CLASS OpenAIProvider(LLMProvider):
    client: OpenAIClient

    FUNCTION __init__(api_key = NULL, base_url = NULL):
        client = OpenAI(api_key = api_key OR ENV["OPENAI_API_KEY"],
                        base_url = base_url)

    FUNCTION chat(messages, tools = NULL, model = "gpt-4o",
                  max_tokens = 4096, temperature = 0.7):
        kwargs = {
            model: model,
            messages: messages,
            max_tokens: max_tokens,
            temperature: temperature
        }
        IF tools:
            kwargs.tools = tools
            kwargs.tool_choice = "auto"

        response = client.chat.completions.create(**kwargs)
        msg = response.choices[0].message

        RETURN ModelResponse(
            content = msg.content OR "",
            tool_calls = [SERIALIZE(tc) FOR tc IN (msg.tool_calls OR [])],
            finish_reason = response.choices[0].finish_reason,
            model = response.model,
            usage = {
                input: response.usage.prompt_tokens,
                output: response.usage.completion_tokens
            }
        )

    FUNCTION count_tokens(messages):
        encoder = tiktoken.encoding_for_model("gpt-4o")
        RETURN SUM(encoder.count(m.content) FOR m IN messages)

Anthropic Provider

CLASS AnthropicProvider(LLMProvider):
    client: AnthropicClient

    FUNCTION __init__(api_key = NULL):
        client = Anthropic(api_key = api_key OR ENV["ANTHROPIC_API_KEY"])

    FUNCTION chat(messages, tools = NULL, model = "claude-sonnet-4-20250514",
                  max_tokens = 4096, temperature = 0.7):
        // Convert tools to Anthropic format
        anthropic_tools = NULL
        IF tools:
            anthropic_tools = [
                {
                    name: t.function.name,
                    description: t.function.description,
                    input_schema: t.function.parameters
                }
                FOR t IN tools
            ]

        // Separate system message (Anthropic handles it differently)
        system = ""
        api_messages = []
        FOR msg IN messages:
            IF msg.role == "system":
                system += msg.content + "\n"
            ELSE:
                ADD msg TO api_messages

        response = client.messages.create(
            model = model,
            system = system.strip() OR NULL,
            messages = api_messages,
            tools = anthropic_tools,
            max_tokens = max_tokens
        )

        // Parse Anthropic response into common format
        tool_calls = []
        text_content = ""
        FOR block IN response.content:
            IF block.type == "text":
                text_content += block.text
            ELSE IF block.type == "tool_use":
                ADD {
                    id: block.id,
                    function: {
                        name: block.name,
                        arguments: JSON_STRINGIFY(block.input)
                    }
                } TO tool_calls

        RETURN ModelResponse(
            content = text_content,
            tool_calls = tool_calls,
            finish_reason = response.stop_reason,
            model = response.model,
            usage = {
                input: response.usage.input_tokens,
                output: response.usage.output_tokens
            }
        )

Fallback Chains

CLASS FallbackChain:
    providers: list[LLMProvider]
    default_model: string
    provider_status: dictionary = {}  // provider_id  {failures, cooldown_until}

    FUNCTION call(messages, tools = NULL, model = NULL, max_tokens = 4096):
        last_error = NULL

        FOR provider IN providers:
            // Check cooldown
            status = provider_status[ID(provider)]
            IF status.cooldown_until > NOW:
                CONTINUE

            TRY:
                response = provider.chat(
                    messages, tools,
                    model = model OR default_model,
                    max_tokens = max_tokens
                )
                mark_success(provider)
                RETURN response
            CATCH error:
                last_error = error
                mark_failure(provider, error)
                CONTINUE

        RAISE "All {LENGTH(providers)} providers failed. Last: {last_error}"

    FUNCTION mark_failure(provider, error):
        pid = ID(provider)
        failures = provider_status[pid].failures + 1
        cooldown = MIN(60, 2 ^ failures)  // Exponential backoff
        provider_status[pid] = {
            failures: failures,
            cooldown_until: NOW + cooldown,
            last_error: error
        }

    FUNCTION mark_success(provider):
        DELETE provider_status[ID(provider)]

Model Routing (PicoClaw Pattern)

CLASS ModelRouter:
    simple_patterns = [
        (r"^(what|who|when|where|how) (is|are|was|were) ", 0.8),
        (r"^(list|show|display) ", 0.9),
        (r"^(hi|hello|hey|thanks|thank you)", 1.0),
        (r"\bweather\b", 0.7),
        (r"^(\w+\s){0,5}$", 0.6)  // Very short queries
    ]

    FUNCTION should_route_to_cheap(message):
        words = WORD_COUNT(message)
        IF words < 10:
            RETURN true

        FOR pattern, threshold IN simple_patterns:
            IF REGEX_MATCH(pattern, message):
                RETURN true

        RETURN false  // Complex query — use expensive model

    FUNCTION route(message):
        IF should_route_to_cheap(message):
            RETURN "gpt-4o-mini"
        RETURN "gpt-4o"

How Frameworks Handle Providers

Framework Providers Key Feature
Hermes 8+ transports (chat_completions, anthropic, bedrock, codex, ACP, copilot, gemini, kimi, deepseek) Transport abstraction normalizes to common AssistantMessage
OpenClaw Provider plugins with auth profiles Auth profile rotation, provider-specific prompt contributions
PicoClaw 30+ LLM backends Single LLM interface across all providers
ZeroClaw ModelProvider trait Provider resolution with config-defined aliases, runtime model switching
gptme Anthropic, OpenAI, Google, xAI, DeepSeek, local Unified reply() function

Pitfalls

  1. Schema differences. OpenAI and Anthropic use different tool schema formats. Always normalize. Hermes uses model_tools.py to convert between formats.

  2. Token counting variation. Each provider counts tokens differently. Use provider-specific tokenizers or conservative estimates.

  3. Feature gaps. Not all providers support all features (streaming, tool calling, vision). Check capabilities before routing.

  4. Cost tracking inconsistency. Each provider reports usage differently. Normalize to a common format for cost tracking.

What We Built

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Chapter 18: The Landscape — Six Agents Compared

The Complete Picture

After building an agent from scratch, let's survey the landscape. Here's how six mature frameworks compare across the dimensions that matter.

Architecture Comparison

┌──────────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
                Hermes   OpenClaw  PicoClaw  ZeroClaw   gptme   IronClaw  
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
 Language      Python    TS/Node   Go        Rust      Python    Policy   
 Codebase      ~50K LOC  ~400K LOC ~30K LOC  ~126K LOC ~15K LOC  Spec     
 Startup       ~500ms    ~2s       <100ms    <100ms    ~300ms    N/A      
 RAM (idle)    ~200MB    ~500MB    <10MB     <50MB     ~100MB    N/A      
 Min Hardware  Raspberry Server    $10 RISC  $15 ARM   Laptop    N/A      
               Pi                  -V                                     
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
 TOOLS                                                                    
 Built-in      ~50       ~40       ~15       ~70       ~20       N/A      
 Discovery     Registry  Plugin    Registry  Trait     Auto-disc N/A      
               Self-reg  SDK       Interface derive    Module             
 Parallel      Optional  Yes       Yes       Yes       Optional  N/A      
                                   Goroutine Tokio                        
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
 AGENT                                                                    
 Max Iter.     90        Config    ~10       10        Config    N/A      
 Streaming     Primary   Yes       No        SSE       Yes       N/A      
 Compression   LLM-sum   Pluggable Basic     LLM-sum   Multi     N/A      
 Delegation    Yes       Yes       Yes       Yes       Yes       N/A      
 Multi-Agent   Kanban    Routing   SubTurn   Peers     File-locksN/A      
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
 PLATFORMS                                                                
 Messaging     20+       25+       19+       30+       CLI only  N/A      
 Architecture  Gateway   WS Gateway Adapter   Channel   Server    N/A      
               Process   Daemon    Package   Crate     Mode               
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
 MEMORY                                                                   
 Types         MD+SQLite MD+JSONL  JSONL     Multi     JSONL     N/A      
               +Vector                       MD+SQLite                    
                                             +Vector                      
 Review        Auto      Manual    Auto      Auto      Lessons   N/A      
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
 SECURITY                                                                 
 Sandbox       Partial   Docker    Path-rest WASM      None      5-layer  
 Approval      ACP-only  DM-pair   Path-allow Autonomy  InteractiveL4  
 Inject Def.   Steer-ch  Limited   None      None      None      L2       
 Audit         Credits   Logs      None      Observer  Logs      L1       
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
 UNIQUE       Prompt-cacPlugin SDK│$10 HW    VerifiableLessons   Defense   
              he obses. boundary  10MB RAM  Intent    system    model     
└──────────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘

When to Use Which

Framework Best For Not Ideal For
Hermes Personal assistants, prompt-cache-optimized deployments, self-improving workflows Minimal-resource environments, library/SDK use
OpenClaw Production multi-tenant hosting, enterprise deployments, plugin ecosystems Quick prototypes, resource-constrained devices
PicoClaw Embedded systems, IoT, $10 hardware, edge computing Complex plugin ecosystems, heavy TypeScript shops
ZeroClaw Security-critical applications, WASM extensibility, performance-sensitive workloads Rapid prototyping, Python/TS developer teams
gptme Quick setup, CLI-first workflows, research/experimentation Multi-platform messaging, large-scale production
IronClaw Security architecture reference, compliance requirements Implementation — it's a spec, not code

Design Philosophy Comparison

Hermes:     "The core is a narrow waist; capability lives at the edges."
OpenClaw:   "The Gateway is just the control plane — the product is the assistant."
PicoClaw:   "Minimal memory, minimal CPU, maximal utility."
ZeroClaw:   "No piece of state lives in two places."
gptme:      "Keep core small, put specialized features in contrib."

Key Innovation Each Framework Contributed

  1. Hermes: Prompt caching as a first-class architectural concern. The three-tier (stable/context/volatile) prompt system with SQLite persistence is unmatched.

  2. OpenClaw: The plugin SDK boundary — a real, enforced API contract between core and extensions. Plus multi-tenant agent isolation with deterministic binding routing.

  3. PicoClaw: Proof that you can run a full AI agent on $10 hardware with <10MB RAM. The Go single-binary approach eliminates runtime dependencies.

  4. ZeroClaw: The trait-driven microkernel design with WASM plugin support. Plus verifiable intent with Ed25519 signatures — the only framework with cryptographic audit trails.

  5. gptme: The lessons system — contextual guidance auto-injected by keyword matching. Brilliantly simple alternative to the skill pattern.

  6. IronClaw: The five-layer defense model that every framework should reference for security architecture.


Chapter 19: Building Your Own Framework — Synthesis

Design Principles

What problem are we solving? After studying six production frameworks, what patterns consistently produce the best results? What decisions matter most when building your own?

Think about it: If you could only implement 5 features before shipping, which would they be? What's the one mistake that will cost you the most to fix later? What can you defer? Take a moment before reading on.

How the frameworks solve it: Ten principles emerge from the comparative analysis. The most important: start simple, separate stable from volatile, make the agent loop sacred, and implement provider abstraction early.

After studying all six frameworks, these design principles consistently produce the best results:

1. Start With the Simplest Thing That Works

gptme's first commit was 2023. It started tiny and grew. Hermes started with a single run_conversation() function. Don't architect first; build a working agent in 40 lines, then iterate.

2. The Agent Loop Is Sacred

Every framework converges on the same loop pattern: build context → call LLM → parse response → execute tools → repeat. Get this right first. Everything else (skills, memory, multi-platform) attaches to this core.

THE UNIVERSAL AGENT LOOP:
  WHILE iterations_remaining AND NOT terminal:
      response = llm(messages, tools)
      IF response.has_tools:
          execute_tools(response.tools)
          append_results_to_messages()
      ELSE:
          RETURN response.content

3. Separate Stable from Volatile

Hermes's three-tier prompt architecture is the single most impactful optimization for cost. Stable content (identity, rules) should be byte-identical across turns. Volatile content (timestamps, memory snapshots) changes per session. Cache the stable part.

// WRONG: Timestamps in cacheable section
"You are Agent. Current time: 2026-06-11T14:32:17Z"  // New key every millisecond

// RIGHT: Date-only in cacheable section
"You are Agent." + "Current time: Thursday, June 11, 2026"  // Cache stable for 24h

4. Tools Should Be Self-Contained

A tool file should be a single module that registers itself. Adding a new tool should not require modifying core files.

// Perfect: one file, zero core changes
// tools/my_tool.py
registry.register(name="my_tool", ...)
FUNCTION handle_my_tool(args): ...

5. Memory Should Be Declarative, Not Imperative

Hermes's memory guidance is instructive: "Write memories as declarative facts, not instructions to yourself. 'User prefers concise responses' ✓ — 'Always respond concisely' ✗."

6. Error Recovery Is Not Optional

Agents run in loops. One unhandled error cascades. Implement recovery for:
- Invalid tool names (Levenshtein repair)
- Invalid JSON arguments (retry with error context)
- Empty responses (prefill/nudge/retry/fallback)
- Context overflow (compress and retry)
- Rate limits (backoff with jitter)
- Network errors (retry with fallback provider)

7. Security Is a Layered Problem

No single security measure is sufficient. Implement at minimum:
- Prompt injection detection (Layer 2)
- Tool guardrails with workspace boundaries (Layer 3)
- Command approval for destructive operations (Layer 4)
- Audit logging with receipts (Layer 1)

8. Provider Abstraction Pays Off

Lock-in to a single provider is the most expensive mistake. Implement the provider interface early — it costs ~200 lines and saves unlimited future refactoring.

9. The Plugin Boundary Must Be Real

OpenClaw's plugin SDK boundary is the gold standard: plugins import from openclaw/plugin-sdk/* only, never from core internals. This allows independent evolution of core and extensions.

10. Less Code Is Better Code

PicoClaw runs on $10 hardware. gptme's arewetiny metric tracks code size. Every line of code is a liability — it needs testing, maintenance, and understanding. Prefer simplicity.

The Architecture Decision Matrix

When building your own framework, these are the decisions that matter most:

Decision Options Recommendation
Language Python, TypeScript, Go, Rust Python for rapid dev, Go/Rust for performance/embedded
Tool Discovery Registry, Auto-discovery, Traits Registry for control, Auto-discovery for simplicity
Prompt Caching Provider-native, Byte-stable, None Byte-stable system prompt (works everywhere)
Memory Backend Markdown, SQLite, Vector DB Start with Markdown, add SQLite for search, Vector for semantic
Session Storage JSONL, SQLite, In-memory SQLite for multi-user, JSONL for single-user
Compression LLM summary, Truncation, None LLM summary (20% ratio) with truncation fallback
Gateway Single process, Per-platform processes Single process with adapter pattern
Sandbox None, Docker, WASM, Landlock Docker (ubiquitous), WASM (strongest)
Provider Model Direct, Abstract interface, Multi-provider Abstract interface from day one

The Minimal Production Agent Checklist

Before deploying your agent, ensure you have:

What You've Built

Congratulations. You've built — incrementally, chapter by chapter — a complete AI agent framework:

  1. Chapter 1-2: A working agent in 40 lines
  2. Chapter 3: A production-grade agent loop with error recovery
  3. Chapter 4: A multi-section prompt builder with cache awareness
  4. Chapter 5: A tool system with registry, auto-discovery, and hallucination repair
  5. Chapter 6: File and terminal capabilities with security
  6. Chapter 7: Web search and browser automation
  7. Chapter 8: Multi-turn sessions with SQLite persistence
  8. Chapter 9: Persistent memory (markdown and vector)
  9. Chapter 10: Self-improving skills system
  10. Chapter 11: Multi-platform messaging gateway
  11. Chapter 12: Context compression with LLM summarization
  12. Chapter 13: Subagents and parallel delegation
  13. Chapter 14: Five-layer security model
  14. Chapter 15: Cron scheduler and autonomous operation
  15. Chapter 16: Multi-agent orchestration (kanban, swarms, coordinators)
  16. Chapter 17: Provider abstraction with fallback chains
  17. Chapter 18: Comparative analysis of six production frameworks
  18. Chapter 19: Design principles and architecture decisions

Your agent can: reason with tools, remember across sessions, learn new skills, compress its context, delegate to subagents, run on schedule, operate across platforms, and fail over between providers — all while maintaining security boundaries.

Where to Go From Here

Remember: the agent you build should get better over time. Skills accumulate. Memory grows. The system learns from its mistakes. That's the difference between a tool and a companion.

→ See [[#appendix-A|Appendix A]] for the complete runnable implementation of everything in this chapter.


Appendix A: Complete Reference Agent Implementation

Below is the complete, ~500-line reference agent implementation synthesizing all concepts from the book. All real Python code from chapters 2-19 is collected here, organized by chapter reference. This is a single runnable file.

#!/usr/bin/env python3
"""
complete_agent.py — Complete reference AI agent implementation.
Synthesizes concepts from all 19 chapters.
Run: python complete_agent.py "your task here"

See BUILDING.md for full documentation.
"""
import json, os, re, sqlite3, subprocess, sys, time, uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional, Callable

# ─── Configuration ───────────────────────────────────────────
# From Chapter 3: Agent Loop Deep Dive — AgentConfig

@dataclass
class AgentConfig:
    max_iterations: int = 30
    max_tool_retries: int = 3
    base_backoff: float = 2.0
    max_backoff: float = 60.0
    result_truncation: int = 8000
    workspace: str = "."
    memory_dir: str = "~/.agent-memory"
    session_db: str = "~/.agent-sessions/sessions.db"
    skills_dir: str = "~/.agent-skills"

# ─── Provider Interface ──────────────────────────────────────
# From Chapter 17: Provider Abstraction — LLMProvider, ModelResponse

@dataclass
class ModelResponse:
    content: str
    tool_calls: list = field(default_factory=list)
    finish_reason: str = "stop"

class LLMProvider(ABC):
    @abstractmethod
    def chat(self, messages, tools=None, model=None, max_tokens=4096) -> ModelResponse:
        ...

# From Chapter 17: OpenAI Provider
class OpenAIProvider(LLMProvider):
    def __init__(self, api_key=None):
        from openai import OpenAI
        self.client = OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))

    def chat(self, messages, tools=None, model="gpt-4o", max_tokens=4096) -> ModelResponse:
        try:
            kwargs = {"model": model, "messages": messages, "max_tokens": max_tokens}
            if tools:
                kwargs["tools"] = tools
                kwargs["tool_choice"] = "auto"
            resp = self.client.chat.completions.create(**kwargs)
            msg = resp.choices[0].message
            return ModelResponse(
                content=msg.content or "",
                tool_calls=[tc.model_dump() for tc in (msg.tool_calls or [])],
                finish_reason=resp.choices[0].finish_reason or "stop"
            )
        except Exception as e:
            raise RuntimeError(f"OpenAI error: {e}")

# ─── Tool System ─────────────────────────────────────────────
# From Chapter 5: Building a Tool System — ToolDef, ToolRegistry

@dataclass
class ToolDef:
    name: str
    description: str
    parameters: dict
    handler: Callable
    category: str = "general"

    def to_openai(self):
        return {"type": "function", "function": {
            "name": self.name,
            "description": self.description,
            "parameters": {"type": "object", "properties": self.parameters,
                          "required": list(self.parameters.keys())}
        }}

class ToolRegistry:
    def __init__(self):
        self._tools: dict[str, ToolDef] = {}

    def register(self, name, description, parameters, handler, **kw):
        self._tools[name] = ToolDef(name, description, parameters, handler, **kw)

    @property
    def names(self):
        return list(self._tools.keys())

    def get_definitions(self, names=None):
        tools = self._tools
        if names:
            tools = {k: v for k, v in tools.items() if k in names}
        return [t.to_openai() for t in tools.values()]

    def execute(self, name, args):
        if name not in self._tools:
            return f"Error: Tool '{name}' not found. Available: {', '.join(self.names)}"
        try:
            return str(self._tools[name].handler(**args))
        except Exception as e:
            return f"Error: {e}"

    # From Chapter 5: Hallucination repair via Levenshtein similarity
    def closest_match(self, name, threshold=0.6):
        best, best_score = None, 0.0
        for tn in self._tools:
            score = self._sim(name, tn)
            if score > best_score:
                best_score, best = score, tn
        return best if best_score >= threshold else None

    def _sim(self, a, b):
        a_bg = set(a[i:i+2] for i in range(len(a)-1))
        b_bg = set(b[i:i+2] for i in range(len(b)-1))
        if not a_bg or not b_bg:
            return 0.0
        return len(a_bg & b_bg) / len(a_bg | b_bg)

# ─── Built-in Tools ──────────────────────────────────────────
# From Chapter 6: Working with Files and the Terminal

def _tool_read(path, offset=1, limit=500):
    """From Chapter 6: Safe file reading with line numbers and pagination."""
    try:
        p = Path(path).expanduser()
        if not p.exists():
            return f"File not found: {path}"
        if p.stat().st_size > 50 * 1024 * 1024:
            return f"File too large ({p.stat().st_size // 1024 // 1024}MB)"
        lines = p.read_text(errors="replace").splitlines()
        sel = lines[offset-1:offset-1+limit]
        return "\n".join(f"{i+offset:6d}|{l}" for i, l in enumerate(sel))
    except Exception as e:
        return f"Error: {e}"

# From Chapter 6: File writing
def _tool_write(path, content):
    p = Path(path).expanduser()
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(content)
    return f"Written {len(content)} bytes to {path}"

# From Chapter 6: Shell command execution with timeout
def _tool_shell(command, timeout=60):
    try:
        r = subprocess.run(command, shell=True, capture_output=True,
                          text=True, timeout=timeout)
        return r.stdout + ("\n[stderr]\n" + r.stderr if r.stderr else "")
    except subprocess.TimeoutExpired:
        return f"Timed out after {timeout}s"
    except Exception as e:
        return f"Error: {e}"

# From Chapter 7: Web search (DuckDuckGo no-key backend)
def _tool_web_search(query):
    try:
        from urllib.request import urlopen
        from urllib.parse import quote_plus
        url = f"https://api.duckduckgo.com/?q={quote_plus(query)}&format=json"
        with urlopen(url, timeout=10) as r:
            data = json.loads(r.read())
        lines = [f"Results for: {query}"]
        if data.get("AbstractText"):
            lines.append(data["AbstractText"])
        return "\n".join(lines)
    except Exception as e:
        return f"Search error: {e}"

# From Chapter 9: Memory system — markdown-file-based persistent memory
def _tool_memory(action, fact="", category="general"):
    mem_dir = Path(os.environ.get("AGENT_MEMORY_DIR", "~/.agent-memory")).expanduser()
    mem_dir.mkdir(parents=True, exist_ok=True)
    mem_file = mem_dir / "MEMORY.md"
    if action == "add":
        existing = mem_file.read_text() if mem_file.exists() else ""
        if fact.strip() in existing:
            return "Fact already in memory."
        mem_file.write_text(existing + f"\n## {datetime.now():%Y-%m-%d} [{category}]\n{fact.strip()}\n")
        return "Saved to memory."
    elif action == "recall":
        if mem_file.exists():
            return mem_file.read_text()
        return "No memories stored."
    return f"Unknown action: {action}"

def create_default_registry():
    """From Chapter 5: Tool registration with categories."""
    r = ToolRegistry()
    r.register("read_file", "Read a file with line numbers.",
               {"path": {"type": "string", "description": "File path"},
                "offset": {"type": "integer", "description": "Start line"},
                "limit": {"type": "integer", "description": "Max lines"}},
               _tool_read, category="file")
    r.register("write_file", "Write content to a file.",
               {"path": {"type": "string", "description": "File path"},
                "content": {"type": "string", "description": "Content to write"}},
               _tool_write, category="file")
    r.register("shell", "Execute a shell command.",
               {"command": {"type": "string", "description": "Command to run"},
                "timeout": {"type": "integer", "description": "Timeout seconds"}},
               _tool_shell, category="shell")
    r.register("web_search", "Search the web.",
               {"query": {"type": "string", "description": "Search query"}},
               _tool_web_search, category="web")
    r.register("memory", "Access persistent memory. Actions: add, recall.",
               {"action": {"type": "string", "description": "add or recall"},
                "fact": {"type": "string", "description": "Fact to store (for add)"},
                "category": {"type": "string", "description": "Category"}},
               _tool_memory, category="memory")
    return r

# ─── Agent Loop ──────────────────────────────────────────────
# From Chapter 4: Multi-section system prompt
# From Chapter 3: Production agent loop with error recovery

SYSTEM_PROMPT = """You are Agent, an intelligent AI assistant.
You help users accomplish tasks using tools.

## Core Rules
1. Use tools to take action — do not just describe what you'd do.
2. When asked to build or verify something, deliver a working artifact.
3. Never fabricate tool output. If a tool fails, report it honestly.
4. Be concise and direct.

## Environment
Host: {host}
OS: {os}
Workspace: {workspace}
Date: {date}
Python: {python}

## Memory
{memory}

## Output
Use markdown formatting. Code blocks with language tags."""

class Agent:
    def __init__(self, provider=None, registry=None, config=None):
        self.provider = provider or OpenAIProvider()
        self.tools = registry or create_default_registry()
        self.config = config or AgentConfig()

    # From Chapter 4: Prompt builder with stable/volatile separation
    def build_system_prompt(self, extra_context=""):
        mem_path = Path(os.environ.get("AGENT_MEMORY_DIR", "~/.agent-memory")).expanduser() / "MEMORY.md"
        memory = mem_path.read_text()[:2000] if mem_path.exists() else "No memories yet."
        return SYSTEM_PROMPT.format(
            host=os.uname().nodename,
            os=f"{sys.platform} ({os.uname().sysname} {os.uname().release})",
            workspace=str(Path.cwd()),
            date=datetime.now().strftime("%A, %B %d, %Y"),
            python=sys.version.split()[0],
            memory=memory
        ) + ("\n\n" + extra_context if extra_context else "")

    # From Chapter 2: Minimal agent loop
    # From Chapter 3: Error recovery, hallucination repair, iteration budgets
    def run(self, task, conversation_history=None, extra_context=""):
        system = self.build_system_prompt(extra_context)
        messages = [{"role": "system", "content": system}]
        if conversation_history:
            messages.extend(conversation_history)
        messages.append({"role": "user", "content": task})

        for iteration in range(self.config.max_iterations):
            try:
                response = self.provider.chat(
                    messages,
                    tools=self.tools.get_definitions()
                )
            except Exception as e:
                err_msg = str(e).lower()
                # From Chapter 3: Rate limit backoff
                if "rate" in err_msg or "429" in err_msg:
                    time.sleep(5 + iteration * 2)
                    continue
                return {"response": f"API Error: {e}", "iterations": iteration + 1}

            # From Chapter 3: Truncation handling
            if response.finish_reason == "length":
                messages.append({"role": "user", "content": "[Continue — you were cut off.]"})
                continue

            if not response.tool_calls:
                messages.append({"role": "assistant", "content": response.content})
                return {"response": response.content, "iterations": iteration + 1,
                       "messages": messages}

            # From Chapter 5: Tool call processing with hallucination repair
            messages.append({"role": "assistant", "content": None,
                           "tool_calls": response.tool_calls})
            for tc in response.tool_calls:
                name = tc["function"]["name"]
                if name not in self.tools.names:
                    repaired = self.tools.closest_match(name)
                    if repaired:
                        name = repaired
                try:
                    args = json.loads(tc["function"]["arguments"])
                except json.JSONDecodeError:
                    messages.append({"role": "tool", "tool_call_id": tc["id"],
                                   "content": f"Error: Invalid JSON"})
                    continue
                result = self.tools.execute(name, args)[:self.config.result_truncation]
                messages.append({"role": "tool", "tool_call_id": tc["id"],
                               "content": result})

            if iteration == self.config.max_iterations - 1:
                messages.append({"role": "user",
                               "content": "Please provide your final response now."})

        return {"response": "Max iterations exceeded.", "iterations": self.config.max_iterations,
               "messages": messages}

# ─── CLI ─────────────────────────────────────────────────────
# From Chapter 2: Command-line entry point

if __name__ == "__main__":
    agent = Agent()
    task = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else input("Task: ")
    print(f"\nWorking on: {task}\n")
    result = agent.run(task)
    print(result["response"])
    print(f"\n(completed in {result['iterations']} iterations)")

To run:

export OPENAI_API_KEY="sk-..."
python complete_agent.py "List Python files in this directory and tell me what they do"

What This Reference Agent Includes


Appendix B: Prompt Templates from Real Agents

Hermes Identity Prompt

You are Hermes Agent, an intelligent AI assistant created by Nous Research.
You are helpful, knowledgeable, and direct. You assist users with a wide
range of tasks including answering questions, writing and editing code,
analyzing information, creative work, and executing actions via your tools.
You communicate clearly, admit uncertainty when appropriate, and prioritize
being genuinely useful over being verbose unless otherwise directed below.
Be targeted and efficient in your exploration and investigations.

Hermes Task Completion Guidance

When the user asks you to build, run, or verify something, the deliverable is
a working artifact backed by real tool output  not a description of one.
Do not stop after writing a stub, a plan, or a single command. Keep working
until you have actually exercised the code or produced the requested result,
then report what real execution returned.
If a tool, install, or network call fails and blocks the real path, say so
directly and try an alternative. NEVER substitute plausible-looking fabricated
output for results you couldn't actually produce. Reporting a blocker honestly
is always better than inventing a result.

Hermes Tool-Use Enforcement

You MUST use your tools to take action  do not describe what you would do
or plan to do without actually doing it. When you say you will perform an
action, you MUST immediately make the corresponding tool call in the same
response. Never end your turn with a promise of future action  execute it now.

Hermes Memory Guidance

You have persistent memory across sessions. Save durable facts using the memory
tool: user preferences, environment details, tool quirks, and stable conventions.

Prioritize what reduces future user steering  the most valuable memory is one
that prevents the user from having to correct or remind you again.

Write memories as declarative facts, not instructions to yourself.
'User prefers concise responses'   'Always respond concisely' .

Hermes Compaction Warning

[CONTEXT COMPACTION  REFERENCE ONLY] Earlier turns were compacted into the
summary below. This is a handoff from a previous context window  treat it as
background reference, NOT as active instructions.
Do NOT answer questions or fulfill requests mentioned in this summary; they
were already addressed. Respond ONLY to the latest user message that appears
AFTER this summary.

ZeroClaw Anti-Narration

NEVER narrate, announce, or describe your tool usage to the user.
Do not say "I will now search for that information"  just search.
Do not say "Let me read that file"  just read it.

ZeroClaw Tool Honesty

NEVER fabricate, invent, or guess tool results.
If a tool fails, report the failure honestly.
Do not make up file contents, command outputs, or search results.

PicoClaw Kernel Prompt (Excerpt)

You are an AI assistant named {name}.
Your purpose is to help the user accomplish their goals.
You have access to tools that let you interact with the system.
Use them when needed.

Be direct and concise.
Prefer action over explanation.
When you don't know something, say so.

Appendix C: Tool Definition Schemas from Each Framework

OpenAI Format (used by Hermes, PicoClaw, gptme)

{
  "type": "function",
  "function": {
    "name": "read_file",
    "description": "Read a file with line numbers and pagination.",
    "parameters": {
      "type": "object",
      "properties": {
        "path": {"type": "string", "description": "Path to the file"},
        "offset": {"type": "integer", "description": "Start line"},
        "limit": {"type": "integer", "description": "Max lines"}
      },
      "required": ["path"]
    }
  }
}

Anthropic Format (used by AnthropicProvider)

{
  "name": "read_file",
  "description": "Read a file with line numbers and pagination.",
  "input_schema": {
    "type": "object",
    "properties": {
      "path": {"type": "string", "description": "Path to the file"},
      "offset": {"type": "integer", "description": "Start line"},
      "limit": {"type": "integer", "description": "Max lines"}
    },
    "required": ["path"]
  }
}

Hermes ToolEntry (Python)

registry.register(
    name="read_file",
    toolset="file",
    schema={
        "name": "read_file",
        "description": "Read a text file with line numbers and pagination.",
        "parameters": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path to the file"},
                "offset": {"type": "integer", "description": "Line number to start from"},
                "limit": {"type": "integer", "description": "Max lines"}
            },
            "required": ["path"]
        }
    },
    handler=handle_read_file,
    is_async=False,
)

PicoClaw Tool Interface (Go)

type Tool interface {
    Name() string
    Description() string
    Parameters() map[string]any
    Execute(ctx context.Context, args map[string]any) *ToolResult
}

ZeroClaw Tool Trait (Rust)

#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn parameters_schema(&self) -> serde_json::Value;
    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
}

gptme ToolSpec (Python)

@dataclass(frozen=True, eq=False)
class ToolSpec:
    name: str
    desc: str
    instructions: str = ""
    functions: list[Callable] | None = None
    execute: ExecuteFunc | None = None
    block_types: list[str] = field(default_factory=list)
    available: bool | Callable[[], bool] = True
    parameters: list[Parameter] = field(default_factory=list)

Appendix D: Glossary of Terms

Term Definition
Agent Loop The core while-loop: call LLM → parse response → execute tools → repeat until done
Tool A function the agent can call: shell commands, file operations, web searches, etc.
Tool Call The LLM's request to execute a tool, containing the tool name and arguments
Tool Result The output of a tool execution, returned to the LLM for further reasoning
System Prompt The initial message that defines the agent's identity, rules, and capabilities
Memory Persistent storage of facts, preferences, and learned information across sessions
Skill A reusable workflow document that the agent can load on demand
Lesson Contextual guidance auto-injected by keyword matching (gptme pattern)
Context Window The maximum number of tokens the LLM can process in one call
Token A unit of text (~4 chars in English) that the LLM processes
Compression Summarizing old conversation turns to stay within the context window
Delegation Spawning a subagent to handle a task independently
Gateway A central process that manages connections to multiple messaging platforms
Fallback Chain A sequence of LLM providers to try when the primary fails
Prompt Injection A malicious attempt to override the agent's instructions via user input or tool output
Sandbox An isolated execution environment (Docker, WASM) for running untrusted code
Cron Scheduled, recurring execution of agent tasks
Kanban A visual task board for multi-agent coordination
SOP (Standard Operating Procedure) A predefined multi-step workflow (ZeroClaw pattern)
Verifiable Intent Cryptographically signed agent actions for auditable trust (ZeroClaw)
Steering Injecting messages into a running agent loop (PicoClaw/Hermes pattern)
Turn One complete user request → agent response cycle, which may include multiple iterations
Iteration One LLM call + tool execution cycle within a turn
Transport The API communication layer between the agent and the LLM provider
Cache Control Markers that tell the LLM provider which parts of the prompt to cache
Byte Stability Ensuring identical bytes in cached prompt sections across turns
Levenshtein Repair Auto-correcting hallucinated tool names by finding the closest real tool
Prefill Injecting synthetic messages to nudge the LLM into continuing
Hook A lifecycle callback that runs at specific points (e.g., pre_tool, post_turn)
Plugin SDK A public API boundary that extensions use without importing core internals

End of "Building Autonomous AI Agents: From Zero to Production"

Built with insights from Hermes, OpenClaw, PicoClaw, ZeroClaw, gptme, and IronClaw.