Sitemap
DevOps<>AI

Musings Around DevOps<>AI

What if an AI Agent Could Grow Its Own Tools?

19 min readOct 8, 2026

--

Press enter or click to view image in full size

Building bba: a small Clojure agent that grows its own programming environment.

Something interesting happened while I was experimenting with bba, a small AI agent I’m building in Clojure.

I gave the agent a fairly open-ended request: “I need some functions to make this an awesome coding agent.”

It started building tools for itself. Nothing particularly surprising there. That’s what coding agents do. But as I dug through the history of what it had created, I found something I wasn’t expecting.

The agent had built its own conversation-compaction logic.

It created functions for estimating token usage, organizing exchanges, identifying messages, and planning which parts of a conversation should be summarized. These were ordinary Clojure functions living inside the agent’s programming environment.

The interesting part is that those same functions eventually made their way into bba’s own extension system. One of them, estimate-tokens, was promoted line for line into the compaction extension that now ships with the project.

The agent had prototyped a capability for its own harness.

That got me thinking about something I’ve been exploring for a while. What if agents didn’t just produce code as an output? What if the code they created became part of the environment they used to do their work?

And what if that environment kept growing, even after the conversation that created those capabilities was gone?

That’s the idea behind bba, which takes inspiration from Geoffrey Huntley’s Growing Lisp Applications and the minimalist architecture of pi.

The premise is pretty simple.

Instead of giving an agent an ever-growing collection of tools, give it a programming environment where it can build and reuse its own tools.

A shell is useful. A REPL is interesting.

Most coding agents follow a familiar pattern. Give a language model access to a shell, let it read files, write code, run commands, inspect the results, and repeat until the task is finished.

Tools like Claude Code and pi have demonstrated just how effective this can be.

But there’s an interesting limitation to this approach.

When the agent writes software, that software generally lives somewhere outside the agent. If it creates a script and wants to use it again, it needs to find the script, invoke it, parse the output, and decide what to do next.

There’s nothing inherently wrong with that. The shell is an incredibly useful interface because it can do almost anything.

But a shell is fundamentally process-oriented. You launch something, pass it arguments, collect the output, and move on.

A REPL gives us something different.

REPL stands for Read-Eval-Print Loop. It’s a live programming environment where you can define functions, evaluate expressions, change existing definitions, and keep working with the same running program.

Lisp languages have a long history of working this way. You don’t necessarily need to stop an application, rebuild it, and start again when you want to change its behavior.

You can modify the running environment directly.

Imagine asking an agent to count the lines in a file and make the operation reusable.

It might start with a shell command:

(sys/sh "wc -l notes.txt")

Then it could create a function:

(defn line-count [path]
(count (str/split-lines (slurp path))))

And call it:

(line-count "notes.txt")

Nothing especially exotic so far.

Except that line-count now exists in the agent's live Clojure environment. It can call it again, combine it with other functions, or redefine it when requirements change.

It doesn’t have to regenerate the implementation every time.

And when bba exits, it saves the accepted definitions so they’re available the next time the agent starts.

The application grows as the agent works.

Why Lisp makes this easier

There’s a reason I wanted to explore this with Lisp rather than simply building another Python or JavaScript agent.

Most languages allow you to generate and execute code, but there’s often some additional machinery involved in turning that code into a capability the running application can reuse.

You might need to create a module, manage imports, dynamically load the code, expose an interface, or register something with the agent’s tool system.

Individually, none of those steps is particularly difficult. But when an agent is continually creating, modifying, and composing capabilities, the extra friction starts to matter.

Lisp has an interesting advantage here because of how it represents programs.

In Lisp, code is data.

Lisp expressions are represented using the same basic data structures that Lisp programs manipulate. That means programs can construct expressions, inspect their structure, transform them, and evaluate them.

Combine that with an interactive programming environment, and the distance between writing a program and using it becomes pretty small.

For an AI agent, that’s an interesting property.

The model is already generating code. Why make it continually translate that code into external scripts, command-line interfaces, and tool schemas when it could extend the environment it’s already operating in?

