Most of the time, prompting coding agents is not practicing the art of software engineering. And that’s totally fine. Sometimes, you simply want to describe a feature in a few sentences and have the agent spit out the changes. But other times, you want real engineering, which involves applying well-reasoned consideration to the constraints, the current state of the code, and possible future states, and then combining these concepts into an elegant solution that minimally meets the requirements in a way that’s scalable and extensible.
We don’t engineer for engineering’s sake. We do it so future code changes can be made clearly and concisely, in a way that’s unlikely to introduce new bugs. And it’s this last part in particular, that bites unstructured agentic code bases in the proverbial ass. Over time, as the complexity of the application grows, numerous issues develop if care is not taken: inadvertent dependencies get added, invariants that guided the original design are forgotten, and the amount of bespoke state balloons. As a result, prompting the agent to change one piece of functionality frequently breaks another or leaves the application in an inconsistent state.
Additionally, prompting is imperative not declarative. We never lay out exactly what we want, instead opting to guide agents in small increments over and over again. Often, in between these steps, the agent will have forgotten something we said earlier.
That’s not to say we should stop prompting agents. We should continue to leverage them to speed up development. But we need higher-level primitives that allow us to specify the important parts of a design and have the agent enforce those across multiple changes and iterations.
Over the last few weeks, I’ve been experimenting with ways of doing this, and I my current flow kind of works. It’s not perfect by any means, but overall trying to deal in higher level abstractions has been a breath of fresh air compared to dealing with prompt minutia.
The idea is to turn projects into long-lived specifications. These aren’t “plans” and they aren’t agent generated. The specifications are a human-digestible transcription of the core ideas and invariants of a design. If I want to update something about the engineering design, I simply update the spec to include the new information. Then, I ask my agent to look at what changed in the spec and to update the code so that the spec is accurate.
Here’s how it works:
Note: I packaged up everything below into a
[plugin]. Feel free to use it. How it works is very simple, so you can leverage the basic principals without the plugin.
Step 1 - Define a spec file (Markdown is fine)
The spec file defines what you want to build. At first, it just contains your initial idea, but it will grow over time as your specify new functionality. This is the master plan of your application. Do not slop it up. This should be simple and human-digestible.
Step 2 - Update the spec
Define your new functionality, in a declarative way, leveraging composition with previous parts of the spec where applicable.
Step 3 - Kick of your agent
Rather than instruct your agent to implement the feature directly, you direct the agent to look at changes to your spec and figure out what should be done.
Tip: You can commit the changes to your spec and kick of the agent in a worktree so that you can continue specing out other functionality. If you’re using the
[plugin], just run specling skill with your spec file to do this automatically.
And that’s basically it.
Example #
What does a TODO app look like with declarative prompting?
An web application that shows a list of TODOs. A plus button at the top of the list
adds a new TODO.
## Data model
type Todo = string
That leaves you with a standard looking TODO app
That looks ok, but we can’t mark them as done. Let’s update our data model:
> git diff
diff --git a/todo.spec.md b/todo.spec.md
index c50d17c..2f89f96 100644
--- a/todo.spec.md
+++ b/todo.spec.md
@@ -5,4 +5,4 @@ adds a new TODO.
## Data model
-type Todo = string
+type Todo = { text: string; done: boolean }
A simple change to our data model lets the agent know what type of functionality we want.