Skip to content

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 .md to the URL or sending Accept: 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.

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.

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.

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 →

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 up ticks every few seconds on the dev environment’s clock, so a4 dev warp +7d runs 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"

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 server

In 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.

  • fromView keeps one signal for the whole view (which market is best).
  • fromViewRows keeps one per row, for example “this wallet’s position health crossed below 1.15”. It takes the same key and derive, plus a filter for the rows to watch.
  • fromAppendView dispatches 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);
}

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.