Sid Ngeth's Blog A blog about anything (but mostly development)

nanoclaw's memory architecture: sqlite + 200-message rolling window



what is nanoclaw

nanoclaw is a WhatsApp-integrated AI assistant built on Claude that runs in Docker containers with per-group isolation. it’s designed for multi-group conversations with separate memory contexts, scheduled tasks, and persistent message history.

the interesting question isn’t what it does – it’s how it manages conversation context across multiple groups with potentially thousands of messages.

the memory question

when building LLM-based agents, one of the first architectural decisions is: how do you handle conversation history that exceeds the context window?

popular approaches:

  • RAG (Retrieval-Augmented Generation): embed messages, store in vector DB, retrieve semantically relevant chunks
  • Summarization: periodically summarize old messages, keep summaries in context
  • Sliding window: keep last N messages, drop older ones
  • Hybrid: combine multiple strategies

nanoclaw uses none of these. let’s see what it actually does.

architecture overview

nanoclaw’s memory system has two primary components:

  1. SQLite database (store/messages.db) - stores all messages with metadata
  2. Markdown files (conversations/ folder) - searchable conversation exports

the database schema is straightforward:

CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_jid TEXT NOT NULL, -- WhatsApp group ID sender TEXT NOT NULL, -- Phone number sender_name TEXT, -- Display name content TEXT NOT NULL, -- Message text timestamp TEXT NOT NULL, -- ISO 8601 is_from_me INTEGER DEFAULT 0, -- Bot's own messages is_bot_message INTEGER DEFAULT 0 -- Messages from bot ); 

source: src/db.ts lines 15-25

no embeddings. no vector columns. no fancy indexing beyond the standard B-tree on chat_jid and timestamp.

the 200-message rolling window

when nanoclaw processes a new message, it retrieves conversation history using getMessagesSince():

export function getMessagesSince( chatJid: string, sinceTimestamp: string, botPrefix: string, limit: number = 200, ): NewMessage[] { const sql = ` SELECT * FROM ( SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me FROM messages WHERE chat_jid = ? AND timestamp > ? AND is_bot_message = 0 AND content NOT LIKE ? AND content != '' AND content IS NOT NULL ORDER BY timestamp DESC LIMIT ? ) ORDER BY timestamp `; return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit); } 

source: src/db.ts lines 341-364

key observations:

  1. Hard limit of 200 messages - enforced at the SQL level via LIMIT ?
  2. Cursor-based pagination - uses sinceTimestamp instead of fixed time window
  3. Chronological order - ORDER BY timestamp ensures messages are in conversation order
  4. Filters bot’s own messages - excludes is_bot_message = 1 to avoid self-references

the 200-message limit is not configurable and not adaptive based on token count. it’s a simple message count cap.

cursor-based retrieval mechanism

the “rolling window” isn’t time-based – it’s cursor-based. here’s how it works:

// src/index.ts lines 158-163 const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); // After processing... // src/index.ts lines 183-184 lastAgentTimestamp[chatJid] = missedMessages[missedMessages.length - 1].timestamp; 

flow:

  1. Initial state: lastAgentTimestamp is empty string ''
    • retrieves last 200 messages from entire history
  2. After first run: cursor advances to timestamp of last processed message
  3. Next run: retrieves all messages AFTER that timestamp (up to 200)
  4. If < 200 new messages: gets all of them
  5. If > 200 new messages: gets only 200 most recent, older ones are dropped

this means:

  • if you send 500 messages while the bot is offline, it only sees the last 200
  • the cursor never goes backward
  • there’s no “lookback” or “re-retrieval” of older context

accessing context beyond the window

so what happens if you reference something from message #201?

nanoclaw provides manual retrieval tools:

1. conversation folder exports

# conversations/ The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. 

source: groups/main/CLAUDE.md line 39

agents can use the Read tool to read exported conversation markdown files.

2. grep tool

agents can search message content using the Grep tool:

Grep({ pattern: "budget discussion", path: "/workspace/project/conversations/", output_mode: "content" }) 

3. direct database queries

agents can query the SQLite database directly via Bash tool:

sqlite3 /workspace/project/store/messages.db " SELECT timestamp, sender_name, content FROM messages WHERE chat_jid = '[email protected]' AND content LIKE '%budget%' ORDER BY timestamp DESC LIMIT 10; " 

key point: retrieval is manual and tool-initiated. the agent must explicitly decide to search for old context. it’s not automatic like RAG.

comparison to RAG-based systems

feature nanoclaw typical RAG system
storage SQLite (relational) Vector DB (Pinecone, Chroma, Weaviate)
retrieval manual tool calls automatic semantic search
context selection chronological (last 200) semantic similarity top-k
embeddings none required
search SQL WHERE / Grep vector similarity (cosine, euclidean)
latency sub-millisecond SQL depends on vector DB, usually 10-100ms
cost zero (SQLite is free) vector DB hosting + embedding API calls
complexity low (just SQL) medium-high (embedding pipeline, vector indexing)

why this matters:

RAG systems automatically retrieve relevant context based on semantic similarity:

  • user asks “what was our budget discussion?”
  • system embeds the query
  • retrieves top 5 semantically similar messages
  • adds them to context

nanoclaw requires the agent to explicitly search:

  • agent sees “what was our budget discussion?”
  • agent thinks “I need to search for this”
  • agent calls Grep or Read tool
  • agent adds findings to response

this is more transparent (you see the search happening) but less automatic (agent might forget to search).

trade-offs and design decisions

why 200 messages?

likely a balance between:

  • context window limits: keeping token count manageable
  • conversation coherence: 200 messages covers most multi-turn conversations
  • query performance: SQLite LIMIT 200 is fast even on large tables

why no embeddings?

embeddings add complexity:

  • need embedding API (OpenAI, Cohere, etc.) or local model
  • need vector storage and indexing
  • need embedding refresh on message updates
  • adds latency and cost

for a personal assistant handling dozens of groups, simplicity > sophistication.

why cursor-based?

alternatives:

  • time window (last 7 days): breaks if conversation is inactive for a week
  • fixed offset (messages 1000-1200): doesn’t adapt to conversation growth
  • cursor (since last processed): always picks up where you left off

cursor-based ensures continuity even with irregular message patterns.

why manual retrieval?

automatic RAG retrieval can:

  • add irrelevant context (semantic similarity isn’t perfect)
  • increase latency (every message triggers vector search)
  • use more tokens (retrieved chunks added to every request)

manual retrieval gives the agent control over when to pay the cost of searching.

source citations

all analysis based on nanoclaw repository source code:

  1. database schema: src/db.ts lines 15-25
  2. getMessagesSince function: src/db.ts lines 341-364
  3. cursor advancement: src/index.ts lines 158-163, 183-184
  4. conversation folder docs: groups/main/CLAUDE.md line 39
  5. message filtering: excludes is_bot_message and bot-prefixed content

bottom line: nanoclaw’s memory is simple by design. no embeddings, no RAG, just SQLite with a 200-message rolling window and manual tool-based search. it trades automatic semantic retrieval for simplicity, transparency, and zero external dependencies.

for a multi-group WhatsApp assistant, that’s probably the right call.

adding vim keybindings to opencode's tui



the request

claude code shipped vim mode in late 2025. within days, opencode had two open issues asking for the same thing – #1764 and #11111, the latter with 20+ comments and a fair amount of frustration from people who’d switched from claude code specifically to get an open-source alternative but then had to give up muscle memory every time they opened the prompt.

opencode is exactly that: an open-source alternative to claude code. same agentic AI coding concept, same terminal-first workflow, but the source is public and you can modify it. the TUI is built on SolidJS and @opentui/core, a terminal rendering framework. the prompt input is a TextareaRenderable widget that handles text, cursor position, and syntax highlighting via extmarks.

