Powered by AppSignal & Oban Pro

Prerequisites

first-workflow.livemd

Prerequisites

Complete Your first agent before starting this tutorial. You need a working agent module and familiarity with single-action execution.

Setup

Mix.install([
  {:jido, "~> 2.0"}
])

Why compose actions?

A single action that does everything is easy to write and hard to maintain. Splitting work into small actions gives you three things: isolated tests, clear failure boundaries, and reuse across agents.

Jido workflows run a list of actions in sequence. Each action returns state updates. cmd/2 deep-merges them into agent state. The next action reads the updated state through context.state.

Define the agent

The agent declares a schema that describes the shape of its state. All action results merge into this state as the workflow progresses.

defmodule MyApp.OrderAgent do
  use Jido.Agent,
    name: "order_agent",
    description: "Processes orders through validation, discount, and total",
    schema: Zoi.object(%{
      order_id: Zoi.string() |> Zoi.default(""),
      validated: Zoi.boolean() |> Zoi.default(false),
      discount: Zoi.float() |> Zoi.default(0.0),
      total: Zoi.float() |> Zoi.default(0.0),
      status: Zoi.atom() |> Zoi.default(:pending)
    })
end

Define the actions

Build three actions for an order processing workflow. Each action declares its inputs with a Zoi schema and returns a state map.

defmodule MyApp.ValidateOrder do
  use Jido.Action,
    name: "validate_order",
    schema: Zoi.object(%{order_id: Zoi.string()})

  @impl true
  def run(params, _context) do
    {:ok, %{order_id: params.order_id, validated: true}}
  end
end

ValidateOrder takes an order_id param and returns validated: true. The runtime merges this into the agent’s state.

defmodule MyApp.ApplyDiscount do
  use Jido.Action,
    name: "apply_discount",
    schema: Zoi.object(%{})

  @impl true
  def run(_params, context) do
    discount = if context.state[:validated], do: 0.1, else: 0.0
    {:ok, %{discount: discount}}
  end
end

ApplyDiscount takes no params. It reads validated from context.state, which holds the agent’s current state after prior actions ran.

defmodule MyApp.CalculateTotal do
  use Jido.Action,
    name: "calculate_total",
    schema: Zoi.object(%{})

  @impl true
  def run(_params, context) do
    base_price = 100.0
    total = base_price * (1.0 - context.state.discount)
    {:ok, %{order_id: context.state.order_id, total: total}}
  end
end

CalculateTotal reads discount and order_id from context.state. It computes the final price and returns the result for merging.

Notice the pattern: each action reads prior results from context.state, not from params. Params are what you pass in the {Action, %{params}} tuple and are validated against the action’s schema.

Chain actions with cmd/2

Create an agent struct and pass a list of action tuples to cmd/2. The runtime executes them in order, merging each result into the agent’s state before running the next action.

agent = MyApp.OrderAgent.new()

{agent, directives} =
  MyApp.OrderAgent.cmd(agent, [
    {MyApp.ValidateOrder, %{order_id: "ord_99"}},
    MyApp.ApplyDiscount,
    MyApp.CalculateTotal
  ])

The first tuple provides initial params. ApplyDiscount and CalculateTotal have no extra params, so you pass the module alone. Each action receives the latest agent state through context.state.

Inspect the result

After execution, the agent struct holds the merged state from all three actions. Directives collect any side effects the actions emitted.

IO.inspect(agent.state, label: "Final state")
IO.inspect(directives, label: "Directives")

You should see state containing order_id, validated, discount, and total. The values accumulate in agent state across the chain without any glue code.

Return directives from actions

Actions can return directives alongside state updates. A directive is a struct that tells the agent runtime to perform a side effect like emitting a signal.

defmodule MyApp.ConfirmOrder do
  use Jido.Action,
    name: "confirm_order",
    schema: Zoi.object(%{})

  @impl true
  def run(_params, context) do
    signal = Jido.Signal.new!(
      "order.confirmed",
      %{order_id: context.state.order_id, total: context.state.total},
      source: "/orders"
    )

    {:ok, %{status: :confirmed},
     %Jido.Agent.Directive.Emit{signal: signal}}
  end
end

ConfirmOrder reads order_id and total from context.state, builds a signal, and returns it as an Emit directive. The directive appears in the second element of the cmd/2 return tuple.

Run the full workflow

Add ConfirmOrder to the end of the chain. Run the complete workflow from a fresh agent.

agent = MyApp.OrderAgent.new()

{agent, directives} =
  MyApp.OrderAgent.cmd(agent, [
    {MyApp.ValidateOrder, %{order_id: "ord_99"}},
    MyApp.ApplyDiscount,
    MyApp.CalculateTotal,
    MyApp.ConfirmOrder
  ])

Inspect directives

IO.inspect(agent.state, label: "Final state")
IO.inspect(directives, label: "Emitted directives")

The state now includes status: :confirmed, and the directives list contains the Emit directive with the order.confirmed signal.

Next steps

You now know how to compose actions into workflows with shared state and directive output. Read Actions and validation for the full action API, or continue to Directives to learn how the runtime routes emitted signals.