Imagine the agent discovers it needs a reusable operation. Instead of writing a standalone script and registering another tool, it defines a function.

That function becomes available to the running environment. Other functions can call it. The agent can inspect it, modify it, and build more sophisticated capabilities around it.

Now, Lisp isn’t the only language that can do this. Python and JavaScript support dynamic evaluation and interactive programming. Smalltalk has an especially rich history of live development.

You can build similar systems with those languages.

The distinction is how naturally the pieces fit together.

Lisp’s code-as-data model, interactive development style, and ability to redefine a running program make this approach fairly direct. And while code-as-data is useful, especially for manipulating programs, the immediate benefits in bba come from the live REPL and its persistent definitions.

What interests me is how much infrastructure we can avoid.

Every interface we introduce between the model and the environment is another thing we have to describe, translate, maintain, and potentially debug.

If we can express more of the system as ordinary executable code, the agent harness itself can stay remarkably small.

Which brings me to another interesting part of bba.

What if the agent only needed two tools?

One of the ideas I liked from Jiti was separating the development of a program from its execution.

bba follows a similar approach.

Instead of exposing a long collection of tools to the model, it provides two primary operations.

develop_form allows the agent to add, change, or remove definitions in its live Clojure environment.

execute_form evaluates code against that environment.

That’s the core interface.

The agent doesn’t need a separate model-facing tool for every filesystem operation or shell command because Clojure already provides a programmable interface to those capabilities.

Reading a file is just:

(slurp "README.md")

Writing one:

(spit "hello.txt" "Hello, world!")

Running a command:

(sys/sh "git status --short")

Or finding files:

(fs/glob "." "**/*.clj")

When the agent discovers an operation it’ll probably need again, it can define a function and make that behavior reusable.

This changes how I think about the role of inference.

The language model is useful when we need to figure out how to do something new. It can reason through a problem, explore possible implementations, test an approach, and capture a working solution.

But once we’ve captured that behavior as code, why keep asking the model to work through the same implementation?

Just call the function.

Use inference to create capabilities. Use code to reuse them.

There will still be inference involved in deciding when and how to invoke those capabilities. But we don’t have to spend it reconstructing behavior we’ve already implemented.

And what’s interesting is that the early usage data from bba shows this pattern emerging.

I looked across four different worlds created during experimentation. Together, they recorded 2,314 tool operations: 194 calls to develop_form and 2,120 calls to execute_form.

That’s roughly eleven executions for every development operation.

The coding agent’s world was even more interesting. It recorded 623 executions against just 19 development operations, about 33 to 1.

The job-search world showed almost the same pattern, with 254 executions and eight development operations.

Those numbers don’t prove that the system saves a particular number of tokens. The model still has to decide which functions to call and interpret their results.

But they do show something useful about the execution model.

The agent isn’t continually modifying its environment. It spends much more of its time using the capabilities that are already there.

And as those capabilities accumulate, the environment becomes increasingly useful.

Inference is a resource, too

There’s another reason I’ve become interested in separating the creation of capabilities from their execution.

Model inference isn’t free, even when you’re using a subscription that includes a generous amount of usage.

I’ve been experimenting with Ollama Cloud alongside my other AI development tools, and looking at the usage dashboard made that pretty tangible.

In one week, the dashboard showed 1,993 cloud requests, representing 10.3% of my weekly allowance. There was also a separate rolling four-hour usage bucket, which was sitting at 8.6%.

So there are two different constraints at work. There’s the longer-term allowance, and there’s the shorter-term limit on how quickly that allowance can be consumed.

The interesting part is where those requests were coming from.

A lot of the activity was concentrated around two fast models, deepseek-v4.1-flash and glm-5.3-flash. My local proxy logs showed requests arriving through two different API interfaces.

One used the Anthropic-compatible /v1/messages endpoint, associated with the Claude integration. Another used the OpenAI-compatible chat-completions interface.

Underneath those interfaces, both were reaching Ollama Cloud.

That distinction is useful because the interface an agent uses to communicate with a model doesn’t necessarily tell you where the inference is actually happening.

