Jido.Signal

Hex.pmHex DocsCILicenseWebsiteEcosystemDiscord

Agent Communication Envelope, Routing, and Delivery

Jido.Signal is part of the Jido project. Learn more about Jido at jido.run.

The v3 public API has five primary areas: Jido.Signal, Jido.Signal.Serialization, Jido.Signal.Router, Jido.Signal.Dispatch, and Jido.Signal.Bus.

Version 3.0.0-beta.3 is a public beta. The v3 API can change before the stable release. Use it for evaluation and controlled trials before you use it for critical production work.

If you use v2, see From v2 to v3 for the progression and migration guide.

Overview

Jido.Signal is a toolkit for event-driven and agent-based systems in Elixir. It provides a small CloudEvents 1.0 envelope, typed domain Signals, routing, dispatch, serialization, and a Signal Bus.

Use it when processes or services need a validated message envelope, explicit routing, trace context, or retained local delivery.

Why Do I Need Signals?

Agent Communication in Elixir's Process-Driven World

Elixir's strength lies in lightweight processes that communicate via message passing, but raw message passing has limitations when building complex systems:

Traditional Elixir messaging (send, GenServer.cast/call) works great for simple scenarios, but falls short when you need:

# Traditional Elixir messaging
GenServer.cast(my_server, {:user_created, user_id, email}) # Unstructured
send(pid, {:event, data}) # No routing or reliability
# With Jido.Signal
{:ok, signal} = UserCreated.new(%{user_id: user_id, email: email})
Bus.publish(:app_bus, [signal]) # Structured, routed, traceable, and retained

Jido.Signal transforms Elixir's message passing into a sophisticated communication system that scales from simple GenServer interactions to complex multi-agent orchestration across distributed systems.

Key Features

Standardized Signal Structure

High-Performance Signal Bus

Advanced Routing Engine

Pluggable Dispatch System

Installation

Add jido_signal to your list of dependencies in mix.exs:

def deps do
[
{:jido_signal, "~> 3.0.0-beta.3"}
]
end

If you use :pubsub dispatch, also add Phoenix.PubSub to your application:

def deps do
[
{:jido_signal, "~> 3.0.0-beta.3"},
{:phoenix_pubsub, "~> 2.1"}
]
end

Then run:

mix deps.get

Quick Start

1. Start a Signal Bus

Add to your application's supervision tree:

# In your application.ex
children = [
{Jido.Signal.Bus, name: :my_app_bus}
]
Supervisor.start_link(children, strategy: :one_for_one)

2. Create a Subscriber

defmodule MySubscriber do
use GenServer
def start_link(_opts), do: GenServer.start_link(__MODULE__, %{})
def init(state), do: {:ok, state}
# Handle incoming signals
def handle_info({:signal, signal}, state) do
IO.puts("Received: #{signal.type}")
{:noreply, state}
end
end

3. Subscribe and Publish

alias Jido.Signal.Bus
alias Jido.Signal
# Start subscriber and subscribe to user events
{:ok, sub_pid} = MySubscriber.start_link([])
{:ok, _sub_id} = Bus.subscribe(:my_app_bus, "user.*", target: sub_pid)
# Create and publish a signal
# Preferred: positional constructor (type, data, attrs)
{:ok, signal} = Signal.new("user.created", %{user_id: "123", email: "user@example.com"},
source: "/auth/registration"
)
# Also available: Map/keyword constructor (backwards compatible)
{:ok, signal} = Signal.new(%{
type: "user.created",
source: "/auth/registration",
data: %{user_id: "123", email: "user@example.com"}
})
Bus.publish(:my_app_bus, [signal])
# Output: "Received: user.created"

Core Concepts

The Signal

Signals are CloudEvents-compliant message envelopes that carry your application's events:

# Basic signal with positional constructor (preferred)
{:ok, signal} = Signal.new("order.created", %{order_id: "ord_123", amount: 99.99},
source: "/ecommerce/orders"
)
# Map constructor (also available)
{:ok, signal} = Signal.new(%{
type: "order.created",
source: "/ecommerce/orders",
data: %{order_id: "ord_123", amount: 99.99}
})
# Dispatch is configured when subscribing or dispatching, not on the signal
:ok = Dispatch.dispatch(signal, [
{:pubsub, target: MyApp.PubSub, topic: "payments"},
{:http,
url: "https://api.partner.com/events",
headers: [{"authorization", "Bearer token"}]}
])

Custom Signal Types

Define strongly-typed signals with validation:

defmodule UserCreated do
use Jido.Signal,
type: "user.created.v1",
default_source: "/users",
schema: Zoi.object(%{
user_id: Zoi.string(),
email: Zoi.string(),
name: Zoi.string()
})
end
# Usage
{:ok, signal} = UserCreated.new(%{
user_id: "u_123",
email: "john@example.com",
name: "John Doe"
})
# Validation errors
{:error, reason} = UserCreated.new(%{user_id: "u_123"})
# reason identifies the missing email field.