the goal was exact parity with claude code’s vim feature set. not a subset where we skip the hard parts, not a superset where we add visual mode and count prefixes. whatever claude code supports, we support. whatever it doesn’t, we don’t. that constraint actually made the scope manageable.

what claude code’s vim mode actually supports

the official docs at code.claude.com/docs/en/interactive-mode list the full feature set. two modes only: NORMAL and INSERT. no visual mode, no command mode, no ex commands.

mode switching:

  • i – insert before cursor
  • I – insert at first non-blank of line
  • a – insert after cursor
  • A – insert at end of line
  • o – open new line below, enter insert mode
  • O – open new line above, enter insert mode
  • Esc – return to normal mode

navigation:

  • h/j/k/l – left/down/up/right
  • w/e/b – word motions
  • 0/$ – line start/end
  • ^ – first non-blank
  • gg/G – buffer start/end
  • f/F/t/T{char} – character search on current line
  • ;/, – repeat last f/F/t/T forward/backward

editing:

  • x – delete character
  • dd/D – delete line / delete to end
  • d{motion} – delete with motion
  • cc/C – change line / change to end
  • c{motion} – change with motion
  • yy/Y – yank line
  • y{motion} – yank with motion
  • p/P – paste after/before
  • >>/<< – indent/dedent
  • J – join lines
  • . – repeat last change

