Arete documentation
Triggers and fan-out
For the complete documentation index optimized for AI agents, see llms.txt or llms-full.txt. A markdown version of this page is available by appending.mdto the URL or sendingAccept: text/markdown.
For AI agents: the documentation index is at llms.txt (full corpus: llms-full.txt). A markdown source for this page is /automations/triggers.md.
A trigger is what starts an automation: a protocol event, a time of day, or a change in live data. Your handler turns it into the steps to run. This page covers each kind of trigger, and how one trigger becomes a step per user.
Handlers
Section titled “Handlers”A handler receives the trigger and returns step requests. It reads whatever it needs to decide, but never prepares, signs or submits anything itself; the library does that for every step it returns, after its checks.
automations.on("round.settled", async (event, ctx) => { const grants = await ctx.consents.list({ step: "claim", instanceId: event.instanceId }); const steps = []; for (const grant of grants) { const player = await client.participants.get({ instance: event.instanceId, wallet: grant.wallet }); if (player.state === "won") { steps.push(ctx.step("claim", { wallet: grant.wallet, instance: event.instanceId })); } } return steps;});The won state and claim step are this example protocol’s; use your
protocol’s own.
ctx.step(step, { wallet, instance, args }, { cooldown }) describes one step
for one wallet. Event names and step names are typed from the protocol’s
generated client.
A trigger means “go and look”, not the truth. Read current state in the
handler rather than trusting a trigger’s payload for amounts. Each step is
checked again before signing: the protocol’s allowedSteps, prepare, and a
simulation. A stale or wrong trigger can only lead to a step that is checked
and then skipped, refused or rejected.
Fan-out: one trigger, many users
Section titled “Fan-out: one trigger, many users”Most triggers are not about one user. A schedule, a rate change or an event
about one instance concerns everyone who opted in. ctx.consents.list returns
the active consent grants in the deployment, each with the user’s own
settings:
automations.on("weeklyBuy", async (_trigger, ctx) => { const grants = await ctx.consents.list({ step: "buy" }); return grants .filter((grant) => grant.params.weekday === today) .map((grant) => ctx.step("buy", { wallet: grant.wallet, args: { amount: String(grant.params.amount) } }), );});Pass instanceId to get one grant per wallet for that instance: a grant
scoped to the instance wins over one for every instance. The list is a
starting set; each step checks consent again before it signs.
Every step request becomes its own job, identified by the trigger, the step, the wallet and the instance. A handler that fails halfway and runs again never doubles a step, and one user’s refusal does not affect the others.
Protocol events
Section titled “Protocol events”A protocol’s reactions can emit named events. Receive them as signed
webhooks in production:
export const POST = automations.webhookHandler({ secret: process.env.ARETE_WEBHOOK_SECRET! });The handler verifies the raw body’s signature and timestamp, ignores events
for other deployments or without a handler, records the delivery once and
answers straight away. A replay of the same signed body is a duplicate, even
under a different delivery header. For Express, use
automations.expressWebhookHandler(...) behind
express.raw({ type: "application/json" }).
Under a4 dev up, events reach the same handlers as signed deliveries:
dev environments send no webhooks,
so a4 dev follows the environment’s instances and signs each event the way
a production webhook is signed.
To follow one instance yourself, use its event stream:
const subscription = automations.follow(instanceId);// later: subscription.close();An event the store could not record is retried until it is saved, so a brief database outage does not lose it.
Set up webhook subscriptions →
Schedules
Section titled “Schedules”Declare a schedule in the definition and handle it like any trigger:
export default defineAutomations(PROTOCOL, (automations) => { automations.schedule("weeklyBuy", { every: "week" }); automations.on("weeklyBuy", async (trigger, ctx) => { // trigger: { schedule: "weeklyBuy", slot: "weeklyBuy:2026-W43", at } const grants = await ctx.consents.list({ step: "buy" }); return grants.map((grant) => ctx.step("buy", { wallet: grant.wallet, args: { amount: grant.params.amount } })); });});every is a calendar period (minute, hour, day, week or month, in
UTC) or an interval such as 15m or 6h. A schedule runs once per slot of
that period, at the first tick inside it. Ticks come from wherever the
automations run:
a4 dev upticks every few seconds on the dev environment’s clock, soa4 dev warp +7druns a weekly schedule.- A long-running server (
@usearete/automations/node) ticks itself. - A serverless host ticks from its cron (a Workers cron trigger, a Vercel
cron) through the adapter’s tick route, or by calling
automations.tick()yourself.
A slot is accepted once, so a cron that fires twice in one week, or several replicas ticking at once, still runs the buys once. For each user’s own day, schedule daily and filter on their settings, as in the fan-out example above.
Without a definition, dispatch the trigger from your own scheduler with an id that names the slot:
import { scheduleSlot } from "@usearete/automations";
await automations.dispatch("weeklyBuy", {}, { id: scheduleSlot("weekly-buy", "week") });// → id "weekly-buy:2026-W43"Live data
Section titled “Live data”A view source watches a live view and dispatches when a condition becomes true. For example, a savings product moves deposits when the best lending market changes:
import { fromView } from "@usearete/automations";
// `marketsView` is a list view from an installed stack.const bestRate = fromView(marketsView, { name: "bestRateChanged", key: (market) => market.id, derive: (markets) => pickBest(markets, { minEdgeBps: 25 }), changed: (previous, next) => previous !== undefined && previous !== next, stableFor: "2m",});
automations.on("bestRateChanged", async (trigger, ctx) => { // trigger.value is the new best market; fan out over the users who opted in.});
automations.source(bestRate); // a long-running serverIn a definition (defineAutomations), open the stack with
automations.connect(stack) rather than connecting it yourself: under
a4 dev it connects to the dev environment’s stack, and in production to the
published one.
export default defineAutomations(PROTOCOL, async (automations) => { const lending = await automations.connect(LENDING_STACK); automations.source(fromView(lending.views.Market.list, { /* as above */ }));});A stack that is not in the dev environment connects to its published data,
and a4 dev says so when it starts.
fromViewkeeps one signal for the whole view (which market is best).fromViewRowskeeps one per row, for example “this wallet’s position health crossed below 1.15”. It takes the samekeyandderive, plus afilterfor the rows to watch.fromAppendViewdispatches one trigger per row appended to an append view, and resumes from the last row it saw.
changed(previous, next) says which changes count, so you can ignore the
first value or require a crossing. stableFor makes a new value hold for a
while before it counts, so one bad update (an oracle blip, a briefly
mispriced market) does not fire. Put hysteresis, such as a minimum edge, in
derive.
On a serverless platform, poll instead of keeping a subscription open.
automations.use(definition, { sources: "poll" }) registers the
definition’s list sources for tick(), which polls them on every cron run;
the host adapters do this for you. Polling sees the same changes, but only at
each poll. Append sources need a live subscription, so they run on a
long-running server. A source outside a definition can be polled directly:
export async function GET() { await automations.poll(bestRate);}Cooldowns
Section titled “Cooldowns”Limit how often a user is acted on with a cooldown on the step:
ctx.step("rebalance", { wallet, instance, args }, { cooldown: "24h" });A step that completed for the same wallet within the cooldown is skipped. A
cooldown limits how often users are acted on; stableFor limits how quickly
a signal is believed. Most view-driven automations want both.