Pinned Post

OpenSpec UI: ein Live-Dashboard für deine Specs

Bild
OpenSpec UI: ein Live-Dashboard für deine Specs Wer mit einem KI-Agenten und OpenSpec arbeitet, kennt das Bild. Im Projekt wächst ein Verzeichnis openspec/ heran: Changes mit proposal.md , design.md , tasks.md und ihren Delta-Specs, daneben die kanonischen Specs der einzelnen Capabilities. Die Absicht hinter dem Code steht damit endlich geschrieben, statt sich in ihm zu verstecken. Nur verteilt sie sich über Dutzende Markdown-Dateien, und der Editor zeigt eben Dateien. Was er nicht zeigt, ist der Zustand. Woran arbeitet der Agent gerade? Welcher Task ist der nächste offene? Und was hat sich verändert, während ich zehn Minuten woanders hingeschaut habe? Genau diese Lücke füllt OpenSpec UI , ein kleines Phoenix-LiveView-Dashboard, das lokal neben deinem Projekt läuft und den OpenSpec-Workspace im Browser zeigt — live, während gearbeitet wird. Der Code liegt offen auf GitLab: https://gitlab.com/public_elixir/openspec_ui Ein Blick statt Datei-Hopping Du startest den Server, gibst d...

Jido in Practice: Agents in Elixir as Composable Actions

At Fiatbitcoin, around two dozen small agents run in the background. They fetch prices, read the mempool, score news, compute tax deadlines, send alerts. In the beginning these were all just GenServers and Oban jobs, each built a little differently. That works, but it gets messy fast: every job has its own input and output, its own way of reporting errors, its own idea of what “done” means.

Jido is a framework for exactly this kind of work. It hands you a small set of clearly defined building blocks that let you assemble agents from small, testable units. I use it in version 2.1. This article is a tour through real code from the project, and the question behind it stays the same throughout: where does Jido pull its weight, and where does it not.

If you don’t write Elixir, a few words up front. A module is a collection of functions. {:ok, value} and {:error, reason} are the usual way to return success or failure, a tagged pair. A GenServer is a lightweight, long-lived process with its own state, and a supervisor is the process that starts it and restarts it if it crashes. That is all you need to follow along.

The smallest unit: an action

The central building block in Jido is not the agent, it is the action. An action is a single unit of work with a name, a description and a parameter schema. Here is the action that produces a portfolio summary:

defmodule Backend.Agents.Actions.GenerateSummary do
  use Jido.Action,
    name: "generate_summary",
    description:
      "Generates comprehensive portfolio and tax summary with German natural language text",
    schema: [
      user_id: [
        type: :integer,
        required: true,
        doc: "The ID of the user to generate summary for"
      ]
    ]

  @impl true
  def run(%{user_id: user_id}, context) do
    # ...
  end
end

The schema describes the inputs (NimbleOptions under the hood), including type, required fields and a doc line per field. The actual code lives in run/2: the first argument is the parameters, the second a context map for things coming in from the outside. The return value is always {:ok, map} or {:error, reason}. This one convention runs through every action, and it is the reason everything ends up connecting to everything else.

Composing actions

An action is allowed to call other actions. GenerateSummary computes nothing itself, it pulls performance and tax data from two other actions and weaves a text out of them:

def run(%{user_id: user_id}, context) do
  with {:ok, performance} <- GetPerformanceSummary.run(%{user_id: user_id}, context),
       {:ok, tax} <- GetTaxReport.run(%{user_id: user_id}, context) do
    summary_text = build_summary_text(performance, tax, cross_ctx, prev_value)

    {:ok,
     %{
       user_id: user_id,
       performance: Map.drop(performance, [:user_id]),
       tax: Map.drop(tax, [:user_id]),
       summary_text: summary_text
     }}
  else
    {:error, reason} -> {:error, reason}
  end
end

The with is Elixir’s way of chaining several steps that all have to succeed. As long as each step returns {:ok, ...}, it continues. The moment one returns {:error, ...}, execution jumps into the else branch and passes the error straight up unchanged. Because GetPerformanceSummary and GetTaxReport satisfy the same contract as GenerateSummary, they stack without either one needing to know anything special about the other.

Anatomy of a Jido agent: agent, actions and tool

The agent as a bundle

The agent itself is surprisingly thin. In essence it declares which actions it can run and what state it holds:

