your clanker loves state
every time you ask codex to add a feature it will happily add a new field to your class. a new global variable. a new `useState`
this post has one simple thesis: coding agents overproduce state, and event sourcing is one of the best ways to stop them.
LLM coding agents are very good at local fixes and very bad at respecting the total state surface of your app. every bug looks like it wants one more flag. one more cached answer. one more special case.
that is how you end up with a perfectly reasonable-looking codebase that behaves like a boolean landfill.
this is bad. why? because every new field expands the state space of the app.
every boolean global variable you add:
doubles your final app state
doubles your bugs
doubles the coverage you need to get to 100% in the worst case
here is a real-world example: I asked codex to make kimaki (a discord bot) show the message footer only when the bot was not interrupted by a user message. here is his idea
to answer one yes/no UI question, the agent starts mirroring facts into state.
this is the whole vibe of the bad solution. even after simplifying it, you still end up caching facts that are already present in the assistant message itself.
there is an interruption flag
there is a finished flag
there is an error flag
there is a tool-call-only flag
then there is another function that tries to recombine all of that back into a simple answer like "should I show the footer?"
none of these fields looks insane on its own. that is the trap. every field feels locally justified. globally, though, you are building a machine nobody can hold in their head.
this kind of state machinery is exactly what agents love to build. every fix adds one more field. then another helper. then another branch. then another impossible combination appears.
event sourcing
event sourcing is what will save you from agent state explosion
I removed all the state added by clankers. converted everything to use event sourcing
the fix was not a better set of flags. the fix was deleting the flags
the important shift is this: stop storing conclusions and store evidence instead. if the footer depends on what the assistant actually did, keep the assistant events and derive the answer from them.
here is the example from before using the event sourcing approach:
every possible piece of state is now computed as a pure function that takes as input the last 1000 opencode session events
notice what disappeared:
no interruption flag
no finished flag
no special footer state
no extra state machine just to explain another state machine
you keep the raw thing that happened, then compute the answer when needed.
this increased stability, testability, and correctness by 10x
also, this style is much easier for models to work with. the fix surface becomes obvious: either the events are wrong, or the pure function is wrong. there are far fewer haunted in-between states.
easy debugging with event streams
I also added a command `kimaki session export-events-jsonl <id>` that lets you export the opencode events for a session
now every time I find a bug in kimaki in Discord I tell kimaki "export opencode events from session xxx, create a failing test, then fix it"
the agent then adds a new test using the event stream as input. then it calls a pure transformation that computes data from the event stream
I had to do this, for example, when I discovered there was a new way a session can end naturally in opencode. I just had to reference a session with this new behaviour to Opus and ask it to fix the bug. it just did so. one shot.
the bug is obvious. the test is simple. the transformation code is pure. it has referential transparency
the reproduction artifact is just data:
no mocking Discord
no mocking timers
no begging the runtime to reproduce the exact bad interleaving again
just events in, answer out
any model is able to one-shot these problems because the feedback loop is obvious.
state encapsulation
the next best thing after no state is state you don't care about: encapsulated state
not everything needs event sourcing. the second-best option is state you successfully hide.
a good example of this is React `useState`. `useState` cannot escape the component it is declared in
state can only be written to in event handlers in the component subtree (which is usually small)
state can only be read in the current component
state is local. easy to reason about.
this same thing can be done in backend code
imagine adding a feature to debounce the messages you write in kimaki
one approach could be to add a timer and `setTimeout` in the class field.
now this state is accessible by the full class methods. next time codex could see this field and decide to do weird shit with it.
you also now need to care about destroying the `setTimeout`
again, none of this is catastrophic. it is just a bad trade. you took a tiny implementation detail and promoted it into application state.
a better approach is to encapsulate this state in a closure. a generic debounce function
only this function has access to this state. there is no other consumer that can write to this state
this prevents an explosion of states in your app.
the timer still exists, but now it is trapped in a tiny box. that is the next best thing after deleting state entirely
so if a global variable has the potential of doubling your app state, this function has none. it can only double the states of this encapsulated function. given it's so small we don't care about it. spotting a bug inside it is easy for you and agents
easy persistency
if you want to make an event sourcing system persistent you just store the events. these are easily versionable
instead of storing the end state of your app you store the full event stream that created that state.
if a user has a bug, you just get that event stream, run your pure transformation on it, reproduce the bug. fix it
the persistence trade is basically this:
if a user manages to create a broken state of your app and you persist that, the user would be screwed. the project would be gone. if the user tries to open the project it would crash the app. this happens a lot with things like video editors and complex apps. some projects are simply gone. broken forever. to fix the user project you would need to create migration code that fixes the state. which is tedious.
if instead you store the event stream directly you can fix the state transformation functions, release a new version, the user opens the project, and it will just start working again. what matters is making events immutable and versioned. type-safe. so your transformation functions are guaranteed to process those events, even from older app versions, and return a valid working state.
that is the real payoff. state is cached conclusions. events are stored evidence. evidence ages much better.
if you can derive it, don't store it.