WardrikTriggers

Copy Markdown

Asks agents something on a clock, when a feed has new entries, or when a service sends a webhook.

WardrikTriggers.put(MyApp.Triggers, "acme/digest", [
  %{"name" => "daily", "every" => "0 9 * * *", "prompt" => "Write today's update."},
  %{"name" => "rivals", "every" => "6h", "feed" => "https://rivalco.example/changelog.rss", "prompt" => "What changed?"},
  %{"name" => "issues", "webhook" => %{"secret" => "${ISSUES_HOOK_SECRET}"}, "prompt" => "Triage it."}
])

With Wardrik, triggers are part of an agent's definition and all of this is set up for you. See Trigger agents.

Install

def deps do
  [
    {:wardrik_triggers, "~> 0.1.0-rc.1"}
  ]
end

Start it

Add WardrikTriggers to your supervision tree. It needs a WardrikStore to keep triggers in, and a WardrikTriggers.Target to ask your agents.

store = WardrikStore.ETS.new(name: MyApp.Store)

children = [
  {WardrikStore, store},
  {WardrikVault, name: MyApp.Vault, store: store, keys: [System.fetch_env!("VAULT_KEY")]},
  {WardrikCore, backend: {WardrikStore.Durable, store: store}},
  {WardrikTriggers, name: MyApp.Triggers, store: store, target: {MyApp.Triggered, []}}
]

Here the agents are WardrikCore's, and webhook secrets are kept in a WardrikVault.

Options

  • :name - required. What you pass to every other function.
  • :store - required. The WardrikStore triggers are kept in.
  • :target - required. A WardrikTriggers.Target, as {module, config}.
  • :req_options - Req options for reading feeds. [] unless set.
  • :reconcile - milliseconds between each node reading every trigger again, or :infinity. One minute unless set.

The target

The target asks the agent, hears how each run went, and resolves webhook secrets:

defmodule MyApp.Triggered do
  @behaviour WardrikTriggers.Target

  require Logger

  @impl true
  def ask(_config, agent_id, prompt, opts) do
    case WardrikCore.whereis(agent_id) do
      nil -> :no_agent
      agent -> asked(agent, prompt, opts)
    end
  end

  @impl true
  def fired(_config, agent_id, trigger, status),
    do: Logger.info("#{agent_id} ran #{trigger}: #{status}")

  @impl true
  def resolve_secret(_config, _agent_id, name) do
    case WardrikVault.fetch(MyApp.Vault, %WardrikCore.Principal{id: "acme"}, name) do
      {:ok, secret} -> {:ok, secret}
      {:error, _reason} -> :error
    end
  end

  defp asked(agent, prompt, opts) do
    if opts[:overlap] == :skip and WardrikCore.status(agent) != :idle do
      :busy
    else
      :ok = WardrikCore.ask(agent, prompt, %{trigger: opts[:trigger]})
      :asked
    end
  end
end

Every run also emits [:wardrik_triggers, :fired] telemetry, with agent_id, trigger and status in its metadata.

Give an agent triggers

put/3 replaces an agent's triggers. A trigger is a map with string keys, as an agent file writes it.

{:ok, _agent} = WardrikCore.start_agent(agent_id: "acme/digest", model: "anthropic:claude-haiku-4-5")
{:ok, _vsn} = WardrikVault.put(MyApp.Vault, %WardrikCore.Principal{id: "acme"}, "ISSUES_HOOK_SECRET", "s3cret")

:ok =
  WardrikTriggers.put(MyApp.Triggers, "acme/digest", [
    %{"name" => "daily", "every" => "0 9 * * *", "timezone" => "America/Los_Angeles", "prompt" => "Write today's update."},
    %{"name" => "rivals", "every" => "6h", "feed" => "https://rivalco.example/changelog.rss", "prompt" => "What changed?"},
    %{"name" => "issues", "webhook" => %{"secret" => "${ISSUES_HOOK_SECRET}"}, "prompt" => "Triage it."}
  ])

set/3 and remove/3 change one trigger. list/2 returns each trigger with its next run, nil for a webhook:

WardrikTriggers.list(MyApp.Triggers, "acme/digest")
#=> [
#=>   {%WardrikTriggers.Trigger{name: "daily", every: "0 9 * * *", timezone: "America/Los_Angeles", ...},
#=>    ~U[2026-10-24 16:00:00Z]},
#=>   {%WardrikTriggers.Trigger{name: "issues", webhook: %{secret: "${ISSUES_HOOK_SECRET}", verify: :shared}, ...}, nil},
#=>   ...
#=> ]

Trigger keys

A trigger has every or webhook, and prompt or prompt_file.

KeyMeansUnless set
everya cron expression, as 0 9 * * 1 or @daily, or an interval of at least a minute, as 30m, 2h or 1d
webhook%{"secret" => "${NAME}", "verify" => "shared"}; verify is shared or standard. In place of everyverify is shared
promptwhat the agent is asked
prompt_filea file the agent is told to read and follow, in place of prompt
namethe trigger's name; no /, : or whitespacetrigger-1, trigger-2, ... by position
timezonethe zone a cron expression is read in, as Europe/BerlinEtc/UTC
overlapskip leaves a busy agent alone; queue asks it anywayskip, or queue for a webhook
missedrun_once runs once after missed runs; skip drops themrun_once
jitterup to this many seconds added to each run0
feedan RSS or Atom feed URL, or a list of them

On a clock

A cron expression is read in timezone. A time skipped by a clock change runs at the first instant after the gap. A time repeated by a clock change runs on its first pass only. An interval counts from when the trigger was set or last ran.

A run more than a minute late runs once, however many runs were missed. With missed: skip it doesn't run, and its status is :missed.

Watching a feed

A trigger with feed asks only when a feed has entries it hasn't seen. The prompt lists up to 20 of them.

  • The first run only records what each feed holds. It asks nothing.
  • An unchanged feed (304) has nothing new.
  • A run fails only when no feed can be read.

Receiving a webhook

Pass each request to deliver/4 with its raw body and headers. In a Plug.Router:

post "/hooks/:org/:name/:trigger" do
  {:ok, body, conn} = Plug.Conn.read_body(conn)
  request = %{body: body, headers: Map.new(conn.req_headers), path_secret: nil}

  case WardrikTriggers.deliver(MyApp.Triggers, "#{org}/#{name}", trigger, request) do
    :ok -> send_resp(conn, 202, "")
    {:error, :not_found} -> send_resp(conn, 404, "")
    {:error, _busy} -> send_resp(conn, 503, "")
  end
end

Read the body before Plug.Parsers does. A signature covers the exact bytes. WardrikHTTP serves this route for Wardrik.

  • verify: shared - the request presents the secret as a bearer token, or as path_secret.
  • verify: standard - the request is signed as Standard Webhooks specifies.

The target resolves the secret that ${NAME} names. A request that fails to verify returns {:error, :not_found}. Each request is asked about once, keyed by its Idempotency-Key, its webhook-id, or a hash of its body.

In a cluster

Every node runs triggers. A node claims a run by moving the trigger's next run forward in the store, conditional on the version it read. So each run happens once in the cluster, and when a node is lost another picks up its triggers.

License

Apache-2.0