Skip to content

Latest commit

 

History

History
1499 lines (1226 loc) · 84.4 KB

File metadata and controls

1499 lines (1226 loc) · 84.4 KB

Clawlet

A personal AI agent in a single Docker container. Thinks for itself. Remembers everything. Responds like a human. Fits in your head.


What It Is

Clawlet is a self-hosted AI assistant that runs 24/7 inside a Docker container. You talk to it through a web dashboard or any messaging channel. It talks to any LLM through OpenRouter. Between conversations, it reflects on what it knows, updates its own personality, and manages its own memory — without being asked.

What makes Clawlet different from every other bot: it separates conversation from cognition. When you ask it to do something, it responds instantly — "On it, let me dig into that" — then reasons, plans, and works in the background. Results arrive when they're ready. The chat stays clean. Just like talking to a competent human.

Three commands to start:

cp .env.example .env        # paste your OpenRouter key
docker compose up -d         # done
open http://localhost:3000   # talk to your bot

Why

Every AI assistant in the ecosystem — from 400-line bash scripts to production agents like Hermes Agent — shares the same flaw: the conversation and the cognition are fused into one serial pipeline. Receive message → call LLM → wait → respond. The user's chat blocks while the bot thinks. This creates the worst behaviors in modern AI assistants: the bot vomits a plan before understanding the problem, complex tasks freeze the conversation, there's no concept of "let me think about that," and there's no background processing.

Even sophisticated agents with persistent memory and multi-channel presence still operate as synchronous ReAct loops. The agent reasons, acts, and responds in series. When it's working a 15-step problem, the conversation is frozen.

There's a second flaw, less obvious but equally damaging: agents skip the planning step. They receive a goal and immediately start executing — writing migration scripts without understanding schema differences, chaining tool calls without identifying dependencies, grinding through fifteen steps when step three was based on a misunderstanding. The best human collaborators don't work this way. They say "here's how I'd approach this — does that look right?" before investing hours. The planning step catches misunderstandings early, when they're cheap to fix.

Humans don't work either way. When you ask a colleague to research something, they say "sure, I'll look into it" and get back to you later. If the task is complex, they come back with an approach first. The conversation continues. The work happens in parallel. Planning happens when it's needed, not as bureaucratic overhead.

There's a third insight, drawn from production agent engineering: the scaffolding around the model matters more than the model. Claude Opus 4.5 scores 42% on CORE-Bench with one scaffold and 78% with another. Cursor's lazy tool loading cuts token usage by 46.9%. Vercel deleted 80% of their agent's tools and watched it go from failing to completing tasks. Same model, same benchmark — the only variable is the harness. The industry has converged on a set of patterns: flat agentic loops where the model controls execution, progressive disclosure of context, and harnesses that get simpler over time rather than more complex.

Clawlet is the first small agent built around all three insights. Its architecture is modeled on how brains actually manage cognition — not as a gimmick, but because the brain solved these exact problems millions of years ago: filter input, respond fast, think deep in the background, plan before committing to complex work, consolidate learning during downtime. And its harness is designed to present the model with only the information it needs, when it needs it — because the best scaffold is the one that stays out of the model's way.


The Cognitive Architecture

The architecture maps six brain primitives to system components. Each one earns its place. Nothing is decorative.

                    ┌─────────────────────────┐
                    │  IDENTITY               │
                    │  personality + rules     │
                    │  "who I am, what I do"   │
                    │  "what I never do"       │
                    └────────────┬─────────────┘
                                 │ constrains all output
                                 ▼
 sensory input          ┌───────────────────┐
 (user message) ────────► ATTENTION FILTER  │
                        │  heuristics first │
                        │  fast model       │
                        │  fallback         │
                        │  → respond        │
                        └───┬───────────┬───┘
                   no       │           │  yes
                   ▼        │           ▼
              reflexive     │    ┌──────────────┐
              response      │    │ WORKING      │
              (habits +     │    │ MEMORY       │
              heuristics    │    │ agenda       │
              or fast       │    │ ≤ 5 items    │
              model)        │    └──────┬───────┘
                            │           │
                            │           ▼
                            │    ┌──────────────┐
                            │    │ SUBCONSCIOUS  │
                            │    │ flat loop     │
                            │    │ model controls│
                            │    │ tools + plan  │
                            │    │ results       │
                            │    │ "surface"     │
                            │    └──────┬───────┘
                            │           │
                            ▼           ▼
                    ┌───────────────────────────┐
                    │  MEMORY                    │
                    │                            │
                    │  episodic: conversations   │ ◄── what happened
                    │  semantic: facts           │ ◄── what I know
                    │  procedural: skills        │ ◄── learned workflows
                    └────────────┬──────────────┘
                                 │
                          during idle periods
                                 │
                                 ▼
                    ┌───────────────────────────┐
                    │  CONSOLIDATION            │
                    │  autonomy / reflection     │
                    │                            │
                    │  episodic → semantic       │ extract durable facts
                    │  compact old episodes      │ forget raw details
                    │  decay stale confidence    │ prune unreinforced facts
                    │  review working memory     │ reprioritize agenda
                    │  check schedules           │ fire due tasks
                    │  update identity           │ personality evolves
                    └───────────────────────────┘

1. Attention Filter (fast path)

The brain's reticular activating system decides what reaches conscious processing and what's handled reflexively. Most sensory input never reaches your prefrontal cortex.

The fast path does the same thing — but it works in two layers. Heuristic short-circuits handle the common case without any LLM call at all. Pattern-match known intents: greetings (regex), simple acknowledgments ("thanks," "ok," "got it"), cancellations ("never mind," "forget it"), schedule patterns ("remind me X at Y," "every day at 9am"), and plan approvals ("looks good," "go ahead," "cancel that"). These save an API call on the majority of messages. Just like how your brainstem handles a hot stove before your cortex even knows what happened.

When heuristics don't match, the fast model takes over as fallback (~500ms). It does four things: classifies the intent, estimates complexity, generates an immediate natural reply, and decides whether deep cognition is needed. The conversation never blocks.

The attention filter is also where procedural memory operates. High-confidence semantic facts (≥ 0.8) about the user are injected into the triage prompt: "User hates small talk," "User prefers bullet points," "User is a morning person." These shape reflexive responses without requiring deep processing — the same way you instinctively grab your keys before leaving the house, without deliberating.

Additionally, the fast path checks for loaded skills — if the user's request matches a domain where the bot has a skill document (see Procedural Memory), the skill name is included in triage context so the fast path can route to the deep worker with the right skill attached. Only skill names appear in the triage prompt — not full descriptions — to keep the fast path's context lean. Full skill content is loaded on demand by the deep worker.

Domain routing. The fast path also classifies the domain of the request (e.g., technical, personal, creative, scheduling). This tag flows into the deep worker's context assembly, which uses it to load only the relevant slice of semantic memory. A gardening question doesn't load deployment preferences. A code question doesn't load relationship facts. This is progressive disclosure applied to memory — load the routing tag first, then the domain-specific facts, then the task-specific data. Each layer adds only what the next step needs.

Complexity estimation. When a message requires deep work, the fast path also classifies its complexity: simple, moderate, or complex. This signal is a hint for the deep worker — it can reclassify once it starts working. The classification uses straightforward heuristics embedded in the triage prompt:

  • Simple — single-step lookup, factual question, one tool call likely sufficient. ("What time is it in Tokyo?" "Remind me at 5pm.")
  • Moderate — multi-step but potentially well-understood, or matches an existing skill. ("Research ARM deploy failures" when the arm-deploy skill exists.)
  • Complex — ambiguous goal, multi-domain, requires decomposition, no matching skill, involves irreversible actions, or the user's phrasing suggests open-ended exploration. ("Migrate the customer data." "Help me plan my garden for next season.")

Plan approval recognition. The fast path has one additional responsibility: recognizing when a user message is a response to a surfaced plan. When there's a task awaiting approval on the agenda, the triage prompt (or heuristic layer, for simple approvals like "go ahead") includes that context. The fast path can recognize "looks good," "go ahead," "change step 3," or "cancel that" and update the agenda accordingly — moving the task to active, modifying the plan, or cancelling it. Plan approval is a fast-path operation, not deep work.