It also reinforces something I want to preserve in bba: the model provider should be interchangeable without forcing us to redesign the agent’s programming environment.

bba already supports multiple providers, including Ollama. That gives me the option to experiment with different models while keeping the world, its functions, and its execution contracts intact.

But the more interesting observation isn’t really about which model I’m using.

It’s about how many model requests an active AI development workflow can generate.

Those 1,993 requests came from my broader tooling, so I wouldn’t attribute them all to bba or claim that the REPL architecture reduced that number.

What they do illustrate is that inference has an operational budget, even when individual requests aren’t separately billed.

And if we’re building systems that rely on agents doing more and more work, that budget matters.

Think about the difference between asking a model to figure out how to run a project’s tests every time and asking it to create a reusable function that knows how to run those tests.

The first time, the model might need to inspect the repository, identify the build system, discover the right command, and determine how to interpret the output.

That’s useful reasoning.

Once the agent has a working test! function, though, we can reuse the implementation instead of asking the model to rediscover the process.

If the agent still needs a model call to decide to invoke test!, we haven't eliminated inference. But we've avoided repeating some of the reasoning that went into creating it.

And when the function is invoked directly by another program, workflow, or scheduled process, the operation itself doesn’t require a model call at all.

That’s the opportunity I find interesting.

Inference should be spent where reasoning is needed. Deterministic work should become executable capability whenever it makes sense.

I don’t have a controlled benchmark showing how many tokens this saves, and the current operation ratios aren’t a substitute for one.

But the architecture creates a way to measure that question. We can compare how much inference is needed to accomplish a task before and after the world has accumulated the relevant capabilities.

Over time, I’d like to see whether a more capable world can accomplish the same work with fewer model calls, smaller prompts, or less repeated exploration.

The goal isn’t necessarily to eliminate inference. It’s to stop treating inference as the only way an agent can make progress.

The world becomes the application

In bba, I call the live programming environment the world.

Each project gets its own world, represented by a Clojure namespace. The agent can define functions, invoke them, inspect what’s already available, and build new capabilities from the existing ones.

What’s important is that the world exists independently of the conversation.

Most agents rely heavily on conversation history to maintain context. That works reasonably well for reasoning, but it’s a poor place to store durable capabilities.

Conversations grow, get summarized, and eventually fall outside the context window. Even when the information remains available, processing it consumes tokens.

If an agent figured out how to perform an operation yesterday, I don’t necessarily need it to remember the conversation where that happened.

I need it to know how to perform the operation today.

Executable code gives us another way to preserve that knowledge.

And the worlds created during these experiments are starting to look like small software libraries rather than collections of disposable snippets.

The coder world accumulated 44 live functions. It built helpers for running shell commands, searching files, inspecting Git changes, editing files, and running tests. It even built its own task tracker with functions like todo!, todo-list, and todo-done!.

The job-search world accumulated 27 functions. Instead of repeatedly reasoning through how to evaluate job opportunities, it started building a domain model.

Functions like score-job, rank-jobs, qualify-job, and deduplicate-jobs captured behavior that could be reused across different searches.

For example, it created a function for scoring a job against a candidate’s skills:

(defn score-job
"Score a job against a candidate profile by counting matching skills."
[item profile]
(let [required (set (:skills item))
available (set (:skills profile))
matches (count (clojure.set/intersection
required available))]
(assoc item :score matches)))

That function doesn’t do anything especially sophisticated, but that’s part of the point.

The agent now has a named operation with defined behavior. Another function, rank-jobs, can build on it without recreating the scoring logic.

The functions also have source code, arguments, and documentation. They’re inspectable pieces of software rather than instructions buried in a transcript.

bba exposes commands for exploring that environment:

/functions
/describe line-count
/history
/status

A new conversation can start with the same world available. The agent doesn’t need the original conversation to keep using what it built.

And that brings me back to the compaction example.

During the coder session, the agent created a group of functions around revisions 71 through 81:

estimate-tokens
msg-chars
exchanges
has-block?
user-text?
final-answer?
compaction-plan
summarizer-system

