Dynamic actors allow agent definitions to be stored in the database and hydrated into running supervised processes at runtime — without requiring compiled Elixir modules. This guide covers defining dynamic actors, the built-in strategy templates, gatherers, and safe lifecycle operations.

Examples use a generic resource domain.

When to use dynamic actors

  • Users create custom monitoring agents through a UI
  • Agent definitions are stored per-tenant
  • Agent configurations change frequently without requiring code deploys

How it works

A single Cyclium.DynamicActor GenServer module serves all DB-defined actors. Each instance is started with different config/expectations args under Cyclium.ActorSupervisor. This avoids runtime module pollution — no Module.create/3 needed.

Cyclium.ActorSupervisor (DynamicSupervisor)
 MyApp.Actors.ResourceMonitor    (compiled, use Cyclium.Actor)
 Cyclium.DynamicActor            (from DB: "user_monitor_1")
 Cyclium.DynamicActor            (from DB: "user_monitor_2")

Defining an agent in the database

Insert a row into cyclium_agent_definitions:

%Cyclium.Schemas.AgentDefinition{
  actor_id: "custom_resource_check",
  domain: "monitoring",
  strategy_template: "observe_classify_converge",   # built-in template
  config: Jason.encode!(%{max_concurrent_episodes: 3, episode_overflow: "queue"}),
  expectations: Jason.encode!([
    %{
      id: "check_target",
      trigger: %{type: "schedule", interval_ms: 300_000},
      budget: %{max_turns: 5, max_tokens: 10_000, max_wall_ms: 60_000},
      log_strategy: "timeline"
    }
  ]),
  enabled: true
}

Loading dynamic actors

# At application startup — loads all enabled definitions
Cyclium.DynamicActor.Loader.load_all()

# Load a single actor
Cyclium.DynamicActor.Loader.load("custom_resource_check")

# Reload after updating the definition in DB
Cyclium.DynamicActor.Loader.reload("custom_resource_check")

# Stop a dynamic actor
Cyclium.DynamicActor.Loader.stop("custom_resource_check")

Database table

cyclium_agent_definitions (V7 migration):

ColumnTypeNotes
iduuidPK
actor_idstring(255)Unique identifier
domainstring(255)Grouping domain
configtext (JSON)max_concurrent_episodes, episode_overflow, etc.
expectationstext (JSON)Array of expectation definitions
strategy_refstring(255)Strategy module or registry lookup key
strategy_templatestring(255)Template name (e.g. "observe_synthesize_converge")
strategy_configtext (JSON)Template parameters (gatherer, system_prompt, finding_config, etc.)
enabledbooleanSoft toggle
created_bystring(255)User/tenant

Strategy templates (data-driven strategies)

Dynamic actors use strategy templates — built-in parameterized strategy modules that are configured via strategy_config JSON in the DB. The compiled app defines what data sources (gatherers) and outputs are available; the DB definition composes them.

TemplatePatternUse Case
"observe_synthesize_converge"Gather → LLM → FindingHealth checks, advisors, analysis
"observe_classify_converge"Gather → Rules → FindingThreshold/rule-based monitoring
"dispatch"Load entities → Broadcast eventsFan-out triggers

Gatherers

Gatherers are compiled modules that know how to collect domain-specific data. The compiled app implements and registers them:

defmodule MyApp.Gatherers.ResourceData do
  @behaviour Cyclium.Gatherer

  @impl true
  def gather(trigger_payload, _opts) do
    resource_id = trigger_payload["resource_id"]
    resource = Repo.get!(Resource, resource_id)
    events = load_recent_events(resource_id)
    {:ok, %{resource: resource, events: events, event_count: length(events)}}
  end
end

Register in app config:

config :cyclium, :gatherer_registry, %{
  "resource_data" => MyApp.Gatherers.ResourceData,
  "resource_metrics" => MyApp.Gatherers.ResourceMetrics
}

Observe → Synthesize → Converge

The main workhorse. Gathers data, sends to LLM, maps result to findings:

%AgentDefinition{
  actor_id: "resource_health_dynamic",
  strategy_template: "observe_synthesize_converge",
  strategy_config: Jason.encode!(%{
    "gatherer" => "resource_data",
    "system_prompt" => "You are an operations analyst. Evaluate the resource data and classify its health status.",
    "finding_config" => %{
      "actor_id_field" => "resource_health_dynamic",
      "finding_key_template" => "resource:health:${subject_id}",
      "class_field" => "class",
      "severity_field" => "severity",
      "summary_field" => "summary",
      "subject_kind" => "resource",
      "subject_id_key" => "resource_id"
    },
    "outputs" => ["email"]
  }),
  expectations: Jason.encode!([
    %{id: "evaluate", trigger: %{type: "event", event_type: "resource.check_requested"}}
  ])
}

Observe → Classify → Converge

Rule-based classification without LLM. Rules are evaluated in order, first match wins:

%AgentDefinition{
  actor_id: "resource_risk_monitor",
  strategy_template: "observe_classify_converge",
  strategy_config: Jason.encode!(%{
    "gatherer" => "resource_metrics",
    "classify_rules" => [
      %{"field" => "percent_used", "op" => "gt", "value" => 1.0, "class" => "over_limit", "severity" => "high"},
      %{"field" => "idle_days", "op" => "gt", "value" => 30, "class" => "stale", "severity" => "medium"}
    ],
    "default_class" => "healthy",
    "default_severity" => "low",
    "finding_config" => %{
      "finding_key_template" => "resource:risk:${subject_id}",
      "subject_kind" => "resource",
      "subject_id_key" => "resource_id"
    }
  })
}

Rule operators: lt, gt, eq, neq, in, not_in.

Dispatch

Fan-out pattern. Calls a gatherer that returns a list of entities, broadcasts an event for each:

%AgentDefinition{
  actor_id: "resource_dispatch",
  strategy_template: "dispatch",
  strategy_config: Jason.encode!(%{
    "gatherer" => "active_resources",
    "event_type" => "resource.check_requested",
    "entity_id_field" => "id",
    "entity_payload_fields" => ["id", "name"]
  })
}

Strategy resolution for dynamic actors

Dynamic actors use the same :persistent_term registration path as compiled actors. When a dynamic actor boots, Loader resolves the strategy from strategy_template and injects it into each expectation — init_state_from_config then registers it automatically. No strategy registry entries needed.

If you need to override a strategy without updating the DB record, add a strategy_for/2 clause to your registry as usual.

Custom templates can be registered in app config:

config :cyclium, :strategy_templates, %{
  "my_custom_template" => MyApp.Strategies.CustomTemplate
}

Lifecycle and draining

Safe lifecycle operations for updating dynamic actors without losing in-flight episodes.

Drain and reload

# Graceful: waits for active episodes to finish, then reloads from DB
Cyclium.DynamicActor.Lifecycle.drain_and_reload("my_monitor")

# Graceful stop (waits for episodes)
Cyclium.DynamicActor.Lifecycle.drain_and_stop("my_monitor")

# Instant stop/reload (existing behavior, may lose in-flight episodes)
Cyclium.DynamicActor.Loader.stop("my_monitor")
Cyclium.DynamicActor.Loader.reload("my_monitor")

Event-driven refresh

Start the optional Watcher in your supervision tree for automatic refresh:

children = [
  # ... your app ...
  Cyclium.DynamicActor.Watcher
]

Then broadcast events when definitions change:

# Agent definitions:
Cyclium.Bus.broadcast("agent_definition.created", %{actor_id: "my_monitor"})
Cyclium.Bus.broadcast("agent_definition.updated", %{actor_id: "my_monitor"})
Cyclium.Bus.broadcast("agent_definition.disabled", %{actor_id: "my_monitor"})

# Workflow definitions:
Cyclium.Bus.broadcast("workflow_definition.created", %{workflow_id: "onboarding"})
Cyclium.Bus.broadcast("workflow_definition.updated", %{workflow_id: "onboarding"})
Cyclium.Bus.broadcast("workflow_definition.disabled", %{workflow_id: "onboarding"})

The Watcher handles each event appropriately — created loads, updated reloads, disabled stops/unloads. For actors, updates use drain-and-reload to preserve in-flight episodes.

Deploy patterns

Rolling deploy:

# In application stop callback or shutdown hook:
Cyclium.DynamicActor.Lifecycle.stop_all(drain: true, timeout: 30_000)

Blue-green: The new instance calls Loader.load_all() on startup. Global name registration ensures only one instance runs per actor across the cluster.


Related guides: Actors & Strategies · Workflows · Distributed Ops