async function fastPath(msg: UserMessage): Promise<void> {
  // ─── LAYER 1: Heuristic short-circuits (no LLM call) ───
  const heuristic = matchHeuristic(msg.content);

  if (heuristic) {
    broadcast("reply", heuristic.reply);
    await memory.appendEpisodic("user", msg.content);
    await memory.appendEpisodic("assistant", heuristic.reply);

    // Some heuristics have side effects
    if (heuristic.schedule) await schedules.create(heuristic.schedule);
    if (heuristic.planResponse) await agenda.handlePlanResponse(heuristic.planResponse);
    if (heuristic.agendaCancel) await agenda.cancel(heuristic.agendaCancel);
    return;
  }

  // ─── LAYER 2: Fast model fallback (ambiguous input) ───
  const habits = await memory.getHighConfidenceFacts();  // procedural memory
  const recent = await memory.getRecentEpisodic(5);      // short-term context
  const skillNames = await skills.getNames();             // just names, not descriptions
  const awaitingApproval = await agenda.getAwaitingApproval();

  const triage = await llm.fast({
    system: buildTriagePrompt(identity, habits, recent, skillNames, awaitingApproval),
    user: msg.content,
    maxTokens: 300,
    responseFormat: "json",
  });

  const {
    reply,
    needsDeepWork,
    taskSummary,
    priority,
    domain,
    skillHint,
    complexity,
    planResponse,
  } = triage;

  // Always respond immediately
  broadcast("reply", reply);
  await memory.appendEpisodic("user", msg.content);
  await memory.appendEpisodic("assistant", reply);

  // Handle plan approval/modification if the user is responding to a surfaced plan
  if (planResponse) {
    await agenda.handlePlanResponse(planResponse);
    return;
  }

  // Promote to working memory if deep work needed
  if (needsDeepWork) {
    await agenda.add({
      summary: taskSummary,
      priority,
      domain,
      skillHint,
      complexity,
      originMsg: msg.id,
    });
  }
}

The heuristic layer is a set of pattern matchers — regex, keyword lists, and simple parsers — that handle the 80% case. Adding a new pattern is ~5 lines. The fast model handles the remaining 20% where intent is genuinely ambiguous. This two-layer approach means most messages cost zero API calls while still handling anything the user throws at it.

