This guide covers the essential workflow authoring and integration concepts. It introduces runtime, reliability, and operations features incrementally.

Learn with Livebook

The fastest way to start is the interactive Livebook. It demonstrates workflow creation, step modules, run inspection, and approval flows. Run in Livebook

For production integration, follow the steps below. They introduce retries, manual gates, cron, child runs, and Bedrock leases after establishing the base execution loop.

Mental Model

Squidie has three boundaries:

  1. Workflow definition - a compiled Elixir module that declares triggers, payload fields, steps, transitions, retries, waits, approvals, and recovery markers.
  2. Journal runtime - the Jido-native runtime that records run, dispatch, attempt, manual-control, and terminal facts in durable storage.
  3. Host execution - supervised host processes that call Squidie.execute_next/1, plus optional schedulers or lease-capable backends such as Bedrock.

The workflow definition says what should happen. The journal says what did happen and what is ready next. Host workers provide capacity; they do not own workflow state.

1. Install The Runtime

Start with the smallest embedded setup:

config :squidie,
  repo: MyApp.Repo,
  queue: "default"

Install and run the migration:

mix squidie.install
mix ecto.migrate

This creates a Jido-backed journal store in the host repo. Production stores must provide ordered per-thread appends, optimistic conflict detection, and durable checkpoint reads.

Read next: Host app integration.

2. Write A Small Workflow

Workflow authors should think in business steps, not agents or jobs:

defmodule MiddleEarth.Workflows.RingErrand do
  use Squidie.Workflow

  workflow do
    trigger :leave_shire do
      manual()

      payload do
        field :bearer, :string, default: "Frodo"
        field :ring_id, :string
      end
    end

    step :pack_lembas, Hobbiton.Steps.PackLembas
    step :cross_moria, Fellowship.Steps.CrossMoria,
      retry: [max_attempts: 3]
    step :reach_mordor, Mordor.Steps.ReachMordor

    transition :pack_lembas, on: :ok, to: :cross_moria
    transition :cross_moria, on: :ok, to: :reach_mordor
    transition :reach_mordor, on: :ok, to: :complete
  end
end

Prefer use Squidie.Step for custom step modules. Raw Jido.Action modules remain available for interop, but the Squidie step contract keeps workflow code easier to read.

Read next: Workflow authoring, or run the workflow-authoring Livebook for an interactive dependency and input-mapping walkthrough.

3. Start And Drain A Run

Manual triggers start through the public API:

{:ok, run} =
  Squidie.start(
    MiddleEarth.Workflows.RingErrand,
    :leave_shire,
    %{ring_id: "one-ring"}
  )

Inspection keeps explicit names such as inspect_run/2 and inspect_run_graph/2 rather than adding an inspect/2 alias.

Public start, replay, and control helpers use concise names: start/3, resume/3, approve/3, reject/3, cancel/2, and replay/2. Squidie.Runtime.Signal constructors keep run-suffixed names because those names describe persisted command intent.

Workers drain journal attempts:

Squidie.execute_next(owner_id: "worker-1")

Wrap this call in a supervised worker loop. Start simple: call execute_next/1, back off when it returns {:ok, :none}, then add metrics and capacity controls as needed.

Read next: Host app integration.

4. Start Child Runs When Work Expands

When a native step discovers runtime work that should have its own durable history, start a child workflow from that step's Squidie.Step.Context:

defmodule Hobbiton.Steps.SendPartyInvites do
  use Squidie.Step, name: :send_party_invites

  @impl true
  def run(%{party_id: party_id, guests: guests}, %Squidie.Step.Context{} = context) do
    children =
      for guest <- guests do
        {:ok, child} =
          Squidie.start_child_run(
            context,
            Hobbiton.Workflows.DeliverInvite,
            %{party_id: party_id, guest_id: guest.id},
            child_key: "invite_#{guest.id}",
            metadata: %{guest_id: guest.id}
          )

        child.run_id
      end

    {:ok, %{invite_run_ids: children}}
  end