These functions handled different pieces of conversation management. Some estimated message sizes, some identified message types, and others helped determine how conversation history could be compacted.

One of them looked like this:

(defn estimate-tokens
"Rough token count for a message list: about 4 characters per token."
[msgs]
(quot (reduce + (map msg-chars msgs)) 4))

It’s a simple heuristic rather than an exact tokenizer, but it provides a reusable estimate.

And that exact function now appears in extensions/compaction.clj.

The agent originally developed it as part of its world. Later, the working implementation was promoted into bba’s extension source.

That promotion is an important distinction. The agent wasn’t silently changing its own harness while it ran. It developed the capability inside the world, where it could be inspected and tested, before the implementation became part of the harness.

Get Owen Zanzal’s stories in your inbox

Join Medium for free to get updates from this writer.

I find that progression particularly interesting.

A useful behavior starts as an experiment, becomes an accepted world function, gets reused, and can eventually graduate into the application itself.

The world isn’t just remembering what the agent did. It’s preserving the software that came out of the work.

Of course, now the agent can break itself

There’s an obvious problem with allowing an agent to modify its own running programming environment.

What happens when it changes something that already worked?

Imagine the agent has accumulated several functions that depend on one another. It decides to modify one, and that change accidentally breaks another.

If we’re going to let an agent continually grow a live application, we need a way to reject changes that violate the contracts we’ve already established.

That’s why bba has checkpoints, invariants, and revisions around evaluation.

Before an operation, bba captures a checkpoint of the managed world state. It evaluates the requested code and checks whether the resulting state still satisfies its invariants.

If the change passes, it becomes a new accepted revision. If it fails, bba reconstructs the previous accepted state and reports the problem to the agent.

The agent can then decide how to recover.

I originally thought of this mostly as a safety mechanism, but looking through the actual execution history made me appreciate how important it is to the development workflow itself.

Here’s an example from a job-search session.

The agent tried to create a persistent definition through execute_form:

(def jobs (parse-jobs (slurp "/tmp/li-gj.html")))

But execute_form isn't supposed to introduce new definitions. That's the responsibility of develop_form.

Instead of allowing the operation to modify the world, bba rejected it:

{:status :rejected
:reason :new-var
:vars [jobs]
:message "execute_form must not define vars; use develop_form"
:restored-to 44
:op "execute"
:ms 17}

The interesting part isn’t the error. It’s what happened next.

The model received the rejection, understood that it had used the wrong operation, and switched to develop_form on the next turn.

The world stayed intact, and the agent continued working.

The rejection and restoration were recorded in just 17 milliseconds.

Across the observed rejection cases, restoration timings ranged from 6 to 147 milliseconds. That’s a useful property because it means the cost of rejecting a bad change can be very small compared with the inference needed to propose and repair it.

The system also uses clj-kondo to catch certain problems before evaluation. The operation history included 43 lint rejections, including issues such as unresolved symbols and incorrect function arity.

I like how this changes the interaction.

The agent doesn’t have to get every operation right on the first attempt. It can propose a change, receive a concrete explanation when the change is rejected, and try something else.

But the accepted world doesn’t have to absorb every mistake along the way.

Invariants and goals are different things

One distinction I wanted to preserve in bba is the difference between what must remain true and what we’re trying to accomplish.

Invariants describe the behavior the system has already committed to preserving.

Goals describe behavior we’d like to introduce.

Both can be represented in a simple EDN configuration file:

{:ns world
:invariants
[{:name "reverse works"
:form (= "cba" (reverse-string "abc"))}]
 :goals
[{:name "has shout"
:form (ifn? shout)}]
:timeout-ms 30000}

If an invariant fails, the proposed change doesn’t get accepted.

If a goal remains unmet, that’s useful information, but it doesn’t necessarily invalidate the current world.

Those are two different questions.

Does the system still satisfy its existing contracts?

And has it accomplished the new thing we’re trying to build?

I think this matters whenever we talk about letting agents modify software autonomously.

We want them to have enough freedom to experiment, but we also need a way to express the things we’ve decided must not change.

The more capable the agent becomes, the more useful those boundaries are.

