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"}
]
endAdd 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()
end2. 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 costThe 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:
| Return | Meaning |
|---|---|
:ok | success, 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
endDownstream 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
end4. 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.