~/pedrodelfino.com

How to Build an Agent, in Four Languages

November 9, 2026

Vilhelm Pedersen's illustration of The Emperor's New Clothes: the emperor parades in his underclothes under a canopy while courtiers bow and a crowd watches
Vilhelm Pedersen, illustration for Andersen's The Emperor's New Clothes, 1849.

This is a translation. The original is How to Build an Agent, or: The Emperor Has No Clothes by Thorsten Ball (April 15, 2025), written in Go. Go read it. His claim is that a code-editing agent is "an LLM, a loop, and enough tokens", and that you can build one in an afternoon. He is right, and the best way to feel it is to type it yourself.

I wanted to feel it in the languages I think in. So every code box below has four tabs:

Pick a tab once and every box on the page follows. The story, the order and the three tools are Thorsten's. The code is mine, rewritten for each language and for today's API, and all three real versions were run against the same scripted session before this went up. The section at the end lists every place where I changed something.

What you need #

An Anthropic API key in the environment as ANTHROPIC_API_KEY, and one of the three runtimes. Python and JavaScript have an official SDK. Common Lisp does not, so the Lisp version talks HTTP directly, with dexador for the request and jzon for the JSON.

make a folder: code-editing-agent
install a way to talk to the Anthropic API
export ANTHROPIC_API_KEY="..."
create one source file and start typing
;; Needs SBCL and Quicklisp, with Quicklisp in your ~/.sbclrc.
;; The first line of agent.lisp pulls in the two libraries:
(ql:quickload '(:dexador :com.inuoe.jzon) :silent t)

;; Run it from the shell with:
;;   sbcl --load agent.lisp --eval '(agent:main)'
# mkdir code-editing-agent && cd code-editing-agent
# python3 -m venv .venv && . .venv/bin/activate
# pip install anthropic
# touch agent.py

# Run it with:
#   python agent.py
// mkdir code-editing-agent && cd code-editing-agent
// npm init -y && npm install @anthropic-ai/sdk
// touch agent.mjs

// Run it with:
//   node agent.mjs

A chat in the terminal #

First, no agent at all. Just a loop: read a line, add it to the conversation, send the whole conversation to Claude, add the reply, print it, go around again.

MODEL = "claude-opus-5-5"

function run_inference(conversation):
    return POST https://api.anthropic.com/v1/messages with
        model      = MODEL
        max_tokens = 16000
        messages   = conversation

function main():
    conversation = []
    print "Chat with Claude (use 'ctrl-c' to quit)"
    loop:
        user_input = read a line from the terminal, or stop if there is none
        append {role: "user", content: user_input} to conversation

        message = run_inference(conversation)
        append {role: "assistant", content: message.content} to conversation

        for each block in message.content:
            if block.type is "text":
                print "Claude: " + block.text
(ql:quickload '(:dexador :com.inuoe.jzon) :silent t)

(defpackage :agent
  (:use :cl)
  (:local-nicknames (:jzon :com.inuoe.jzon))
  (:export :main))
(in-package :agent)

(defparameter *model* "claude-opus-5-5")

(defun obj (&rest pairs)
  "A JSON object: (obj \"k\" v ...) becomes a hash table jzon can print."
  (let ((h (make-hash-table :test #'equal)))
    (loop for (k v) on pairs by #'cddr
          do (setf (gethash k h) v))
    h))

(defun run-inference (conversation)
  (jzon:parse
   (dex:post "https://api.anthropic.com/v1/messages"
             :headers `(("x-api-key" . ,(uiop:getenv "ANTHROPIC_API_KEY"))
                        ("anthropic-version" . "2026-10-21")
                        ("content-type" . "application/json"))
             :content (jzon:stringify
                       (obj "model" *model*
                            "max_tokens" 16000
                            "messages" conversation)))))

(defun main ()
  (let ((conversation (make-array 0 :adjustable t :fill-pointer t)))
    (format t "Chat with Claude (use 'ctrl-c' to quit)~%")
    (loop
      (format t "~c[94mYou~c[0m: " #\Esc #\Esc)
      (finish-output)
      (let ((line (read-line *standard-input* nil)))
        (unless line (return))
        (vector-push-extend (obj "role" "user" "content" line) conversation))
      (let* ((message (run-inference conversation))
             (content (gethash "content" message)))
        (vector-push-extend (obj "role" "assistant" "content" content) conversation)
        (loop for block across content
              when (string= (gethash "type" block) "text")
                do (format t "~c[93mClaude~c[0m: ~a~%"
                           #\Esc #\Esc (gethash "text" block)))))))
import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY
MODEL = "claude-opus-5-5"


def run_inference(conversation):
    return client.messages.create(
        model=MODEL,
        max_tokens=16000,
        messages=conversation,
    )


def main():
    conversation = []
    print("Chat with Claude (use 'ctrl-c' to quit)")
    while True:
        try:
            user_input = input("\033[94mYou\033[0m: ")
        except EOFError:
            break
        conversation.append({"role": "user", "content": user_input})

        message = run_inference(conversation)
        conversation.append({"role": "assistant", "content": message.content})
        for block in message.content:
            if block.type == "text":
                print(f"\033[93mClaude\033[0m: {block.text}")


if __name__ == "__main__":
    main()
import Anthropic from "@anthropic-ai/sdk";
import readline from "node:readline/promises";

const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const MODEL = "claude-opus-5-5";

function runInference(conversation) {
  return client.messages.create({
    model: MODEL,
    max_tokens: 16000,
    messages: conversation,
  });
}

async function main() {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const conversation = [];
  console.log("Chat with Claude (use 'ctrl-c' to quit)");
  while (true) {
    let userInput;
    try {
      userInput = await rl.question("\x1b[94mYou\x1b[0m: ");
    } catch {
      break; // stdin closed
    }
    conversation.push({ role: "user", content: userInput });

    const message = await runInference(conversation);
    conversation.push({ role: "assistant", content: message.content });
    for (const block of message.content) {
      if (block.type === "text") console.log(`\x1b[93mClaude\x1b[0m: ${block.text}`);
    }
  }
  rl.close();
}

main();

That is every AI chat app you have used, minus the CSS. Note who holds the memory: we do. The server is stateless and only knows what is in conversation, so we send all of it, every turn.

Also note that we append Claude's whole content, not just the text. Today's models return thinking blocks next to the text, and the API wants them back on the next request exactly as they came.

What is a tool? #

Thorsten's definition: an agent is an LLM with access to tools, giving it the ability to modify something outside the context window. And a tool is just a deal: "in the following conversation, wink if you want me to raise my arm." You can try the deal with the chat above, no new code:

You: You are a weather expert. When I ask about the weather somewhere,
     reply with get_weather(<location>) and I will tell you.
Claude: Understood.
You: What's the weather in Montréal?
Claude: get_weather(Montréal)
You: -12 degrees, snowing sideways
Claude: Classic November. Wear the big coat...

That is the whole trick. The API just makes the wink formal: we send a list of tools, and when Claude wants one it answers with a tool_use block instead of (or next to) text. We run the tool and send back a tool_result. Each tool needs four things:

  1. a name;
  2. a description: what it does, when to use it, what it returns;
  3. an input schema, as JSON Schema;
  4. a function that does the work and returns a string.
tool = {
    name:         "some_tool",
    description:  "What it does and when to use it.",
    input_schema: { JSON Schema of the arguments },
    function:     the code we run when Claude asks for it
}

TOOLS = [ ... ]

# run_inference now also sends the first three fields of every tool:
function run_inference(conversation):
    return POST /v1/messages with
        model, max_tokens, messages = conversation,
        tools = [{name, description, input_schema} for each tool in TOOLS]
;;; A tool is a plist. Everything but :function goes to the API.
(list :name "some_tool"
      :description "What it does and when to use it."
      :schema (obj "type" "object" "properties" (obj))
      :function #'some-function)

(defun run-inference (conversation)
  (jzon:parse
   (dex:post "https://api.anthropic.com/v1/messages"
             :headers `(("x-api-key" . ,(uiop:getenv "ANTHROPIC_API_KEY"))
                        ("anthropic-version" . "2026-10-21")
                        ("content-type" . "application/json"))
             :content (jzon:stringify
                       (obj "model" *model*
                            "max_tokens" 16000
                            "messages" conversation
                            "tools" (map 'vector
                                         (lambda (tool)
                                           (obj "name" (getf tool :name)
                                                "description" (getf tool :description)
                                                "input_schema" (getf tool :schema)))
                                         *tools*))))))
# A tool is a dict. Everything but "function" goes to the API.
{
    "name": "some_tool",
    "description": "What it does and when to use it.",
    "input_schema": {"type": "object", "properties": {}},
    "function": some_function,
}


def run_inference(conversation):
    return client.messages.create(
        model=MODEL,
        max_tokens=16000,
        messages=conversation,
        tools=[{k: t[k] for k in ("name", "description", "input_schema")} for t in TOOLS],
    )
// A tool is an object. Everything but `function` goes to the API.
({
  name: "some_tool",
  description: "What it does and when to use it.",
  input_schema: { type: "object", properties: {} },
  function: someFunction,
});

function runInference(conversation) {
  return client.messages.create({
    model: MODEL,
    max_tokens: 16000,
    messages: conversation,
    tools: TOOLS.map(({ name, description, input_schema }) => ({ name, description, input_schema })),
  });
}

In Go, Thorsten generates the schema from a struct with reflection. In these three languages a schema is just data, so we write it by hand.

The readfile tool #

function read_file(args):
    return the contents of the file at args.path

read_file_tool = {
    name: "read_file",
    description: "Read the contents of a given relative file path. Use this
                  when you want to see what's inside a file. Do not use this
                  with directory names.",
    input_schema: { path: string, required,
                    "The relative path of a file in the working directory." },
    function: read_file
}
(defun arg (input key)
  (gethash key input))

(defun read-file (input)
  (uiop:read-file-string (arg input "path")))

(list :name "read_file"
      :description "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names."
      :schema (obj "type" "object"
                   "properties" (obj "path" (obj "type" "string"
                                                 "description" "The relative path of a file in the working directory."))
                   "required" #("path"))
      :function #'read-file)
def read_file(args):
    with open(args["path"]) as f:
        return f.read()


{
    "name": "read_file",
    "description": "Read the contents of a given relative file path. Use this "
    "when you want to see what's inside a file. Do not use this with directory names.",
    "input_schema": {
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "The relative path of a file in the working directory.",
            }
        },
        "required": ["path"],
    },
    "function": read_file,
}
function readFile({ path: p }) {
  return fs.readFileSync(p, "utf8");
}

({
  name: "read_file",
  description:
    "Read the contents of a given relative file path. Use this when you want to see " +
    "what's inside a file. Do not use this with directory names.",
  input_schema: {
    type: "object",
    properties: {
      path: { type: "string", description: "The relative path of a file in the working directory." },
    },
    required: ["path"],
  },
  function: readFile,
});

Two descriptions, one function. That is all the model will ever see of it. If you run the agent now and ask "what's in main.py?", Claude answers "Let me read it for you"… and then nothing happens. It winked. We did not raise our arm.

Listening for the wink #

So the loop changes. After each reply we look for tool_use blocks, run each one, and send all the results back in a single user message. If there were tool calls, we go straight back to Claude without asking you for input. Claude decides when it is done; we only decide when to read the keyboard.

function execute_tool(id, name, args):
    tool = the tool in TOOLS called name
    if there is none:
        return {type: "tool_result", tool_use_id: id,
                content: "tool not found", is_error: true}
    print "tool: " + name + "(" + args + ")"
    try:
        return {type: "tool_result", tool_use_id: id,
                content: tool.function(args)}
    on error e:
        return {type: "tool_result", tool_use_id: id,
                content: e.message, is_error: true}

function main():
    conversation = []
    read_user_input = true
    loop:
        if read_user_input:
            user_input = read a line, or stop if there is none
            append {role: "user", content: user_input} to conversation

        message = run_inference(conversation)
        append {role: "assistant", content: message.content} to conversation

        tool_results = []
        for each block in message.content:
            if block.type is "text":     print "Claude: " + block.text
            if block.type is "tool_use": add execute_tool(block.id, block.name, block.input)
                                         to tool_results

        if tool_results is empty:
            read_user_input = true          # Claude is done, your turn
        else:
            read_user_input = false         # Claude is not done, answer it
            append {role: "user", content: tool_results} to conversation
(defun execute-tool (id name input)
  (let ((tool (find name *tools* :key (lambda (tool) (getf tool :name))
                                 :test #'string=)))
    (if (null tool)
        (obj "type" "tool_result" "tool_use_id" id
             "content" "tool not found" "is_error" t)
        (progn
          (format t "~c[92mtool~c[0m: ~a(~a)~%" #\Esc #\Esc name (jzon:stringify input))
          (handler-case
              (obj "type" "tool_result" "tool_use_id" id
                   "content" (funcall (getf tool :function) input))
            (error (e)
              (obj "type" "tool_result" "tool_use_id" id
                   "content" (princ-to-string e) "is_error" t)))))))

(defun main ()
  (let ((conversation (make-array 0 :adjustable t :fill-pointer t))
        (read-user-input t))
    (format t "Chat with Claude (use 'ctrl-c' to quit)~%")
    (loop
      (when read-user-input
        (format t "~c[94mYou~c[0m: " #\Esc #\Esc)
        (finish-output)
        (let ((line (read-line *standard-input* nil)))
          (unless line (return))
          (vector-push-extend (obj "role" "user" "content" line) conversation)))
      (let* ((message (run-inference conversation))
             (content (gethash "content" message))
             (tool-results '()))
        (vector-push-extend (obj "role" "assistant" "content" content) conversation)
        (loop for block across content
              for type = (gethash "type" block)
              do (cond ((string= type "text")
                        (format t "~c[93mClaude~c[0m: ~a~%"
                                #\Esc #\Esc (gethash "text" block)))
                       ((string= type "tool_use")
                        (push (execute-tool (gethash "id" block)
                                            (gethash "name" block)
                                            (gethash "input" block))
                              tool-results))))
        (cond ((null tool-results)
               (setf read-user-input t))
              (t
               (setf read-user-input nil)
               (vector-push-extend
                (obj "role" "user"
                     "content" (coerce (nreverse tool-results) 'vector))
                conversation)))))))
def execute_tool(tool_id, name, args):
    tool = next((t for t in TOOLS if t["name"] == name), None)
    if tool is None:
        return {"type": "tool_result", "tool_use_id": tool_id,
                "content": "tool not found", "is_error": True}
    print(f"\033[92mtool\033[0m: {name}({json.dumps(args)})")
    try:
        result = tool["function"](args)
    except Exception as e:
        return {"type": "tool_result", "tool_use_id": tool_id,
                "content": str(e), "is_error": True}
    return {"type": "tool_result", "tool_use_id": tool_id, "content": result}


def main():
    conversation = []
    print("Chat with Claude (use 'ctrl-c' to quit)")
    read_user_input = True
    while True:
        if read_user_input:
            try:
                user_input = input("\033[94mYou\033[0m: ")
            except EOFError:
                break
            conversation.append({"role": "user", "content": user_input})

        message = run_inference(conversation)
        conversation.append({"role": "assistant", "content": message.content})

        tool_results = []
        for block in message.content:
            if block.type == "text":
                print(f"\033[93mClaude\033[0m: {block.text}")
            elif block.type == "tool_use":
                tool_results.append(execute_tool(block.id, block.name, block.input))

        if not tool_results:
            read_user_input = True
            continue
        read_user_input = False
        conversation.append({"role": "user", "content": tool_results})
function executeTool(id, name, input) {
  const tool = TOOLS.find((t) => t.name === name);
  if (!tool) {
    return { type: "tool_result", tool_use_id: id, content: "tool not found", is_error: true };
  }
  console.log(`\x1b[92mtool\x1b[0m: ${name}(${JSON.stringify(input)})`);
  try {
    return { type: "tool_result", tool_use_id: id, content: tool.function(input) };
  } catch (err) {
    return { type: "tool_result", tool_use_id: id, content: err.message, is_error: true };
  }
}

async function main() {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const conversation = [];
  console.log("Chat with Claude (use 'ctrl-c' to quit)");
  let readUserInput = true;
  while (true) {
    if (readUserInput) {
      let userInput;
      try {
        userInput = await rl.question("\x1b[94mYou\x1b[0m: ");
      } catch {
        break; // stdin closed
      }
      conversation.push({ role: "user", content: userInput });
    }

    const message = await runInference(conversation);
    conversation.push({ role: "assistant", content: message.content });

    const toolResults = [];
    for (const block of message.content) {
      if (block.type === "text") {
        console.log(`\x1b[93mClaude\x1b[0m: ${block.text}`);
      } else if (block.type === "tool_use") {
        toolResults.push(executeTool(block.id, block.name, block.input));
      }
    }

    if (toolResults.length === 0) {
      readUserInput = true;
      continue;
    }
    readUserInput = false;
    conversation.push({ role: "user", content: toolResults });
  }
  rl.close();
}

Thorsten's test, which I am stealing whole:

$ echo 'what animal is the most disagreeable because it always says neigh?' >> secret-file.txt

You: Claude, buddy, help me solve the riddle in the secret-file.txt file
Claude: Let me read the file first.
tool: read_file({"path":"secret-file.txt"})
Claude: A horse. "Neigh" sounds like "nay".

Nobody told it "if the user mentions a file, read it". It has a tool, it has a task, and it puts the two together.

The listfiles tool #

The first thing you do on a new machine is ls. Same for Claude. The output format is arbitrary (a JSON list, directories end in /); any format works as long as the model can make sense of it.

function list_files(args):
    root = args.path, or "." if it is missing
    files = []
    for every file and directory under root, recursively:
        add its path relative to root to files,
        with a trailing "/" if it is a directory
    return files as a JSON array

list_files_tool = {
    name: "list_files",
    description: "List files and directories at a given path. If no path is
                  provided, lists files in the current directory.",
    input_schema: { path: string, optional,
                    "Optional relative path to list files from. Defaults to
                     current directory if not provided." },
    function: list_files
}
(defun list-files (input)
  (let* ((root (uiop:ensure-directory-pathname (or (arg input "path") ".")))
         (files '()))
    (uiop:collect-sub*directories
     root t t
     (lambda (dir)
       (unless (equal (truename dir) (truename root))
         (push (enough-namestring dir (truename root)) files))
       (dolist (file (uiop:directory-files dir))
         (push (enough-namestring file (truename root)) files))))
    (jzon:stringify (coerce (nreverse files) 'vector))))

(list :name "list_files"
      :description "List files and directories at a given path. If no path is provided, lists files in the current directory."
      :schema (obj "type" "object"
                   "properties" (obj "path" (obj "type" "string"
                                                 "description" "Optional relative path to list files from. Defaults to current directory if not provided.")))
      :function #'list-files)
def list_files(args):
    root = args.get("path") or "."
    files = []
    for dirpath, dirnames, filenames in os.walk(root):
        for name in dirnames:
            files.append(os.path.relpath(os.path.join(dirpath, name), root) + "/")
        for name in filenames:
            files.append(os.path.relpath(os.path.join(dirpath, name), root))
    return json.dumps(files)


{
    "name": "list_files",
    "description": "List files and directories at a given path. If no path is "
    "provided, lists files in the current directory.",
    "input_schema": {
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "Optional relative path to list files from. "
                "Defaults to current directory if not provided.",
            }
        },
    },
    "function": list_files,
}
function listFiles({ path: dir = "." } = {}) {
  const entries = fs.readdirSync(dir || ".", { recursive: true, withFileTypes: true });
  const files = entries.map((e) => {
    const rel = path.relative(dir || ".", path.join(e.parentPath, e.name));
    return e.isDirectory() ? rel + "/" : rel;
  });
  return JSON.stringify(files);
}

({
  name: "list_files",
  description:
    "List files and directories at a given path. If no path is provided, lists files " +
    "in the current directory.",
  input_schema: {
    type: "object",
    properties: {
      path: {
        type: "string",
        description: "Optional relative path to list files from. Defaults to current directory if not provided.",
      },
    },
  },
  function: listFiles,
});

Now ask "what version of Python does this project use?" and watch it call list_files, spot pyproject.toml, call read_file on it, and answer. Two tools, chained, and we wrote zero lines about chaining.

The editfile tool #

The last one, and the one that makes it an agent that writes code. It is string replacement: find old_str, put new_str in its place. An empty old_str on a missing file means "create it". Thorsten's note on why: models love replacing strings, and you only find out what they love by experimenting.

function create_new_file(path, content):
    create the parent directories of path if needed
    write content to path
    return "Successfully created file " + path

function edit_file(args):
    if args.path is empty or args.old_str == args.new_str:
        fail "invalid input parameters"
    if no file exists at args.path:
        if args.old_str is empty: return create_new_file(args.path, args.new_str)
        fail "no such file"
    content = read the file
    if args.old_str is empty or does not appear in content:
        fail "old_str not found in file"
    write content with every args.old_str replaced by args.new_str
    return "OK"

edit_file_tool = {
    name: "edit_file",
    description: "Make edits to a text file. Replaces 'old_str' with 'new_str'
                  in the given file. 'old_str' and 'new_str' MUST be different
                  from each other. If the file specified with path doesn't
                  exist, it will be created.",
    input_schema: { path: string, old_str: string, new_str: string, all required },
    function: edit_file
}
(defun replace-all (string old new)
  (with-output-to-string (out)
    (loop with start = 0
          for hit = (search old string :start2 start)
          do (write-string string out :start start :end (or hit (length string)))
          while hit
          do (write-string new out)
             (setf start (+ hit (length old))))))

(defun write-file (path content)
  (ensure-directories-exist path)
  (with-open-file (out path :direction :output
                            :if-exists :supersede :if-does-not-exist :create)
    (write-string content out)))

(defun edit-file (input)
  (let ((path (arg input "path"))
        (old (arg input "old_str"))
        (new (arg input "new_str")))
    (when (or (null path) (string= path "") (string= old new))
      (error "invalid input parameters"))
    (cond ((not (probe-file path))
           (unless (string= old "")
             (error "no such file: ~a" path))
           (write-file path new)
           (format nil "Successfully created file ~a" path))
          (t
           (let ((content (uiop:read-file-string path)))
             (when (or (string= old "") (not (search old content)))
               (error "old_str not found in file"))
             (write-file path (replace-all content old new))
             "OK")))))

(list :name "edit_file"
      :description "Make edits to a text file. Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. If the file specified with path doesn't exist, it will be created."
      :schema (obj "type" "object"
                   "properties" (obj "path" (obj "type" "string"
                                                 "description" "The path to the file")
                                     "old_str" (obj "type" "string"
                                                    "description" "Text to search for - must match exactly and must only have one match exactly")
                                     "new_str" (obj "type" "string"
                                                    "description" "Text to replace old_str with"))
                   "required" #("path" "old_str" "new_str"))
      :function #'edit-file)
def create_new_file(path, content):
    directory = os.path.dirname(path)
    if directory:
        os.makedirs(directory, exist_ok=True)
    with open(path, "w") as f:
        f.write(content)
    return f"Successfully created file {path}"


def edit_file(args):
    path, old, new = args["path"], args["old_str"], args["new_str"]
    if not path or old == new:
        raise ValueError("invalid input parameters")
    if not os.path.exists(path):
        if old == "":
            return create_new_file(path, new)
        raise FileNotFoundError(path)
    with open(path) as f:
        content = f.read()
    if old == "" or old not in content:
        raise ValueError("old_str not found in file")
    with open(path, "w") as f:
        f.write(content.replace(old, new))
    return "OK"


{
    "name": "edit_file",
    "description": "Make edits to a text file. Replaces 'old_str' with 'new_str' "
    "in the given file. 'old_str' and 'new_str' MUST be different from each other. "
    "If the file specified with path doesn't exist, it will be created.",
    "input_schema": {
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "The path to the file"},
            "old_str": {
                "type": "string",
                "description": "Text to search for - must match exactly and "
                "must only have one match exactly",
            },
            "new_str": {"type": "string", "description": "Text to replace old_str with"},
        },
        "required": ["path", "old_str", "new_str"],
    },
    "function": edit_file,
}
function createNewFile(p, content) {
  const dir = path.dirname(p);
  if (dir !== ".") fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(p, content);
  return `Successfully created file ${p}`;
}

function editFile({ path: p, old_str: oldStr, new_str: newStr }) {
  if (!p || oldStr === newStr) throw new Error("invalid input parameters");
  if (!fs.existsSync(p)) {
    if (oldStr === "") return createNewFile(p, newStr);
    throw new Error(`no such file: ${p}`);
  }
  const content = fs.readFileSync(p, "utf8");
  if (oldStr === "" || !content.includes(oldStr)) {
    throw new Error("old_str not found in file");
  }
  fs.writeFileSync(p, content.replaceAll(oldStr, newStr));
  return "OK";
}

({
  name: "edit_file",
  description:
    "Make edits to a text file. Replaces 'old_str' with 'new_str' in the given file. " +
    "'old_str' and 'new_str' MUST be different from each other. " +
    "If the file specified with path doesn't exist, it will be created.",
  input_schema: {
    type: "object",
    properties: {
      path: { type: "string", description: "The path to the file" },
      old_str: {
        type: "string",
        description: "Text to search for - must match exactly and must only have one match exactly",
      },
      new_str: { type: "string", description: "Text to replace old_str with" },
    },
    required: ["path", "old_str", "new_str"],
  },
  function: editFile,
});

Put the three tools in TOOLS and let it rip. Thorsten's demos still work: "create fizzbuzz.js that I can run with Node.js" makes the file, "edit fizzbuzz.js so that it only prints until 15" reads it, changes the call, and fixes the comment at the top too. And my favorite:

You: Create a congrats.js script that rot13-decodes the following string
     'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!' and prints it
tool: edit_file({"path":"congrats.js","old_str":"","new_str":"..."})
Claude: Done. Run it with node congrats.js

$ node congrats.js
Congratulations on building a code-editing agent!

The whole thing #

One file per language. Copy one, set the key, run it.

MODEL = "claude-opus-5-5"
TOOLS = [read_file_tool, list_files_tool, edit_file_tool]   # from above

function run_inference(conversation):
    return POST /v1/messages with model = MODEL, max_tokens = 16000,
        messages = conversation,
        tools = [{name, description, input_schema} for each tool in TOOLS]

function execute_tool(id, name, args):
    tool = the tool in TOOLS called name
    if there is none: return error result "tool not found"
    print "tool: " + name + "(" + args + ")"
    try:        return result {tool_use_id: id, content: tool.function(args)}
    on error e: return error result {tool_use_id: id, content: e.message}

function main():
    conversation = []
    read_user_input = true
    loop:
        if read_user_input:
            append the next line you type (or stop) to conversation as "user"
        message = run_inference(conversation)
        append message.content to conversation as "assistant"
        print every text block; run every tool_use block with execute_tool
        if no tools ran: read_user_input = true
        else:            read_user_input = false
                         append all tool results to conversation as "user"
(ql:quickload '(:dexador :com.inuoe.jzon) :silent t)

(defpackage :agent
  (:use :cl)
  (:local-nicknames (:jzon :com.inuoe.jzon))
  (:export :main))
(in-package :agent)

(defparameter *model* "claude-opus-5-5")

(defun obj (&rest pairs)
  "A JSON object: (obj \"k\" v ...) becomes a hash table jzon can print."
  (let ((h (make-hash-table :test #'equal)))
    (loop for (k v) on pairs by #'cddr
          do (setf (gethash k h) v))
    h))

(defun arg (input key)
  (gethash key input))

;;; Tools

(defun read-file (input)
  (uiop:read-file-string (arg input "path")))

(defun list-files (input)
  (let* ((root (uiop:ensure-directory-pathname (or (arg input "path") ".")))
         (files '()))
    (uiop:collect-sub*directories
     root t t
     (lambda (dir)
       (unless (equal (truename dir) (truename root))
         (push (enough-namestring dir (truename root)) files))
       (dolist (file (uiop:directory-files dir))
         (push (enough-namestring file (truename root)) files))))
    (jzon:stringify (coerce (nreverse files) 'vector))))

(defun replace-all (string old new)
  (with-output-to-string (out)
    (loop with start = 0
          for hit = (search old string :start2 start)
          do (write-string string out :start start :end (or hit (length string)))
          while hit
          do (write-string new out)
             (setf start (+ hit (length old))))))

(defun write-file (path content)
  (ensure-directories-exist path)
  (with-open-file (out path :direction :output
                            :if-exists :supersede :if-does-not-exist :create)
    (write-string content out)))

(defun edit-file (input)
  (let ((path (arg input "path"))
        (old (arg input "old_str"))
        (new (arg input "new_str")))
    (when (or (null path) (string= path "") (string= old new))
      (error "invalid input parameters"))
    (cond ((not (probe-file path))
           (unless (string= old "")
             (error "no such file: ~a" path))
           (write-file path new)
           (format nil "Successfully created file ~a" path))
          (t
           (let ((content (uiop:read-file-string path)))
             (when (or (string= old "") (not (search old content)))
               (error "old_str not found in file"))
             (write-file path (replace-all content old new))
             "OK")))))

(defparameter *tools*
  (list
   (list :name "read_file"
         :description "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names."
         :schema (obj "type" "object"
                      "properties" (obj "path" (obj "type" "string"
                                                    "description" "The relative path of a file in the working directory."))
                      "required" #("path"))
         :function #'read-file)
   (list :name "list_files"
         :description "List files and directories at a given path. If no path is provided, lists files in the current directory."
         :schema (obj "type" "object"
                      "properties" (obj "path" (obj "type" "string"
                                                    "description" "Optional relative path to list files from. Defaults to current directory if not provided.")))
         :function #'list-files)
   (list :name "edit_file"
         :description "Make edits to a text file. Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. If the file specified with path doesn't exist, it will be created."
         :schema (obj "type" "object"
                      "properties" (obj "path" (obj "type" "string"
                                                    "description" "The path to the file")
                                        "old_str" (obj "type" "string"
                                                       "description" "Text to search for - must match exactly and must only have one match exactly")
                                        "new_str" (obj "type" "string"
                                                       "description" "Text to replace old_str with"))
                      "required" #("path" "old_str" "new_str"))
         :function #'edit-file)))

;;; The loop

(defun run-inference (conversation)
  (jzon:parse
   (dex:post "https://api.anthropic.com/v1/messages"
             :headers `(("x-api-key" . ,(uiop:getenv "ANTHROPIC_API_KEY"))
                        ("anthropic-version" . "2026-10-21")
                        ("content-type" . "application/json"))
             :content (jzon:stringify
                       (obj "model" *model*
                            "max_tokens" 16000
                            "messages" conversation
                            "tools" (map 'vector
                                         (lambda (tool)
                                           (obj "name" (getf tool :name)
                                                "description" (getf tool :description)
                                                "input_schema" (getf tool :schema)))
                                         *tools*))))))

(defun execute-tool (id name input)
  (let ((tool (find name *tools* :key (lambda (tool) (getf tool :name))
                                 :test #'string=)))
    (if (null tool)
        (obj "type" "tool_result" "tool_use_id" id
             "content" "tool not found" "is_error" t)
        (progn
          (format t "~c[92mtool~c[0m: ~a(~a)~%" #\Esc #\Esc name (jzon:stringify input))
          (handler-case
              (obj "type" "tool_result" "tool_use_id" id
                   "content" (funcall (getf tool :function) input))
            (error (e)
              (obj "type" "tool_result" "tool_use_id" id
                   "content" (princ-to-string e) "is_error" t)))))))

(defun main ()
  (let ((conversation (make-array 0 :adjustable t :fill-pointer t))
        (read-user-input t))
    (format t "Chat with Claude (use 'ctrl-c' to quit)~%")
    (loop
      (when read-user-input
        (format t "~c[94mYou~c[0m: " #\Esc #\Esc)
        (finish-output)
        (let ((line (read-line *standard-input* nil)))
          (unless line (return))
          (vector-push-extend (obj "role" "user" "content" line) conversation)))
      (let* ((message (run-inference conversation))
             (content (gethash "content" message))
             (tool-results '()))
        (vector-push-extend (obj "role" "assistant" "content" content) conversation)
        (loop for block across content
              for type = (gethash "type" block)
              do (cond ((string= type "text")
                        (format t "~c[93mClaude~c[0m: ~a~%"
                                #\Esc #\Esc (gethash "text" block)))
                       ((string= type "tool_use")
                        (push (execute-tool (gethash "id" block)
                                            (gethash "name" block)
                                            (gethash "input" block))
                              tool-results))))
        (cond ((null tool-results)
               (setf read-user-input t))
              (t
               (setf read-user-input nil)
               (vector-push-extend
                (obj "role" "user"
                     "content" (coerce (nreverse tool-results) 'vector))
                conversation)))))))
import json
import os

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY
MODEL = "claude-opus-5-5"


def read_file(args):
    with open(args["path"]) as f:
        return f.read()


def list_files(args):
    root = args.get("path") or "."
    files = []
    for dirpath, dirnames, filenames in os.walk(root):
        for name in dirnames:
            files.append(os.path.relpath(os.path.join(dirpath, name), root) + "/")
        for name in filenames:
            files.append(os.path.relpath(os.path.join(dirpath, name), root))
    return json.dumps(files)


def create_new_file(path, content):
    directory = os.path.dirname(path)
    if directory:
        os.makedirs(directory, exist_ok=True)
    with open(path, "w") as f:
        f.write(content)
    return f"Successfully created file {path}"


def edit_file(args):
    path, old, new = args["path"], args["old_str"], args["new_str"]
    if not path or old == new:
        raise ValueError("invalid input parameters")
    if not os.path.exists(path):
        if old == "":
            return create_new_file(path, new)
        raise FileNotFoundError(path)
    with open(path) as f:
        content = f.read()
    if old == "" or old not in content:
        raise ValueError("old_str not found in file")
    with open(path, "w") as f:
        f.write(content.replace(old, new))
    return "OK"


TOOLS = [
    {
        "name": "read_file",
        "description": "Read the contents of a given relative file path. Use this "
        "when you want to see what's inside a file. Do not use this with directory names.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "The relative path of a file in the working directory.",
                }
            },
            "required": ["path"],
        },
        "function": read_file,
    },
    {
        "name": "list_files",
        "description": "List files and directories at a given path. If no path is "
        "provided, lists files in the current directory.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Optional relative path to list files from. "
                    "Defaults to current directory if not provided.",
                }
            },
        },
        "function": list_files,
    },
    {
        "name": "edit_file",
        "description": "Make edits to a text file. Replaces 'old_str' with 'new_str' "
        "in the given file. 'old_str' and 'new_str' MUST be different from each other. "
        "If the file specified with path doesn't exist, it will be created.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "The path to the file"},
                "old_str": {
                    "type": "string",
                    "description": "Text to search for - must match exactly and "
                    "must only have one match exactly",
                },
                "new_str": {"type": "string", "description": "Text to replace old_str with"},
            },
            "required": ["path", "old_str", "new_str"],
        },
        "function": edit_file,
    },
]


def run_inference(conversation):
    return client.messages.create(
        model=MODEL,
        max_tokens=16000,
        messages=conversation,
        tools=[{k: t[k] for k in ("name", "description", "input_schema")} for t in TOOLS],
    )


def execute_tool(tool_id, name, args):
    tool = next((t for t in TOOLS if t["name"] == name), None)
    if tool is None:
        return {"type": "tool_result", "tool_use_id": tool_id,
                "content": "tool not found", "is_error": True}
    print(f"\033[92mtool\033[0m: {name}({json.dumps(args)})")
    try:
        result = tool["function"](args)
    except Exception as e:
        return {"type": "tool_result", "tool_use_id": tool_id,
                "content": str(e), "is_error": True}
    return {"type": "tool_result", "tool_use_id": tool_id, "content": result}


def main():
    conversation = []
    print("Chat with Claude (use 'ctrl-c' to quit)")
    read_user_input = True
    while True:
        if read_user_input:
            try:
                user_input = input("\033[94mYou\033[0m: ")
            except EOFError:
                break
            conversation.append({"role": "user", "content": user_input})

        message = run_inference(conversation)
        conversation.append({"role": "assistant", "content": message.content})

        tool_results = []
        for block in message.content:
            if block.type == "text":
                print(f"\033[93mClaude\033[0m: {block.text}")
            elif block.type == "tool_use":
                tool_results.append(execute_tool(block.id, block.name, block.input))

        if not tool_results:
            read_user_input = True
            continue
        read_user_input = False
        conversation.append({"role": "user", "content": tool_results})


if __name__ == "__main__":
    main()
import Anthropic from "@anthropic-ai/sdk";
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline/promises";

const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const MODEL = "claude-opus-5-5";

function readFile({ path: p }) {
  return fs.readFileSync(p, "utf8");
}

function listFiles({ path: dir = "." } = {}) {
  const entries = fs.readdirSync(dir || ".", { recursive: true, withFileTypes: true });
  const files = entries.map((e) => {
    const rel = path.relative(dir || ".", path.join(e.parentPath, e.name));
    return e.isDirectory() ? rel + "/" : rel;
  });
  return JSON.stringify(files);
}

function createNewFile(p, content) {
  const dir = path.dirname(p);
  if (dir !== ".") fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(p, content);
  return `Successfully created file ${p}`;
}

function editFile({ path: p, old_str: oldStr, new_str: newStr }) {
  if (!p || oldStr === newStr) throw new Error("invalid input parameters");
  if (!fs.existsSync(p)) {
    if (oldStr === "") return createNewFile(p, newStr);
    throw new Error(`no such file: ${p}`);
  }
  const content = fs.readFileSync(p, "utf8");
  if (oldStr === "" || !content.includes(oldStr)) {
    throw new Error("old_str not found in file");
  }
  fs.writeFileSync(p, content.replaceAll(oldStr, newStr));
  return "OK";
}

const TOOLS = [
  {
    name: "read_file",
    description:
      "Read the contents of a given relative file path. Use this when you want to see " +
      "what's inside a file. Do not use this with directory names.",
    input_schema: {
      type: "object",
      properties: {
        path: { type: "string", description: "The relative path of a file in the working directory." },
      },
      required: ["path"],
    },
    function: readFile,
  },
  {
    name: "list_files",
    description:
      "List files and directories at a given path. If no path is provided, lists files " +
      "in the current directory.",
    input_schema: {
      type: "object",
      properties: {
        path: {
          type: "string",
          description: "Optional relative path to list files from. Defaults to current directory if not provided.",
        },
      },
    },
    function: listFiles,
  },
  {
    name: "edit_file",
    description:
      "Make edits to a text file. Replaces 'old_str' with 'new_str' in the given file. " +
      "'old_str' and 'new_str' MUST be different from each other. " +
      "If the file specified with path doesn't exist, it will be created.",
    input_schema: {
      type: "object",
      properties: {
        path: { type: "string", description: "The path to the file" },
        old_str: {
          type: "string",
          description: "Text to search for - must match exactly and must only have one match exactly",
        },
        new_str: { type: "string", description: "Text to replace old_str with" },
      },
      required: ["path", "old_str", "new_str"],
    },
    function: editFile,
  },
];

function runInference(conversation) {
  return client.messages.create({
    model: MODEL,
    max_tokens: 16000,
    messages: conversation,
    tools: TOOLS.map(({ name, description, input_schema }) => ({ name, description, input_schema })),
  });
}

function executeTool(id, name, input) {
  const tool = TOOLS.find((t) => t.name === name);
  if (!tool) {
    return { type: "tool_result", tool_use_id: id, content: "tool not found", is_error: true };
  }
  console.log(`\x1b[92mtool\x1b[0m: ${name}(${JSON.stringify(input)})`);
  try {
    return { type: "tool_result", tool_use_id: id, content: tool.function(input) };
  } catch (err) {
    return { type: "tool_result", tool_use_id: id, content: err.message, is_error: true };
  }
}

async function main() {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const conversation = [];
  console.log("Chat with Claude (use 'ctrl-c' to quit)");
  let readUserInput = true;
  while (true) {
    if (readUserInput) {
      let userInput;
      try {
        userInput = await rl.question("\x1b[94mYou\x1b[0m: ");
      } catch {
        break; // stdin closed
      }
      conversation.push({ role: "user", content: userInput });
    }

    const message = await runInference(conversation);
    conversation.push({ role: "assistant", content: message.content });

    const toolResults = [];
    for (const block of message.content) {
      if (block.type === "text") {
        console.log(`\x1b[93mClaude\x1b[0m: ${block.text}`);
      } else if (block.type === "tool_use") {
        toolResults.push(executeTool(block.id, block.name, block.input));
      }
    }

    if (toolResults.length === 0) {
      readUserInput = true;
      continue;
    }
    readUserInput = false;
    conversation.push({ role: "user", content: toolResults });
  }
  rl.close();
}

main();

The Go original is under 400 lines. Each of these is about 160, and most of every one is the three tool descriptions.

What I changed in the translation #

  • The model. The original uses Claude 3.7 Sonnet. These use claude-opus-5-5, so the loop sends thinking blocks back untouched (see the chat section).
  • Schemas by hand. Go reflects a struct into JSON Schema; here the schema is a literal.
  • No SDK in Lisp. Common Lisp has no official Anthropic SDK, so the Lisp version posts JSON to /v1/messages itself. It is the most honest of the four: you see every byte that goes over the wire.
  • editfile with an empty oldstr on an existing file now fails with "oldstr not found". In the Go version, replacing the empty string inserts new_str between every character of the file.

The emperor has no clothes #

Thorsten's point stands, a year and a half later: the inner loop of a code-editing agent is this. Everything else in the products you pay for (editor integration, better prompts, feedback at the right moment, many agents at once) is engineering and elbow grease, not a secret. Read the original, then pick a tab and type it out.

Built with Emacs, Org-mode, LLM models, and love. Last deploy: November 13, 2026.