text objects (used with d/c/y):

  • iw/aw – inner/around word
  • iW/aW – inner/around WORD (whitespace-delimited)
  • i"/a" – inner/around double quotes
  • i'/a' – inner/around single quotes
  • i(/a( – inner/around parentheses
  • i[/a[ – inner/around square brackets
  • i{/a{ – inner/around curly braces

explicitly not supported:

  • no visual mode (v/V)
  • no count prefixes (3j, 5dd)
  • no undo/redo (u, Ctrl+R)
  • no / search
  • no r (replace char in place)

that’s the spec. now let’s look at the codebase we’re modifying.

opencode’s TUI architecture

opencode is a monorepo. the TUI lives in packages/opencode/. the relevant source tree:

packages/opencode/src/ config/ tui-schema.ts # Zod schema for tui.json tui.ts # config loading cli/cmd/tui/ app.tsx # root component, provider tree context/ keybind.tsx # keybind context (existing pattern we follow) vim.tsx # NEW: vim context component/ prompt/ index.tsx # ~1171 lines, the prompt component textarea-keybindings.ts # maps keybinds to KeyBinding[] lib/ vim-engine.ts # NEW: pure TS state machine 

if you’re wondering “why is a terminal app using SolidJS” – the JSX you see in opencode’s source isn’t React. it’s the same angle-bracket syntax, but SolidJS compiles it into direct reactive wiring with no virtual DOM and no diffing. the rendering stack is:

SolidJS (reactive state) → @opentui/solid (binding layer) → @opentui/core (Zig-based terminal engine)

SolidJS manages signals and effects. @opentui/solid translates JSX elements like <box> and <textarea> into native renderables backed by @opentui/core, which is written in Zig. bun is just the JavaScript runtime; there’s no browser, no electron, no DOM at any point.

the way Zig ends up rendering to your terminal is through bun’s FFI (foreign function interface). @opentui/core ships a platform-specific shared library (.dylib on macOS, .so on Linux) compiled from Zig source. the TypeScript side calls into it via bun:ffi – you can see this in the type definitions: createRenderer returns a Pointer, render takes a Pointer, everything crosses the FFI boundary as raw pointers and primitives. no serialization, no IPC, just direct function calls into native code.

on the TypeScript side, each renderable (BoxRenderable, TextRenderable, TextareaRenderable) holds a Yoga layout node – that’s Facebook’s cross-platform flexbox engine. when you write <box flexGrow={1} paddingLeft={2}>, Solid creates a renderable with those layout properties set on its Yoga node. the layout tree is computed in JavaScript, but the actual painting happens in Zig.

the render loop looks roughly like this:

  1. something changes – a signal updates, text is typed, the terminal resizes
  2. Yoga recomputes the layout tree (which nodes moved, which resized)
  3. each renderable paints its content into an OptimizedBuffer – a Zig-allocated character grid where each cell holds a character, foreground color, background color, and text attributes
  4. the Zig renderer diffs the current buffer against the previous frame
  5. only the cells that actually changed get written to stdout as ANSI escape codes – cursor movement sequences to skip unchanged regions, SGR sequences for colors and styles, then the character data

step 4 is where the performance comes from. a full-screen terminal might be 200x50 = 10,000 cells. if you type one character in the prompt, maybe 20 cells change. the Zig renderer writes escape codes for those 20 cells and skips the other 9,980. this is the same approach that tmux and neovim use – compute the minimal diff, emit the minimal escape sequences.

when a signal like vim.mode changes, only the one <text> node showing -- INSERT -- updates. that update is a direct property mutation on the Zig-backed renderable, which marks those cells dirty for the next paint. nothing else in the tree re-evaluates. this is why SolidJS is a good fit for TUI work: its granular reactivity maps directly to granular terminal updates.

the terminal rendering framework is @opentui/core at v0.1.87. it provides TextareaRenderable, which is the core input widget. the interface you care about:

interface TextareaRenderable { plainText: string; // current text content cursorOffset: number; // get/set cursor position (byte offset) setText(text: string): void; // replace entire content insertText(text: string): void; // insert at cursor clear(): void; extmarks: Extmark[]; // syntax highlighting regions } 

key flow for keyboard input: config in tui-schema.ts defines what keybinds are valid, keybind.ts parses them, keybind.tsx provides them via SolidJS context, and textarea-keybindings.ts maps them to KeyBinding[] objects that the textarea widget understands. the prompt component (prompt/index.tsx) has an onKeyDown handler that intercepts keys before they reach the textarea.

the context pattern throughout the codebase uses a createSimpleContext helper:

// from keybind.tsx -- the pattern we follow export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ name: "Keybind", init: () => { // ... setup logic return { /* context value */ }; }, }); 

createSimpleContext returns a { provider, use } pair. the provider wraps children and makes the context available. use is the hook that components call to access it. we follow this exact pattern for vim.

the implementation of createSimpleContext is straightforward – it’s a thin wrapper around SolidJS’s createContext and useContext:

// simplified from context/common.ts export function createSimpleContext<T>(opts: { name: string init: () => T }) { const Context = createContext<T | undefined>(undefined) function provider(props: { children: JSX.Element }) { const value = opts.init() return <Context.Provider value={value}>{props.children}</Context.Provider>  } function use(): T { const ctx = useContext(Context) if (!ctx) throw new Error(`${opts.name} context not found`) return ctx } return { provider, use } } 

the provider tree in app.tsx is a nested stack of these providers. order matters – a provider can only call use on contexts that are higher in the tree (already initialized). VimProvider reads from TuiConfigProvider, so it must be nested inside it:

// app.tsx (simplified) export function App() { return ( <TuiConfigProvider> <ThemeProvider> <KeybindProvider> <VimProvider> {" "} {/* reads TuiConfig, provides vim context */} <Prompt /> </VimProvider> </KeybindProvider> </ThemeProvider> </TuiConfigProvider> ); } 

the actual tree has more providers, but this shows the nesting relationship. Prompt can call useVim(), useKeybind(), useTheme(), and useTuiConfig() because all four are ancestors in the tree.

the four-layer architecture

the implementation splits across four layers. each layer has a single responsibility and doesn’t reach into the others’ concerns.

layer 1: config

packages/opencode/src/config/tui-schema.ts defines the shape of tui.json. we add one field:

vim: z.boolean() .optional() .describe("Enable vim editing mode in the prompt input"); 

opt-in via { "vim": true } in .opencode/tui.json. defaults to false (or rather, undefined, which we treat as false).

we used .optional() instead of .default(false) deliberately. the tui.ts loader has several fallback paths that construct empty {} objects and merge them. with .default(false), Zod’s output type inference makes vim non-optional (boolean instead of boolean | undefined), which causes type errors at those empty-object fallback sites. .optional() keeps vim as boolean | undefined throughout, and we handle the ?? false defaulting in the context layer where we actually use it.

layer 2: VimEngine

packages/opencode/src/cli/cmd/tui/lib/vim-engine.ts is 957 lines of pure TypeScript. zero imports. no SolidJS, no @opentui/core, no external dependencies at all.

the public interface:

export type VimMode = "normal" | "insert"; export interface VimKeyEvent { key: string; ctrl: boolean; shift: boolean; meta: boolean; } export interface VimResult { consumed: boolean; newText?: string; newCursor?: number; modeChange?: VimMode; } export class VimEngine { getMode(): VimMode; handleKey(event: VimKeyEvent, text: string, cursor: number): VimResult; reset(): void; } 

handleKey takes the current text and cursor position as arguments. it returns what changed. the engine never holds a reference to the textarea – it doesn’t know TextareaRenderable exists. the caller reads the current state, passes it in, and applies the result. this makes the engine testable with plain strings and integers, no UI framework required.

internal state:

private mode: VimMode = "normal" private pending: string = "" // multi-key buffer: "d", "c", "y", "g", ">", "<", "f", "F", "t", "T" private register: string = "" // unnamed yank register private lastChange: ChangeRecord | null = null // for dot repeat private lastFtMotion: FtMotion | null = null // for ; and , repeat private insertSession: InsertSession | null = null // tracks text on insert entry 

pending is how operator + motion composition works. pressing d doesn’t immediately do anything – it sets pending = "d" and returns { consumed: true }. the next key press sees pending === "d" and knows to interpret the key as a motion. pressing w with pending === "d" triggers applyOperator("d", text, cursor, "w").

applyOperator calls getMotionRange to find the affected character range, then deletes it and stores the deleted text in register. for the c operator, it also switches to insert mode after deleting. for y, it just stores the range in register without modifying text.

text objects (iw, a", i{, etc.) go through applyTextObjectOperator instead, which calls findTextObjectRange to locate the object boundaries.

lastChange is a ChangeRecord:

interface ChangeRecord { type: "operator-motion" | "operator-textobj" | "insert-change" | "simple"; operator?: string; motion?: string; textobj?: string; insertedText?: string; // what was typed during the insert session simpleOp?: () => VimResult; // for x, dd, D, J, >>, << } 

dot repeat replays whatever lastChange describes. for dw, it re-runs applyOperator("d", text, cursor, "w") against the current text and cursor. for cw{text}Esc, it re-runs the delete, switches to insert mode, inserts insertedText, then switches back to normal.

insertSession tracks the text content when we entered insert mode. on Esc, we diff the current text against insertSession.textBefore to find what was typed, and store that as insertedText in lastChange. this is how dot repeat knows what to re-insert.

lastFtMotion stores the last f/F/t/T operation:

interface FtMotion { type: "f" | "F" | "t" | "T"; char: string; } 

; replays it in the same direction. , replays it in the opposite direction (f becomes F, t becomes T, and vice versa).

the key dispatch loop:

handleKey(event: VimKeyEvent, text: string, cursor: number): VimResult { if (this.mode === "insert") { if (event.key === "escape") { return this.enterNormal(text, cursor) } return { consumed: false } // let the textarea handle it } // normal mode const key = this.resolveKey(event) // normalize shift+key combos if (this.pending) { return this.handlePendingKey(key, text, cursor) } return this.handleNormalKey(key, text, cursor) } 

in insert mode, the engine only intercepts Escape. everything else returns { consumed: false }, which tells the prompt component to let the key fall through to the textarea’s normal handling. this is the key insight: vim mode doesn’t replace the textarea’s input handling, it wraps it. regular typing in insert mode goes through the existing path unchanged.

layer 3: VimProvider

packages/opencode/src/cli/cmd/tui/context/vim.tsx is 35 lines:

import { createSignal } from "solid-js"; import { createSimpleContext } from "./common"; import { useTuiConfig } from "../../config/tui"; import { VimEngine, type VimMode, type VimKeyEvent } from "../lib/vim-engine"; export const { use: useVim, provider: VimProvider } = createSimpleContext({ name: "Vim", init: () => { const config = useTuiConfig(); const enabled = config.vim ?? false; const engine = new VimEngine(); const [mode, setMode] = createSignal<VimMode>( enabled ? "normal" : "insert", ); return { get enabled() { return enabled; }, get mode() { return mode(); }, handleKey(event: VimKeyEvent, text: string, cursor: number) { const result = engine.handleKey(event, text, cursor); if (result.modeChange) { setMode(result.modeChange); } return result; }, reset() { engine.reset(); setMode(enabled ? "normal" : "insert"); }, }; }, }); 

the provider’s job is to bridge the pure engine to SolidJS reactivity. mode is a signal so any component that reads vim.mode will re-render when the mode changes. the engine itself doesn’t know about signals – it returns modeChange in the result, and the provider calls setMode when it sees one.

VimProvider gets added to the provider tree in app.tsx, nested inside TuiConfigProvider (since it reads the config) but outside Prompt (since the prompt consumes it).

layer 4: prompt integration

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx has three touch points.

1. hook call – alongside the other context hooks at the top of the component:

const vim = useVim(); 

2. key interception – at the top of the onKeyDown handler, right after the disabled check:

if (vim.enabled) { const result = vim.handleKey( { key: e.name, ctrl: !!e.ctrl, shift: !!e.shift, meta: !!e.meta }, input.plainText, input.cursorOffset, ); if (result.consumed) { e.preventDefault(); if (result.newText !== undefined) { input.setText(result.newText); syncExtmarksWithPromptParts(); setStore("prompt", "input", result.newText); } if (result.newCursor !== undefined) { input.cursorOffset = result.newCursor; } return; } } 

when consumed is true, we apply the result and return early. the key never reaches the textarea’s default handler. when consumed is false (insert mode, regular typing), we fall through and the textarea handles it normally.

syncExtmarksWithPromptParts() is an existing function in the prompt component that re-applies syntax highlighting after text changes. we call it whenever we modify text through the vim engine, same as any other text modification path.

setStore("prompt", "input", result.newText) keeps the SolidJS store in sync with the textarea content. the store is the source of truth for the prompt value that gets sent to the agent.

3. mode indicator – in the tray section of the prompt JSX:

<Show when={vim.enabled}> <text fg={theme.textMuted}> {vim.mode === "normal" ? "-- NORMAL --" : "-- INSERT --"} </text> </Show> 

this only renders when vim is enabled. the vim.mode signal drives it reactively – when you press i to enter insert mode, the indicator updates immediately.

cross-referencing against neovim’s source

the spec from claude code’s docs tells you what commands exist. it doesn’t tell you the exact semantics. for that, we went to neovim’s C source.

the key files:

  • src/nvim/textobject.c – word motions (fwd_word, bck_word, end_word) and the cls() character classification function
  • src/nvim/normal.c – normal mode dispatch table, including the cw special case
  • src/nvim/charset.c / src/nvim/mbyte.c – character classification (utf_class)

character classification

word motions in vim depend on character classification. from textobject.c around line 271:

static int cls(void) { int c = gchar_cursor(); if (c == ' ' || c == '\t' || c == NUL) return 0; c = utf_class(c); if (c != 0 && cls_bigword) return 1; // W/B/E: all non-blank = class 1 return c; } 

three classes: 0 = whitespace, 1 = punctuation, 2+ = keyword characters. cls_bigword is set when processing W/B/E motions (WORD, not word), which treat all non-whitespace as the same class.

the default iskeyword setting is @,48-57,_,192-255, which expands to [a-zA-Z0-9_] plus the Latin-1 supplement range (characters 192-255). our isWordChar function uses /[A-Za-z0-9_]/, which matches the ASCII portion. for a prompt input that’s mostly code and English text, this is correct.

the w motion algorithm from fwd_word in textobject.c:

  1. get the class of the character at cursor
  2. skip all characters of the same class
  3. skip whitespace
  4. you’re now at the start of the next word

in C, the inner loop looks roughly like:

// simplified from fwd_word() int cls_start = cls(); while (!end_of_buffer()) { inc_cursor(); if (cls() != cls_start) break; } // now skip whitespace while (!end_of_buffer() && cls() == 0) { inc_cursor(); } 

our TypeScript equivalent:

function findNextWordStart(text: string, cursor: number): number { if (cursor >= text.length - 1) return cursor; const startClass = charClass(text[cursor]); let i = cursor; // skip same class while (i < text.length - 1 && charClass(text[i + 1]) === startClass) { i++; } i++; // skip whitespace while (i < text.length && (text[i] === " " || text[i] === "\t")) { i++; } return i; } function charClass(ch: string): number { if (ch === " " || ch === "\t" || ch === "") return 0; if (isWordChar(ch)) return 2; return 1; // punctuation } 

the b motion is the reverse: skip whitespace backward, then skip same-class characters backward. in TypeScript:

function findPrevWordStart(text: string, cursor: number): number { if (cursor <= 0) return 0; let i = cursor - 1; // skip whitespace while (i > 0 && (text[i] === " " || text[i] === "\t")) { i--; } // skip same class backward const endClass = charClass(text[i]); while (i > 0 && charClass(text[i - 1]) === endClass) { i--; } return i; } 

the symmetry between w and b is clean. w skips forward past same-class then whitespace. b skips backward past whitespace then same-class. the only asymmetry is that w ends at the start of the next word while b ends at the start of the previous word – both are word-start positions, just in different directions.

three bugs found

bug 1: cw should behave like ce

from normal.c around line 5951:

/* * "cw" is a special case - it works like "ce" if the cursor is * on a non-blank. This is not Vi compatible, but it's what Vim * has always done. */ if (cap->cmdchar == 'c' && cap->nchar == 'w' && !u_save_cursor() && !lineempty(curwin->w_cursor.lnum)) { if (!vim_iswhite(gchar_cursor())) { cap->nchar = 'e'; } } 

the comment says it all. cw maps to ce when the cursor is on a non-blank character. this is not Vi compatible – it’s a Vim-specific behavior that’s been there long enough that everyone expects it.

our initial implementation treated cw as a normal c + w composition, which deleted from cursor to the start of the next word (including the whitespace between words). the fix: in applyOperator, when the operator is c and the motion is w, check if the cursor is on whitespace. if not, redirect the motion to e.

// in applyOperator if (op === "c" && motion === "w") { const ch = text[cursor] ?? ""; if (ch !== " " && ch !== "\t" && ch !== "") { motion = "e"; } } 

bug 2: e motion starting position

neovim’s end_word function always calls inc_cursor() first – it advances at least one character before looking for the end of a word. this means pressing e when you’re already at the end of a word moves to the end of the next word.

our initial findNextWordEnd started at cursor:

// wrong let i = cursor; while (i < text.length - 1 && isWordChar(text[i + 1])) { i++; } 

if the cursor is at the last character of a word, this loop doesn’t advance at all and returns cursor. pressing e does nothing.

the fix is let i = cursor + 1:

// correct let i = cursor + 1; if (i >= text.length) return cursor; // skip whitespace while (i < text.length && (text[i] === " " || text[i] === "\t")) { i++; } // find end of word while (i < text.length - 1 && isWordChar(text[i]) && isWordChar(text[i + 1])) { i++; } return i; 

bug 3: iw on whitespace

vim’s documentation for iw (inner word): “Select [count] words (see word ). White space between words is counted too, see aw if you don’t want this.”

the actual behavior when the cursor is on whitespace: iw selects the whitespace run itself, not the adjacent word. aw selects the whitespace plus the adjacent word.

our initial implementation jumped to the adjacent word when the cursor was on whitespace, which is wrong. the fix:

function findWordRange( text: string, cursor: number, inner: boolean, ): [number, number] { const onWhitespace = text[cursor] === " " || text[cursor] === "\t"; if (onWhitespace) { // find the whitespace run let start = cursor; let end = cursor; while (start > 0 && (text[start - 1] === " " || text[start - 1] === "\t")) start--; while ( end < text.length - 1 && (text[end + 1] === " " || text[end + 1] === "\t") ) end++; if (!inner) { // aw: include the adjacent word (prefer the word after the whitespace) if (end + 1 < text.length && isWordChar(text[end + 1])) { while (end + 1 < text.length && isWordChar(text[end + 1])) end++; } else if (start > 0 && isWordChar(text[start - 1])) { while (start > 0 && isWordChar(text[start - 1])) start--; } } return [start, end]; } // cursor is on a word character let start = cursor; let end = cursor; while (start > 0 && isWordChar(text[start - 1])) start--; while (end < text.length - 1 && isWordChar(text[end + 1])) end++; if (!inner) { // aw: include trailing whitespace (or leading if at end of line) if ( end + 1 < text.length && (text[end + 1] === " " || text[end + 1] === "\t") ) { while ( end + 1 < text.length && (text[end + 1] === " " || text[end + 1] === "\t") ) end++; } else if ( start > 0 && (text[start - 1] === " " || text[start - 1] === "\t") ) { while (start > 0 && (text[start - 1] === " " || text[start - 1] === "\t")) start--; } } return [start, end]; } 

the escape key name bug

this one wasn’t from neovim source – it came up during live testing. @opentui/core sends the escape key as "escape" (lowercase). we were checking for "Escape" (capital E). the engine never saw escape key events, so you couldn’t exit insert mode.

the fix is a single character change in the key dispatch:

if (event.key === "escape") { // not "Escape" return this.enterNormal(text, cursor); } 

worth noting because it’s the kind of thing that’s invisible in unit tests if you’re not careful about what key names your test framework uses.

full vim mode reference

mode switching

key action
i enter insert mode at cursor
I enter insert mode at first non-blank of line
a enter insert mode after cursor
A enter insert mode at end of line
o open new line below, enter insert mode
O open new line above, enter insert mode
Esc return to normal mode / clear pending command
key action
h move left
j move down (next line)
k move up (previous line)
l move right
w next word start
e end of current/next word
b previous word start
W next WORD start (whitespace-delimited)
E end of current/next WORD
B previous WORD start
0 beginning of line
$ end of line
^ first non-blank character of line
gg beginning of buffer
G end of buffer
f{char} jump to next occurrence of {char} on current line
F{char} jump to previous occurrence of {char} on current line
t{char} jump to just before next {char} on current line
T{char} jump to just after previous {char} on current line
; repeat last f/F/t/T in same direction
, repeat last f/F/t/T in opposite direction

editing (normal mode)

key action
x delete character at cursor
dd delete entire line
D delete from cursor to end of line
dw delete to next word start
de delete to end of word
db delete to previous word start
d0 delete to beginning of line
d$ delete to end of line
d{motion} delete with any supported motion
cc change entire line (delete + enter insert)
C change from cursor to end of line
cw change to end of word (behaves like ce)
ce change to end of word
cb change to previous word start
c{motion} change with any supported motion
yy / Y yank entire line into register
yw yank to next word start
ye yank to end of word
yb yank to previous word start
y{motion} yank with any supported motion
p paste register after cursor (linewise: below current line)
P paste register before cursor (linewise: above current line)
>> indent current line by one shiftwidth
<< dedent current line by one shiftwidth
J join current line with next (adds space, strips leading whitespace)
. repeat last change

text objects (used with d/c/y in normal mode)

text object description
iw inner word – the word under cursor, or whitespace run if on whitespace
aw around word – word plus surrounding whitespace
iW inner WORD – whitespace-delimited token
aW around WORD – WORD plus surrounding whitespace
i" inner double quotes – content between "..."
a" around double quotes – includes the quote characters
i' inner single quotes – content between '...'
a' around single quotes – includes the quote characters
i( inner parentheses – content between (...)
a( around parentheses – includes the parentheses
i[ inner square brackets – content between [...]
a[ around square brackets – includes the brackets
i{ inner curly braces – content between {...}
a{ around curly braces – includes the braces

not supported (matching claude code)

feature reason
visual mode (v/V) not in claude code’s feature set
count prefixes (3j, 5dd) not in claude code’s feature set
undo/redo (u, Ctrl+R) not in claude code’s feature set
search (/, ?, n, N) not in claude code’s feature set
replace char (r) not in claude code’s feature set
marks (m, `, ') not in claude code’s feature set
registers ("a, "b, etc.) not in claude code’s feature set
ex commands (:) not in claude code’s feature set
macros (q, @) not in claude code’s feature set

how to try it

the full source is on github: sngeth/opencode (feat/vim-keybindings)

git clone https://github.com/sngeth/opencode.git cd opencode git checkout feat/vim-keybindings bun install 

create ~/.config/opencode/tui.json to enable vim mode globally:

{ "vim": true } 

you can also use .opencode/tui.json in a specific project directory, but the global path is better when running the compiled binary since CWD changes per project.

bun run dev from packages/opencode/ works but locks your terminal to the repo directory. sessions and file paths resolve relative to process.cwd(), so you want to invoke it from the project you’re actually working on.

compile a native binary instead. bun bundles everything – dependencies, the SolidJS JSX transform, tree-sitter parsers, migrations – into a single executable:

# from packages/opencode bun run build --single --skip-install 

this produces a ~116MB binary at dist/opencode-<platform>-<arch>/bin/opencode. symlink it somewhere on your $PATH under a name that won’t shadow the official opencode binary:

ln -sf $(pwd)/dist/opencode-darwin-arm64/bin/opencode ~/.local/bin/opencode-vim 

now opencode-vim works from any directory. sessions are scoped correctly and the official opencode stays untouched. rebuild after source changes with --single --skip-install (~10 seconds).

the mode indicator appears in the prompt tray. it shows -- NORMAL -- when you’re in normal mode and -- INSERT -- when you’re in insert mode. opencode starts in normal mode when vim is enabled, same as neovim’s default.

if you want to start in insert mode automatically, press i after the prompt loads. there’s no config option for the initial mode – that’s a deliberate choice to match how vim itself works.

files changed

file change
packages/opencode/src/config/tui-schema.ts added vim: z.boolean().optional() field
packages/opencode/src/cli/cmd/tui/lib/vim-engine.ts NEW – 957-line pure TypeScript state machine
packages/opencode/src/cli/cmd/tui/context/vim.tsx NEW – 35-line SolidJS context wrapping the engine
packages/opencode/src/cli/cmd/tui/app.tsx added VimProvider to the provider tree
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx key interception in onKeyDown + mode indicator in tray

five files. the engine is the bulk of it. the integration is thin by design – the engine does the work, the prompt just routes keys through it and applies the results.

the pure engine approach paid off during debugging. when the cw behavior was wrong, we could write a test that called engine.handleKey directly with a string and cursor position, without spinning up a terminal or a SolidJS component tree. the neovim source comparison was also easier because we were comparing TypeScript logic to C logic, not trying to trace through UI event handling at the same time.

the escape key name bug is a good reminder that integration tests matter even when unit tests pass. the engine was correct. the key name mismatch was in the boundary between @opentui/core’s event format and our engine’s expected format. that boundary only exists in the running application.

more gotchas (found after shipping)

the initial post covered three bugs found during development. three more showed up after the implementation was running in daily use.

x silently fails on trailing spaces

the symptom: pressing x at the end of a line with a trailing space does nothing. the character is there, the cursor is on it, but x returns without deleting.

the root cause is a cursor model mismatch. @opentui/core’s TextareaRenderable.cursorOffset is a gap-position – it sits between characters, like a text insertion point. vim’s normal mode cursor is a cell-position – it sits on a character. for the string "xxx " (four characters), cursorOffset=4 means “after the space” in the gap model. in vim’s model, that position doesn’t exist: the valid range is 0-3.

the x handler had an early return for this case:

if (safeCursor >= text.length) return { consumed: true }; 

when cursorOffset=4 and text.length=4, this fires and returns without deleting anything. the user sees nothing happen.

the same mismatch caused a second problem: pressing Escape to exit insert mode didn’t move the cursor left by one position. real neovim does this – if you’re at the end of a word in insert mode and press Escape, the cursor steps back one character so it’s sitting on the last character rather than past it. without that adjustment, the cursor stays at offset 4 after switching to normal mode, which puts it in the gap-position zone where operations silently fail.

two fixes in vim-engine.ts:

// before -- safeCursor could equal text.length in normal mode const safeCursor = this.clamp(cursor, 0, text.length); // after -- clamp to text.length - 1 so cursor is always on a character const safeCursor = text.length > 0 ? this.clamp(cursor, 0, text.length - 1) : 0; 
// before -- escape exits insert mode but leaves cursor at end-of-text gap position return { consumed: true, modeChange: "normal" }; // after -- step cursor back one, matching neovim's behavior return { consumed: true, newCursor: text.length > 0 ? Math.max(0, cursor - 1) : 0, modeChange: "normal", }; 

the clamping fix is the more important one. any operation that checked safeCursor >= text.length or safeCursor >= end would silently fail when the cursor was at the end of text. x was the most visible case, but the same condition could affect other operators.

every shifted command is silently broken

pressing Shift+j to join lines moved the cursor down instead. pressing Shift+o to open a line above did nothing visible. every uppercase command – J, O, A, I, G, D, C, Y, P, W, E, B, F, T – was broken.

@opentui/core sends key events with the base character in e.name (always lowercase) and shift state as a separate boolean e.shift. so Shift+j arrives as { name: "j", shift: true }, not { name: "J" }. the vim engine was doing const key = event.key and checking against uppercase literals like if (key === "J"). the key was always lowercase. the uppercase branch never matched.

the lowercase branch matched instead: if (key === "j") → move down. Shift+j moved the cursor down when it should have joined lines. Shift+o hit if (key === "o") → open line below instead of above.

the fix is one line at the top of handleKey:

// before const key = event.key; // after const key = event.shift && event.key.length === 1 ? event.key.toUpperCase() : event.key; 

when shift is held and the key is a single character, uppercase it before dispatch. this is the kind of bug that’s invisible in unit tests where you construct key events with the character you expect ("J") rather than the character the framework actually sends ("j" + shift: true).

escape key routing during generation

opencode has a session interrupt command – when a task is running, pressing Escape should abort the generation. after adding vim mode, that stopped working. pressing Escape during generation always triggered the interrupt instead of letting vim switch from insert to normal mode.

the root cause wasn’t in the textarea’s onKeyDown handler at all. opencode’s command system registers a global useKeyboard handler in dialog-command.tsx that fires before any component-level key handler:

// dialog-command.tsx -- global handler, fires first useKeyboard((evt) => { if (suspended()) return; if (dialog.stack.length > 0) return; for (const option of entries()) { if (!isEnabled(option)) continue; if (option.keybind && keybind.match(option.keybind, evt)) { evt.preventDefault(); option.onSelect?.(dialog); return; } } }); 

the session_interrupt command is registered with keybind: "session_interrupt" (mapped to "escape") and enabled: status().type !== "idle". during generation, every Escape keypress matches the interrupt command before the textarea ever sees it. vim never gets a chance to handle the key.

the fix is one line – make the interrupt command aware of vim’s mode:

// before enabled: status().type !== "idle", // after enabled: status().type !== "idle" && !(vim.enabled && vim.mode === "insert"), 

when vim is in insert mode, the interrupt command is disabled. Escape passes through the global handler without matching, reaches the textarea’s onKeyDown, and vim processes it normally (insert to normal). on the next Escape, vim is in normal mode, the interrupt command is enabled again, and the abort fires.

the resulting behavior:

mode task running Escape does
insert yes switches to normal mode
normal yes interrupts the generation
insert no switches to normal mode
normal no clears pending command

this gives you the two-step sequence that feels natural: Escape to get to normal mode, Escape again to interrupt. it matches the mental model of “escape gets me out of whatever I’m in” – first out of insert mode, then out of the generation.

opencode permission rules: protecting your code from ai agents

a terraform destroy that wiped production

in february 2026, alexey grigorev let his claude code agent run terraform destroy on what he thought were duplicate resources. the agent had silently unpacked an old terraform state file that pointed at production infrastructure instead. the entire course management platform for datatalks.club went down – database, vpc, ecs cluster, load balancers, bastion host, all gone. every automated snapshot was deleted with it.

“i over-relied on the ai agent to run terraform commands. i treated plan, apply, and destroy as something that could be delegated. that removed the last safety layer.”

the agent told him it would run terraform destroy. he approved it. the permission system worked – it asked. but he didn’t realize the state file had changed underneath him, so “yes, destroy those duplicates” actually meant “yes, destroy production.”

this is the hard lesson: ask only protects you if you understand what you’re approving. he saw terraform destroy and it looked logical in context. the agent had even explained its reasoning. but the premise was wrong because the state file was wrong, and no amount of prompting catches that.

“what happened was that i didn’t notice claude unpacking my terraform archive. it replaced my current state file with an older one that had all the info about the datatalks.club course management platform.”

his fix was to stop delegating entirely:

“agents no longer execute commands. every plan is reviewed manually. every destructive action is run by me.”

that works, but it’s also the nuclear option. opencode’s permission system offers a middle ground: let agents run safe commands freely, prompt you for risky ones, and hard-block the ones where no amount of context makes them safe to delegate.

the same class of problem applies to git worktrees. an agent running git restore, git reset --hard, or git clean can silently wipe uncommitted changes with no recovery path. here’s a practical config that draws the line.

the configuration

add this to your ~/.config/opencode/opencode.json:

{ "permission": { "bash": { "*": "ask", "git status*": "allow", "git diff*": "allow", "git log*": "allow", "git add*": "allow", "git commit*": "ask", "git stash*": "ask", "git checkout*": "ask", "git restore*": "ask", "git reset*": "ask", "git clean*": "deny", "git worktree*": "ask" } } } 

how it works

opencode evaluates permission rules using last-match-wins ordering. the "*": "ask" catch-all at the top means any bash command not explicitly listed requires your approval. more specific rules below it override the default for their matched patterns.

three permission levels:

  • allow – runs silently, no prompt
  • ask – prompts you with once/always/reject before executing
  • deny – blocked outright, the agent cannot run it at all

test results

all tests run against a stub git repo with uncommitted modifications and untracked files present.

“allow” commands – ran without prompting

these are safe, read-only (or low-risk staging) operations. the agent runs them freely.

test 1: git status

rule: "git status*": "allow" result: executed immediately, no prompt.
$ git status On branch main Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory)	modified: main.py Untracked files: (use "git add <file>..." to include in what will be committed)	untracked-work.txt no changes added to commit (use "git add" and/or "git commit -a") 

test 2: git diff

rule: "git diff*": "allow" result: executed immediately, no prompt.
$ git diff diff --git a/main.py b/main.py index 110c807..11a6bc3 100644 --- a/main.py +++ b/main.py @@ -4,3 +4,7 @@ def greet(name): if __name__ == "__main__": print(greet("world")) + + +def farewell(name): + return f"Goodbye, {name}!" 

test 3: git log

rule: "git log*": "allow" result: executed immediately, no prompt.
$ git log --oneline bf23d2b initial commit 

test 4: git add

rule: "git add*": "allow" result: executed immediately, no prompt.
$ git add . (no output -- files staged successfully) 

“ask” commands – prompted for approval

these commands can modify your working tree or history. opencode shows a prompt with once, always, or reject options before the command runs.

test 5: git commit

rule: "git commit*": "ask" result: prompted, approved, then executed.
$ git commit -m "test commit from agent" [main 7db8a3c] test commit from agent 2 files changed, 6 insertions(+) create mode 100644 untracked-work.txt 

test 6: git stash

rule: "git stash*": "ask" result: prompted, approved, then executed.
$ git stash Saved working directory and index state WIP on main: 7db8a3c test commit from agent 

test 7: git restore

rule: "git restore*": "ask" result: prompted, approved, then executed.
$ git restore main.py (no output -- changes silently discarded) 

this is one of the most dangerous “quiet” commands. without the ask rule, an agent could silently discard your uncommitted modifications with zero output to indicate anything happened.

test 8: git checkout

rule: "git checkout*": "ask" result: prompted, approved, then executed.
$ git checkout feature-branch Switched to branch 'feature-branch' 

test 9: git reset --hard

rule: "git reset*": "ask" result: prompted, approved, then executed.
$ git reset --hard HEAD HEAD is now at c44ce28 test commit from agent 

this catches the most destructive variant (--hard) which resets the index and working tree, throwing away all uncommitted changes.

test 10: git worktree

rule: "git worktree*": "ask" result: prompted, approved, then executed.
$ git worktree add ../permission-test-wt main Preparing worktree (checking out 'main') HEAD is now at 7db8a3c test commit from agent 

“deny” command – blocked entirely

test 11: git clean -fd

rule: "git clean*": "deny" result: blocked. the agent received an error and the command never executed.
$ git clean -fd Error: The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules: [ {"permission":"*","pattern":"*","action":"allow"}, {"permission":"bash","pattern":"*","action":"ask"}, {"permission":"bash","pattern":"git status*","action":"allow"}, {"permission":"bash","pattern":"git diff*","action":"allow"}, {"permission":"bash","pattern":"git log*","action":"allow"}, {"permission":"bash","pattern":"git add*","action":"allow"}, {"permission":"bash","pattern":"git commit*","action":"ask"}, {"permission":"bash","pattern":"git stash*","action":"ask"}, {"permission":"bash","pattern":"git checkout*","action":"ask"}, {"permission":"bash","pattern":"git restore*","action":"ask"}, {"permission":"bash","pattern":"git reset*","action":"ask"}, {"permission":"bash","pattern":"git clean*","action":"deny"}, {"permission":"bash","pattern":"git worktree*","action":"ask"} ] 

the untracked file (expendable.txt) survived:

$ ls expendable.txt expendable.txt 

git clean is the only command set to deny because it permanently removes untracked files with no recovery path – not even git reflog can help you. the ask tier at least gives you a chance to think; deny takes the option off the table entirely.


why this matters for worktrees

when you use git worktree to run multiple agents in parallel on different branches, each worktree has its own working tree but shares the same .git directory. this creates a unique risk surface.

commands that destroy work inside a worktree

  1. git restore <file> – the most common agent culprit. agents run this reflexively to “undo” a bad edit. it silently discards your modifications with zero output. you’d never know it happened.
  2. git reset --hard – agents reach for this when things get messy and they want a “clean slate.” resets the index and working tree, all uncommitted changes gone.
  3. git checkout <branch> – if uncommitted changes conflict with the target branch, git overwrites your working tree files. agents switch branches casually.
  4. git clean -fd – deletes all untracked files permanently. new files that were never staged are gone with no recovery path, not even git reflog.

worktree management commands that destroy work

the worktree management commands themselves are also dangerous:

  1. git worktree remove <path> – deletes the entire worktree directory. git refuses if there are uncommitted modifications, but agents will retry with --force when they hit the error, which bypasses the safety check and nukes everything.
  2. git worktree remove --force <path> – skips the dirty-tree check entirely. if an agent decides to “clean up” a worktree you’re still working in, all uncommitted files in that worktree directory are gone.
  3. git branch -D <branch> – while not a worktree command, an agent in one worktree can delete a branch that’s checked out in another worktree, leaving it in a broken state.

shared state risks across worktrees

all worktrees share the same .git directory. an agent operating in one worktree can affect others:

  • git gc --prune=now can clean up loose objects that other worktrees still reference
  • ref updates (tags, branches) in one worktree are immediately visible to all others
  • git stash in one worktree is accessible from all worktrees – an agent could accidentally pop another worktree’s stash

the "*": "ask" catch-all is critical. it means any command not explicitly listed – rm, curl, python, whatever – still requires your approval rather than running blind.

applying this to terraform

the datatalks.club incident would have been prevented with:

{ "permission": { "bash": { "terraform plan*": "ask", "terraform apply*": "deny", "terraform destroy*": "deny", "terraform import*": "ask" } } } 

deny on terraform apply and terraform destroy forces you to run them yourself in a separate terminal where you see the full plan output and can verify which state file terraform is using. the agent can still generate plans and write config – it just can’t pull the trigger.

this wouldn’t have helped if he’d rubber-stamped an ask prompt the same way he approved the agent’s reasoning in chat. but deny removes the option entirely – the agent physically cannot execute the command, no matter how logical its explanation sounds.

additional recommendations

if you’re using multiple worktrees in different directories, add external_directory rules to control cross-worktree access:

{ "permission": { "external_directory": { "~/Code/my-project-wt-*": "ask" } } } 

this ensures the agent in one worktree has to get your approval before touching files in another worktree that’s outside the directory where you launched opencode.

important notes

  • restart required: permission changes in opencode.json take effect on the next opencode launch, not mid-session.
  • last-match-wins: rules are evaluated in order and the last matching rule wins. put the "*" catch-all first, specific rules after.
  • wildcard behavior: * matches zero or more of any character (including spaces). "git status*" matches both git status and git status --porcelain.
  • per-agent overrides: you can set different permissions per agent in the "agent" config section. for example, a review-only agent could have "edit": "deny".
  • “always” is session-only: when opencode prompts you and you choose “always”, that approval only lasts for the current session. it lives in memory, not on disk – it does not update opencode.json. once you restart opencode, you’ll be prompted again. if you want a permanent allow rule, add it to opencode.json yourself.

server-side vs client-side tools in llm apis

when building agentic applications with the anthropic api, understanding the difference between client tools and server tools is essential. they have different execution models, response structures, and handling requirements.

the two types of tools

from the anthropic documentation:

Claude supports two types of tools:

  1. Client tools: Tools that execute on your systems
  2. Server tools: Tools that execute on Anthropic’s servers, like the web search and web fetch tools. These tools must be specified in the API request but don’t require implementation on your part.
aspect client tools server tools
execution your systems anthropic’s servers
response handling you must return tool_result results arrive automatically
content blocks tool_use server_tool_use + web_search_tool_result
definition input_schema object versioned type field

defining each type

client tool - you provide a schema, claude generates input, you execute it:

{ name: "get_weather", description: "Get the current weather in a given location", input_schema: { type: "object", properties: { location: { type: "string", description: "The city and state, e.g. San Francisco, CA" } }, required: ["location"] } } 

server tool - you specify a versioned type, anthropic handles execution:

{ type: "web_search_20250305", name: "web_search", max_uses: 5 } 

server tools use versioned types (e.g., web_search_20250305) to ensure compatibility across model versions.

different workflows

client tool workflow

  1. you define the tool with a schema
  2. claude returns a tool_use block with stop_reason: "tool_use"
  3. you execute the tool on your systems
  4. you return results via tool_result
  5. claude formulates the final response
// client tool response { "stop_reason": "tool_use", "content": [ { "type": "tool_use", "id": "toolu_01A09q90qw90lq917835lq9", "name": "get_weather", "input": {"location": "San Francisco, CA"} } ] } 

you then execute and return:

messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_01A09q90qw90lq917835lq9", content: "72°F, sunny" }] }); 

server tool workflow

  1. you specify the server tool in your request
  2. claude executes the tool automatically
  3. results are embedded in the response as web_search_tool_result
  4. no tool_result needed from you
  5. claude formulates the final response
// server tool response { "content": [ { "type": "text", "text": "I'll search for that information." }, { "type": "server_tool_use", "id": "srvtoolu_01WYG3ziw53XMcoyKL4XcZmE", "name": "web_search", "input": { "query": "claude shannon birth date" } }, { "type": "web_search_tool_result", "tool_use_id": "srvtoolu_01WYG3ziw53XMcoyKL4XcZmE", "content": [ { "type": "web_search_result", "url": "https://en.wikipedia.org/wiki/Claude_Shannon", "title": "Claude Shannon - Wikipedia", "encrypted_content": "..." } ] }, { "type": "text", "text": "Claude Shannon was born on April 30, 1916...", "citations": [...] } ] } 

the key difference: server tool results are already in the response. anthropic executed the search; you just continue the conversation.

handling both in an agentic loop

when building an agentic loop that uses both tool types, you need to distinguish between them:

if (response.stop_reason === "tool_use") { messages.push({ role: "assistant", content: response.content }); // find client-side tools that need results const clientToolBlocks = response.content.filter( (block): block is Anthropic.ToolUseBlock => block.type === "tool_use" ); if (clientToolBlocks.length > 0) { // execute client tools and return results const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const block of clientToolBlocks) { const result = await executeMyTool(block.name, block.input); toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result, }); } messages.push({ role: "user", content: toolResults }); } } // for server tools, check for server_tool_use blocks const hasServerTool = response.content.some( block => block.type === "server_tool_use" ); 

the server_tool_use block type tells you anthropic is handling execution. the results come back as web_search_tool_result blocks in the same response.

common mistake: sending empty tool_result for server tools

a common bug is treating server tools like client tools:

// wrong: sending tool_result for server-side tools for (const block of response.content) { if (block.type === "tool_use") { toolResults.push({ type: "tool_result", tool_use_id: block.id, content: "", // what would you even put here? }); } } 

this happens when code is adapted from client-tool examples without accounting for server tools. sending empty tool_result messages injects noise into the conversation - the model may interpret it as the tool failing.

pricing

server tools have usage-based pricing in addition to token costs. from the web search documentation:

Web search is available on the Claude API for $10 per 1,000 searches, plus standard token costs for search-generated content.

the usage is tracked in the response:

"usage": { "input_tokens": 105, "output_tokens": 6039, "server_tool_use": { "web_search_requests": 1 } } 

key points

  • client tools: you define, you execute, you return tool_result
  • server tools: you specify, anthropic executes, results arrive automatically
  • detect by block type: tool_use vs server_tool_use
  • never send tool_result for server tools - the results are already in the response
  • server tools are versioned (e.g., web_search_20250305) for compatibility

references

migrating to zig 0.15: the roadblocks nobody warned you about

i built a command-line security tool to analyze shell scripts before executing them (preventing those dangerous curl | bash situations). starting with zig 0.15 meant hitting every breaking change head-on. here’s what actually broke and how to fix it.

the project: safe-curl

the tool analyzes shell scripts for malicious patterns:

  • recursive file deletion (rm -rf /)
  • code obfuscation (base64 decoding, eval)
  • privilege escalation (sudo)
  • remote code execution

zig seemed perfect for a simple CLI tool with minimal dependencies. then i hit the 0.15 changes.

source: safe-curl on github

roadblock 1: arraylist requires allocator everywhere

the change: zig 0.15 replaced std.ArrayList with std.array_list.Managed as the default. the “managed” variant now requires passing an allocator to every method call.

official reasoning: zig 0.15 release notes explain: “Having an extra field is more complicated than not having an extra field.” the unmanaged variant is now the primary implementation, with the managed version as a wrapper.

what broke

my initial attempt looked like this:

const Finding = struct { severity: Severity, message: []const u8, line_num: usize, }; const AnalysisResult = struct { findings: std.ArrayList(Finding), fn init(allocator: std.mem.Allocator) AnalysisResult { return .{ .findings = std.ArrayList(Finding).init(allocator), }; } fn addFinding(self: *AnalysisResult, finding: Finding) !void { try self.findings.append(finding); // Error: missing allocator } }; 

error message:

error: expected 2 arguments, found 1 

the fix

you have two options in 0.15:

option 1: store the allocator and pass it to methods

const AnalysisResult = struct { findings: std.ArrayList(Finding), allocator: std.mem.Allocator, // Store allocator fn init(allocator: std.mem.Allocator) AnalysisResult { return .{ .findings = std.ArrayList(Finding).init(allocator), .allocator = allocator, }; } fn addFinding(self: *AnalysisResult, finding: Finding) !void { try self.findings.append(self.allocator, finding); // Pass allocator } fn deinit(self: *AnalysisResult) void { self.findings.deinit(self.allocator); // Pass here too } }; 

option 2: use the unmanaged variant

const AnalysisResult = struct { findings: std.ArrayListUnmanaged(Finding), fn init() AnalysisResult { return .{ .findings = .{}, // Empty initialization }; } fn addFinding(self: *AnalysisResult, allocator: std.mem.Allocator, finding: Finding) !void { try self.findings.append(allocator, finding); } fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void { self.findings.deinit(allocator); } }; 

i went with option 1 for familiarity, but option 2 is more idiomatic in 0.15.

why this change?

the zig team explains that storing the allocator in the struct adds complexity. with the unmanaged variant as default, you get:

  • simpler method signatures
  • static initialization support (.{})
  • explicit allocator lifetime management

trade-off: you pass the allocator everywhere, but your data structures are cleaner.

roadblock 2: empty struct initialization .{}

the pattern: zig 0.15 introduced a shorthand for empty struct initialization.

what this enables

before, initializing an empty arraylist required:

var findings = std.ArrayList(Finding).init(allocator); 

now you can use struct field inference:

const AnalysisResult = struct { findings: std.ArrayList(Finding), fn init(allocator: std.mem.Allocator) AnalysisResult { return .{ .findings = .{}, // Compiler infers std.ArrayList(Finding).init(allocator) .allocator = allocator, }; } }; 

this syntax confused me initially because .{} looks like an empty struct literal, but it actually calls the appropriate init function based on the field type.

when it works: field type is clear from context when it breaks: compiler can’t infer the type

var list: std.ArrayList(Item) = .{}; // Works var list = .{}; // Error: cannot infer type 

roadblock 3: process api breaking changes

the change: std.process.Child.run() return type changed significantly.

what broke

fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { const result = try std.process.Child.run(.{ .allocator = allocator, .argv = &[_][]const u8{ "curl", "-fsSL", url }, }); defer allocator.free(result.stderr); // This line broke if (result.term.Exited != 0) { allocator.free(result.stdout); return error.HttpRequestFailed; } return result.stdout; } 

error: no field named 'Exited' in union 'std.process.Child.Term'

the fix

the term field changed from having an Exited field to being a tagged union:

fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { const result = try std.process.Child.run(.{ .allocator = allocator, .argv = &[_][]const u8{ "curl", "-fsSL", url }, }); defer allocator.free(result.stderr); // Check the union variant properly switch (result.term) { .Exited => |code| { if (code != 0) { allocator.free(result.stdout); return error.HttpRequestFailed; } }, else => { allocator.free(result.stdout); return error.ProcessFailed; }, } return result.stdout; } 

this is more explicit about handling different termination types (signal, unknown, etc.).

roadblock 4: http client instability

the problem: zig’s std.http.Client is still evolving rapidly between versions.

what i tried

fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { var client = std.http.Client{ .allocator = allocator }; defer client.deinit(); const uri = try std.Uri.parse(url); var server_header_buffer: [16384]u8 = undefined; var req = try client.open(.GET, uri, .{ .server_header_buffer = &server_header_buffer, }); defer req.deinit(); try req.send(); try req.wait(); // ... read response } 

errors:

  • API mismatches between documentation and actual implementation
  • buffer size requirements unclear
  • response reading patterns changed between minor versions

the workaround

the zig 0.15 release notes acknowledge: “HTTP client/server completely reworked to depend only on I/O streams, not networking directly.”

this instability meant falling back to shelling out:

fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { // Use curl as a fallback since the Zig HTTP client API is too unstable const result = try std.process.Child.run(.{ .allocator = allocator, .argv = &[_][]const u8{ "curl", "-fsSL", url }, }); defer allocator.free(result.stderr); switch (result.term) { .Exited => |code| { if (code != 0) { allocator.free(result.stdout); return error.HttpRequestFailed; } }, else => { allocator.free(result.stdout); return error.ProcessFailed; }, } return result.stdout; } 

not ideal for a “zero dependency” tool, but pragmatic given the api churn.

roadblock 5: reader/writer overhaul (“writergate”)

the change: zig 0.15 completely redesigned std.io.Reader and std.io.Writer interfaces.

from the release notes: “A complete overhaul of the standard library Reader and Writer interfaces… designed to usher in a new era of performance and drastically reduce unnecessary copies.”

what changed

before (0.14):

const stdout = std.io.getStdOut().writer(); try stdout.print("Hello {s}\n", .{"world"}); 

after (0.15):

const stdout = std.fs.File.stdout(); try stdout.writeAll("Hello world\n"); // For formatted output, you need a buffer var stdout_buffer: [4096]u8 = undefined; var stdout_writer = stdout.writer(&stdout_buffer); try stdout_writer.print("Hello {s}\n", .{"world"}); 

why this matters

the old api wrapped streams in multiple layers of abstraction. the new api:

  • builds buffering directly into reader/writer
  • supports zero-copy operations (file-to-file transfers)
  • provides precise error sets
  • enables vector i/o and advanced operations

but it requires more explicit buffer management.

my approach

i created a helper function to hide the complexity:

fn printf(allocator: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void { const stdout = std.fs.File.stdout(); const msg = try std.fmt.allocPrint(allocator, fmt, args); defer allocator.free(msg); try stdout.writeAll(msg); } 

this allocates for the formatted string, but keeps the call sites clean:

try printf(allocator, "{s}[{s}]{s} {s}\n", .{ color_code, severity_name, Color.NC, finding.message }); 

roadblock 6: undefined behavior rules tightened

the change: zig 0.15 standardizes when undefined is allowed.

from the release notes: “Only operators which can never trigger Illegal Behavior permit undefined as an operand.”

what this means

// This now errors at compile time const x: i32 = undefined; const y = x + 1; // Error: undefined used in arithmetic // Safe uses of undefined var buffer: [256]u8 = undefined; // OK: just reserves space const ptr: *u8 = undefined; // OK: pointers can be undefined 

this catches bugs earlier but requires more explicit initialization.

the practical impact

in my code, i couldn’t do:

var line_num: usize = undefined; while (condition) : (line_num += 1) { // Error // ... } 

had to initialize explicitly:

var line_num: usize = 1; while (condition) : (line_num += 1) { // ... } 

the verdict: worth it?

what’s better in 0.15:

  • 5Ă— faster debug compilation with x86 backend
  • clearer allocator lifetime management
  • more explicit, less magic
  • better performance fundamentals

what hurts:

  • breaking changes everywhere
  • documentation lags behind implementation
  • http client still unstable
  • community examples are all outdated

resources