There’s a limitation worth mentioning here, though.

Restoring a world isn’t the same as rolling back everything that happened outside it. If an operation deletes a file, writes to an external service, or sends a message, restoring the Clojure namespace won’t undo those effects.

The current checkpoints protect managed world state. They aren’t full transactions across every system the agent can touch.

That’s an important distinction, especially for consequential automation.

Making the world survive a restart

Once the agent can grow a useful programming environment, we need a way to preserve it.

One option would be to save the entire runtime image. Lisp systems have explored variations of that idea for a long time.

But I wanted something simpler for bba.

Accepted definitions are stored as an ordered source log, and supported mutable state is saved as EDN data.

When bba starts, it reconstructs the world from those definitions and restores the saved state.

Each accepted operation produces an immutable revision file. A CURRENT pointer identifies the active revision and is updated after the new revision has been written.

That gives the world a history.

We can inspect earlier revisions and roll back to a previous one without deleting everything that came afterward.

I also like that the durable artifact is ordinary source code and data. There’s no special runtime image format at the center of the architecture.

Of course, replaying code has its own complications. Definitions can contain side effects, and not every runtime object can be serialized cleanly.

But for this experiment, it’s a reasonable compromise.

The important thing is that the useful work survives the process and conversation that produced it.

Keeping the agent itself small

The other big influence on bba is pi.

One of the things I appreciate about pi is that it keeps the agent core relatively small and moves extensibility into plugins and extensions.

That’s an appealing alternative to building an increasingly elaborate agent framework.

In bba, extensions are Clojure files that can register tools, commands, event hooks, and provider behavior.

For example, an extension can register a new tool:

(ns my.ext
(:require [bba.ext :as ext]))
(ext/register-tool!
{:name "reverse"
:description "Reverse a string."
:input-schema
{:type "object"
:properties {:text {:type "string"}}
:required ["text"]}
:handler
(fn [{:keys [text]} ctx]
(clojure.string/reverse text))})

Extensions can also intercept tool calls and enforce policies.

(ext/on! :tool-call
(fn [{:keys [name input]} ctx]
(when (some-policy-violation? name input)
{:block true
:reason "Blocked by policy"})))

This gives us two different places where the system can grow.

The world accumulates application capabilities. These are the functions the agent creates to accomplish its work.

Extensions modify the agent harness itself. They can introduce tools, connect providers, register commands, and change how operations are handled.

The agent can write extension files, although activating them requires an explicit reload.

That’s a boundary I want to keep.

There’s a difference between growing the application and changing the machinery responsible for growing it.

And this is what makes the compaction example so interesting.

The agent first developed the compaction functions inside the world. They were ordinary capabilities, just like the file helpers or job-scoring functions.

Only later did those functions become part of the extension system.

The world effectively served as a place to develop and prove out functionality before promoting it into the harness.

That wasn’t an autonomous deployment mechanism. It was a development path.

But it’s a compelling example of what this architecture makes possible.

Why Clojure and Babashka?

Common Lisp is an obvious candidate for this kind of experiment.

It has a long history of interactive development, dynamically redefining programs, and handling failures through its condition and restart system.

That last capability is particularly interesting because it can allow a running computation to recover from an error without necessarily abandoning everything it was doing.

But I wanted to see how far I could take the idea with Clojure.

And Babashka makes that pretty approachable.

Babashka provides a fast-starting Clojure environment with practical access to the filesystem, processes, HTTP, and the scripting ecosystem.

It’s a good foundation for a terminal-native agent because I can use Lisp’s programming model without having to build a heavyweight application around it.

Running bba is as simple as:

bba

Or I can invoke it with a prompt:

bba -p "Inspect this project and create a reusable function that runs its tests"

The current experiment also supports multiple model providers, including OpenRouter, Anthropic, OpenAI, and Ollama, although testing coverage varies.

There are tradeoffs.

Clojure doesn’t provide Common Lisp’s condition and restart system. For now, bba handles failed world changes by restoring the previous accepted state and letting the model try again.

That’s a less sophisticated recovery model, but it’s also much simpler to implement.

And based on the actual sessions, it’s already useful.