defmodule Backend.Agents.PerformanceTaxAgent do
  use Jido.Agent,
    name: "performance_tax_agent",
    description: "Analyzes Bitcoin portfolio performance and tax status following German tax law",
    actions: [
      GetPerformanceSummary,
      GetTaxReport,
      GenerateSummary,
      GenerateSummaryAI
    ],
    schema: [
      last_user_id: [type: :integer, default: nil],
      last_result: [type: :map, default: nil]
    ]
end

There are two ways to work with it. For the simple case you call the action directly, with no process running at all. That is how the REST endpoint and the LiveView do it:

def generate_summary(user_id) do
  {time_us, result} =
    :timer.tc(fn -> GenerateSummary.run(%{user_id: user_id}, %{}) end)

  TelemetryHandler.record_metric(
    "performance_tax_agent",
    "execution_duration",
    time_us / 1000.0,
    %{action: "generate_summary", user_id: user_id}
  )

  result
end

The second way is the stateful agent process, for workflows where you plan steps and hold state across several calls:

{:ok, agent} = PerformanceTaxAgent.new()
{:ok, agent} = PerformanceTaxAgent.plan(agent, GenerateSummary, %{user_id: 1})
{:ok, agent} = PerformanceTaxAgent.run(agent)
result = agent.result

As a process the agent lives under a Jido.AgentServer, and that is an ordinary child in the supervision tree:

{Jido.AgentServer,
 agent: Backend.Agents.PerformanceTaxAgent, id: "performance_tax_agent", jido: Backend.Jido},

This is one thing I like about Jido: the agent process does not break out of OTP, it fits into it. Supervision, restart strategies, telemetry, PubSub, all of it stays exactly as you know it from plain Elixir. Jido does not replace the toolbox, it lays a structure on top of it.

Four doors, one action

This is where the discipline of the uniform action contract really pays off. The same GenerateSummary is called from four completely different directions at Fiatbitcoin:

  • An Oban cron job runs daily at 06:15 UTC and writes the summary into the system’s memory.
  • The REST endpoint GET /api/ai/performance_tax/:user_id serves it as JSON to external callers.
  • The LiveView at /ai_insights renders it in the browser.
  • An LLM calls it as a tool via tool-calling (more on that in a moment).
One action, four callers

Four entry points, one piece of logic. I did not have to write the calculation four times, and I do not keep it in sync across four places. This is not a Jido-specific trick, clean functions achieve the same. But Jido pushes you into this shape from the start, because the action is the default unit and the {:ok, map} contract is non-negotiable. You slide into the good structure instead of having to fight your way to it.

From an action to an LLM tool

The path where this pays off most clearly is tool-calling. An action already has everything a language model needs to know about a tool: a name, a description, a parameter schema. To turn an action into a tool, there is barely anything left to translate:

def tools(user_id) do
  [
    ReqLLM.Tool.new!(
      name: "get_tax_report",
      description:
        "Liefert den Steuerstatus des aktuellen Nutzers nach deutschem Recht: " <>
          "steuerfreie und steuerpflichtige Positionen sowie das nächste steuerfreie Datum.",
      parameter_schema: [],
      callback: fn _args -> run_action(GetTaxReport, user_id) end
    )
  ]
end

defp run_action(action, user_id) do
  case action.run(%{user_id: user_id}, %{}) do
    {:ok, result} -> {:ok, Jason.encode!(result)}
    {:error, reason} -> {:error, inspect(reason)}
  end
end

There is one detail here I find neat. The user_id is bound into the callback, the tool itself takes no parameters. So the model decides which data to fetch, but never whose. It cannot point at someone else’s portfolio, because it has no lever for it. Authorization lives in the closure, not in the model’s hands.

The matching action GenerateSummaryAI delegates the actual data fetching to the model and, at the end, only replaces the summary text with the model-written variant. If the model is unavailable, the template text from GenerateSummary takes over. The return value stays the same shape in both cases, so the AI variant is a clean drop-in replacement and not a second code path you have to maintain separately.

What else Jido brings

