A minimal OpenClaw clone built from first principles. A persistent, tool-using, multi-agent AI assistant/autonomous task executor that runs in your terminal.
See purpose.md for a deep dive into the architecture and what each component teaches from an engineering perspective.
- SQLite Storage β All data (sessions, memory, approvals) in a single
nipper.dbfile with WAL mode - Persistent Sessions β Conversation history stored in SQLite, survives restarts
- Long-Term Memory β Key/value memory store with FTS5 full-text search
- Tool Use β Shell commands, file read/write, memory save/search
- Permission Controls β Safe command allowlist with interactive approval for unknown commands
- Context Compaction β Automatic summarization when conversation history approaches token limits
- Multi-Agent Routing β Two agents (Jarvis + Scout) with shared memory
- Scheduled Heartbeats β Cron-style recurring agent tasks
At least one is required. If both are set, Jarvis uses Claude and Scout uses GPT-4o. If only one is set, both agents use that provider.
export ANTHROPIC_API_KEY=your-key-here # https://console.anthropic.com/settings/keys
export OPENAI_API_KEY=your-key-here # https://platform.openai.com/api-keysWith uv (recommended):
uv sync
uv run python nipper.pyWith pip:
pip install anthropic schedule
python nipper.pyZero-install one-liner (uv):
uv run --with anthropic --with schedule python nipper.pyForce a specific provider:
uv run python nipper.py --provider anthropic # both agents use Claude
uv run python nipper.py --provider openai # both agents use GPT-4o-mini
uv run python nipper.py # auto-detect (default)When both API keys are set and no --provider flag is given, Jarvis uses Anthropic and Scout uses OpenAI. The startup banner shows which provider each agent is using.
Once running, you'll see the REPL prompt:
Nipper
Agents: Jarvis, Scout
Workspace: ~/.nipper
Commands: /new (reset), /research <query>, /quit
You:
| Command | Description |
|---|---|
/new |
Start a fresh session (previous session is preserved in the database) |
/research <query> |
Route your message to the Scout research agent |
/quit /exit /q |
Exit the program |
Ctrl+C |
Exit the program |
General assistant (Jarvis):
You: What's the current date and time?
π§ run_command: {"command": "date"}
β Thu Feb 26 21:30:00 PST 2026
π€ [Jarvis] It's Thursday, February 26, 2026 at 9:30 PM PST.
Research agent (Scout):
You: /research what is the capital of France
π€ [Scout] The capital of France is Paris. It has been the country's capital since 987 CE.
Memory persistence:
You: Remember that my favorite language is Python
π§ save_memory: {"key": "user-preferences", "content": "Favorite language: Python"}
β Saved to memory: user-preferences
π€ [Jarvis] Got it. I've saved that to memory.
You: /new
Session reset.
You: What's my favorite programming language?
π§ memory_search: {"query": "favorite language preferences"}
β --- user-preferences ---
Favorite language: Python
π€ [Jarvis] Your favorite language is Python.
The system ships with two agents, each with their own personality (SOUL) and session history:
| Agent | Name | Trigger | Role |
|---|---|---|---|
main |
Jarvis | Default (any message) | General-purpose personal assistant |
researcher |
Scout | /research <query> |
Research specialist, cites sources |
Both agents share the same tool set and memory store, so findings from Scout are accessible to Jarvis and vice versa.
| Tool | Description |
|---|---|
run_command |
Execute shell commands (with permission controls) |
read_file |
Read file contents (truncated to 10,000 chars) |
write_file |
Write content to a file (creates parent directories) |
save_memory |
Store information to long-term memory by key |
memory_search |
Full-text search across all memories (FTS5) |
Shell commands are categorized into three tiers:
-
Safe β Execute immediately without prompting. Includes:
ls,cat,head,tail,wc,date,whoami,echo,pwd,which,git,python,node,npm -
Previously approved β Commands the user has approved before (stored in the
approvalstable) -
Needs approval β Everything else. The user is prompted in the terminal:
β οΈ Command: curl https://example.com
Allow? (y/n):
Approved and denied commands are persisted to the SQLite database so you only need to approve once.
Sessions are stored in the sessions table of the SQLite database (nipper.db). Each message is a row with a session_key, role, JSON-encoded content, and a created_at timestamp. This approach is:
- Append-only β New messages are inserted without rewriting existing data
- ACID-safe β SQLite transactions ensure crash safety
- Indexed β Sessions are keyed by
session_keywith a B-tree index for fast lookups
Session keys follow the pattern agent:<name>:repl (e.g., agent:main:repl, agent:researcher:repl, cron:morning-check).
When a session's estimated token count exceeds 100,000 tokens (~80% of a 128k context window), the system automatically:
- Splits the history in half
- Summarizes the older half using Claude
- Replaces the old messages with the summary
- Preserves recent messages intact
This keeps conversations running indefinitely without hitting context limits.
Memory is separate from session history. It uses two SQLite tables: memories for storage and memories_fts (an FTS5 virtual table) for full-text search.
The agent can:
- Save memories with
save_memory(key + content, upserts on key) - Search memories with
memory_search(FTS5 full-text search with OR-based matching)
Memory survives session resets (/new) and program restarts.
A background scheduler runs cron-style tasks. By default, a morning check is scheduled at 07:30 daily:
schedule.every().day.at("07:30").do(morning_check)Heartbeat tasks run in isolated sessions (e.g., cron:morning-check) so they don't pollute your chat history. Output is printed to the terminal.
To add custom heartbeats, edit the setup_heartbeats() function:
def setup_heartbeats():
# Existing morning check...
# Add your own:
def weekly_summary():
run_agent_turn(
"cron:weekly-summary",
"Summarize what we accomplished this week based on memory.",
AGENTS["main"]
)
schedule.every().monday.at("09:00").do(weekly_summary)The core agent loop follows a standard tool-use pattern:
User message
β
ββββ LLM Call ββββββββββββββββββββ
β β β
β Stop reason? β
β ββ end_turn β Return text β
β ββ tool_use β Execute tool β
β β β
β Tool results ββββββ
ββββββββββββββββββββββββββββββββββ
(max 20 iterations)
Each iteration:
- Sends the full message history + tools to Claude
- If Claude responds with text (
end_turn), returns it - If Claude requests tool use, executes the tools and feeds results back
- Repeats until done or 20 iterations reached
All persistent data lives under ~/.nipper/:
~/.nipper/
βββ nipper.db # SQLite database (WAL mode)
βββ sessions # Conversation history (table)
βββ memories # Long-term memory (table)
βββ memories_fts # Full-text search index (FTS5 virtual table)
βββ approvals # Approved/denied commands (table)
You can inspect the database directly:
sqlite3 ~/.nipper/nipper.db ".tables"
sqlite3 ~/.nipper/nipper.db "SELECT DISTINCT session_key FROM sessions;"
sqlite3 ~/.nipper/nipper.db "SELECT key, content FROM memories;"Edit the model field in the AGENTS dictionary:
AGENTS = {
"main": {
"model": "claude-sonnet-4-5-20250929", # Change this
...
},
}Edit the soul field in the AGENTS dictionary. This is the system prompt sent with every API call:
AGENTS = {
"main": {
"soul": "You are a helpful coding assistant. Be concise and technical.",
...
},
}Add a new entry to the AGENTS dictionary and update resolve_agent():
AGENTS = {
# ...existing agents...
"coder": {
"name": "Dev",
"model": "claude-sonnet-4-5-20250929",
"soul": "You are Dev, a coding specialist. Write clean, tested code.",
"session_prefix": "agent:coder",
},
}
def resolve_agent(message_text):
if message_text.startswith("/research "):
return "researcher", message_text[len("/research "):]
if message_text.startswith("/code "):
return "coder", message_text[len("/code "):]
return "main", message_text- Add the tool schema to the
TOOLSlist - Add the execution logic to
execute_tool()
# In TOOLS list:
{
"name": "web_search",
"description": "Search the web for information",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
# In execute_tool():
elif name == "web_search":
# Integrate with your preferred search API
return search_web(tool_input["query"])Edit the SAFE_COMMANDS set to add or remove commands that execute without approval:
SAFE_COMMANDS = {"ls", "cat", "head", "tail", "wc", "date", "whoami",
"echo", "pwd", "which", "git", "python", "node", "npm",
"docker", "cargo", "go"} # Add your trusted commands# Reset all sessions (keeps memory and approvals)
sqlite3 ~/.nipper/nipper.db "DELETE FROM sessions;"
# Reset long-term memory
sqlite3 ~/.nipper/nipper.db "DELETE FROM memories; DELETE FROM memories_fts;"
# Reset command approvals
sqlite3 ~/.nipper/nipper.db "DELETE FROM approvals;"
# Full reset
rm -rf ~/.nipper/MIT