Scheduling
View SourceAfter: You can schedule delayed and recurring work reliably.
Jido provides two scheduling mechanisms: one-time delays via Schedule and recurring jobs via Cron. Both are timer-based and tied to the agent's process lifecycle.
Delayed Messages with Schedule
The Schedule directive sends a message back to your agent after a delay:
defmodule RetryAction do
use Jido.Action,
name: "retry",
schema: [attempt: [type: :integer, default: 1]]
alias Jido.Agent.Directive
def run(%{attempt: attempt}, context) do
if attempt < 3 do
retry_signal = Jido.Signal.new!(
"task.retry",
%{attempt: attempt + 1},
source: "/agent/#{context.agent.id}"
)
{:ok, %{scheduled_retry: true}, [Directive.schedule(5_000, retry_signal)]}
else
{:error, Jido.Error.execution_error("Max retries exceeded")}
end
end
endThe message arrives as a signal after the delay. Process.send_after/3 powers the implementation — if the agent crashes before the timer fires, the scheduled message is lost.
Schedule API
alias Jido.Agent.Directive
Directive.schedule(delay_ms, message)
Directive.schedule(5_000, :timeout)
Directive.schedule(1_000, {:check, some_ref})
Directive.schedule(30_000, my_signal)Recurring Jobs with Cron
The Cron directive registers recurring jobs using standard cron expressions:
defmodule SetupCronAction do
use Jido.Action, name: "setup_cron", schema: []
alias Jido.Agent.Directive
def run(_params, context) do
tick_signal = Jido.Signal.new!(
"heartbeat.tick",
%{},
source: "/agent/#{context.agent.id}"
)
{:ok, %{}, [
Directive.cron("*/5 * * * *", tick_signal, job_id: :heartbeat)
]}
end
endCron Expressions
Standard 5-field expressions are supported:
| Expression | Meaning |
|---|---|
* * * * * | Every minute |
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour |
0 0 * * * | Daily at midnight |
0 9 * * MON | Every Monday at 9 AM |
Aliases are also available:
| Alias | Equivalent |
|---|---|
@yearly / @annually | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily / @midnight | 0 0 * * * |
@hourly | 0 * * * * |
Timezone Support
Directive.cron("0 9 * * *", morning_signal,
job_id: :morning_task,
timezone: "America/New_York"
)Default timezone is Etc/UTC.
Upsert Behavior
Registering a cron job with an existing job_id cancels the old job and replaces it:
Directive.cron("*/5 * * * *", tick_signal, job_id: :heartbeat)
Directive.cron("*/10 * * * *", tick_signal, job_id: :heartbeat)The second directive cancels the 5-minute job and starts a 10-minute one.
Cancelling Scheduled Jobs
Use CronCancel to stop a recurring job by its job_id:
defmodule StopHeartbeatAction do
use Jido.Action, name: "stop_heartbeat", schema: []
alias Jido.Agent.Directive
def run(_params, _context) do
{:ok, %{}, [Directive.cron_cancel(:heartbeat)]}
end
endCancelling a non-existent job is a no-op — it doesn't raise an error.
Semantics & Guarantees
Timer-Based, Not Persistent
Both Schedule and Cron use in-memory timers (Process.send_after/3 and SchedEx).
What this means:
| Scenario | Behavior |
|---|---|
| Agent crashes before timer fires | Scheduled message lost |
| Agent restarts | Cron jobs must be re-registered |
| Node restart | All schedules lost |
| Timer fires during agent busy | Message queued in mailbox |
Missed-Run Behavior
Cron jobs do not catch up on missed runs. If your agent is down when a cron tick would fire, that tick is simply missed. When the agent restarts and re-registers the job, scheduling resumes from the next scheduled time.
Example: An agent with a @daily job at midnight crashes at 11:50 PM and restarts at 12:30 AM. The midnight run is missed entirely — no catch-up occurs.
Cleanup on Termination
When an agent stops (normal or crash), all its cron jobs are automatically cancelled in the terminate/2 callback. You don't need to manually clean up.
Idempotency Patterns
Since Jido scheduling provides at-most-once delivery (messages can be lost on crash), you need patterns to handle potential gaps or duplicates.
Dedupe Keys
Track processed work to avoid duplicates if you retry externally:
defmodule ProcessTickAction do
use Jido.Action, name: "process_tick", schema: []
alias Jido.Agent.StateOp
def run(%{tick_id: tick_id}, context) do
processed = Map.get(context.state, :processed_ticks, MapSet.new())
if MapSet.member?(processed, tick_id) do
{:ok, %{skipped: true}}
else
new_processed = MapSet.put(processed, tick_id)
{:ok, %{processed: true}, [StateOp.set_state(%{processed_ticks: new_processed})]}
end
end
endLast-Run Timestamps
Track when work last ran to detect gaps:
defmodule DailyReportAction do
use Jido.Action, name: "daily_report", schema: []
alias Jido.Agent.StateOp
def run(_params, context) do
last_run = Map.get(context.state, :last_report_at)
now = DateTime.utc_now()
if last_run && DateTime.diff(now, last_run, :hour) < 20 do
{:ok, %{skipped: true, reason: "Too soon since last run"}}
else
report = generate_report()
{:ok, %{report: report}, [StateOp.set_state(%{last_report_at: now})]}
end
end
defp generate_report, do: %{generated_at: DateTime.utc_now()}
endExactly-Once Semantics
Jido does not provide exactly-once guarantees for scheduled work. If you need exactly-once:
- Use external persistent schedulers (Oban, Quantum with database backing)
- Implement your own persistence layer
- Use idempotency keys with external storage
For many use cases, at-most-once with last-run tracking is sufficient.
Complete Example: Daily Report Generation
Here's a complete agent that generates a daily report:
defmodule DailyReportAgent do
use Jido.Agent,
name: "daily_report_agent",
schema: [
last_report_at: [type: {:custom, DateTime, :from_iso8601, []}, default: nil],
report_count: [type: :integer, default: 0]
]
alias Jido.Agent.Directive
def signal_routes do
[
{"agent.started", SetupScheduleAction},
{"report.generate", GenerateReportAction},
{"report.cancel", CancelReportAction}
]
end
defmodule SetupScheduleAction do
use Jido.Action, name: "setup_schedule", schema: []
def run(_params, context) do
report_signal = Jido.Signal.new!(
"report.generate",
%{},
source: "/agent/#{context.agent.id}"
)
{:ok, %{}, [
Directive.cron("0 6 * * *", report_signal,
job_id: :daily_report,
timezone: "America/New_York"
)
]}
end
end
defmodule GenerateReportAction do
use Jido.Action, name: "generate_report", schema: []
alias Jido.Agent.{Directive, StateOp}
def run(_params, context) do
last_run = Map.get(context.state, :last_report_at)
now = DateTime.utc_now()
cond do
last_run && DateTime.diff(now, last_run, :hour) < 20 ->
{:ok, %{skipped: true}}
true ->
report = build_report(context.state)
count = Map.get(context.state, :report_count, 0)
notification = Jido.Signal.new!(
"notification.send",
%{type: :report, data: report},
source: "/agent/#{context.agent.id}"
)
{:ok, %{report: report}, [
StateOp.set_state(%{
last_report_at: now,
report_count: count + 1
}),
Directive.emit(notification)
]}
end
end
defp build_report(state) do
%{
generated_at: DateTime.utc_now(),
report_number: Map.get(state, :report_count, 0) + 1,
summary: "Daily metrics summary"
}
end
end
defmodule CancelReportAction do
use Jido.Action, name: "cancel_report", schema: []
def run(_params, _context) do
{:ok, %{}, [Directive.cron_cancel(:daily_report)]}
end
end
endStart the agent:
{:ok, _} = Jido.start_link(name: MyApp.Jido)
{:ok, pid} = Jido.start_agent(MyApp.Jido, DailyReportAgent,
id: "report-agent-1",
state: %{}
)
Jido.signal(MyApp.Jido, "report-agent-1",
Jido.Signal.new!("agent.started", %{}, source: "/app")
)Related guides: Directives • Runtime