end

Each child is a normal journal run with its own inspection, retry, replay, and cancellation boundary. The child_key makes the start idempotent for the parent run and parent step, so step retries do not create duplicate children.

Read next: Workflow authoring.

5. Inspect What Happened

Every run should be explainable from durable facts:

{:ok, run} = Squidie.inspect_run(run.run_id, include_history: true)
{:ok, explanation} = Squidie.explain_run(run.run_id)

Use list APIs for dashboard indexes and inspection APIs for details:

{:ok, runs} = Squidie.list_runs([])
{:ok, graph} = Squidie.inspect_run_graph(run.run_id)

This is the surface SquidSonar and other tooling should build on: list runs by workflow or globally, then fetch one run's graph, history, and explanation by id.

Read next: Architecture and Jido runtime architecture.

6. Add Reliability Deliberately

Retries, waits, and recovery routes are workflow semantics, not job-backend accidents.

Use retries for recoverable steps:

step :cross_moria, Fellowship.Steps.CrossMoria,
  retry: [max_attempts: 5, backoff: [type: :exponential, min: 1_000, max: 30_000]]

Use waits for workflow-scale delays:

step :wait_for_gandalf, :wait, duration: 30_000

Use recovery markers when an error path has a business meaning:

transition :cross_moria,
  on: :error,
  to: :walk_home_awkwardly,
  recovery: :compensation

Keep external side effects idempotent. Squidie can fence stale workflow mutations, but it cannot make a payment provider, email API, or webhook exactly once.

Read next: Operations.

7. Add Human Boundaries

Manual steps are durable workflow state:

approval_step :wait_for_council, output: :approval

transition :wait_for_council, on: :ok, to: :cross_moria
transition :wait_for_council, on: :error, to: :walk_home_awkwardly

Operators resolve them through public APIs:

Squidie.approve(run_id, %{actor: "ops_123", comment: "verified"})
Squidie.reject(run_id, %{actor: "ops_123", comment: "fraud risk"})

Inspection history keeps pause, approval, rejection, and resume facts visible with the rest of the run history.

8. Add Cron Only When Needed

Cron triggers declare schedule intent in the workflow, but the host app owns the recurring scheduler:

trigger :daily_digest do
  cron "0 9 * * 1-5", timezone: "Etc/UTC", idempotency: :return_existing_run
end

The scheduler should deliver a Squidie.Executor.Payload.cron/3 payload to Squidie.Runtime.Runner.perform/2. Step and compensation payloads are not part of the journal-backed runtime contract.

For idempotent cron starts, pass a stable signal_id or a complete intended_window so duplicate scheduler delivery returns or skips the existing run instead of starting a second one.

9. Use Bedrock For Backend-Owned Leases

The core runtime stays backend-neutral. A basic host can run a worker loop that calls execute_next/1; a larger host can use a durable backend for delivery and lease ownership.

Bedrock is the recommended reference backend today because the example app already covers durable queueing, delayed visibility, claims, heartbeats, completion, retry, and dead-letter behavior. That path is useful when multiple workers or nodes may compete for work and the host wants backend-owned lease semantics around the Squidie journal.

Read next: Bedrock setup and the Bedrock minimal host app.

Common Gotchas

GotchaWhat to do
Treating Squidie like only a job queueModel business lifecycle in workflow steps, transitions, retries, waits, and manual boundaries.
Depending on external exactly-once behaviorUse idempotency keys, natural keys, or domain duplicate checks in side-effecting steps.
Hiding decisions in step internalsPut branches, manual gates, retries, and recovery routes in the workflow where inspection can explain them.
Using long waits as general timersUse waits for workflow-scale delays; use host scheduling when the whole run should start later.
Letting delivery code own workflow rulesKeep delivery and job boundaries thin; call host-owned modules that wrap Squidie public APIs.
Assuming every database is a good journal storeKeep the adapter boundary database-agnostic, but require ordered appends, conflict detection, and durable checkpoint reads for production.

Where To Go Next