Agents that wake themselves
Copy MarkdownMix.install([{:wardrik, "~> 0.1.0-rc.1"}, {:kino, "~> 0.19"}])What you'll build
By the end, an agent wakes every minute on its own, and again whenever a signed webhook arrives. A table beside it fills in as it wakes, saying what woke it and what it said.
Add your Anthropic key as ANTHROPIC_API_KEY in the Secrets panel (the key icon on the left).
Each wake is one small model call. Leaving the clock running for ten minutes costs about a cent.
System.put_env("ANTHROPIC_API_KEY", System.fetch_env!("LB_ANTHROPIC_API_KEY"))Start Wardrik
A webhook checks requests with a secret, kept sealed in Wardrik's vault. :keys is what seals it:
{:ok, _} = Wardrik.start_link(name: MyApp.AgentRuntime, keys: [WardrikVault.generate_key()])
scope = Wardrik.as(MyApp.AgentRuntime, "acme/@maria", roles: [:owner], resources: ["acme/@maria/*"])In your app, keep the key in config, not in code. A key generated here is gone when the runtime stops, and so is everything it sealed.
Keep the webhook's secret
The webhook will be signed as Standard Webhooks describes.
Its secret is whsec_ and a base64 key:
hook_secret = "whsec_" <> Base.encode64(:crypto.strong_rand_bytes(24))
:ok = Wardrik.put_secret(MyApp.AgentRuntime, "acme/@maria", "ORDERS_HOOK_SECRET", hook_secret)The sender keeps the same secret. Wardrik never returns it.
Define the agent and its triggers
{:ok, agent} =
Wardrik.Agent.define(scope, "acme/@maria/watcher",
model: "anthropic:claude-haiku-4-5",
instructions: "You watch Acme's orders. Answer in one short line.",
policy: [budget: [usd: 0.25, per: :day]],
triggers: [
%{name: "heartbeat", every: "1m", prompt: "Say you're awake and watching."},
%{
name: "orders",
webhook: %{secret: "${ORDERS_HOOK_SECRET}", verify: "standard"},
prompt: "A new order came in. Summarise it."
}
]
)
agent.next_runheartbeatruns every minute, counted from now.everyalso takes cron, as"0 9 * * 1".ordersruns when a request signed withORDERS_HOOK_SECRETarrives.${NAME}names a secret in the vault.
Watch it wake
Each wake writes wardrik.trigger.fired to the agent's log. Its timeline holds what it was asked
and what it answered. This table reads both every two seconds:
woke = fn ->
{:ok, page} = Wardrik.Agent.events(scope, agent, types: ["wardrik.trigger.fired"])
for event <- page.events do
%{
time: Calendar.strftime(event.time, "%H:%M:%S"),
trigger: event.data["trigger"]["name"],
status: event.data["status"]
}
end
end
said = fn ->
{:ok, timeline} = Wardrik.Agent.timeline(scope, agent)
for %{"kind" => "run"} = run <- timeline.items do
%{
time: run["started_at"] && String.slice(run["started_at"], 11, 8),
status: run["status"],
answer: Enum.map_join(run["steps"], &(&1["text"] || ""))
}
end
end
Kino.animate(2000, fn _tick ->
Kino.Layout.grid(
[
Kino.DataTable.new(woke.(), keys: [:time, :trigger, :status], name: "What woke it"),
Kino.DataTable.new(said.(), keys: [:time, :status, :answer], name: "What it said")
],
columns: 1
)
end)Leave this running. A heartbeat row appears each minute. Status asked means it asked the
agent. A clock that comes due while the agent is still busy is busy, and skipped.
Send it a webhook
A service sends its request to POST /v1/hooks/acme/@maria/watcher/orders. Here the notebook
plays the service. It signs the raw body with the secret and delivers it, as the HTTP route would:
sign = fn body ->
"whsec_" <> key = hook_secret
id = "msg_" <> Base.url_encode64(:crypto.strong_rand_bytes(9))
timestamp = Integer.to_string(System.os_time(:second))
signature = :crypto.mac(:hmac, :sha256, Base.decode64!(key), "#{id}.#{timestamp}.#{body}")
%{
"webhook-id" => id,
"webhook-timestamp" => timestamp,
"webhook-signature" => "v1," <> Base.encode64(signature)
}
end
body = ~s({"order": "A-3", "customer": "Initech", "total": "$1,240.00"})
headers = sign.(body)
Wardrik.Hook.deliver(scope, agent, "orders", body: body, headers: headers)An orders row appears in the table. The body goes under the trigger's prompt.
A request that doesn't prove itself returns not_found, the same as a trigger that isn't there:
Wardrik.Hook.deliver(scope, agent, "orders",
body: ~s({"order": "A-3", "total": "$1.00"}),
headers: headers
)The service may send the same request twice. Each webhook-id is asked about once, so this adds
no row:
Wardrik.Hook.deliver(scope, agent, "orders", body: body, headers: headers)Change a trigger
Change one trigger without defining the agent again:
Wardrik.Agent.trigger_set(scope, agent, "heartbeat",
every: "2m",
prompt: "Say you're still awake."
)Stop the clock
The heartbeat runs while this runtime runs. Remove it when you're done:
Wardrik.Agent.trigger_remove(scope, agent, "heartbeat")Where next
- Trigger agents: every trigger key, feeds, and triggers in a cluster.
- Credentials: the vault, and secrets agents use.
- Events notebook: follow every event an agent writes.
- Tools notebook: give the agent something to do when it wakes.