Baton turns a set of Oban jobs into a dependency-ordered workflow: you declare which steps depend on which, and Baton guarantees execution order, passes results between steps, and gates each step at runtime — all on Oban OSS, no Oban Pro required.

This guide gets you from zero to a running workflow. For a fuller example with parallelism and a live UI, see the building a workflow guide.

1. Install

Add Baton alongside Oban:

def deps do
  [
    {:baton, "~> 0.1"},
    {:oban, "~> 2.17"}
  ]
end

Add Baton's tables in a migration. up/0 installs the latest schema and is idempotent, so the same migration can be re-run to pick up future versions:

defmodule MyApp.Repo.Migrations.AddBaton do
  use Ecto.Migration
  def up, do: Baton.Migration.up()
  def down, do: Baton.Migration.down()
end

2. Configure

Baton inherits its repo from Oban, so you don't pass one. Register Baton.Plugin in your Oban plugins: list and give Oban a queue to run on:

# config/config.exs
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [default: 20],
  plugins: [
    Oban.Plugins.Pruner,
    {Baton.Plugin, interval: :timer.seconds(60)}
  ]

Baton-specific settings are optional — these are the defaults:

config :baton,
  oban_name: Oban,              # the Oban instance to use
  pubsub: nil,                  # set to MyApp.PubSub for live events (step 5)
  pricing: Baton.Pricing.Default # only used if you track LLM cost

The plugin keeps workflows healthy on each sweep — it rescues jobs orphaned by pruning, emits failure telemetry, backstops the terminal "workflow finished" notification, and (opt-in) prunes Baton's own tables. See Baton.Plugin.

3. Define steps

A step is a module that does use Baton.Worker and implements perform_workflow/1. It's a drop-in for Oban.Worker — the same options (queue:, max_attempts:, etc.) apply — but you implement perform_workflow/1 instead of perform/1, and Baton handles dependency gating, result storage, and broadcasting around it.

Return one of:

ReturnMeaning
:oksuccess, no result to pass downstream
{:ok, result}success; result is stored and passed to dependents
{:error, reason}failure; retried per max_attempts, then discarded
{:snooze, seconds}not ready; recheck later without consuming an attempt
{:discard, reason}give up now, without retrying
defmodule MyApp.Steps.Fetch do
  use Baton.Worker, queue: :default, max_attempts: 3

  @impl true
  def perform_workflow(%Oban.Job{args: %{"url" => url}}) do
    {:ok, %{"body" => HTTPClient.get!(url)}}
  end
end

Downstream steps read a dependency's stored result with Baton.Results.get_result/2:

defmodule MyApp.Steps.Parse do
  use Baton.Worker, queue: :default
  alias Baton.Results

  @impl true
  def perform_workflow(%Oban.Job{} = job) do
    {:ok, %{"body" => body}} = Results.get_result(job, :fetch)
    {:ok, %{"parsed" => MyApp.Parser.run(body)}}
  end
end

4. Build and insert

Chain Baton.add/4, declaring dependencies with deps:. The whole workflow is validated (no cycles, every dep exists) and inserted in a single transaction — it's all-or-nothing:

Baton.new(workflow_name: "ingest")
|> Baton.add(:fetch, MyApp.Steps.Fetch.new(%{url: "https://example.com"}))
|> Baton.add(:parse, MyApp.Steps.Parse.new(%{}), deps: [:fetch])
|> Baton.add(:store, MyApp.Steps.Store.new(%{}), deps: [:parse])
|> Baton.insert!()

insert!/1 returns the inserted jobs or raises on a bad graph; insert/1 returns {:ok, jobs} or {:error, {message, reason}}. To check a graph without inserting, use Baton.validate/1.

Oban runs fetch immediately (no deps); parse and store gate themselves and start as soon as their dependencies complete.

5. Observe

Everything observable is emitted as telemetry, so you can integrate without Phoenix. Attach the built-in logger in dev to see failures and rescues:

Baton.Telemetry.attach_default_logger()

Key events (see Baton.Telemetry):

  • [:baton, :step, <state>] — every step transition
  • [:baton, :workflow, :finished] — a workflow settled (:completed/:failed)
  • [:baton, :plugin, :rescued] / [:baton, :plugin, :pruned] — plugin activity

If you set config :baton, pubsub: MyApp.PubSub, the same step transitions are also broadcast over Phoenix.PubSub for a live dashboard — see the building a workflow guide for a LiveView example.

Next steps

  • Building a workflow — fan-out/fan-in, pruning, and a live LiveView.
  • Multi-model workflows — run a step across several models and synthesize the results, and track per-step LLM cost.
  • Baton.LLMWorker — LLM-tuned worker with jittered backoff, idempotent retries, and automatic cost/usage recording.