There are still some hard problems

I want to be clear that bba is a spike, not a production-ready agent runtime.

There are some important things that still need work, and looking at the execution history has made a few of them more concrete.

The first is sandboxing.

Right now, code runs with the permissions of the user running bba. A live REPL with filesystem and shell access is powerful precisely because it can do almost anything, but that also means it needs proper isolation before handling untrusted workloads.

Then there’s the side-effect problem.

Restoring the namespace doesn’t undo changes to files, remote services, or other external systems. I think a future version needs to distinguish between pure computation, managed state mutation, and external side effects as first-class concepts.

Execution cancellation is another issue.

Across the four worlds, 26 operations hit the configured 30-second timeout. The longest recorded operation ran for a little over 30 seconds.

That matters because a timeout doesn’t necessarily guarantee that a CPU-bound evaluation thread has stopped. Moving world execution behind a process boundary would give the runtime stronger isolation and cancellation behavior.

Latency also varies depending on what the agent is doing. Median operation times ranged from 38 milliseconds in the main world to 373 milliseconds in the job-search world, while longer-running shell operations could take tens of seconds.

So the live programming loop can be fast, but that doesn’t mean every operation is cheap or predictable.

Then there’s state serialization. EDN works well for ordinary data, but arbitrary runtime state is harder to preserve. We need clear rules about what constitutes durable state and what should be reconstructed.

And eventually there’s the question of scaling the world.

A single Clojure namespace is beautifully simple, but what happens when the agent accumulates hundreds or thousands of functions?

We’ll probably need better capability discovery, dependency tracking, independently versioned modules, and ways to understand how changes propagate through the world.

Those aren’t peripheral implementation details.

They’re central to turning an interesting self-growing program into a reliable operating model.

The more freedom we give an agent to change its environment, the more important it becomes to define what it can change, what must remain true, and what happens when something goes wrong.

What if agents accumulated capabilities instead of just context?

There’s a lot of attention on making AI agents smarter.

We’re getting better models, larger context windows, more sophisticated orchestration, improved memory, retrieval systems, and increasingly elaborate tool frameworks.

All of that has value.

But I keep coming back to a slightly different question.

What if an agent became less dependent on inference as it accumulated capabilities?

Not because the model itself was learning new weights, but because the environment it operated in was accumulating executable knowledge.

Think about what happens when you ask an agent to automate a workflow.

The first time, it needs to reason through the problem, inspect the environment, experiment with an implementation, and verify that the result works.

Once it has a working solution, it captures the behavior as a function.

The next time, it invokes that function.

Later, it might combine the function with other capabilities it’s accumulated. When requirements change, it can redefine the implementation while checking that the existing contracts still hold.

The useful work survives the conversation that created it.

That’s the part of this experiment I find most compelling.

Across the sessions I examined, the worlds accumulated real libraries of reusable functions. Some handled coding tasks. Others handled job searches. And a handful of functions originally developed inside a world eventually became part of bba itself.

The conversations provided the reasoning needed to create those capabilities, but the capabilities no longer depend on those conversations being available.

This isn’t learning in the machine-learning sense. The model isn’t becoming more intelligent.

It’s closer to incremental software development, where the model acts as an interactive programmer inside an application that continues to evolve.

And that’s why the Lisp REPL is such an interesting interface.

It brings writing code, executing code, and modifying the running application into the same environment.

A shell-enabled agent can already create reusable software, of course. The opportunity is to make that accumulation native, inspectable, testable, and persistent.

That’s what I’m exploring with bba.

The compaction story is a good example of why I think there’s something here. The agent needed a capability, developed it inside its world, and that capability eventually became part of the agent harness itself.

The conversation was useful while the work was happening, but it wasn’t the durable artifact.

The durable artifact was the code.

And in this case, some of that code became part of the product.

Maybe the next interesting step in agent architecture isn’t another orchestration layer.

Maybe it’s giving the agent a live programming environment, a few contracts it has to respect, and somewhere durable to put the things it builds.

Then letting the software grow from there.

Code: bba on GitHub

Influences:

--

--