function matchHeuristic(content: string): HeuristicResult | null {
  // Greetings
  if (/^(hey|hi|hello|yo|sup|what'?s up)\b/i.test(content)) {
    return { reply: pickGreeting() };
  }
  // Acknowledgments
  if (/^(thanks|thank you|ok|okay|got it|cool|nice)\b/i.test(content)) {
    return { reply: pickAck() };
  }
  // Cancellations
  if (/^(never mind|forget it|cancel that|nvm)\b/i.test(content)) {
    return { reply: "Done — dropped it.", agendaCancel: "latest" };
  }
  // Schedule patterns: "remind me X at Y" / "every day at 9am"
  const schedMatch = parseSchedulePattern(content);
  if (schedMatch) {
    return { reply: `Got it — ${schedMatch.summary}.`, schedule: schedMatch };
  }
  // Plan approvals when a task is awaiting approval
  // (detected here for simple cases; complex modifications fall through to fast model)
  const planMatch = parsePlanApproval(content);
  if (planMatch) {
    return { reply: planMatch.reply, planResponse: planMatch };
  }
  return null;
}

2. Working Memory (agenda)

The prefrontal cortex holds ~4 items in active manipulation. Not storage — active manipulation. It's the scratchpad where you juggle the things you're currently working on.

The agenda table is working memory. It has a hard cap (default: 5 active items). When it's full and a new task arrives, the bot must triage: complete something, deprioritize something, or negotiate with the user.

CREATE TABLE agenda (
  id TEXT PRIMARY KEY,
  summary TEXT NOT NULL,
  status TEXT DEFAULT 'pending',    -- pending | active | blocked | done | cancelled
  priority REAL DEFAULT 0.5,        -- 0.0 to 1.0, limbic weighting
  domain TEXT,                      -- technical | personal | creative | scheduling | general
  complexity TEXT,                  -- simple | moderate | complex (set by fast path, revisable by deep worker)
  parent_id TEXT,                   -- task decomposition
  origin_msg TEXT,                  -- what triggered this
  skill_hint TEXT,                  -- skill doc to load for context (nullable)
  plan TEXT,                        -- plan JSON, populated by write_plan tool (nullable)
  awaiting_approval INTEGER DEFAULT 0,  -- 1 if plan surfaced and waiting for user
  notes TEXT,                       -- bot's scratchpad for this item
  result TEXT,                      -- final output
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

The status flow is simple:

pending → active → done | cancelled
any status → blocked (external dependency)

Planning is not a status — it's something the deep worker does during execution by calling the write_plan tool (see Subconscious Processing). When a plan needs user approval, the awaiting_approval flag pauses the task. This keeps the status machine minimal — four states cover every lifecycle.

The complexity column records the fast path's estimate (revisable by the deep worker). The plan column stores the structured plan as JSON — separate from notes because plans are user-facing (surfaced in chat and visible in the dashboard) while notes are the bot's private scratchpad.

When working memory overflows:

User:  Can you also look into the best soil mix for succulents?
Claw:  I'm juggling a few things right now — I've got the ARM deploy
       research, the herb recommendations, and your weekend plans
       queued up. Want me to swap one out, or should this wait?

This is better UX than silently queueing 20 tasks and doing none of them well.

3. Subconscious Processing (deep worker)

You're consciously chatting with someone while your subconscious grinds on a problem in the background. Then the answer "surfaces" — you suddenly have the insight. This isn't a metaphor. The deep path is functionally identical.

The deep worker is a flat agentic loop that processes agenda items using the full model. The model controls the loop — it decides when to use tools, when to plan, and when it's done. Results "surface" into the conversation via WebSocket when ready.

This follows the pattern every production agent has converged on: while(model returns tool calls) → execute → append result → call model again. The harness provides tools and enforces budgets. The model decides everything else.

async function deepWorkerLoop() {
  while (true) {
    const task = await agenda.nextPending();
    if (!task) { await sleep(1000); continue; }

    await agenda.update(task.id, { status: "active" });
    broadcast("status", { task: task.summary, state: "working" });

    // ─── FLAT AGENTIC LOOP: model controls execution ───
    const tools = getAllTools();  // always load all tools; gate via prompt
    const systemPrompt = buildDeepPrompt(identity, task);
    let messages = await memory.buildDeepContext(task);
    let toolCallCount = 0;
    let finalResult: string | null = null;

    while (true) {
      const response = await llm.deep({
        system: systemPrompt,
        messages,
        tools,
      });

      // Log all reasoning
      if (response.text) {
        await workLog.append(task.id, { kind: "reasoning", content: response.text });
      }

      // If model returned text only (no tool calls), it's done
      if (!response.toolCalls?.length) {
        finalResult = response.text;
        break;
      }

      // Execute tools, append results, loop
      for (const call of response.toolCalls) {
        await workLog.append(task.id, { kind: "tool_call", content: JSON.stringify(call) });

        const result = await executeTool(call, task);
        await workLog.append(task.id, {
          kind: call.name === "write_plan" ? "plan" : "tool_result",
          content: result,
        });

        messages.push({ role: "assistant", content: null, tool_calls: [call] });
        messages.push({ role: "tool", content: result, tool_call_id: call.id });

        // Identity reminder after each tool result — keeps personality anchored
        // across long tool chains (proven pattern from Claude Code)
        messages.push({ role: "system", content: buildIdentityReminder(identity) });
      }

      toolCallCount += response.toolCalls.length;

      // Budget check
      if (toolCallCount >= getToolBudget(task)) {
        await workLog.append(task.id, { kind: "reasoning", content: "Tool budget reached." });
        // One final call to let the model wrap up with what it has
        const wrapUp = await llm.deep({ system: systemPrompt, messages, tools: [] });
        finalResult = wrapUp.text;
        break;
      }

      // Check if plan needs approval (write_plan tool set the flag)
      const refreshed = await agenda.get(task.id);
      if (refreshed.awaiting_approval) {
        // Pause execution. Consolidation or fast path will resume when approved.
        finalResult = null;
        break;
      }
    }

    // ─── DELIVER OR DISCARD ───
    if (finalResult === null) {
      // Task paused (awaiting approval) — don't deliver, don't mark done
      continue;
    }

    // Coherence check: is the result still relevant?
    if (await isStillRelevant(task)) {
      broadcast("reply", { content: finalResult, async: true, re: task.summary });
      await memory.appendEpisodic("assistant", finalResult, { deepWork: true });
      await agenda.update(task.id, { status: "done", result: finalResult });
    } else {
      await agenda.update(task.id, { status: "cancelled", notes: "context shifted" });
    }
  }
}

The model controls the loop. The harness doesn't decide when to plan, how many tools to call, or when the task is done — the model does. The harness provides tools, enforces budgets, and delivers results. This is the flat loop pattern that Claude Code, Cursor, Manus, and every other production agent has converged on.

Planning is a tool, not a phase. Instead of a separate planning phase with its own status flow, the deep worker has access to a write_plan tool that it can call at any point during execution (see Tools section). Complex tasks? The model calls write_plan before doing anything else. Simple tasks? The model skips it. The model decides — just as it would with Claude Code's TodoWrite. This eliminates the planning and planned statuses from V6 while preserving identical behavior: plans still get surfaced, approval still blocks execution, auto-approve timeouts still fire.

Identity reminders after tool execution. Every tool result is followed by a compressed identity reminder — the bot's core tone and top never constraints in ~50 tokens. This is a proven harness pattern from Claude Code: repeating behavioral constraints after every tool call achieves higher adherence than system-prompt-only instructions, because it keeps the personality in the model's recent attention window across long tool chains.

Error handling is model-driven. When a tool call fails, the full error (stdout + stderr, or exception message) is returned as a tool result. The model sees it and decides what to do — retry, try a different approach, or deliver a partial result. The harness never silently retries or suppresses errors. The only harness-level intervention is budget enforcement. This follows the consensus across every production agent: preserve errors, don't clean them.

Coherence checking — before delivering results, the worker checks whether the user has cancelled, the conversation has shifted, or the task is stale. This prevents the annoying pattern where a bot delivers a detailed answer to a question you stopped caring about 10 minutes ago. The check itself is a fast-model call: cheap, quick, prevents waste.

Work log — the bot's reasoning traces (intermediate steps, false starts, tool calls, plans) go into work_log, visible from the dashboard but never polluting the conversation.

CREATE TABLE work_log (
  id TEXT PRIMARY KEY,
  agenda_id TEXT NOT NULL,
  step INTEGER,
  kind TEXT DEFAULT 'reasoning',   -- reasoning | plan | tool_call | tool_result | error
  content TEXT NOT NULL,
  timestamp INTEGER NOT NULL,
  FOREIGN KEY (agenda_id) REFERENCES agenda(id)
);

A typical work_log for a planned task looks like:

step 1: { kind: "tool_call", content: "write_plan({steps: [...], needsApproval: true})" }
step 2: { kind: "plan", content: "Plan surfaced. Awaiting approval." }
step 3: { kind: "reasoning", content: "User approved with modification: skip calendar step" }
step 4: { kind: "tool_call", content: "web_search(companion planting zones)" }
step 5: { kind: "tool_result", content: "Results: ..." }
step 6: { kind: "reasoning", content: "Found relevant data. Synthesizing." }
...

And a simple task skips planning entirely:

step 1: { kind: "tool_call", content: "web_search(time in Tokyo)" }
step 2: { kind: "tool_result", content: "Results: ..." }
step 3: { kind: "reasoning", content: "It's 2:30 AM in Tokyo." }

4. Memory (three types + search)

The brain doesn't have "a memory." It has several memory systems optimized for different retrieval patterns. Clawlet uses three, plus full-text search across episodes.

Episodic memory — what happened. Timestamped events in sequence. "User asked about ARM deploys at 2:30." "I recommended basil and mint at 3:15." This is the conversation log plus context about what the bot was doing (reflections, deep work completions, surfaced plans).

CREATE TABLE episodic (
  id TEXT PRIMARY KEY,
  role TEXT NOT NULL,           -- user | assistant | system
  content TEXT NOT NULL,
  channel TEXT,
  metadata TEXT,                -- JSON: { deepWork: true, originTask: "...", media: [...] }
  timestamp INTEGER NOT NULL
);

-- Full-text search index on episodic content.
-- Enables the deep worker to search conversation history
-- for context that consolidation hasn't yet extracted into semantic memory.
CREATE VIRTUAL TABLE episodic_fts USING fts5(content, content=episodic, content_rowid=rowid);

Full-text search on episodic memory closes a gap between consolidation cycles. If the user mentions something from three days ago that hasn't been extracted into semantic memory yet, the deep worker can still find it via episodic_fts rather than relying on the most recent N episodes fitting in context. This is cheap to maintain — SQLite FTS5 adds negligible overhead — and dramatically improves recall for the deep worker.

Append-only safety log. In addition to the SQLite table, every episodic entry is appended to data/episodic.jsonl — a raw, never-compacted, never-truncated log. SQLite is for querying and context assembly; the JSONL file is the safety net. If compaction ever goes wrong, if a consolidation cycle misclassifies something important as forgettable, the raw log survives. This is cheap insurance — a few lines of code, negligible disk cost, and it eliminates the single worst failure mode of any memory system: irreversible data loss. The JSONL file is append-only by design (no overwrites, no deletes). Each line is self-contained valid JSON with the same fields as the episodic table.

{"id":"01J...","role":"user","content":"Can you figure out why deploy scripts fail on ARM?","channel":"dashboard","metadata":null,"timestamp":1708000000}
{"id":"01J...","role":"assistant","content":"On it — I'll dig into that.","channel":"dashboard","metadata":null,"timestamp":1708000001}

Semantic memory — what I know. Extracted facts without temporal anchoring. "User is interested in gardening." "User's timezone is UTC+1." "User prefers concise answers." These are durable — they survive compaction.

CREATE TABLE semantic (
  id TEXT PRIMARY KEY,
  category TEXT,                -- preference | habit | relationship | knowledge | failure
  content TEXT NOT NULL,
  confidence REAL DEFAULT 0.5,  -- 0.0 to 1.0, increases with reinforcement
  last_referenced INTEGER,      -- timestamp of last retrieval or reinforcement
  source_episode TEXT,          -- which conversation this was extracted from
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

The failure category deserves special attention. When the bot gives bad advice, misunderstands a request pattern, or makes a wrong assumption, consolidation extracts it as a failure fact: { category: "failure", content: "Assumed user wanted verbose explanations — they prefer bullet points", confidence: 0.9 }. Failures are the most valuable semantic memories because they encode pattern recognition that was earned through error. They prevent the bot from making the same mistake twice.

The last_referenced field enables confidence decay (see Consolidation, step 1b). Facts that haven't been referenced or reinforced in a long time gradually lose confidence. This prevents the semantic store from accumulating stale, possibly outdated facts that pollute context indefinitely. A fact about the user's timezone is reinforced constantly (high confidence, stable). A fact about the user's interest in a specific project may decay if they haven't mentioned it in months.

High-confidence semantic facts (≥ 0.8) function as habits — they're injected into the fast path's triage prompt. "User hates small talk" (confidence 0.95) → the attention filter skips pleasantries automatically. "User prefers morning check-ins" (confidence 0.85) → the autonomy loop adjusts its timing. Habits are semantic memory that has been reinforced enough to become automatic.

Procedural memory — skills. When the bot solves a complex problem — a multi-step workflow, a hard debugging session, a research chain that required course correction — consolidation can extract the successful approach as a skill document. Skills are stored as markdown files in data/skills/, human-readable and human-editable, alongside their index in SQLite.

CREATE TABLE skills (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  description TEXT NOT NULL,       -- one-line trigger summary
  domain TEXT,                     -- maps to agenda domain tags
  file_path TEXT NOT NULL,         -- relative path in data/skills/
  source_task TEXT,                -- agenda item that spawned this skill
  use_count INTEGER DEFAULT 0,     -- how often this skill has been loaded
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);
data/skills/
├── arm-deploy-debugging.md
├── herb-garden-research.md
└── weekend-planning.md

A skill document follows a minimal structure:

---
name: arm-deploy-debugging
description: Debugging deploy script failures on ARM64 architectures
domain: technical
---

# ARM Deploy Debugging

## When to Use
User asks about deploy failures on ARM, aarch64, or Apple Silicon targets.

## Approach
1. Check if the deploy script assumes x86 — look for hardcoded arch flags
2. Verify base Docker images support multi-arch (alpine does, some don't)
3. Check for Node.js native modules that need ARM rebuilds
4. ...

## Pitfalls
- Don't assume the user has already tried emulation (qemu)
- Cross-compilation flags differ between GCC and Clang on ARM

## Learned
- Created from task "Research ARM deploy failures" (2024-02-15)
- Updated after user correction: Rosetta 2 doesn't help for Linux containers

Skills differ from semantic facts in a crucial way: semantic facts are atomic ("user prefers bullet points"), while skills are structured procedures that capture how to do something. A semantic fact influences behavior across all interactions; a skill is loaded on demand when a matching task appears. Skills are Clawlet's version of what neuroscience calls procedural memory — you don't consciously recall how to ride a bike, but the procedure activates when you get on one.

Skill creation is autonomous. During consolidation (step 5), the bot reviews completed agenda items. If a task involved 3+ work_log steps, or if the log shows a course correction (the bot tried something, it failed, it found a better approach), consolidation proposes a new skill or an update to an existing one. The user can review skills from the dashboard but doesn't need to — the bot manages its own procedural knowledge.

Skills reduce planning overhead. When a task matches an existing skill, the deep worker is less likely to call write_plan — the skill is the plan, already validated by past experience.

Skill loading is demand-driven. The fast path's triage includes only skill names (not descriptions). If the triage model identifies a relevant skill, it sets skillHint on the agenda item. The deep worker loads the full skill document into its context. Skills that are never loaded gradually lose relevance and can be pruned during consolidation. Skills that are loaded frequently are reinforced.

Context building — when the deep path needs full context, it assembles based on the task's domain tag with a hard context budget. Research on LLM attention (Liu et al., TACL 2024) shows performance follows a U-shaped curve — highest when relevant information is at the beginning or end of input, degraded in the middle. The context builder exploits this: task and identity anchor the beginning (high-attention zone), high-confidence semantic facts anchor the end (high-attention zone), and lower-priority context sits in the middle.

The assembled context never exceeds max_context_ratio (default: 40%) of the deep model's context window. Past that threshold, signal-to-noise degrades and the model enters what Dex Horthy calls the "dumb zone" — mistakes that look like reasoning failures but are actually information overload.

async function buildDeepContext(task: AgendaItem): Promise<Message[]> {
  const budget = getModelContextWindow(DEEP_MODEL) * rules.max_context_ratio;
  let tokenCount = 0;

  // Domain-filtered retrieval: only load facts relevant to this task
  const semanticFacts = await memory.getRelevantFacts(task.summary, task.domain);
  const recentEpisodes = await memory.getRecentEpisodic(20);
  const agendaState = await agenda.getOpen();

  // FTS: search for older episodes relevant to this task
  const relatedEpisodes = await memory.searchEpisodic(task.summary, 5);

  // Load skill document if available
  const skillContext = task.skillHint
    ? await skills.load(task.skillHint)
    : null;

  // ─── ASSEMBLY ORDER: exploits U-shaped attention curve ───
  // Beginning (high attention): identity + task
  // Middle (lower attention): episodes, FTS results, agenda
  // End (high attention): semantic facts sorted by confidence desc

  const messages: Message[] = [];

  // 1. Identity + task description (beginning — always relevant, high attention)
  messages.push({ role: "system", content: buildIdentityPrompt(identity) });
  messages.push({ role: "user", content: `Task: ${task.summary}\n${task.notes ?? ""}` });

  // 2. Skill document (near beginning — directly actionable for this task)
  if (skillContext) {
    messages.push({ role: "system", content: `Relevant skill:\n${skillContext}` });
  }

  // 3. Agenda state (middle)
  messages.push({ role: "system", content: `My current agenda:\n${formatAgenda(agendaState)}` });

  // 4. Related past conversations via FTS (middle)
  if (relatedEpisodes.length > 0) {
    messages.push({ role: "system", content: `Related past conversations:\n${formatEpisodes(relatedEpisodes)}` });
  }

  // 5. Recent episodes (middle — model already has conversational context)
  messages.push(...recentEpisodes);

  // 6. Semantic facts sorted by confidence desc (end — high-attention zone)
  const memoryPreamble =
    `[Semantic memory: each fact has category (preference|habit|relationship|knowledge|failure), ` +
    `content, and confidence 0.0-1.0. Facts above 0.8 are well-established.]`;
  messages.push({ role: "system", content: `${memoryPreamble}\n\nWhat I know:\n${formatFacts(semanticFacts)}` });

  // Budget enforcement: trim from the middle if over budget
  return enforceContextBudget(messages, budget);
}

The enforceContextBudget() function counts tokens and, if over budget, drops content from the middle of the array (where attention is weakest) — removing FTS results first, then trimming recent episodes, then reducing semantic facts by confidence. Identity, task, and skill context are never trimmed.

5. Consolidation (sleep cycle)

During sleep, the brain replays episodic memories and selectively transfers important patterns into semantic storage. Unimportant details decay. This is how you go from "I remember the exact conversation" to "I know that person likes jazz" — the episode fades, the fact endures.

The autonomy loop is the sleep cycle. It runs every N minutes during active hours, performing six operations:

async function consolidate(bot: Clawlet) {
  const cycleStart = Date.now();
  let tokensConsumed = 0;

  // 1a. EXTRACT: episodic → semantic
  //     Review recent conversations. What durable facts can be extracted?
  //     What should be remembered? What can be forgotten?
  //     Explicitly look for: failures, corrections, misunderstandings.
  const extraction = await llm.deep({
    system: `Review recent conversations. Extract durable facts about the user
             into categories: preference, habit, relationship, knowledge, failure.
             For each fact, state your confidence (0.0-1.0).
             Pay special attention to:
             - Corrections: where the user corrected you or was unsatisfied
             - Patterns: repeated requests, recurring topics, emerging interests
             - Failures: where you gave wrong advice or made bad assumptions
             - Plan feedback: where the user modified or rejected your plans
               (these reveal misunderstandings about what the user actually wants)
             Also note if any existing facts should have their confidence adjusted.`,
    context: await memory.getUnconsolidatedEpisodes(),
  });
  const extractionResult = await memory.applySemanticsUpdate(extraction);
  tokensConsumed += extraction.usage.totalTokens;

  // 1b. DECAY: reduce confidence on stale, unreinforced facts
  //     Facts not referenced or reinforced in N consolidation cycles lose confidence.
  //     This prevents the semantic store from accumulating outdated noise.
  //     Facts that decay below 0.2 are archived (kept in DB but excluded from context).
  const decayResult = await memory.decayStaleConfidence({
    threshold: CONSOLIDATION_CYCLES_BEFORE_DECAY,  // default: 20 cycles (~5 hours)
    decayRate: 0.1,                                 // subtract 0.1 per decay pass
    archiveBelow: 0.2,                              // stop loading into context
  });

  // 2. COMPACT: decay old episodes
  //    Summarize and discard raw conversation history beyond the token window.
  //    The summaries become episodic entries with type "compaction".
  //    Note: raw episodes are always preserved in data/episodic.jsonl regardless.
  //    FTS index is updated to include summaries and exclude compacted raw entries.
  let compactedCount = 0;
  if (await memory.episodicTokenCount() > TOKEN_WINDOW * 0.8) {
    const summary = await llm.deep({
      system: "Summarize these older conversations into a concise narrative. " +
              "Preserve anything not already captured in semantic memory.",
      context: await memory.getOldestEpisodes(50),
    });
    compactedCount = await memory.compact(summary);
    tokensConsumed += summary.usage.totalTokens;
  }

  // 3. REVIEW AGENDA: reprioritize working memory
  //    Notice stale tasks, decompose complex items, surface completed work.
  //    Also check for tasks awaiting approval past the timeout — auto-approve them.
  const openItems = await agenda.getOpen();
  if (openItems.length > 0) {
    await agenda.autoApproveStale(PLAN_AUTO_APPROVE_TIMEOUT);

    const review = await llm.deep({
      system: `Review your working memory (agenda). Reprioritize if needed.
               Flag anything that's been pending too long.
               If any completed work hasn't been surfaced to the user, note it.
               If any item should be broken into subtasks, specify them.`,
      context: formatAgenda(openItems),
    });
    await agenda.applyReview(review);
    tokensConsumed += review.usage.totalTokens;
  }

  // 4. CHECK SCHEDULES: fire any due tasks
  //    Scheduled tasks that are due enter the agenda as pending items.
  //    Same deep worker, same code path — schedules just auto-generate agenda items.
  const dueSchedules = await schedules.getDue();
  for (const sched of dueSchedules) {
    await agenda.add({
      summary: sched.task,
      priority: sched.priority,
      domain: sched.domain,
      originMsg: `schedule:${sched.id}`,
    });
    await schedules.markFired(sched.id);
    broadcast("status", { schedule: sched.task, state: "fired" });
  }

  // 5. LEARN: extract skills from completed complex tasks
  //    Review recently completed agenda items. If any involved multi-step
  //    deep work, course corrections, or non-obvious solutions, propose
  //    a skill document (or update an existing one).
  let skillsCreated = 0;
  const recentlyCompleted = await agenda.getRecentlyCompleted();
  for (const task of recentlyCompleted) {
    const taskLog = await memory.getWorkLog(task.id);
    if (taskLog.length >= 3 || taskLog.some(w => w.kind === 'error')) {
      const skillProposal = await llm.deep({
        system: `You completed a non-trivial task. Review your work log and decide:
                 1. Should this become a new skill document? (workflow was reusable)
                 2. Should this update an existing skill? (you learned something new)
                 3. Neither — this was a one-off.
                 If creating/updating, produce a skill document in markdown format.
                 If the task had a plan that the user modified, incorporate those
                 corrections — they reveal what the right approach actually looks like.`,
        context: [
          { role: "system", content: `Task: ${task.summary}\nDomain: ${task.domain}` },
          ...taskLog.map(w => ({ role: "system" as const, content: `[${w.kind}] ${w.content}` })),
          { role: "system", content: `Existing skills:\n${await skills.getIndex()}` },
        ],
      });
      const created = await skills.applyProposal(skillProposal);
      if (created) skillsCreated++;
      tokensConsumed += skillProposal.usage.totalTokens;
    }
  }

  // 6. REFLECT: update identity
  //    Has anything changed about who I am or how I should behave?
  const reflection = await llm.deep({
    system: `Reflect on your recent interactions and current state.
             Has anything changed about your personality or approach?
             Should any 'never' constraints be added or removed?
             Is there anything you should proactively tell your user?`,
    context: await memory.getRecentEpisodic(10),
  });
  tokensConsumed += reflection.usage.totalTokens;

  if (reflection.personalityUpdates) {
    await personality.apply(reflection.personalityUpdates);
  }
  if (reflection.userMessage) {
    broadcast("reply", { content: reflection.userMessage, proactive: true });
  }

  broadcast("thought", reflection.thought);
  await memory.markConsolidated();

  // ─── LOG CONSOLIDATION METRICS ───
  await consolidationLog.append({
    tokensConsumed,
    factsExtracted: extractionResult.created,
    factsDecayed: decayResult.decayed,
    factsArchived: decayResult.archived,
    episodesCompacted: compactedCount,
    skillsCreated,
    durationMs: Date.now() - cycleStart,
  });
}
CREATE TABLE consolidation_log (
  id TEXT PRIMARY KEY,
  timestamp INTEGER NOT NULL,
  tokens_consumed INTEGER NOT NULL,
  facts_extracted INTEGER DEFAULT 0,
  facts_decayed INTEGER DEFAULT 0,
  facts_archived INTEGER DEFAULT 0,
  facts_referenced_since INTEGER DEFAULT 0,   -- updated retroactively when extracted facts are used
  episodes_compacted INTEGER DEFAULT 0,
  skills_created INTEGER DEFAULT 0,
  skills_loaded_since INTEGER DEFAULT 0,      -- updated retroactively when created skills are loaded
  duration_ms INTEGER NOT NULL
);

Consolidation metrics. The consolidation_log table tracks the cost and yield of every cycle. After 50 cycles, you can compute extraction efficiency (facts_referenced_since / facts_extracted), skill utility (skills_loaded_since / skills_created), and cost per useful fact (total tokens / facts_referenced_since). If consolidation isn't earning its keep, think_interval_minutes should increase. The dashboard exposes these metrics.

The six consolidation operations — extract, decay, compact, review agenda, check schedules, learn, reflect — are ordered by importance. If the token budget is tight, the system can skip reflect (step 6) and learn (step 5) but should always extract, decay, and compact (steps 1-2). Memory hygiene is more important than skill creation or personality evolution.

6. Identity

A YAML seed file that the agent evolves through consolidation. Not the Freudian ego — just a persistent self-model that constrains behavior across every interaction.

# personality.yaml
name: "Claw"
tone: "direct, warm, occasionally dry"
values: ["honesty", "brevity", "respecting boundaries"]
never:
  - "corporate jargon or marketing speak"
  - "unsolicited life advice"
  - "apologizing when not at fault"
  - "emoji in serious context"
quirks: []

The never list is as important as tone and values. Negative constraints are easier for LLMs to follow than positive aspirations — "never use corporate jargon" produces more consistent output than "be casual." The never list is injected into both fast and deep system prompts alongside the positive personality traits. During consolidation (step 6), the bot can propose additions to the never list based on patterns it notices: if the user repeatedly corrects a behavior, the bot learns to never do it.

Identity reminders. A compressed version of the personality — tone + the top 3 never constraints, ~50 tokens — is injected after every tool execution in the deep worker loop. This keeps the bot's character anchored during long tool chains where the system prompt has scrolled far from the model's attention window. The reminder is built from personality.yaml and cached; it updates when personality changes during consolidation.

function buildIdentityReminder(identity: Identity): string {
  const topNevers = identity.never.slice(0, 3).join("; ");
  return `[Reminder: tone=${identity.tone}. Never: ${topNevers}.]`;
}
CREATE TABLE personality (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  updated_at INTEGER,
  reason TEXT               -- why this changed, traceable
);

Rules:

  • Updates happen only during consolidation (step 6)
  • Every change is logged with a reason
  • Visible and revertable from the dashboard
  • Lockable via personality_lock: true in rules.yaml

Identity constrains every output in the system — both fast and deep paths include the personality in their system prompts. The identity reminder provides continuity mid-execution. The bot has continuity of character across conversations because its identity persists independently of any single interaction.


The Two Models

OpenRouter via the OpenAI SDK. One dependency, every model. Two tiers.

import OpenAI from "openai";

const router = new OpenAI({
  baseURL: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1",
  apiKey: process.env.OPENROUTER_API_KEY,
});

// FAST: triage (fallback only — heuristics handle common cases),
//       acknowledgments, coherence checks, habit-driven responses
// Optimized for latency. Cheap. Good enough for classification + short replies.
const FAST_MODEL = process.env.CLAWLET_FAST_MODEL ?? "meta-llama/llama-3.1-8b-instruct";

// DEEP: the agentic loop — reasoning, tool use, planning (via write_plan tool),
//       consolidation, skill creation
// Optimized for quality. Unbounded time.
const DEEP_MODEL = process.env.CLAWLET_MODEL ?? "anthropic/claude-sonnet-4";

Swap models by changing env vars. Point at a local vLLM server for zero API cost. Run the fast model locally on a tiny LLM for sub-100ms triage, deep model on cloud. Or both local. The architecture doesn't care.


Tools (optional, deep-path only)

Clawlet's primary value is cognitive — it thinks, remembers, and learns. But thinking without acting is limited. The tool system gives the deep worker the ability to act on tasks, while keeping the architecture simple and the conversation path untouched.

Design principle: the fast path never calls tools. Only the deep worker invokes tools, only during background processing. This preserves the conversation/cognition split — the user never waits for a tool to execute. Tools are defined as OpenAI-compatible function schemas and executed inside the deep worker loop.

All tools are always loaded. Every permitted tool's schema is included in every deep worker call. Availability per-task is controlled via the system prompt ("For this task, use only: ..."), not by adding or removing tool definitions. Changing tool definitions between calls invalidates the model's KV-cache — a lesson from Manus's production experience. Static tool loading preserves cache efficiency.

// tools.ts — tool registry
interface Tool {
  name: string;
  description: string;
  parameters: JSONSchema;
  execute: (args: Record<string, unknown>, task: AgendaItem) => Promise<string>;
}

// Built-in tools (v1 — intentionally minimal)
const BUILTIN_TOOLS: Tool[] = [
  writePlan,         // articulate and track a plan for the current task
  checkAgenda,       // read current working memory state
  webFetch,          // fetch a URL, return text content
  webSearch,         // search via a search API, return results
  shellExec,         // run a command inside the container, return stdout/stderr
  fileRead,          // read a file from the data volume
  fileWrite,         // write a file to the data volume
];

write_plan — the cognitive anchor. Inspired by Claude Code's TodoWrite pattern, write_plan is a tool that forces the model to articulate its approach before executing. For complex tasks, the model calls it early. For simple tasks, it skips it. The tool stores the plan in the agenda, optionally surfaces it to the user, and can pause execution for approval.

const writePlan: Tool = {
  name: "write_plan",
  description: `Write or update your plan for the current task. Call this BEFORE starting
                complex work — especially if the task is ambiguous, has multiple steps,
                involves irreversible actions, or you're unsure about the user's intent.
                Skip this for simple, well-understood tasks.`,
  parameters: {
    type: "object",
    properties: {
      steps: {
        type: "array",
        items: { type: "object", properties: {
          summary: { type: "string" },
          dependsOn: { type: "array", items: { type: "number" } },
        }},
        description: "Ordered steps, 3-7 max. Concise.",
      },
      risks: { type: "array", items: { type: "string" }, description: "What could go wrong." },
      needsApproval: {
        type: "boolean",
        description: "True if task involves irreversible actions, high ambiguity, " +
                     "or you're unsure about user's intent. False if confident.",
      },
    },
    required: ["steps", "needsApproval"],
  },
  execute: async (args, task) => {
    const plan = args as TaskPlan;
    await agenda.update(task.id, { plan: JSON.stringify(plan) });

    // Surface the plan to the user
    const formatted = formatPlanForUser(task.summary, plan);
    broadcast("reply", {
      content: formatted,
      async: true,
      re: task.summary,
      awaitingApproval: plan.needsApproval,
    });
    await memory.appendEpisodic("assistant", formatted, { deepWork: true });

    if (plan.needsApproval) {
      await agenda.update(task.id, { awaiting_approval: 1 });
      return "Plan surfaced to user. Execution paused — waiting for approval. " +
             "Do not proceed until the task is resumed.";
    }

    return "Plan recorded and surfaced. Proceeding with execution.";
  },
};

check_agenda — the reorientation tool. After a long tool chain, the model can call check_agenda to read its broader state — what else is pending, what's done, what the priorities are. This serves the same function as Claude Code's TodoWrite in reverse: instead of writing state, the model reads it to reorient. Especially valuable when the tool budget is high and the deep worker has been running for several minutes.

const checkAgenda: Tool = {
  name: "check_agenda",
  description: "Review your current working memory — what you're working on, " +
               "what's pending, what's done. Use this to reorient during long tasks.",
  parameters: { type: "object", properties: {} },
  execute: async (args, task) => {
    const items = await agenda.getOpen();
    return formatAgenda(items);
  },
};

Container sandbox. shellExec runs inside the same Docker container as Clawlet. The container is already isolated. For users who want stricter separation, the tool can be configured to exec into a sibling container via Docker socket — but the default is good enough. The container is the sandbox.

shellExec is the escape hatch. Five atomic tools handle 90% of use cases. For everything else — grep, curl, jq, ffmpeg, python scripts — the model uses shellExec. New capabilities should be shell commands first, dedicated tools only when the model consistently fails to construct the right command. This follows the hierarchical action space pattern from Manus: keep tool definitions minimal, push complex capabilities to shell invocation.

Tool calls are logged. Every tool invocation goes into work_log with kind: 'tool_call' and the result with kind: 'tool_result'. The dashboard shows the full trace. The conversation stays clean.

Tool budgets. Each agenda item has a configurable tool-call limit (default: 10). This prevents runaway loops where the deep worker chains 50 searches. When the limit is reached, the model gets one final call (with no tools) to wrap up with what it has. The user can increase the limit per-task or globally in rules.yaml.

# rules.yaml (additions)
tools_enabled: true
tool_call_limit_per_task: 10
allowed_tools: ["write_plan", "check_agenda", "web_fetch", "web_search", "shell_exec", "file_read", "file_write"]
# Omit tools from the list to disable them. Empty list = tools off.
# All permitted tools are loaded in every call; availability is gated by the system prompt.

Adding custom tools. A tool is a function that takes args and returns a string. Drop a file in src/tools/, export a Tool object, register it in the tool array. ~20 lines per tool. The architecture makes no distinction between built-in and custom tools.

Tools are the bridge between "agent that thinks" and "agent that does." But the cognitive architecture is the foundation — tools are the optional hands, not the brain.


Schedules

Tasks that fire on a schedule — daily briefings, periodic checks, recurring reminders. Not a separate daemon. Schedules are checked at the top of each consolidation cycle (step 4). Due items enter the agenda as pending tasks. Same deep worker, same code path.

CREATE TABLE schedules (
  id TEXT PRIMARY KEY,
  task TEXT NOT NULL,              -- what to do: "Morning news briefing"
  cron TEXT NOT NULL,              -- cron expression: "0 9 * * *"
  domain TEXT,
  priority REAL DEFAULT 0.5,
  last_fired INTEGER,
  next_fire INTEGER NOT NULL,
  enabled INTEGER DEFAULT 1,
  created_at INTEGER NOT NULL
);

Schedules are created three ways:

  1. Heuristic short-circuit — the fast path detects "remind me every morning at 9" and creates a schedule directly (no LLM call needed).
  2. Deep worker — a task result includes a schedule proposal ("I should check this weekly").
  3. Dashboard — the user creates one manually from the UI.

The consolidation loop computes next_fire from the cron expression after each firing. Overdue schedules (e.g., from downtime) fire once on the next cycle, not repeatedly. The enabled flag lets users pause without deleting.

Schedules are distinct from agenda items. A schedule generates agenda items. The schedule persists; the agenda item is consumed. This mirrors how a recurring meeting on your calendar creates individual events — the recurrence rule is separate from any single occurrence.

Scheduled tasks enter the agenda without a complexity tag. The deep worker evaluates complexity organically — a daily briefing skips write_plan; a weekly strategic review might call it.


Message Queue

File-based queue. Messages are JSON files moved atomically between directories.

data/queue/
├── incoming/       # new messages land here
├── processing/     # one file at a time moves here
└── outgoing/       # responses ready for delivery
interface QueueMessage {
  id: string;                              // ulid
  channel: "dashboard" | "webhook" | string;
  from: string;
  content: string;
  timestamp: number;
  replyTo?: string;
  media?: {                                // optional: voice, images, files
    type: "audio" | "image" | "file";
    mimeType: string;
    path: string;                          // relative to data/media/
  }[];
}

The media field is optional and designed for forward-compatibility. Channel adapters that handle voice memos or image uploads can attach them here. The fast path can note media in its triage ("user sent an image") and route to the deep worker with appropriate context. v1 doesn't need to implement media processing — but the schema supports it without migration.

The processor polls incoming/, moves one file to processing/, runs the fast path, writes the immediate response to outgoing/, deletes the processed file. If the fast path promotes to deep work, it goes into the agenda (SQLite, durable, queryable) — not the file queue.

Adding a channel = writing JSON files to incoming/ and reading from outgoing/. ~100 lines per adapter.


How It Feels

Simple message (reflexive — no LLM call)

User:  Hey, what's up?
       │
       ├──► heuristic match: greeting
       ├──► habits loaded: "user prefers casual tone"
       └──► reply in < 10ms: "Hey! Been thinking about that gardening
            thing you mentioned. How'd it go?"

Ambiguous message (fast model fallback)

User:  That thing we talked about yesterday — any update?
       │
       ├──► no heuristic match (ambiguous reference)
       ├──► fast model: classifies as check-in on pending agenda item
       ├──► agenda lookup: ARM deploy research is active
       └──► reply in ~500ms: "Still working on the ARM deploy thing —
            should have something in a few minutes."

Simple deep task (flat loop, no planning)

User:  What time is it in Tokyo?
       │
       ├──► attention filter → needs deep work, complexity: simple
       ├──► immediate reply: "Let me check."
       └──► deep worker: flat loop
            ├──► model calls web_search("current time Tokyo")
            ├──► identity reminder injected
            ├──► model returns text (no more tool calls — done)
            └──► result surfaces: "It's 2:30 AM in Tokyo right now."

Moderate task, skill loaded, no planning

User:  Can you figure out why deploy scripts fail on ARM?
       │
       ├──► attention filter → needs deep work, complexity: moderate
       ├──► skill hint: "arm-deploy-debugging" (skill exists)
       ├──► immediate reply: "On it — I'll dig into that."
       └──► deep worker: flat loop with skill loaded
            ├──► model skips write_plan (skill covers approach)
            ├──► model calls web_search("ARM64 deploy script failures")
            ├──► identity reminder
            ├──► model calls web_fetch(top result URL)
            ├──► identity reminder
            ├──► model returns text (synthesis + recommendations)
            ├──► coherence check: still relevant?
            └──► result surfaces: "Re: ARM deploys — here's what I found..."

Complex task, model plans and auto-approves

User:  Research the best indoor herbs for a north-facing London kitchen.
       │
       ├──► attention filter → needs deep work, complexity: complex
       ├──► immediate reply: "Good question — let me figure out the best approach."
       └──► deep worker: flat loop
            ├──► model calls write_plan({steps: [...], needsApproval: false})
            │    (confident it understands the task)
            ├──► plan logged + surfaced briefly in chat
            ├──► model proceeds: web_search → web_fetch → web_search → ...
            ├──► identity reminders between tool calls
            ├──► model returns text (structured recommendations)
            └──► result surfaces

Complex task, model plans and requests approval

User:  Help me reorganize my entire project's file structure.
       │
       ├──► attention filter → needs deep work, complexity: complex
       ├──► immediate reply: "That's a big one — let me think about
       │    how to approach it."
       └──► deep worker: flat loop
            ├──► model calls write_plan({steps: [...], needsApproval: true})
            │    (irreversible actions, ambiguous scope)
            ├──► write_plan sets awaiting_approval=1
            ├──► plan surfaces: "Here's what I'd do:
            │      1. Audit current structure and map dependencies
            │      2. Propose new layout based on your conventions
            │      3. Identify safe-to-move files vs. path-dependent ones
            │      4. Generate move commands (you review before running)
            │    Risk: I'm not sure which files have hardcoded paths.
            │    Want me to proceed, or adjust anything?"
            └──► loop exits (awaiting_approval flag detected)

User:  Go ahead but start with the audit only.
       │
       ├──► heuristic matches plan approval (agenda has awaiting task)
       ├──► updates plan scope, appends note: "user wants audit first"
       ├──► awaiting_approval=0, task resumes in deep worker
       └──► deep worker continues flat loop from where it paused

Working memory full

User:  Can you also look into soil mixes for succulents?
       │
       ├──► attention filter → needs deep work
       ├──► agenda: 5/5 items active
       └──► reply: "I'm juggling a few things — ARM deploy research,
            herb recommendations, and your weekend plans. Want me
            to swap one out, or should succulents wait?"

Scheduled task fires

[Consolidation tick — 9:00 AM]
       │
       ├──► check schedules → "Morning briefing" is due
       ├──► agenda.add("Morning briefing", origin: "schedule:abc123")
       │
       │    [deep worker picks it up]
       │    [model skips write_plan — simple, recurring]
       │
       └──► result surfaces: "Good morning! Here's what I've been
            thinking about since yesterday..."

Consolidation surfaces insight + learns a skill

[Autonomy tick — no user message, 45 min since last chat]
       │
       ├──► extract: "User has asked about gardening 3 times this week"
       │    → semantic: { category: "interest", content: "gardening",
       │                  confidence: 0.85 }
       ├──► extract: "User modified file-reorg plan to audit-only"
       │    → semantic: { category: "preference",
       │                  content: "prefers incremental changes over big-bang rewrites",
       │                  confidence: 0.7 }
       ├──► decay: "User's interest in Kubernetes" (0.6 → 0.5, not mentioned in weeks)
       ├──► compact: summarize yesterday's conversation, discard raw from SQLite
       │    (raw preserved in episodic.jsonl)
       ├──► review agenda: herbs research done but not delivered
       ├──► auto-approve: file-reorg plan waiting 8 min, past timeout → resume
       ├──► check schedules: nothing due
       ├──► learn: ARM deploy task had 5 work_log steps + a course correction
       │    → emit skill: data/skills/arm-deploy-debugging.md
       ├──► metrics: 847 tokens, 2 facts extracted, 1 decayed, 1 skill created
       └──► surface: "I finished the herb research from earlier —
            want me to share what I found?"

Dashboard

One HTML file. Express serves it. WebSocket pushes live updates.

┌──────────────────────────────────────────────────────┐
│  🦞 Clawlet                                ● Online  │
├──────────────────────────────────────────────────────┤
│  Status: Working "ARM deploy issue" (4 tool calls)   │
│  Last thought: "User seems increasingly interested   │
│  in gardening — now a high-confidence interest."     │
│                                                      │
│  ─── Working Memory ───                              │
│  🔵 ARM deploy issue            active (3 min)        │
│  📋 File structure reorg        awaiting approval     │
│  ⚪ Herb recommendations        pending               │
│  ✅ Vet reminder                 done                  │
│  ■■ 1 of 5 slots free                                │
│                                                      │
│  ─── Activity ───                                    │
│  14:35  📋 Plan surfaced: file reorg (4 steps,        │
│            awaiting approval)                         │
│  14:32  🧠 Consolidation (2 facts, 1 decayed,         │
│            1 skill — 847 tokens)                      │
│  14:30  🔧 Deep: ARM deploy (3 tool calls)            │
│  14:28  ⚡ Fast: acknowledged ARM question             │
│  14:15  💬 Chat (3 turns, 2 heuristic)                │
│  09:00  ⏰ Schedule fired: Morning briefing            │
├──────────────────────────────────────────────────────┤
│  ┌────────────────────────────────────────────── Send ┐│
│  │ Type a message...                                ││
│  └──────────────────────────────────────────────────┘│
│  [Identity] [Memory] [Skills] [Agenda] [Thinking]    │
│  [Schedules] [Metrics]                               │
└──────────────────────────────────────────────────────┘

Tabs:

  • Identity — personality state (including never list) + evolution history
  • Memory — semantic facts (with confidence + last referenced) + episodic log + FTS search
  • Skills — procedural skill documents, creation history, load count
  • Agenda — working memory with status, priority, domain, capacity. Clicking an awaiting approval item shows the full plan with approve/modify/cancel actions.
  • Thinking — work_log traces including plans and tool calls (the bot's scratchpad, never in chat)
  • Schedules — cron jobs with next-fire time, enable/disable toggle
  • Metrics — consolidation efficiency (extraction rate, skill utility, tokens per useful fact), fast path heuristic hit rate, context budget utilization

User Rules

# rules.yaml
active_hours: "08:00-23:00"
think_interval_minutes: 15
max_daily_tokens: 50000
working_memory_cap: 5              # max concurrent agenda items
deep_work_timeout_minutes: 10
personality_lock: false
confidence_decay_cycles: 20        # consolidation cycles before decay kicks in
confidence_decay_rate: 0.1         # per decay pass
confidence_archive_below: 0.2      # stop loading facts below this
max_context_ratio: 0.4             # max % of model context window to fill (0.0-1.0)
tools_enabled: true
tool_call_limit_per_task: 10
allowed_tools: ["write_plan", "check_agenda", "web_fetch", "web_search", "shell_exec", "file_read", "file_write"]
skill_auto_create: true            # let consolidation create skills autonomously
max_skills: 50                     # cap total skill documents (disk + context budget)
plan_auto_approve_timeout_minutes: 5   # awaiting-approval tasks auto-resume after this
max_plan_steps: 7                      # cap plan verbosity
forbidden_topics: []

Token budget enforced across both model tiers. Working memory cap enforced at the agenda level. Tool budget enforced per agenda item. Skill budget enforced at the consolidation level. Plan timeout enforced by the consolidation loop's agenda review. Context budget enforced per deep worker call. The user sets boundaries. The bot respects them.


File Structure

clawlet/
├── Dockerfile
├── docker-compose.yaml
├── .env.example
├── package.json
├── tsconfig.json
├── src/
│   ├── agent.ts           # fast path + deep worker loop + consolidation    (~600 lines)
│   ├── memory.ts          # SQLite + FTS5 + JSONL: episodic, semantic,      (~300 lines)
│   │                      # context builder with budget enforcement
│   ├── skills.ts          # skill CRUD, index, load, propose                (~120 lines)
│   ├── agenda.ts          # working memory: CRUD, cap, priority, approval   (~120 lines)
│   ├── tools.ts           # tool registry + built-ins (write_plan,          (~200 lines)
│   │                      # check_agenda, web, shell, file)
│   ├── heuristics.ts      # pattern matchers for fast path short-circuits   (~80 lines)
│   ├── schedules.ts       # cron table, due-check, next-fire calc           (~80 lines)
│   ├── queue.ts           # file-based message queue                        (~80 lines)
│   ├── server.ts          # Express + WebSocket + dashboard                 (~130 lines)
│   ├── llm.ts             # OpenRouter client, dual-tier                    (~60 lines)
│   └── types.ts           # shared interfaces                               (~70 lines)
├── config/
│   ├── rules.yaml
│   └── personality.yaml
├── dashboard/
│   └── index.html         # single-file UI
└── data/                  # Docker volume
    ├── clawlet.db         # SQLite (all tables including FTS + consolidation_log)
    ├── episodic.jsonl     # append-only safety log (never compacted)
    ├── skills/            # procedural skill documents (markdown)
    ├── media/             # uploaded media (voice, images) — future
    └── queue/
        ├── incoming/
        ├── processing/
        └── outgoing/

~1,840 lines of TypeScript. 1 HTML file. 1 Dockerfile.

Compared to V6, the net line count is roughly even despite adding several features. The planning simplification (replacing shouldPlan(), generatePlan(), and separate planning/planned status handling with the write_plan tool) saved ~80 lines in agent.ts and ~30 lines in agenda.ts. The flat agentic loop in the deep worker is actually shorter than V6's two-phase execute pattern. These savings were reinvested into: the heuristic layer (~80 lines in a new heuristics.ts), write_plan and check_agenda tools (~50 lines in tools.ts), context budget enforcement (~20 lines in memory.ts), identity reminders (~15 lines in agent.ts), and consolidation metrics (~25 lines in agent.ts + schema). One new file (heuristics.ts) was added; the total source file count goes from 10 to 11.


Lifecycle

docker compose up
       │
       ▼
  Load personality.yaml + rules.yaml
       │
       ▼
  Open/create SQLite (9 tables + FTS) + queue dirs + skill dir + episodic.jsonl
       │
       ▼
  Start Express (dashboard + WebSocket)
       │
       ├─────────────────────┬───────────────────────┐
       ▼                     ▼                       ▼
  Queue Processor       Deep Worker            Consolidation Loop
  (poll incoming/)      (poll agenda)          (every N min)
       │                     │                       │
       ▼                     │                  ┌────┴────┐
  fastPath()                 │               extract  compact
  ├── heuristic match?       │               decay    review
  │   YES → respond          │               schedule check
  │   NO  ↓                  │               auto-approve stale
  ├── fast model (fallback)  │               learn skills
  ├── classify domain        │               reflect
  ├── estimate complexity    │               log metrics
  ├── reflexive reply        │               └────┬────┘
  └── agenda.add() ────────►  │                     │
                             ▼                     │
                    flat agentic loop              │
                    model controls:                │
                    ├── write_plan (if needed)     │
                    ├── tools (search, fetch...)   │
                    ├── check_agenda (reorient)    │
                    └── return text (done)    ◄────┘
                             │
                             ▼
                  coherenceCheck() → deliver or discard
                             │
                             ▼
                  Persist (SQLite + JSONL + skills/) + broadcast via WebSocket

Three concurrent loops, one Node.js process. The deep worker is a flat agentic loop where the model controls execution — it decides when to plan, which tools to call, and when the task is done. The harness provides tools, enforces budgets, and delivers results. The fast path is the separate conversation path — intentionally minimal, heuristics-first.


Running

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
VOLUME /app/data
EXPOSE 3000
CMD ["node", "dist/server.js"]
services:
  clawlet:
    build: .
    ports: ["3000:3000"]
    volumes: [clawlet-data:/app/data]
    env_file: .env
    restart: unless-stopped
volumes:
  clawlet-data:
# .env.example
OPENROUTER_API_KEY=sk-or-...
CLAWLET_MODEL=anthropic/claude-sonnet-4
CLAWLET_FAST_MODEL=meta-llama/llama-3.1-8b-instruct
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# DASHBOARD_PASSWORD=

Extending

Add a channel. Write an adapter that reads/writes JSON to the queue directories. Telegram via grammY, WhatsApp via Baileys, Discord via discord.js. ~100 lines each. The media field in QueueMessage lets adapters pass voice memos and images through without schema changes.

Add a tool. Export a Tool object from a file in src/tools/. Register it in the tool array. ~20 lines. Tools are deep-path only — they never block the conversation.

Voice support. Add a channel adapter that transcribes audio (Whisper API or local whisper.cpp) before writing to incoming/. Attach the audio file path in QueueMessage.media. The fast path sees text; the original audio is archived.

Local models. Set OPENROUTER_BASE_URL=http://localhost:8000/v1. Run a tiny model locally for the fast path (Phi-3-mini, sub-100ms) and a bigger model for deep work.

Custom heuristics. Add pattern matchers to heuristics.ts for domain-specific intents. Each heuristic is ~5 lines. The fast model becomes a fallback for anything heuristics don't catch.

Share skills. Skill documents in data/skills/ are plain markdown. Copy them between Clawlet instances, version them in git, or publish them. The format is minimal and portable.


Security

  • Container isolation at the Docker layer, not application level.
  • Dashboard auth via DASHBOARD_PASSWORD env var.
  • API key isolation — only in .env, never exposed to client or stored in DB.
  • Token budget enforced in SQLite across both model tiers.
  • Context budget enforced per deep worker call — never exceeds max_context_ratio of model window.
  • Tool budget enforced per agenda item, preventing runaway tool loops.
  • Deep work timeout prevents runaway costs from a single task.
  • Work log isolation — reasoning traces, plans, and tool outputs never leak into the conversation stream.
  • Data sovereignty — everything in your Docker volume.
  • Append-only episodic log — raw conversation history can never be lost through compaction errors.
  • Shell exec sandboxing — runs inside the existing container. No host access unless the user explicitly mounts volumes.
  • Tool allowlist — rules.yaml controls which tools are available. Disable shell access entirely by omitting shell_exec.
  • Static tool loading — all permitted tools loaded once per call. No dynamic tool manipulation that could be exploited.
  • Plan approval gate — the write_plan tool can pause execution for user confirmation before proceeding with irreversible actions. The model decides when approval is needed, but the user always has the final say.

Complexity Budget

Guidance, not hard limits. These keep the project honest. If a feature pushes past them, justify it — but treat sustained overruns as a design smell, not a target.

Metric Guidance
Source files ~11
Lines of TypeScript ~1,500–1,850
npm dependencies ≤ 10
Config files 2 (rules.yaml + personality.yaml)
SQLite tables 9 (+ 1 FTS virtual table)
Read entire codebase < 25 min
Time to running bot < 5 min
Dockerfiles 1

The point is legibility. If you can't read the whole thing in an afternoon, it's too big. V7 adds one file (heuristics.ts) and one table (consolidation_log) compared to V6, while simplifying the agenda status machine and the deep worker loop. The justification: harness engineering research shows that simpler scaffolding produces better agent performance. Removing the separate planning phase and letting the model control the loop via tools follows the pattern every production agent has converged on.


Implementation Roadmap

Phase Deliverable Hours
1. Skeleton Express, WebSocket, dashboard shell, .env loading ~2
2. Queue File-based queue with media field, processor loop ~2
3. LLM OpenRouter dual-tier client (fast + deep) ~2
4. Fast Path Heuristic short-circuit layer, fast model fallback, triage prompt, intent classification, domain routing, complexity estimation, skill-name check, plan approval recognition ~4
5. Memory Episodic + semantic + FTS5 tables, JSONL safety log, context builder with domain filtering + FTS retrieval + budget enforcement + U-curve ordering ~5
6. Working Memory Agenda table, capacity cap, priority management, domain tag, skill hint, plan + approval columns ~2
7. Deep Path Flat agentic loop (model controls execution), identity reminders after tool calls, budget enforcement with wrap-up call, coherence check, async delivery, work_log ~4
8. Tools Tool registry with static loading, built-in tools (write_plan, check_agenda, web_fetch, web_search, shell_exec, file_read, file_write), budget enforcement ~4
9. Skills Skill table + file storage, name index for fast path, load for deep path, proposal from consolidation ~3
10. Consolidation Episodic→semantic extraction (incl. failures + plan feedback), confidence decay, compaction (FTS-aware), agenda review with stale-approval auto-resume, schedule check, skill creation, reflection, metrics logging ~5
11. Schedules Schedule table, cron parsing, due-check in consolidation, creation from heuristic layer + deep path + dashboard ~2
12. Identity YAML seed with never list, identity reminder builder, self-update during consolidation, dashboard tab ~1
13. Docker Dockerfile, compose, volumes, health check ~1
14. Polish Dashboard UI (7 tabs including metrics), activity feed, error handling, rate limiting ~4

~41 hours to v1.

V7 is slightly faster to build than V6 (~43h) despite adding features. The planning simplification saves ~2h (no separate planning phase in agent.ts, simpler agenda status machine). The heuristic layer adds ~1h but saves ongoing API costs. Context budget enforcement adds ~1h to the memory phase. Consolidation metrics add minimal overhead — mostly logging.


Lineage

Clawlet synthesizes ideas from across the self-hosted AI assistant ecosystem and production agent engineering. Key debts:

  • TinyClaw (241★, ~400 LOC): file-based message queue, heartbeat concept → our consolidation loop
  • NanoClaw (6.9k★, ~1.5k LOC): SQLite for state, container isolation, "readable in one sitting" philosophy
  • MicroClaw (34★, ~5k LOC): durable sessions, scheduler-reuses-agent-loop, context compaction
  • PicoClaw (1.3k★, ~2k LOC): multi-provider via OpenRouter, zero local footprint
  • memU (6.9k★): structured memory philosophy, proactive intelligence
  • Mini-Claw (33★, ~1k LOC): JSONL logging, rate limiting
  • Personal Brain OS (Koylan): progressive disclosure for context loading, append-only JSONL as safety net, negative identity constraints, domain-scoped memory retrieval
  • Hermes Agent (NousResearch): skills-as-procedural-memory, markdown skill documents, FTS session search, media-capable message schema, scheduled task patterns, tool-call logging in work traces
  • Claude Code (Anthropic): flat agentic loop ("model controls the loop"), TodoWrite as cognitive anchor → write_plan tool, post-tool-execution system reminders → identity reminders, static tool loading, heuristic-first triage
  • Cursor: lazy context loading, context budget as a quality constraint
  • Manus: static tool definitions to preserve KV-cache, shell_exec as hierarchical escape hatch, "biggest gains from removing things"
  • SWE-Agent (Princeton): agent-computer interface design, error preservation as tool results
  • Harness engineering research (LangChain, Horthy "12 Factor Agents", Liu et al. TACL 2024): U-shaped attention curve → context ordering, 40% context budget threshold, progressive disclosure as architectural pattern

What's new to Clawlet: dual-loop conversation/cognition split, agenda as working memory with hard caps, model-controlled planning via tool (not a separate phase), episodic→semantic consolidation pipeline with autonomous skill extraction and cost metrics, confidence decay on stale facts, domain-routed context assembly with budget enforcement and attention-aware ordering, identity reminders for behavioral continuity during long tool chains, heuristic-first fast path, negative personality constraints, append-only safety logging, deep-path-only tool execution that never blocks conversation. These ideas come from neuroscience, production agent engineering, context engineering, and harness engineering — because the ecosystem converged on the patterns but hasn't combined them with the memory architecture yet.


What Clawlet Is Not

Clawlet is a cognitive architecture that happens to have tools. It is not:

  • A code agent. It won't scaffold your project or run your test suite. Tools exist to support reasoning (fetch a URL, search for context, read a file) not to replace a development workflow. Use Claude Code or Hermes Agent for that.
  • An RL training platform. It doesn't generate trajectories or train models. It's a personal agent, not research infrastructure.
  • A universal tool orchestrator. Seven built-in tools (five for action, two for cognitive anchoring). A clean extension point. Not 30 toolsets. If you need browser automation, image generation, and a mixture-of-agents pipeline, this isn't the right project.
  • A project management tool. The write_plan tool is a lightweight cognitive step, not a Gantt chart. Plans are 3–7 steps, concise, and disposable. If you need project tracking, use a project tracker.
  • A big codebase. If Clawlet grows past ~2,000 lines of TypeScript, something went wrong. The complexity budget exists to keep the project honest. Every line of code is a line someone has to read. The best harnesses get simpler over time, not more complex.

Clawlet's thesis is that the architecture of cognition matters more than the breadth of capabilities — and that the harness matters as much as the model. An agent with the right information flow will outperform one with a better model but a worse scaffold. Build the brain right. Keep the harness simple. The hands can come later.


Clawlet is small on purpose. The best agent is the one you actually understand — and the one that doesn't make you wait.