View Source Actions & Workflows
This guide will introduce you to building composable, maintainable workflows using Jido's action-based architecture. Whether you're building complex business processes, integrating multiple services, or orchestrating distributed tasks, Jido provides a robust foundation for your workflows.
Understanding Jido's Core Concepts
Before we dive into code, let's understand why Jido exists and when you should use it.
Why Actions?
You might be wondering: "Why should I wrap my code in Actions when I could just write regular Elixir functions?" This is an excellent question. The truth is, if you're building a standard Elixir application without agents, you probably shouldn't use Actions – regular Elixir modules and functions would be simpler and more direct.
Actions exist specifically to support agent systems. When you're building agents that need to make autonomous decisions about what steps to take, you need a way to package functionality into discrete, composable units that the agent can reason about and combine in novel ways. Think of Actions as LEGO bricks for agents – standardized, well-described building blocks that can be assembled in different combinations to solve problems.
The Action system provides several critical features for agent-based systems:
- Standardized metadata that agents can use to understand what each Action does
- Schema validation to ensure inputs are correct before execution
- Consistent error handling and compensation patterns
- Built-in telemetry and observability
- Dynamic composition through workflows and chains
If you're not building agent systems, you should implement your logic directly in Elixir. But if you are working with agents, this foundation of composable Actions becomes essential for enabling autonomous behavior.
Now, let's understand the key concepts that make up Jido:
Actions
An Action is the fundamental building block in Jido. Think of an Action as a single, well-defined task that takes some input, performs a specific operation, and produces an output. Actions are designed to be small, focused, and composable – like UNIX commands that you can pipe together.
For example, an Action might:
- Format and validate user input
- Call an external API
- Transform data
- Send a notification
Workflows
A Workflow is a sequence of Actions that work together to accomplish a larger goal. Workflows can be as simple as two Actions in sequence or as complex as dozens of Actions with conditional paths and error handling. The power of Jido comes from being able to compose simple Actions into sophisticated Workflows.
Chains
A Chain is how we connect Actions together into a Workflow. When you chain Actions, the output of each Action becomes available to the next Action in the chain. Think of it like a pipeline where data flows through each step, getting enriched or transformed along the way.
Now, let's see how to build these concepts in practice.
Creating Your First Action
Let's create a simple Action that formats user data. This example will help you understand the basic structure of an Action and how Jido handles data transformation.
defmodule MyApp.Actions.FormatUser do
use Jido.Action,
name: "format_user",
description: "Formats user data by trimming whitespace and normalizing email",
schema: [
name: [
type: :string,
required: true,
doc: "User's full name - will be trimmed"
],
email: [
type: :string,
required: true,
doc: "Email address - will be converted to lowercase"
],
age: [
type: :integer,
required: true,
doc: "User's age in years"
]
]
@impl true
def run(params, _context) do
%{name: name, email: email, age: age} = params
{:ok, %{
formatted_name: String.trim(name),
email: String.downcase(email),
age: age,
is_adult: age >= 18
}}
end
end
Let's break down what's happening here:
- We define our Action as a module using
use Jido.Action
- We provide metadata about our Action:
name
: A unique identifier for the Actiondescription
: What the Action doesschema
: The expected input parameters and their types
- We implement the
run/2
callback which contains our Action's logic - We return a tuple
{:ok, result}
with our transformed data
The schema is particularly important – it's how Jido validates input before running your Action. If someone tries to run this Action with invalid input (like a missing email or an age that's not a number), Jido will return an error before your code even runs.
Running Your First Action
There are two ways to run an Action. Let's understand both approaches:
1. Direct Execution
The simplest way to run an Action is to call it directly:
# Notice the trailing space in name and uppercase email - our Action will clean these
{:ok, result} = FormatUser.run(%{
name: "John Doe ",
email: "JOHN@EXAMPLE.COM",
age: 30
})
This will give us:
%{
formatted_name: "John Doe", # Space removed
email: "john@example.com", # Converted to lowercase
age: 30,
is_adult: true # Derived from age
}
Direct execution is great for testing and development, but it doesn't give you all of Jido's runtime features.
2. Using the Workflow Runtime
For production use, you'll want to use Jido's Workflow runtime:
{:ok, result} = Jido.Workflow.run(FormatUser, %{
name: "John Doe",
email: "john@example.com",
age: 30
})
The Workflow runtime provides several benefits:
- Telemetry events for monitoring
- Consistent error handling
- Timeout management
- Retries and circuit breaking
- Context passing
Building More Complex Actions
Now that we understand basic Actions, let's create two more that we'll use to build a complete user registration workflow:
defmodule MyApp.Actions.EnrichUserData do
use Jido.Action,
name: "enrich_user_data",
description: "Adds username and avatar URL to user data",
schema: [
formatted_name: [type: :string, required: true],
email: [type: :string, required: true]
]
def run(%{formatted_name: name, email: email}, _context) do
{:ok, %{
username: generate_username(name),
avatar_url: get_gravatar_url(email)
}}
end
defp generate_username(name) do
name
|> String.downcase()
|> String.replace(" ", ".")
end
defp get_gravatar_url(email) do
hash = :crypto.hash(:md5, email) |> Base.encode16(case: :lower)
"https://www.gravatar.com/avatar/#{hash}"
end
end
defmodule MyApp.Actions.NotifyUser do
use Jido.Action,
name: "notify_user",
description: "Sends welcome notification to user",
schema: [
email: [type: :string, required: true],
username: [type: :string, required: true]
]
def run(%{email: email, username: username}, _context) do
# In a real app, you'd send an actual email
Logger.info("Sending welcome email to #{email}")
{:ok, %{
notification_sent: true,
notification_type: "welcome_email",
recipient: %{
email: email,
username: username
}
}}
end
end
Each Action has a single responsibility and clearly defined inputs and outputs. This modularity makes our code easier to test and maintain.
Chaining Actions Together
Now comes the powerful part – we can chain these Actions together to create a complete user registration workflow. When we chain Actions, the output of each Action is automatically merged with the existing data and passed to the next Action.
{:ok, result} = Chain.chain(
[
FormatUser,
EnrichUserData,
NotifyUser
],
%{
name: "John Doe",
email: "john@example.com",
age: 30
}
)
Here's what happens in this chain:
FormatUser
runs first:- Takes the raw input
- Formats the name and email
- Adds the is_adult flag
EnrichUserData
runs next:- Gets the formatted name and email from previous step
- Adds username and avatar URL
- Results are merged with existing data
NotifyUser
runs last:- Uses the email and username from previous steps
- Sends the notification
- Adds notification status to results
The final result contains all the accumulated data:
%{
formatted_name: "John Doe",
email: "john@example.com",
age: 30,
is_adult: true,
username: "john.doe",
avatar_url: "https://www.gravatar.com/avatar/...",
notification_sent: true,
notification_type: "welcome_email",
recipient: %{
email: "john@example.com",
username: "john.doe"
}
}
Advanced Chain Features
As your workflows grow more complex, Jido provides several advanced features:
Passing Context
Context lets you provide additional data that any Action in the chain can access:
Chain.chain(
[FormatUser, EnrichUserData, NotifyUser],
user_data,
context: %{
tenant_id: "123",
environment: "test"
}
)
This is useful for passing configuration, authentication info, or other cross-cutting concerns.
Overriding Parameters
You can override specific parameters for individual Actions in the chain:
Chain.chain(
[
{FormatUser, [name: "Jane Doe"]}, # Override name just for this Action
EnrichUserData,
NotifyUser
],
user_data
)
This is particularly useful when you need to customize behavior for specific cases.
Async Execution
For long-running workflows, you can run Actions asynchronously:
# Start the workflow
async_ref = Jido.Workflow.run_async(FormatUser, user_data)
# Do other work...
# Get the result when you need it
{:ok, result} = Jido.Workflow.await(async_ref)
Testing Your Actions
Testing is a crucial part of working with Jido. Here's how to test your Actions thoroughly:
defmodule MyApp.Actions.UserRegistrationTest do
use ExUnit.Case, async: true
@valid_user_data %{
name: "John Doe ",
email: "JOHN@EXAMPLE.COM",
age: 30
}
describe "individual action tests" do
test "FormatUser formats and validates user data" do
{:ok, result} = FormatUser.run(@valid_user_data, %{})
assert result.formatted_name == "John Doe"
assert result.email == "john@example.com"
assert result.is_adult == true
end
end
describe "chaining actions" do
test "chains all user registration actions together" do
{:ok, result} = Chain.chain(
[FormatUser, EnrichUserData, NotifyUser],
@valid_user_data
)
assert result.formatted_name == "John Doe"
assert result.username == "john.doe"
assert result.notification_sent == true
end
test "chain stops on first error" do
invalid_data = %{@valid_user_data | email: nil}
{:error, error} = Chain.chain(
[FormatUser, EnrichUserData, NotifyUser],
invalid_data
)
assert error.type == :validation_error
end
end
end
The complete test suite in test/workflow/user_registration_test.exs
shows more examples, including:
- Testing async workflows
- Testing with context
- Testing parameter overrides
- Testing error conditions
Best Practices
As you build with Jido, keep these principles in mind:
Single Responsibility: Each Action should do one thing well. If an Action is doing too much, split it into smaller Actions.
Clear Contracts: Use schemas to define clear input requirements. Document what each parameter is for.
Error Handling: Return clear error messages. Use the Error structs provided by Jido.
Testing: Test Actions both individually and in chains. Test happy paths and error cases.
Stateless Design: Actions should be stateless and idempotent when possible. Use context for state that needs to be shared.
Documentation: Document your Actions, especially any side effects or external dependencies.
Next Steps
Now that you understand the basics, you can explore:
- More complex workflow patterns
- Error compensation and rollbacks
- Integration with external services
- Custom Action behaviors
- Telemetry and monitoring
The complete test suite provides many more examples and patterns to learn from.
Happy building with Jido!