What I have shown so far is the core: action, agent, tool. Beyond that, Jido brings quite a bit more that I will only sketch here. Some of it I use lightly in the project, some sits there unused. For completeness, an overview.

  • Jido.Exec, the execution engine. Instead of calling Action.run/2 directly, you can run an action through Jido.Exec.run/3. Then you get parameter and output validation, automatic retries with exponential backoff, timeouts, asynchronous execution (run_async/await) and compensation, meaning a clean rollback when a step fails. This is exactly the validating path I said in the lessons learned I still want to adopt.
  • Signals and directives. An agent does not perform side effects itself, it returns typed directives such as %Emit{}. The runtime turns those into signals in CloudEvents format and dispatches them over PubSub, HTTP or a bus. That is how agents talk to each other without knowing each other directly.
  • Sensors. A Jido.Sensor turns external events into signals. It is exactly this pattern by which the error sensor at Fiatbitcoin hangs off the error_tracker table and feeds the self-healing pipeline.
  • Plugins. A plugin bundles actions, its own state and routing rules into a reusable capability that you attach to several agents via plugins:. Useful once capabilities start repeating across agents.
  • Per-agent cron. Jido ships its own process-local scheduler (Jido.Scheduler), so an agent can plan its own runs. I still use Oban for that because it is already in the project, but the option is there.
  • Memory, thread and persistence. An agent has a built-in memory and an append-only event log, and it can write itself into a checkpoint via Jido.Persist and thaw again later. Interesting for long-running, stateful agents. We have our own Brain memory, so this lies idle for us.
  • Tool conversion built in. Wrapping an action as an LLM tool, which I did by hand above, is also available ready-made: Jido.Action.Tool.to_tool/2 produces the tool map including a JSON schema from the action schema. I built it by hand because I wanted to bind the user_id into the closure and encode the output as JSON myself.
  • Observability. Jido.Observe puts telemetry spans around action and agent calls and enriches them with correlation IDs. If you already have a telemetry pipeline, you just hook into it.

The common thread stays the same: all of it builds on the action and its contract. You can start small and pull in the heavier tools once an agent genuinely needs them.

Where Jido does not help, and that is fine

Not every agent in the project is a Jido agent, and that is by design. The BlockEventAgent, for instance, listens to ZMQ events from the local Bitcoin node and writes one entry per new block. It is a plain GenServer:

defmodule Backend.Agents.BlockEventAgent do
  use GenServer

  @impl true
  def init(_opts) do
    Phoenix.PubSub.subscribe(Backend.PubSub, "bitcoin_zmq")
    {:ok, %{}}
  end

  @impl true
  def handle_info({:zmq_raw_block, hash, raw_hex}, state) when is_binary(raw_hex) do
    Task.start(fn -> safe_record(hash, raw_hex) end)
    {:noreply, state}
  end
end

Pure reacting to a PubSub topic, a bit of state, no need for an action schema or a plan. Here Jido would only add ceremony without simplifying anything. The rule of thumb that emerged for me: once an agent has several named capabilities that should also be callable individually from the outside (cron, API, LLM), Jido is worth it. For a single reactive handler, a GenServer is more honest.

And the stateful agent mode with plan/run? I hardly ever use it in the project. Most work is a call in, a result out, with nothing that needs to be held between calls. Jido does not force you into the process mode, and that is a good thing. It would be dishonest to claim we push the whole framework to its limits.

Lessons learned

Three things that worked, and one I would sharpen.

1. The uniform contract is the real feature.

Not the agent DSL, not the process mode. That every action is called run/2 and returns {:ok, map} or {:error, reason} is what makes composition, reuse and tool-calling effortless in the first place. The rest of Jido only builds on top of it.

2. Actions are naturally easy to test.

An action is a function with a clear input and output. No process, no mock framework, no setup. GenerateSummaryAI even takes the LLM function through the context, so in a test you can return a piece of prose without ever touching a real model. That is not a coincidence, it falls straight out of the action design.

3. Not everything has to be an agent.

The reflex to press every background process into the new abstraction costs more than it returns. A GenServer that reacts to a PubSub event is allowed to stay a GenServer. Using Jido where it fits, and leaving it out where it does not, relieved the codebase more than it homogenized it.

4. What I would sharpen.

I do not yet carry the schema validation through consistently. The convenience functions call Action.run/2 directly and thereby bypass the check the executor would bring. For internal, type-safe calls that does not show, but at the REST edge, where user input arrives, I would route the calls through the validating path in the future instead of relying on the controller layer. A contract is only worth as much as it is enforced.

If any part interests you more, the tool-calling, the composition over with, or the question of when a GenServer is the more honest choice, drop it in the comments. I am happy to go deeper.

Kommentare

Beliebte Posts aus diesem Blog

Splitting an ML model and a web app across two BEAM nodes — the technical blueprint

A Foundation Model in the BEAM: On-Chain Anomalies with Google TimesFM in Elixir