Taken out again
I took the framework out again
The first post in this series started with two agents, a classic one and an AI one, "both implemented as Jido agents". Every cover since has carried a small purple label saying "Jido". The last post built a loop, a process per conversation, an approval before generated code runs for the first time.
Now Jido is no longer in the mix.exs. Three merge requests, eleven packages fewer in the lockfile at the end, and the code in lib is fifty lines shorter than before, even though it now does things itself that the framework used to do.
This is not a verdict on Jido. It is a report on what a framework does in a demo that is meant to show what an agent is, and why I did not want it in this demo anymore.
What I actually used
Before taking anything out, I counted. Jido brings a lot: plugins, sensors, scheduled jobs, pods, a memory of its own with threads, observability, compensation on failure, persistence to hibernate and thaw agents. What I used of it:
- the macro for actions: name, description, schema, parameter validation - cmd/2 with %Jido.Instruction{} to run an action on an agent - AgentServer, InstanceManager, signals and the Emit and Spawn directives for the chat agent
That is not nothing, but it is the smaller part. And of what I used, I had switched part of it off again.
![]() |
| What the demo used of Jido, and what of that was switched off in the end. |
The detours
The last post was, without my planning it that way, a collection of places where I worked around Jido.
For a tool to get a memory limit, Jido had to call it with timeout: 0, because with a timeout Jido starts the action in a task of its own under its supervisor, and that task cannot be given a limit. For a tool that reads a page not to request it twice, it needed max_retries: 0, because Jido retries an action that raises. For the chat agent to stay reachable, nothing could wait inside it, because the AgentServer drains its directives in its own process and state/1 gives up after five seconds. For the worker to find its agent, it needed context.agent_server_pid, because an action does not run in the server process. For Emit to work at all, the application needed its own Jido instance. And the restart limit that emptied every conversation on the fourth crash within five seconds was hard-coded in the InstanceManager.
Each of these was reasonable on its own, and each had a comment explaining why something here does not work the way Jido suggests. At some point I noticed that a good share of my comments no longer described what my code does, but what the framework would do if I let it.
A tool is a module with four functions
The first step was the macro for actions. What is left of it when you only take what the demo needs is a behaviour with four callbacks.
@callback name() :: String.t()
@callback description() :: String.t()
@callback schema() :: keyword()
@callback run(params :: map(), context :: map()) :: {:ok, map()} | {:error, term()}
The use macro that goes with it defines the first three and checks the schema at compile time, so a tool with a broken schema never loads. The actual call is one function.
def validate(tool, params) do
schema = tool.schema()
{known, unknown} = Map.split(params, Keyword.keys(schema))
case NimbleOptions.validate(Map.to_list(known), schema) do
{:ok, validated} -> {:ok, Map.merge(unknown, Map.new(validated))}
{:error, %NimbleOptions.ValidationError{message: message}} -> {:error, {:invalid_params, message}}
end
end
The line with Map.split is the one I had to look up in Jido's source. Jido only validates the keys the schema knows and passes all others on unchanged. Rejecting unknown keys would have been stricter and at first sight better, and part of my tools would have stopped working. A replacement has to rebuild the behaviour the code relies on, not the one you would have wished for. There is a test of its own for that now.
The rest of run/3 catches exceptions and turns them into an error, once, without a retry. The max_retries: 0 from the last post was not replaced; it simply disappeared, because nothing retries anymore. I kept the test that checks a raising tool runs exactly once. It no longer guards a setting but a property.
Generated tools have written use AgentDemo.Tool instead of use Jido.Action since. That touched three places: the generator's prompt, the one rule in the sanitizer that demands exactly this use, and the loader's check that the compiled module has the right behaviour. The tools already on disk still say use Jido.Action, and the sanitizer rejects them on boot. The next time they are needed, they are generated again. The pipeline did its own migration.
The chat agent is a GenServer
The second step was the chat agent, and it was the bigger one. A Jido.AgentServer with six actions, signals, directives and a Jido instance of its own became a GenServer.
The six actions became six handle_cast clauses. Emit is a Phoenix.PubSub.broadcast/3. Spawn is a Task.Supervisor.start_child/2. The decision on an approval, which used to travel to the worker's pid as a signal via Emit, is a send/2. The InstanceManager is a Registry, a DynamicSupervisor and some thirty lines for the idle timeout.
def handle_info({:DOWN, ref, :process, owner, _reason}, state) do
case state.attached do
%{^owner => ^ref} ->
state = %{state | attached: Map.delete(state.attached, owner)}
{:noreply, if(state.attached == %{}, do: schedule_idle_stop(state), else: state)}
_other ->
{:noreply, state}
end
end
def handle_info(:idle_stop, %{attached: attached} = state) when attached == %{},
do: {:stop, :normal, state}
The agent monitors the LiveViews that show it. When the last one goes, a timer starts. If another one comes back before it fires, the timer is cancelled. The idle timeout was never more than that.
![]() |
| The same jobs, fewer concepts. Every piece on the right is OTP or Phoenix |
One line in the child spec settled the open question from the last post: restart: :temporary. The InstanceManager restarted crashed agents right away, and every restart counted against the limit of three in five seconds that all conversations shared. Now the supervisor does not restart a crashed agent at all. It does not need to, because the LiveView monitors its agent anyway and opens a new one. A crash affects one conversation, not all of them. A test kills ten agents in a row and checks that the supervisor and an uninvolved conversation survive it.
Along the way the thing I wrote about in the last post almost happened again. One of my new tests failed now and then: right after the crash, the dead agent was still in the Registry. I was sure I had found a bug in open/1 that hands a LiveView a dead pid after a crash, and had the fix in my head already. Three hundred attempts later open/1 had not returned a dead pid a single time. The Registry cleans up dead entries asynchronously, and my test looked too early. The bug was in the test. The fix I had in mind would have solved a problem that did not exist.
What I lost
Jido gave me a vocabulary, and that was not worthless. cmd/2 was pure; it returned a new state and a list of effects, and the effects were carried out elsewhere. Thinking and acting were separate, visibly, in the types. In the GenServer both sit in the same clause: handle_cast changes the state and in the same breath sends a message to the worker. That is perfectly normal in Elixir, but it is less explicit, and someone reading the demo to understand agents no longer sees the separation in the code, only in the split between agent and worker.
I also gave up being close to an ecosystem. Whoever later wants plugins, sensors or agents sending each other signals no longer starts with a framework, but with OTP.
And the first four posts now describe a state the repository no longer has. The Git history still has it. I think that is acceptable, but it is a price.
What I would take Jido for again: a system with many agents that send each other signals, that have to be hibernated and woken up, that need plugins and sensors. So, roughly everything this demo is not.
What an agent is without a framework
The real reason goes deeper than the detours. This demo is meant to show what an agent is. With Jido it showed that too, and on the side, how to use Jido. The second lesson got in the way of the first: anyone who wanted to understand the chat agent first had to understand what an AgentServer is, what a directive is, why an action runs in a different process than its agent.
Without a framework three things are left. A loop that plans, runs and hands the results back, a few dozen lines in the TaskAgent. Tools, which are modules behind a behaviour. And processes: a GenServer per conversation, a Task.Supervisor for the work, a Registry to find them. Everything else is prompts and checks. That is the answer to the question the series started with, and it is now as small in the code as it is in a sentence.
Before, after
| With Jido | Without | |
|---|---|---|
| Dependencies | jido and ten more packages | nimble_options, direct |
Code in lib | 50 lines less (817 in, 867 out) | |
| Tool abstraction | Jido.Action | AgentDemo.Tool, 110 lines with docs |
| Chat agent | 445 lines in eight modules | 364 lines in one module |
| Retrying a tool | switched off by option | does not exist |
| Crashes | the fourth in five seconds empties every conversation | affects one conversation |
| Tests | 210 | 214 |
If you are thinking of doing the same
First count what you actually use. Not what the framework can do, but which of its modules appear in your code. For me it was a small part, and that was half the decision.
Look up what the framework does at the places you replace, before you replace them. The behaviour your code relies on is rarely in the docs and often in a line like that Map.split.
Take it out in steps along the seams, not in one big throw. For me there were three: the tools, the process, the dependency itself. After every step all tests were green, and after the second no code called the framework anymore. The third step was only renaming.
Keep the tests that guarded a behaviour of the framework. "Jido must not retry" becomes "a tool runs exactly once", and you want to know that without Jido too.
Count on what is on disk. Generated code, stored state, anything that still carries the old name inside it.
This time 817 lines came in and 867 went, spread across three merge requests. The whole thing is on GitLab at gitlab.com/public_elixir/agent_demo, the version with Jido in the history before it. If you use Jido in a larger system and see this differently, tell me in the comments. The other side is exactly what I am interested in.



Kommentare