Zoi is the schema format for custom Signals. A schema can accept any Signal data value, including a map, list, scalar, binary, or other Erlang term. validate_data/1 and new/2 return Zoi validation errors without a Jido wrapper. new!/2 raises the Zoi parse exception for invalid data.

JSON serialization accepts JSON values and non-UTF-8 binary data. Use the trusted Erlang Term Format when Signal data contains other Erlang-only values.

Schemas must be static module data. Use named {Module, :function, args} MFA values for refinements, transforms, and other callbacks. Anonymous functions and lazy schemas are rejected at compile time.

CloudEvents extension context attributes are flat transport metadata. They are optional and do not replace a custom Signal data schema:

{:ok, signal} = Jido.Signal.put_context(signal, "tenantid", "tenant-123")
"tenant-123" = Jido.Signal.get_context(signal, "tenantid")

Context names use lower-case letters and digits. Values use CloudEvents context types. Put domain data and dispatch policy outside this metadata map.

The Router

Deterministic Signal type lookup with exact, *, and ** patterns:

alias Jido.Signal.Router
routes = [
# Exact matches have highest priority
{"user.created", :handle_user_creation},
# Single-level wildcards
{"user.*.updated", :handle_user_updates},
# Multi-level wildcards
{"audit.**", :audit_logger, 100}, # High priority
# Pattern matching functions
{"**", fn signal -> String.contains?(signal.type, "error") end, :error_handler}
]
{:ok, router} = Router.new(routes)
# Route signals to handlers
signal = Jido.Signal.new!("user.profile.updated", %{}, source: "/users")
{:ok, targets} = Router.route(router, signal)
# => {:ok, [:handle_user_updates]}
# Manage the immutable Router through public helpers
route_count = Router.count(router)
false = Router.empty?(router)
{:ok, registered_routes} = Router.list(router)

Dispatch System

Flexible delivery to multiple destinations:

Dispatch is delivery infrastructure: it takes an existing signal and sends it to configured destinations. In the wider Jido ecosystem, that does not mean every effect must be modeled as signal dispatch. The broader boundary between pure agent logic, directives, and runtime execution lives in Jido's Core Loop and Actions guides.

alias Jido.Signal.Dispatch
dispatch_configs = [
# Send to process
{:pid, target: my_process_pid},
# Publish via Phoenix.PubSub
# Requires {:phoenix_pubsub, "~> 2.1"} in your app deps.
{:pubsub, target: MyApp.PubSub, topic: "events"},
# Structured CloudEvents JSON over OTP HTTP
{:http,
url: "https://api.example.com/events",
headers: [{"authorization", "Bearer token"}]},
# Log structured data
{:logger, level: :info, structured: true},
# Debug logging
{:logger, level: :debug}
]
# Synchronous dispatch
:ok = Dispatch.dispatch(signal, dispatch_configs)

Dispatch is ordered and synchronous. Start a Task in the calling application if delivery must run asynchronously. Retry and circuit-breaking policy belongs to the calling application.

The HTTP adapter uses OTP :httpc; it needs no external HTTP client. It sends structured JSON with application/cloudevents+json, does not follow redirects, and accepts only url, headers, and timeout options. Dispatch adds no retry loop. OTP :httpc can still honor Retry-After on a 503 response, and this client behavior cannot be disabled on OTP 27. Use a custom adapter for strict single-attempt delivery, request signing, other methods, custom TLS policy, or response data.

Treat each HTTP URL as trusted application configuration. The built-in adapter permits private network targets and does not protect against DNS rebinding. OTP 27 :httpc also has no response body size limit for these requests. Use a custom adapter for untrusted targets or a strict response size limit.

Advanced Features

Durable Subscriptions

Keep accepted Signals while an agent process is unavailable:

# Create a durable subscription with a stable ID.
{:ok, "payments-agent"} =
Bus.subscribe(:my_app_bus, "payment.*", durable: "payments-agent")
# Receive and acknowledge signals
{:ok, [_recorded]} = Bus.publish(:my_app_bus, [payment_signal])
receive do
{:signal, "payments-agent", recorded} ->
process_payment(recorded.signal)
Bus.ack(:my_app_bus, "payments-agent", recorded.cursor)
end
# Attach a replacement process with the same ID and path.
{:ok, "payments-agent"} =
Bus.subscribe(:my_app_bus, "payment.*",
durable: "payments-agent",
target: replacement_pid
)

The Bus sends one durable record at a time. If the target exits before it acknowledges the cursor, the replacement receives the record again. The Bus has no retry timer or dead-letter queue. The application owns these policies.

Observability

Dispatch telemetry keeps the legacy [:jido, :dispatch, :start|:stop|:exception] events with bounded metadata, and package execution logging defaults to config :jido_signal, default_log_level: :info.

The Bus emits publish, delivery, acknowledgement, and subscription events. The Router emits no telemetry. Instrument the operation that calls the Router when route timing is useful.

