---
title: "Run automations in production"
description: "Deploy automations to Cloudflare Workers, Vercel or any Node host, store state in Postgres, follow outcomes, and recover from crashes."
editUrl: true
head: []
template: "doc"
sidebar: {"hidden":false,"attrs":{}}
pagefind: true
draft: false
---

> For the complete documentation index optimized for AI agents, see [llms.txt](https://docs.arete.run/llms.txt) or [llms-full.txt](https://docs.arete.run/llms-full.txt). A markdown version of this page is available at [/automations/production.md](https://docs.arete.run/automations/production.md) or by sending `Accept: text/markdown`.
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.

## Move to mainnet

Develop in your [dev environment](/cli/local-development/), 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. **Unset the dev switch.** The installed client is the same file in both
   places; `ARETE_ENVIRONMENT=dev` (which `a4 dev up` and `a4 dev run` set)
   points it at the dev environment, and with the variable unset it calls
   production. The automations take their target from it: `dev` in dev mode,
   `mainnet` otherwise. If you changed the protocol's spec locally,
   `a4 install --locked` restores the published client's types.
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, after
   `await client.resolveDeployment()` (in dev mode the client learns the
   environment's deployment there).
3. **Point the simulator at your RPC.** `rpcSimulator(process.env.RPC_URL)`,
   a mainnet RPC. In dev mode a simulator is optional: without one the
   automations simulate against the dev environment.
4. **Use a real signer and store.** A delegated wallet provider such as
   [Privy](/automations/signing/) 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](/integrating-protocols/events/#receive-named-emissions-through-webhooks)).
   `a4 protocol webhook add --url <endpoint>` subscribes it and prints the
   signing secret once.

## Deploy to a host

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

| Layer      | File                                               |                                                                                                     |
| ---------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Definition | `automations/index.ts`                             | Handlers, schedules and view sources. `a4 dev up` runs it.                                          |
| Runtime    | `automations/runtime.ts`                           | `runtime(env)`: the client, signer, store, simulator and policy, built from the host's environment. |
| Host entry | `worker.ts`, `api/arete/[route].ts` or `server.ts` | A 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:

```bash
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:

```bash
a4 protocol deploy                                # the protocol release
a4 install --check                                # the installed clients are the published ones
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 `ARETE_CLUSTER` and the client's environment switch
disagree, for example a host that still sets `ARETE_ENVIRONMENT=dev`.

| Host                               | Workers                                       | Schedules and sources                     | Notes                                                                                 |
| ---------------------------------- | --------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------- |
| Cloudflare Workers (`/cloudflare`) | After each webhook and cron run               | Cron trigger, every minute                | Needs `nodejs_compat`. The runtime is built per request; use Hyperdrive for Postgres. |
| Vercel (`/vercel`)                 | After each webhook (`waitUntil`) and cron run | `vercel.json` cron                        | Per-minute crons need a Pro plan. Use a pooled Postgres URL.                          |
| Node (`/node`)                     | Continuously                                  | In-process ticks; live view subscriptions | Any 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.

## The Postgres store

```ts
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:

  ```bash
  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()`](/automations/quickstart/) is for tests only: it forgets
everything on restart. A custom store must pass the package's conformance
suite (`runStoreConformance` from `@usearete/automations/testing`).

## Running workers

On a long-running server:

```ts
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.

## Outcomes

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

| State          | What happened                                                                       |
| -------------- | ----------------------------------------------------------------------------------- |
| `completed`    | The transactions landed                                                             |
| `chain_failed` | A transaction failed on chain                                                       |
| `expired`      | The prepared intent expired twice (it is prepared once more after the first)        |
| `skipped`      | No active consent, or inside the step's cooldown                                    |
| `rejected`     | The protocol said no (a guard or an access rule); a normal outcome                  |
| `refused`      | The signing policy, a consent limit, the exact-bytes checks or the provider refused |
| `failed`       | It 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:

```ts
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.

## Crashes and retries

- **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)`.
