Skip to content

Arete documentation

Run automations in production

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/production.md.

In production, automations keep their state in your Postgres database and run workers in your backend. This page covers deploying them to a host, setting up the store, and what happens when things go wrong.

Develop in your dev environment, then run the same handlers on mainnet. The handlers live in a definition (defineAutomations) that a4 dev up runs in development and your backend runs with automations.use(definition). Only configuration changes:

  1. Install the published client. a4 install --locked replaces the client a4 dev generated with the published one. The automations take their target from the client: dev for the dev environment’s deployment, mainnet for the published one.
  2. Allow mainnet in the policy. A policy allows only dev unless it lists mainnet: definePolicy({ ...definition.policy, deployments: [client.deploymentId], clusters: ["mainnet"] }). The deployment id changes with the client, so build deployments from client.deploymentId rather than writing it in.
  3. Point the simulator at your RPC. rpcSimulator(process.env.RPC_URL), a mainnet RPC instead of a4 dev up’s local one.
  4. Use a real signer and store. A delegated wallet provider such as Privy instead of a dev keypair, and a Postgres schema of its own: one store serves one deployment, so the dev environment’s state never mixes with mainnet’s.
  5. Subscribe a webhook. A dev environment delivers no webhooks; on mainnet your endpoint receives the protocol’s events (events). a4 protocol webhook add --url <endpoint> subscribes it and prints the signing secret once.

A project keeps its automations in three layers, and only the last one knows where they run:

LayerFile
Definitionautomations/index.tsHandlers, schedules and view sources. a4 dev up runs it.
Runtimeautomations/runtime.tsruntime(env): the client, signer, store, simulator and policy, built from the host’s environment.
Host entryworker.ts, api/arete/[route].ts or server.tsA few lines that hand both to an adapter, plus the host’s config.

arete-automations add writes the runtime (when the project has none) and a host’s entry, then prints the remaining steps. It never overwrites a file, and deploying stays with the host’s own CLI:

Terminal window
npx @usearete/automations add cloudflare # worker.ts + wrangler.jsonc
npx @usearete/automations add vercel # api/arete/[route].ts + vercel.json
npx @usearete/automations add node # server.ts + Dockerfile, for Fly.io, Railway, Render or any VM

--signer privy writes a Privy signer for your users’ delegated wallets instead of the default single keypair. Each adapter serves the same routes:

  • POST …/webhook verifies a protocol event with ARETE_WEBHOOK_SECRET, accepts it once and runs its jobs after responding.
  • GET|POST …/tick runs due schedules, polls view sources and retries jobs. The host’s cron calls it with Authorization: Bearer <CRON_SECRET>, which is what Vercel’s cron sends. On Workers the cron trigger calls it directly.
  • GET …/health names the protocol, deployment and cluster.

The steps are the same on every host:

Terminal window
a4 protocol deploy # the protocol release
a4 install --locked # the published client, not the dev one
DATABASE_URL=... npx @usearete/automations migrate # the store's tables, once per schema change
# set ARETE_SECRET_KEY, DATABASE_URL, RPC_URL, CRON_SECRET and the signer's secrets on the host
npx wrangler deploy # or: vercel deploy --prod, fly deploy, ...
a4 protocol webhook add --url https://<host>/arete/webhook --secret-only | npx wrangler secret put ARETE_WEBHOOK_SECRET

Set ARETE_CLUSTER=mainnet to let the policy sign on mainnet. The adapters refuse to start when the bundled protocol client targets another cluster, for example a build made while a4 dev up had the dev client installed.

HostWorkersSchedules and sourcesNotes
Cloudflare Workers (/cloudflare)After each webhook and cron runCron trigger, every minuteNeeds nodejs_compat. The runtime is built per request; use Hyperdrive for Postgres.
Vercel (/vercel)After each webhook (waitUntil) and cron runvercel.json cronPer-minute crons need a Pro plan. Use a pooled Postgres URL.
Node (/node)ContinuouslyIn-process ticks; live view subscriptionsAny container host. The only target for append-view sources.

The adapters are thin. To run in another framework (Hono, Express, Bun, Deno), call createAutomations(await runtime(env)), then automations.use(definition), and route requests to webhookHandler and tick yourself.

