| 1 | --- |
| 2 | title: Actions — run an Effect on deploy, skip when nothing changed |
| 3 | date: 2026-05-13 |
| 4 | draft: true |
| 5 | excerpt: A node in the dependency graph that isn't a Resource. Runs at deploy time, gets typed inputs from upstream resources, returns an Output downstream nodes can consume, and skips when its input hash hasn't moved. |
| 6 | --- |
| 7 | |
| 8 | You finish deploying a stack. The Worker URL changes |
| 9 | because you switched to a custom domain. Now you want to: |
| 10 | |
| 11 | - ping Slack with the new URL, |
| 12 | - invalidate a CDN cache that lives outside this stack, |
| 13 | - seed a database with the latest schema version. |
| 14 | |
| 15 | None of these are *resources* — they don't reconcile a |
| 16 | cloud entity, they don't have a lifecycle, they don't need |
| 17 | to track drift. They're one-shot Effects you want the deploy |
| 18 | pipeline to run when the inputs change, and skip when they |
| 19 | haven't. |
| 20 | |
| 21 | You can hack this with an exec resource that runs a shell |
| 22 | command on every apply. Or a CloudFormation custom resource |
| 23 | that pretends to be CRUD-shaped just so the engine will call |
| 24 | it. Neither is what you actually want. |
| 25 | |
| 26 | What you want is a node in the same dependency graph as |
| 27 | your resources, with the same typed Output references, but |
| 28 | without the lifecycle machinery — just a function that runs |
| 29 | when its input changes. |
| 30 | |
| 31 | Alchemy has one. It's called an **Action**. |
| 32 | |
| 33 | ## The shape |
| 34 | |
| 35 | Define the Action with its type name and a body: |
| 36 | |
| 37 | ```typescript |
| 38 | const Sync = Action("Sync", Effect.fn(function* (input: { table: string }) { |
| 39 | yield* Effect.log(`syncing ${input.table}`); |
| 40 | return { rows: 42 }; |
| 41 | })); |
| 42 | ``` |
| 43 | |
| 44 | Call it inside a stack to register an instance: |
| 45 | |
| 46 | ```typescript |
| 47 | const rows = yield* Sync({ table: bucket.name }); |
| 48 | // ^ Output<{ rows: number }> |
| 49 | rows.rows // Output<number> |
| 50 | ``` |
| 51 | |
| 52 | That's the whole API. The yield does four things: |
| 53 | |
| 54 | - registers an Action instance on the stack under the |
| 55 | logical id `"Sync"` (or an explicit id if you pass one) |
| 56 | - captures the input as an `Output<…>` expression — any |
| 57 | upstream resource references get resolved during apply |
| 58 | - arranges for the body to run when the resolved input |
| 59 | hash differs from what we persisted last time |
| 60 | - returns the body's return value as an `Output<…>` for |
| 61 | downstream resources or other Actions to consume |
| 62 | |
| 63 | The body is just an Effect. It can `yield*` whatever |
| 64 | services it needs — log, fetch, Slack, anything. Inputs are |
| 65 | typed. The output is typed. The graph is the same graph |
| 66 | your resources live in. |
| 67 | |
| 68 | ## What `Sync(...)` actually returns |
| 69 | |
| 70 | Look closely at the call site: |
| 71 | |
| 72 | ```typescript |
| 73 | const rows = yield* Sync({ table: bucket.name }); |
| 74 | ``` |
| 75 | |
| 76 | `rows` is not a Task object with a `.output` field. It's an |
| 77 | `Output` directly. You can pass it as input to another |
| 78 | resource, project a field out of it (`rows.rows` → |
| 79 | `Output<number>`), or yield it inside a Worker to materialize |
| 80 | the value at runtime. |
| 81 | |
| 82 | ```typescript |
| 83 | const Worker = yield* Cloudflare.Worker("Api", { main: "./src/api.ts" }, |
| 84 | Effect.gen(function* () { |
| 85 | return { |
| 86 | fetch: Effect.gen(function* () { |
| 87 | const r = yield* rows; // Accessor<{ rows: number }> |
| 88 | const n = (yield* r).rows; |
| 89 | return HttpServerResponse.text(`${n} rows synced`); |
| 90 | }), |
| 91 | }; |
| 92 | }), |
| 93 | ); |
| 94 | ``` |
| 95 | |
| 96 | There is no separate concept to learn. An Action's output |
| 97 | is an Output. It composes with everything else. |
| 98 | |
| 99 | ## The diff is a hash |
| 100 | |
| 101 | Resources reconcile against observed cloud state. Actions |
| 102 | don't — there's no cloud state to read. So how do we know |
| 103 | whether the body should run? |
| 104 | |
| 105 | We hash the resolved input. |
| 106 | |
| 107 | When the plan walks the stack, it evaluates the Action's |
| 108 | input expression against the current attribute values of any |
| 109 | upstream resources, computes a SHA-256 over the resolved |
| 110 | JSON, and compares it to the hash we stored on the previous |
| 111 | deploy. Match → skip. Mismatch → run. No prior state → run. |
| 112 | |
| 113 | ```typescript |
| 114 | const sameInput = |
| 115 | prior?.status === "ran" && prior.inputHash === inputHash; |
| 116 | if (sameInput && !options.force) { |
| 117 | return { action: "noop", /* ... skip the body ... */ }; |
| 118 | } |
| 119 | return { action: "run", /* ... run the body ... */ }; |
| 120 | ``` |
| 121 | |
| 122 | `--force` flips skip→run for actions, the same flag that |
| 123 | flips noop→update for resources. |
| 124 | |
| 125 | The state row that records all this is tiny — there are no |
| 126 | provider props to track, no attributes to drift against. Just |
| 127 | the input hash, the input we ran with, and the output: |
| 128 | |
| 129 | ```typescript |
| 130 | type ActionState = |
| 131 | | { kind: "action"; status: "running"; inputHash; input; … } |
| 132 | | { kind: "action"; status: "ran"; inputHash; input; output; … }; |
| 133 | ``` |
| 134 | |
| 135 | Remove the Action from the stack, and the row gets dropped |
| 136 | on the next deploy. No body invoked. No lifecycle. |
| 137 | |
| 138 | ## Wait for the *terminal* upstream, not the stub |
| 139 | |
| 140 | The interesting bit, the one we got wrong on the first try. |
| 141 | |
| 142 | Resources have a `precreate` hook that lets cyclic resources |
| 143 | publish a stub identifier — a placeholder Worker script with |
| 144 | a reserved name — before the real `reconcile` runs. That's |
| 145 | what makes Worker A ↔ Worker B cycles work |
| 146 | ([see the cycles post](/blog/2026-04-25-circular-references)). |
| 147 | Other resources can use that stub identifier early because |
| 148 | they only need the name or ARN, not the configured resource. |
| 149 | |
| 150 | Actions can't. If an Action body runs against an upstream's |
| 151 | precreate stub, it sees a placeholder URL, hashes that, and |
| 152 | records "I ran with this URL." Then the real reconcile fills |
| 153 | in the actual URL, the converge phase notices the input |
| 154 | drifted, and re-runs the body. You've now invoked the body |
| 155 | twice — once against bad data, once against good — and |
| 156 | emitted "running" before either of them was really running. |
| 157 | |
| 158 | The fix is a separate signal per node: `readyStable`. It |
| 159 | fires *only* after the real reconcile completes. Resources |
| 160 | keep both signals (so they can still consume each other's |
| 161 | precreate stubs); Actions wait on `readyStable` only. The |
| 162 | body never runs against placeholder data. |
| 163 | |
| 164 | ```typescript |
| 165 | // resources.ts — keeps two signals |
| 166 | yield* signalReady; // fires after precreate (early) |
| 167 | // ... reconcile ... |
| 168 | yield* signalReadyStable; // fires only after reconcile (terminal) |
| 169 | |
| 170 | // actions.ts — only waits on terminal |
| 171 | yield* waitForStableDeps(upstreamFqns); |
| 172 | ``` |
| 173 | |
| 174 | The plan log lines up with what actually happened: |
| 175 | |
| 176 | ```text |
| 177 | [Bucket] creating |
| 178 | [Api] creating |
| 179 | [Api] Uploading worker (280.32 KB) ... |
| 180 | [Api] Enabling workers.dev subdomain... |
| 181 | [AnnounceDeploy] running ← only here, after Api's reconcile |
| 182 | [AnnounceDeploy] ran |
| 183 | ``` |
| 184 | |
| 185 | ## Pulling in dependencies |
| 186 | |
| 187 | The runner signature so far is plain: `(input) => Effect<Out, _, _>`. |
| 188 | But sometimes the runner itself needs a service — a database |
| 189 | handle, a logger, an HTTP client. You don't want to construct |
| 190 | those every time the body runs. |
| 191 | |
| 192 | `Action` accepts a *constructor* form. The second argument |
| 193 | is an Effect that yields its dependencies and returns the |
| 194 | runner: |
| 195 | |
| 196 | ```typescript |
| 197 | const Sync = Action("Sync", Effect.gen(function* () { |
| 198 | const db = yield* Database; |
| 199 | const logger = yield* Logger; |
| 200 | return Effect.fn(function* (input: { table: string }) { |
| 201 | yield* logger.info(`syncing ${input.table}`); |
| 202 | return { rows: yield* db.count(input.table) }; |
| 203 | }); |
| 204 | })); |
| 205 | ``` |
| 206 | |
| 207 | The init Effect's `Req` bubbles to the call site: |
| 208 | |
| 209 | ```typescript |
| 210 | const rows = yield* Sync({ table: bucket.name }); |
| 211 | // ^ Effect.Effect<Output<…>, never, Database | Logger | Stack> |
| 212 | ``` |
| 213 | |
| 214 | The init runs once per process — `Effect.cached` memoizes |
| 215 | it — and every Action instance reuses the resolved runner. |
| 216 | Dependencies show up in your stack's `R`, your stack's |
| 217 | providers satisfy them, the body gets a fully-constructed |
| 218 | closure to call. |
| 219 | |
| 220 | This is the same pattern as Effect's `Layer` construction. |
| 221 | You yield what you need, return what you produce. Most |
| 222 | Alchemy features follow it; Actions are no different. |
| 223 | |
| 224 | ## Tag form for splitting contract and implementation |
| 225 | |
| 226 | If you want the contract — "there is an Action called Sync |
| 227 | with this input shape and this output shape" — to live in a |
| 228 | different file from the implementation, use the class form: |
| 229 | |
| 230 | ```typescript |
| 231 | class Sync extends Action<Sync, { table: string }, { rows: number }>() |
| 232 | ("Sync") {} |
| 233 | |
| 234 | const SyncLive = Sync.make( |
| 235 | Effect.gen(function* () { |
| 236 | const db = yield* Database; |
| 237 | return Effect.fn(function* (input) { |
| 238 | return { rows: yield* db.count(input.table) }; |
| 239 | }); |
| 240 | }), |
| 241 | ); |
| 242 | ``` |
| 243 | |
| 244 | Same pattern as |
| 245 | [Cloudflare.Worker's class form](/blog/2026-04-25-circular-references) — |
| 246 | the class is a Tag carrying the input/output types; `.make()` |
| 247 | binds an implementation Layer that resolves the Tag. Stack |
| 248 | providers include `SyncLive`, the constructor's `Req` picks |
| 249 | up `Sync`, the planner has a runner to call. |
| 250 | |
| 251 | `.make()` accepts either a direct runner or an init Effect. |
| 252 | |
| 253 | ## In the plan |
| 254 | |
| 255 | In the terraform-style preview, Actions get their own |
| 256 | symbol — `λ` for a run, `·` for a skip: |
| 257 | |
| 258 | ```text |
| 259 | Plan: 1 to create | 1 to update | 1 to run |
| 260 | |
| 261 | ~ Api (Cloudflare.Worker) |
| 262 | + BucketA (Cloudflare.R2.Bucket) |
| 263 | λ AnnounceDeploy (AnnounceDeploy) [action] |
| 264 | · DbMigrate (DbMigrate) [action] |
| 265 | ``` |
| 266 | |
| 267 | The `[action]` tag distinguishes them from resources without |
| 268 | moving them out of the namespace tree — they sort |
| 269 | alongside the resources they're related to. During apply |
| 270 | `λ` swaps in a spinner; on success it switches to `✓` (cyan). |
| 271 | |
| 272 | ## What this is not |
| 273 | |
| 274 | An Action is not a runtime function. To run code from inside |
| 275 | a deployed Worker or Lambda, use a |
| 276 | [Platform](/infrastructure-as-effects/runtime). Actions run at deploy time, |
| 277 | once per input change, never on a request. |
| 278 | |
| 279 | An Action is not a Resource. It has no `read`, no |
| 280 | `reconcile`, no drift detection. If you need lifecycle |
| 281 | management of a cloud entity, model that entity as a |
| 282 | Resource. |
| 283 | |
| 284 | An Action is not idempotent for free. The engine guarantees |
| 285 | the body runs only when the input hash changes, but the |
| 286 | body itself has to be safe to retry if the run is |
| 287 | interrupted before it commits — its `running` state is |
| 288 | persisted, the side effect inside the body is your |
| 289 | responsibility. |
| 290 | |
| 291 | ## Where this fits |
| 292 | |
| 293 | Resources are the right shape when you're modeling a cloud |
| 294 | entity that needs to converge to a desired state. Actions |
| 295 | are the right shape for everything else that wants to |
| 296 | participate in the same graph — deploy-time scripts, |
| 297 | notifications, one-shot migrations, derived values |
| 298 | computed from resource outputs. |
| 299 | |
| 300 | The same graph means: upstream Outputs flow in as typed |
| 301 | inputs, downstream resources can read outputs back, cycles |
| 302 | are detected at plan time, `--force` works, removal drops |
| 303 | state cleanly. The same graph means: Actions don't sit |
| 304 | alongside your stack — they live *inside* it, one node |
| 305 | among the others. |
| 306 | |
| 307 | ## Where to go next |
| 308 | |
| 309 | - [Concepts › Action](/infrastructure-as-code/action) — the full reference |
| 310 | for the inline, init-effect, and tagged forms, plus the |
| 311 | state model and lifecycle table. |
| 312 | - [`packages/alchemy/src/Action.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Action.ts) |
| 313 | — the source. Around 270 lines including JSDoc. |
| 314 | - [Bindings — one line, two phases](/blog/2026-04-30-bindings) — |
| 315 | the deploy-vs-runtime split that Actions also follow at |
| 316 | the type level (Stack `Req` picks up the runner Tag, the |
| 317 | runtime bundle excludes deploy-time code). |
| 318 | - [Circular references, without the deadlock](/blog/2026-04-25-circular-references) — |
| 319 | the cycle-aware planner that Actions participate in too, |
| 320 | including the `readyStable` signal that keeps action |
| 321 | bodies off of precreate stubs. |