config :jido_signal,
default_log_level: :info
# Opt in to normalized dispatch errors during the compatibility transition.
config :jido_signal,
normalize_dispatch_errors: true
{:error, error} = Jido.Signal.Dispatch.dispatch(signal, {:http, [url: "https://down.example.com"]})
Jido.Signal.Error.to_map(error)
# => %{
# => type: :dispatch_error,
# => message: "Signal dispatch failed",
# => details: %{
# => "adapter" => "http",
# => "reason" => "timeout",
# => "target" => %{
# => "adapter" => "http",
# => "target" => "https://down.example.com",
# => "target_kind" => "url"
# => }
# => },
# => retryable?: true
# => }

Retained Replay

Read the bounded Bus log by cursor:

{:ok, records} =
Bus.replay(:my_app_bus, "user.*", after: 100, limit: 100)

The default memory Store keeps the newest 100,000 records and does not survive a Bus restart. Set a custom Jido.Signal.Bus.Store implementation for restart durability:

{:ok, _bus} = Bus.start_link(
name: :my_app_bus,
store: MyApp.SignalStore,
store_opts: [repo: MyApp.Repo]
)

Scoped Buses

Use jido: to put a Bus in a separate Registry namespace:

{:ok, _} = Jido.Signal.Bus.start_link(name: :tenant_bus, jido: MyApp.Jido)
# Lookup uses the same scope.
{:ok, bus_pid} = Jido.Signal.Bus.whereis(:tenant_bus, jido: MyApp.Jido)
# The same Bus name can exist in two scopes.
{:ok, _} = Jido.Signal.Bus.start_link(name: :events, jido: TenantA.Jido)
{:ok, _} = Jido.Signal.Bus.start_link(name: :events, jido: TenantB.Jido)

The package uses one Registry. It stores scoped Buses under {jido, bus_name} keys. No separate instance process is necessary.

Use Cases

Microservices Communication

# Service A publishes order events
{:ok, signal} = OrderCreated.new(%{order_id: "123", customer_id: "456"})
Bus.publish(:event_bus, [signal])
# Service B processes inventory
# Service C sends notifications
# Service D updates analytics

Agent-Based Systems

# Agents communicate via signals
{:ok, signal} = AgentMessage.new(%{
from_agent: "agent_1",
to_agent: "agent_2",
action: "negotiate_price",
data: %{product_id: "prod_123", offered_price: 99.99}
})

Event Sourcing

# Commands become events
{:ok, command_signal} = CreateUser.new(user_data)
{:ok, event_signal} = UserCreated.new(user_data)
{:ok, event_signal} =
Jido.Signal.put_context(event_signal, "causeid", command_signal.id)
# Store the canonical map in the application event store.
MyApp.EventStore.append(Jido.Signal.to_map(event_signal))

Distributed Workflows

# Coordinate multi-step processes
workflow_signals = [
Signal.new!("workflow.started", %{workflow_id: "wf_123"}, source: "/workflow"),
Signal.new!("step.completed", %{step: 1, workflow_id: "wf_123"}, source: "/workflow"),
Signal.new!("step.completed", %{step: 2, workflow_id: "wf_123"}, source: "/workflow"),
Signal.new!("workflow.completed", %{workflow_id: "wf_123"}, source: "/workflow")
]

From v2 to v3

v3 starts from the v2.2.2 release. It keeps the Signal envelope, Router precedence, Dispatch target tuples, and the main Bus calls. It removes package-owned runtime policy and duplicate schema and serialization systems.

Areav2.2.2v3
Signal schemasNimbleOptions and duplicate validation pathsZoi only, with MFA callback values in schemas
Wire formatSerializer framework, custom markers, and MessagePackCanonical CloudEvents 1.0 maps, JSON, and safe Erlang terms
RouterLarge routing engine and cacheExact-path map and compact wildcard trie
DispatchAsync, batch, retry, Fuse, and many adaptersOrdered delivery and a small adapter set
BusJournal, partitions, middleware, snapshots, and dead-letter policyLocal ordered delivery, retained replay, durable cursors, and a Store seam
Trace and extensionsNested trace state and a schema extension registryExplicit Trace values and flat CloudEvents context attributes

The v3 reader accepts supported v2 wire maps. New writes use only the v3 canonical form. Read the v2 to v3 migration guide before you convert stored Signals or Bus subscriptions.

Guides

Start here

Signal format

Routing and delivery

Advanced use

Upgrade

For module and function details, use the API reference.

Development

Prerequisites

Setup

git clone https://github.com/agentjido/jido_signal.git
cd jido_signal
mix deps.get

Running Tests

mix test
MIX_ENV=test mix test --cover --warnings-as-errors
mix test --include flaky --warnings-as-errors

Quality Checks

mix quality
mix deps.unlock --check-unused
mix hex.audit

mix quality runs formatting, compilation with warnings as errors, Doctor, ExDoc, Credo, and Dialyzer. The coverage floor is 90 percent.

Generate Documentation

mix docs

Contributing

We welcome contributions! Please see our Contributing Guide for details on:

License

This project is licensed under the Apache License 2.0 - see the LICENSE file for details.