import { postgresStore } from "@usearete/automations/postgres";
const store = postgresStore(pool); // your pg Pool; no connections of its own
  • Its own schema. Everything lives in arete_automation (or postgresStore(pool, { schema })); nothing outside it is created, changed or read.

  • Migrations never run at startup. Run await store.migrate() from a deploy step. It applies pending migrations in one transaction, and concurrent runs wait for each other. Or export the SQL for your own migration tool:

    Terminal window
    npx @usearete/automations migrations --out db/migrations

    A store whose database is behind refuses to run.

  • Two database roles. Migrate with a role that can create tables; run with one limited to reading and writing rows. npx @usearete/automations grants --role app_runtime prints its grants.

  • Inside your transactions. store.using(client) runs store writes inside a transaction you have open, for example to save a consent grant together with your user record.

  • Other pool libraries. A pg.Pool is detected; for another pool adapter, pass postgresStore(pool, { executor: "pool" }).

One store (one schema) serves one deployment. Use a separate schema for each deployment your backend automates.

memoryStore() is for tests only: it forgets everything on restart. A custom store must pass the package’s conformance suite (runStoreConformance from @usearete/automations/testing).

On a long-running server:

automations.start({ concurrency: 4 });
// on shutdown:
await automations.stop();

On a serverless platform, process work after accepting a trigger, and from a scheduled function so nothing waits for the next request: tick() runs due schedules, polls sources and then jobs. The adapters above do both.

Several workers and replicas can run at once. Each job is claimed by one worker at a time, and only one step per wallet is in progress.

Every step ends in one of these states, reported to onOutcome:

StateWhat happened
completedThe transactions landed
chain_failedA transaction failed on chain
expiredThe prepared intent expired twice (it is prepared once more after the first)
skippedNo active consent, or inside the step’s cooldown
rejectedThe protocol said no (a guard or an access rule); a normal outcome
refusedThe signing policy, a consent limit, the exact-bytes checks or the provider refused
failedIt kept failing before signing (for example the protocol API was unreachable)

onRefused and onRejected receive just those outcomes. Each outcome carries the step, wallet, instance and a code. A readable reason is optional; fall back to code when it is missing.

An outcome also carries args: the args the step was prepared with, as the API received them, so integers such as amounts are decimal strings even when the handler passed a bigint. It is null when the job ended before it was prepared (a skipped step, a refusal before prepare, or a failed job whose prepare never answered). With a typed client, checking step narrows args to that step’s args, so you can tell the user what ran without keeping the amount yourself:

import type { AutomationOutcome } from "@usearete/automations";
const USDC_DECIMALS = 6;
// VaultProtocolTypes: the type map `a4 install protocol` generated for your protocol.
function describe(outcome: AutomationOutcome<VaultProtocolTypes>): string {
if (outcome.step === "deposit" && outcome.args?.amount !== undefined) {
const usdc = Number(outcome.args.amount) / 10 ** USDC_DECIMALS;
if (outcome.state === "completed") return `Deposited ${usdc} USDC`;
return `Deposit of ${usdc} USDC ${outcome.state}: ${outcome.reason ?? outcome.code}`;
}
return `${outcome.step} ${outcome.state}: ${outcome.reason ?? outcome.code}`;
}
createAutomations({ /* ... */ onOutcome: (outcome) => notifyUser(outcome.wallet, describe(outcome)) });

A deposit of "25000000" base units renders as “Deposited 25 USDC”.

Outcomes are kept in the store with their job, so you can read them back after a restart, or from another process: automations.outcomes.get(jobId) returns one (null while the job is still running), and automations.outcomes.list({ wallet }) returns every final outcome the store still keeps for a wallet. store.jobs.prune removes them with their jobs.

  • Duplicate triggers. A trigger with an id already seen is ignored, so webhook retries, a double cron and restarted streams are harmless.
  • Before signing, a failure puts the job back in the queue with backoff, and it is prepared again with the same idempotency key.
  • After signing, the signed transactions are saved before they are submitted. After a crash or a lost response, the job reads the intent’s status before anything else. It submits the saved bytes only if they never reached Arete, and it never signs again or resubmits blindly. While an outcome is unknown, that wallet’s other steps wait.

Prune old records from a scheduled job with store.deliveries.prune(before) and store.jobs.prune(before).