# Getting Started With Squidie
This Livebook introduces the core Squidie model through a small workflow you
can run without creating a Phoenix app or a Postgres database.
The examples use ETS-backed journal storage to keep setup light. Real host apps
should use the Ecto/Postgres journal storage described in the host app
integration guide.
## Install
```elixir
Mix.install([
{:squidie, git: "https://github.com/dark-trench/squidie.git"}
])
```
## Runtime Setup
Squidie normally reads runtime configuration from the host application. In
this notebook, we configure it directly and use an ETS table for durable facts
inside the current Livebook session.
```elixir
storage = {Jido.Storage.ETS, table: :squidie_getting_started_livebook}
# This placeholder satisfies the library config contract for the notebook.
# A real host app should configure its actual Ecto repo instead.
defmodule SquidieLivebook.Repo do
end
Application.put_env(:squidie, :repo, SquidieLivebook.Repo)
Application.put_env(:squidie, :runtime, :journal)
Application.put_env(:squidie, :read_model, :read_model)
Application.put_env(:squidie, :journal_storage, storage)
Application.put_env(:squidie, :queue, "livebook")
opts = [
runtime: :journal,
journal_storage: storage,
queue: "livebook"
]
defmodule SquidieLivebook.Output do
def attempt(attempt) do
Map.take(attempt, [
:step,
:status,
:attempt_number,
:visible_at,
:wakeup_emitted?,
:applied?
])
end
def runnable(runnable) do
Map.take(runnable, [:runnable_key, :key, :step, :status, :visible_at])
end
def node(node), do: Map.take(node, [:id, :status, :current?])
def edge(edge) do
Map.take(edge, [:id, :from, :to, :type, :status, :selected?, :pending?])
end
end
```
## Define Steps
Workflow steps do the domain work. The common authoring path is
`use Squidie.Step`; raw `Jido.Action` modules are only needed for explicit
interop.
```elixir
defmodule SquidieLivebook.PackLembas do
use Squidie.Step,
name: :pack_lembas,
description: "Packs provisions for the errand",
input_schema: [
ring_id: [type: :string, required: true]
],
output_schema: [
provisions: [type: :map, required: true]
]
@impl Squidie.Step
def run(%{ring_id: ring_id}, %Squidie.Step.Context{}) do
{:ok,
%{
provisions: %{
ring_id: ring_id,
lembas_count: 11,
packed_by: "Sam"
}
}}
end
end
defmodule SquidieLivebook.CrossMoria do
use Squidie.Step,
name: :cross_moria,
description: "Crosses Moria with the packed provisions",
input_schema: [
provisions: [type: :map, required: true]
],
output_schema: [
moria: [type: :map, required: true]
]
@impl Squidie.Step
def run(%{provisions: provisions}, %Squidie.Step.Context{run_id: run_id}) do
{:ok,
%{
moria: %{
run_id: run_id,
ring_id: provisions.ring_id,
status: "crossed",
lembas_left: provisions.lembas_count - 3
}
}}
end
end
```
## Define A Workflow
A workflow declares the trigger, payload, steps, and transitions. The workflow
definition says what should happen; the journal records what did happen.
```elixir
defmodule SquidieLivebook.RingErrandWorkflow do
use Squidie.Workflow
workflow do
trigger :leave_shire do
manual()
payload do
field :ring_id, :string
end
end
step :pack_lembas, SquidieLivebook.PackLembas
step :cross_moria, SquidieLivebook.CrossMoria
transition :pack_lembas, on: :ok, to: :cross_moria
transition :cross_moria, on: :ok, to: :complete
end
end
```
## Inspect The Workflow Spec
The DSL compiles into a normalized workflow spec. This is the data shape that
tooling can inspect without parsing the module source. It is also the contract
visual editors and runtime-authored workflows will build on.
```elixir
{:ok, spec} = Squidie.Workflow.to_spec(SquidieLivebook.RingErrandWorkflow)
%{
workflow: spec.workflow,
triggers: Enum.map(spec.triggers, &Map.take(&1, [:name, :type, :payload])),
payload: spec.payload,
steps: Enum.map(spec.steps, &Map.take(&1, [:name, :module, :opts])),
transitions: spec.transitions,
entry_steps: spec.entry_steps
}
```
`triggers` describe how a run can start. `payload` is the external input
contract. `steps` are executable workflow nodes. `transitions` describe durable
progression from one step outcome to the next node or to `:complete`.
For deeper authoring rules, see [Workflow Authoring](workflow_authoring.md).
## Start A Run
Manual triggers start through the public API. This workflow has one trigger, so
`start/3` uses it as the default trigger.
```elixir
{:ok, run} =
Squidie.start(
SquidieLivebook.RingErrandWorkflow,
%{ring_id: "one-ring"},
opts
)
%{
run_id: run.run_id,
status: run.status,
reason: run.reason,
planned_runnables: run.planned_runnables,
visible_attempts: Enum.map(run.visible_attempts, &SquidieLivebook.Output.attempt/1),
scheduled_attempts: run.scheduled_attempts,
next_visible_at: run.next_visible_at
}
```
The first snapshot already has one visible attempt: `pack_lembas`. Visible
attempts are work the host can claim now. Scheduled attempts are work that
exists but should not be claimed until `next_visible_at`.
## Execute Visible Work
Workers provide capacity by calling `Squidie.execute_next/1`. Each call claims
one visible journal attempt, runs the step, records the result, and makes the
next attempt visible when the workflow should continue.
A run is the whole workflow instance. An attempt is one executable unit of work
inside that run, such as the visible `:pack_lembas` or `:cross_moria` step.
```elixir
worker_opts = Keyword.put(opts, :owner_id, "livebook-worker")
{:ok, first_step} = Squidie.execute_next(worker_opts)
{:ok, completed_run} = Squidie.execute_next(worker_opts)
{:ok, no_more_work} = Squidie.execute_next(worker_opts)
%{
first_step_status: first_step.status,
first_step_reason: first_step.reason,
visible_after_first_step: Enum.map(first_step.visible_attempts, &SquidieLivebook.Output.attempt/1),
applied_after_first_step: first_step.applied_runnable_keys,
completed_status: completed_run.status,
completed_reason: completed_run.reason,
completed_attempts: Enum.map(completed_run.attempts, &SquidieLivebook.Output.attempt/1),
no_more_work: no_more_work
}
```
After the first call, the `pack_lembas` attempt has been applied and the
`cross_moria` attempt becomes visible. After the second call, the run is
terminal. The third call returns `:none` because this queue has no visible work
left.
## Start A Child Workflow
Steps can start child workflow runs when a larger workflow needs durable
fan-out. Child starts require an explicit `child_key`; calling the same child
start again from a retried parent step returns the same child run instead of
creating a duplicate.
This example keeps the parent and child on different queues so you can see that
the parent retry completes before the child is drained.
```elixir
defmodule SquidieLivebook.DeliverInvite do
use Squidie.Step,
name: :deliver_invite,
description: "Delivers an invite from a child workflow",
input_schema: [
party_id: [type: :string, required: true],
guest_id: [type: :string, required: true],
fail_child_once: [type: :boolean, required: false]
],
output_schema: [
invite_delivery: [type: :map, required: true]
]
@impl Squidie.Step
def run(%{party_id: party_id, guest_id: guest_id} = input, %Squidie.Step.Context{
attempt: attempt
}) do
if Map.get(input, :fail_child_once, false) and attempt == 1 do
{:retry, %{message: "retry child delivery"}}
else
{:ok, %{invite_delivery: %{party_id: party_id, guest_id: guest_id, status: "delivered"}}}
end
end
end
defmodule SquidieLivebook.InviteDeliveryWorkflow do
use Squidie.Workflow
workflow do
trigger :deliver_invite do
manual()
payload do
field :party_id, :string
field :guest_id, :string
field :fail_child_once, :boolean, default: false
end
end
step :deliver_invite, SquidieLivebook.DeliverInvite, retry: [max_attempts: 2]
transition :deliver_invite, on: :ok, to: :complete
end
end
defmodule SquidieLivebook.StartInviteChild do
use Squidie.Step,
name: :start_invite_child,
description: "Starts a child invite workflow",
input_schema: [
party_id: [type: :string, required: true],
guest_id: [type: :string, required: true],
child_queue: [type: :string, required: true],
fail_after_child_start: [type: :boolean, required: false],
fail_child_once: [type: :boolean, required: false]
],
output_schema: [
invite_child: [type: :map, required: true]
]
@impl Squidie.Step
def run(
%{party_id: party_id, guest_id: guest_id, child_queue: child_queue} = input,
%Squidie.Step.Context{attempt: attempt} = context
) do
child_key = "invite_#{guest_id}"
with {:ok, child_run} <-
Squidie.start_child_run(
context,
SquidieLivebook.InviteDeliveryWorkflow,
%{
party_id: party_id,
guest_id: guest_id,
fail_child_once: Map.get(input, :fail_child_once, false)
},
child_key: child_key,
metadata: %{guest_id: guest_id},
queue: child_queue
) do
if Map.get(input, :fail_after_child_start, false) and attempt == 1 do
{:retry, %{message: "retry after child start"}}
else
{:ok,
%{
invite_child: %{
run_id: child_run.run_id,
child_key: child_key,
queue: child_queue,
reused_after_retry?: attempt > 1
}
}}
end
else
{:error, reason} ->
{:error, reason}
end
end
end
```
```elixir
defmodule SquidieLivebook.NestedInviteWorkflow do
use Squidie.Workflow
workflow do
trigger :nested_invite do
manual()
payload do
field :party_id, :string
field :guest_id, :string
field :child_queue, :string
field :fail_after_child_start, :boolean, default: true
field :fail_child_once, :boolean, default: true
end
end
step :start_invite_child, SquidieLivebook.StartInviteChild, retry: [max_attempts: 2]
transition :start_invite_child, on: :ok, to: :complete
end
end
```
```elixir
child_queue = "livebook-child"
{:ok, nested_parent} =
Squidie.start(
SquidieLivebook.NestedInviteWorkflow,
%{
party_id: "shire-party",
guest_id: "frodo",
child_queue: child_queue,
fail_after_child_start: true,
fail_child_once: true
},
opts
)
{:ok, parent_retrying} = Squidie.execute_next(worker_opts)
{:ok, parent_completed} = Squidie.execute_next(worker_opts)
[%{child_run_id: child_run_id}] = parent_completed.child_runs
{:ok, nested_graph} = Squidie.inspect_run_graph(nested_parent.run_id, opts)
nested_graph_payload = Squidie.Runs.GraphInspection.to_map(nested_graph)
{:ok, child_retrying} =
Squidie.execute_next(Keyword.merge(worker_opts, queue: child_queue))
{:ok, child_completed} =
Squidie.execute_next(Keyword.merge(worker_opts, queue: child_queue))
%{
parent_attempts: Enum.map(parent_completed.attempts, &SquidieLivebook.Output.attempt/1),
parent_child_runs: parent_completed.child_runs,
parent_child_links: nested_graph_payload.child_links,
parent_context: parent_completed.context.invite_child,
child_before_drain: child_run_id,
child_retrying_attempts: Enum.map(child_retrying.attempts, &SquidieLivebook.Output.attempt/1),
child_completed_attempts: Enum.map(child_completed.attempts, &SquidieLivebook.Output.attempt/1),
child_parent_link: child_completed.parent_run
}
```
The parent attempted `start_invite_child` twice, but `child_runs` contains one
child because the second parent attempt reused the original `child_key`. The
child also retried once before completing, and its `parent_run` points back to
the parent step that created it. Graph inspection also exposes `child_links` so
UIs can render the parent-to-child subflow without treating the child workflow
as an inline node.
## Inspect The Run
Inspection reads durable journal facts. Use it for dashboards, support tools,
and operator-facing detail pages.
```elixir
{:ok, inspected} =
Squidie.inspect_run(
run.run_id,
Keyword.merge(opts, include_history: true)
)
%{
status: inspected.status,
reason: inspected.reason,
context: inspected.context,
planned_runnables: Enum.map(inspected.planned_runnables, &SquidieLivebook.Output.runnable/1),
visible_attempts: Enum.map(inspected.visible_attempts, &SquidieLivebook.Output.attempt/1),
scheduled_attempts: Enum.map(inspected.scheduled_attempts, &SquidieLivebook.Output.attempt/1),
attempts: Enum.map(inspected.attempts, &SquidieLivebook.Output.attempt/1),
next_visible_at: inspected.next_visible_at
}
```
`context` is the durable run context assembled from completed step outputs.
`attempts` is historical evidence. `visible_attempts` and `scheduled_attempts`
explain what can run now versus what needs a later wakeup.
## Inspect The Graph
Graph inspection gives UI builders a node and edge view of the same run.
```elixir
{:ok, graph} = Squidie.inspect_run_graph(run.run_id, opts)
graph_payload = Squidie.Runs.GraphInspection.to_map(graph)
%{
source: graph.source,
current_node_id: graph.current_node_id,
nodes: Enum.map(graph_payload.nodes, &SquidieLivebook.Output.node/1),
edges: Enum.map(graph_payload.edges, &SquidieLivebook.Output.edge/1),
child_links: graph_payload.child_links
}
```
The graph contract is the shape a host UI can serialize after applying its own
authorization and redaction policy. See the
[Graph Inspection Contract](graph_inspection.md) for the full node, edge, and
child-link shape.
## Explain The State
Explanation condenses the run into a reason and diagnostics that are easier to
show to operators.
```elixir
{:ok, explanation} = Squidie.explain_run(run.run_id, opts)
%{
status: explanation.status,
reason: explanation.reason,
summary: explanation.summary,
next_actions: explanation.next_actions,
evidence: explanation.evidence
}
```
Use explanation output when a support or operator surface needs to answer "what
is the runtime waiting for?" without exposing raw journal entries.
## See A Scheduled Wakeup
Wait steps turn workflow-scale delays into future-visible attempts. They are
useful when the workflow should continue later, while the journal remains the
source of truth.
```elixir
defmodule SquidieLivebook.RecordGandalfArrival do
use Squidie.Step,
name: :record_gandalf_arrival,
description: "Records that the delayed rendezvous became visible",
input_schema: [
ring_id: [type: :string, required: true]
],
output_schema: [
rendezvous: [type: :map, required: true]
]
@impl Squidie.Step
def run(%{ring_id: ring_id}, %Squidie.Step.Context{}) do
{:ok, %{rendezvous: %{ring_id: ring_id, status: "wizard arrived"}}}
end
end
defmodule SquidieLivebook.GandalfRendezvousWorkflow do
use Squidie.Workflow
workflow do
trigger :wait_for_wizard do
manual()
payload do
field :ring_id, :string
end
end
step :wait_for_gandalf, :wait, duration: 1_000
step :record_gandalf_arrival, SquidieLivebook.RecordGandalfArrival
transition :wait_for_gandalf, on: :ok, to: :record_gandalf_arrival
transition :record_gandalf_arrival, on: :ok, to: :complete
end
end
wakeup_time = DateTime.utc_now()
wakeup_opts = Keyword.merge(opts, queue: "livebook-wakeup", now: wakeup_time)
wakeup_worker_opts = Keyword.put(wakeup_opts, :owner_id, "livebook-worker")
{:ok, wakeup_run} =
Squidie.start(
SquidieLivebook.GandalfRendezvousWorkflow,
%{ring_id: "one-ring"},
wakeup_opts
)
{:ok, scheduled_run} = Squidie.execute_next(wakeup_worker_opts)
%{
run_id: wakeup_run.run_id,
reason: scheduled_run.reason,
visible_attempts: scheduled_run.visible_attempts,
scheduled_attempts: Enum.map(scheduled_run.scheduled_attempts, &SquidieLivebook.Output.attempt/1),
next_visible_at: scheduled_run.next_visible_at
}
```
The wait step completed, then Squidie scheduled `record_gandalf_arrival` for later.
Until `next_visible_at`, workers should see no visible work for that queue.
```elixir
{:ok, :none} = Squidie.execute_next(wakeup_worker_opts)
{:ok, completed_rendezvous} =
Squidie.execute_next(Keyword.put(wakeup_worker_opts, :now, scheduled_run.next_visible_at))
%{
status: completed_rendezvous.status,
reason: completed_rendezvous.reason,
attempts: Enum.map(completed_rendezvous.attempts, &SquidieLivebook.Output.attempt/1)
}
```
## Add A Human Approval Boundary
Approval steps pause the workflow until an operator approves or rejects the run.
This is durable workflow state, not a transient process wait.
```elixir
defmodule SquidieLivebook.RecordCouncilApproval do
use Squidie.Step,
name: :record_council_approval,
description: "Records an approved errand",
input_schema: [
ring_id: [type: :string, required: true],
approval: [type: :map, required: true]
],
output_schema: [
recorded: [type: :map, required: true]
]
@impl Squidie.Step
def run(%{ring_id: ring_id, approval: approval}, %Squidie.Step.Context{}) do
{:ok, %{recorded: %{ring_id: ring_id, decision: approval.decision}}}
end
end
defmodule SquidieLivebook.RecordCouncilRejection do
use Squidie.Step,
name: :record_council_rejection,
description: "Records a rejected errand",
input_schema: [
ring_id: [type: :string, required: true],
approval: [type: :map, required: true]
],
output_schema: [
recorded: [type: :map, required: true]
]
@impl Squidie.Step
def run(%{ring_id: ring_id, approval: approval}, %Squidie.Step.Context{}) do
{:ok, %{recorded: %{ring_id: ring_id, decision: approval.decision}}}
end
end
defmodule SquidieLivebook.CouncilApprovalWorkflow do
use Squidie.Workflow
workflow do
trigger :council_review do
manual()
payload do
field :ring_id, :string
end
end
approval_step :wait_for_council, output: :approval
step :record_council_approval, SquidieLivebook.RecordCouncilApproval
step :record_council_rejection, SquidieLivebook.RecordCouncilRejection
transition :wait_for_council, on: :ok, to: :record_council_approval
transition :wait_for_council, on: :error, to: :record_council_rejection
transition :record_council_approval, on: :ok, to: :complete
transition :record_council_rejection, on: :ok, to: :complete
end
end
approval_opts = Keyword.put(opts, :queue, "livebook-approval")
approval_worker_opts = Keyword.put(approval_opts, :owner_id, "livebook-worker")
{:ok, approval_run} =
Squidie.start(
SquidieLivebook.CouncilApprovalWorkflow,
%{ring_id: "one-ring"},
approval_opts
)
{:ok, paused_run} = Squidie.execute_next(approval_worker_opts)
{:ok, paused_explanation} = Squidie.explain_run(approval_run.run_id, approval_opts)
{:ok, resumed_run} =
Squidie.approve(
approval_run.run_id,
%{actor: "ops_123", comment: "looks good"},
approval_opts
)
{:ok, approved_run} = Squidie.execute_next(approval_worker_opts)
%{
paused_status: paused_run.status,
paused_reason: paused_run.reason,
manual_state: paused_run.manual_state,
paused_summary: paused_explanation.summary,
paused_next_actions: paused_explanation.next_actions,
resumed_status: resumed_run.status,
visible_after_approval: Enum.map(resumed_run.visible_attempts, &SquidieLivebook.Output.attempt/1),
completed_status: approved_run.status,
manual_state_after_resume: resumed_run.manual_state
}
```
The paused snapshot exposes `manual_state`, so a host UI can render the
operator decision boundary. `approve/3` records the decision and makes the
approval path visible. The next worker execution runs `record_council_approval`
and finishes the workflow.
## What To Read Next
- [Getting Started](https://github.com/dark-trench/squidie/blob/main/docs/getting_started.md)
- [Workflow Authoring](https://github.com/dark-trench/squidie/blob/main/docs/workflow_authoring.md)
- [Reference Workflows](https://github.com/dark-trench/squidie/blob/main/docs/reference_workflows.md)
- [Graph Inspection Contract](https://github.com/dark-trench/squidie/blob/main/docs/graph_inspection.md)
- [Host App Integration](https://github.com/dark-trench/squidie/blob/main/docs/host_app_integration.md)
- [Runtime Architecture](https://github.com/dark-trench/squidie/blob/main/docs/jido_runtime_architecture.md)