1---
2title: Actions — run an Effect on deploy, skip when nothing changed
3date: 2026-05-13
4draft: true
5excerpt: 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
8You finish deploying a stack. The Worker URL changes
9because 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
15None of these are *resources* — they don't reconcile a
16cloud entity, they don't have a lifecycle, they don't need
17to track drift. They're one-shot Effects you want the deploy
18pipeline to run when the inputs change, and skip when they
19haven't.
20
21You can hack this with an exec resource that runs a shell
22command on every apply. Or a CloudFormation custom resource
23that pretends to be CRUD-shaped just so the engine will call
24it. Neither is what you actually want.
25
26What you want is a node in the same dependency graph as
27your resources, with the same typed Output references, but
28without the lifecycle machinery — just a function that runs
29when its input changes.
30
31Alchemy has one. It's called an **Action**.
32
33## The shape
34
35Define the Action with its type name and a body:
36
37```typescript
38const Sync = Action("Sync", Effect.fn(function* (input: { table: string }) {
39 yield* Effect.log(`syncing ${input.table}`);
40 return { rows: 42 };
41}));
42```
43
44Call it inside a stack to register an instance:
45
46```typescript
47const rows = yield* Sync({ table: bucket.name });
48// ^ Output<{ rows: number }>
49rows.rows // Output<number>
50```
51
52That'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
63The body is just an Effect. It can `yield*` whatever
64services it needs — log, fetch, Slack, anything. Inputs are
65typed. The output is typed. The graph is the same graph
66your resources live in.
67
68## What `Sync(...)` actually returns
69
70Look closely at the call site:
71
72```typescript
73const 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
78resource, project a field out of it (`rows.rows` →
79`Output<number>`), or yield it inside a Worker to materialize
80the value at runtime.
81
82```typescript
83const 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
96There is no separate concept to learn. An Action's output
97is an Output. It composes with everything else.
98
99## The diff is a hash
100
101Resources reconcile against observed cloud state. Actions
102don't — there's no cloud state to read. So how do we know
103whether the body should run?
104
105We hash the resolved input.
106
107When the plan walks the stack, it evaluates the Action's
108input expression against the current attribute values of any
109upstream resources, computes a SHA-256 over the resolved
110JSON, and compares it to the hash we stored on the previous
111deploy. Match → skip. Mismatch → run. No prior state → run.
112
113```typescript
114const sameInput =
115 prior?.status === "ran" && prior.inputHash === inputHash;
116if (sameInput && !options.force) {
117 return { action: "noop", /* ... skip the body ... */ };
118}
119return { action: "run", /* ... run the body ... */ };
120```
121
122`--force` flips skip→run for actions, the same flag that
123flips noop→update for resources.
124
125The state row that records all this is tiny — there are no
126provider props to track, no attributes to drift against. Just
127the input hash, the input we ran with, and the output:
128
129```typescript
130type ActionState =
131 | { kind: "action"; status: "running"; inputHash; input; … }
132 | { kind: "action"; status: "ran"; inputHash; input; output; … };
133```
134
135Remove the Action from the stack, and the row gets dropped
136on the next deploy. No body invoked. No lifecycle.
137
138## Wait for the *terminal* upstream, not the stub
139
140The interesting bit, the one we got wrong on the first try.
141
142Resources have a `precreate` hook that lets cyclic resources
143publish a stub identifier — a placeholder Worker script with
144a reserved name — before the real `reconcile` runs. That's
145what makes Worker A ↔ Worker B cycles work
146([see the cycles post](/blog/2026-04-25-circular-references)).
147Other resources can use that stub identifier early because
148they only need the name or ARN, not the configured resource.
149
150Actions can't. If an Action body runs against an upstream's
151precreate stub, it sees a placeholder URL, hashes that, and
152records "I ran with this URL." Then the real reconcile fills
153in the actual URL, the converge phase notices the input
154drifted, and re-runs the body. You've now invoked the body
155twice — once against bad data, once against good — and
156emitted "running" before either of them was really running.
157
158The fix is a separate signal per node: `readyStable`. It
159fires *only* after the real reconcile completes. Resources
160keep both signals (so they can still consume each other's
161precreate stubs); Actions wait on `readyStable` only. The
162body never runs against placeholder data.
163
164```typescript
165// resources.ts — keeps two signals
166yield* signalReady; // fires after precreate (early)
167// ... reconcile ...
168yield* signalReadyStable; // fires only after reconcile (terminal)
169
170// actions.ts — only waits on terminal
171yield* waitForStableDeps(upstreamFqns);
172```
173
174The 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
187The runner signature so far is plain: `(input) => Effect<Out, _, _>`.
188But sometimes the runner itself needs a service — a database
189handle, a logger, an HTTP client. You don't want to construct
190those every time the body runs.
191
192`Action` accepts a *constructor* form. The second argument
193is an Effect that yields its dependencies and returns the
194runner:
195
196```typescript
197const 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
207The init Effect's `Req` bubbles to the call site:
208
209```typescript
210const rows = yield* Sync({ table: bucket.name });
211// ^ Effect.Effect<Output<…>, never, Database | Logger | Stack>
212```
213
214The init runs once per process — `Effect.cached` memoizes
215it — and every Action instance reuses the resolved runner.
216Dependencies show up in your stack's `R`, your stack's
217providers satisfy them, the body gets a fully-constructed
218closure to call.
219
220This is the same pattern as Effect's `Layer` construction.
221You yield what you need, return what you produce. Most
222Alchemy features follow it; Actions are no different.
223
224## Tag form for splitting contract and implementation
225
226If you want the contract — "there is an Action called Sync
227with this input shape and this output shape" — to live in a
228different file from the implementation, use the class form:
229
230```typescript
231class Sync extends Action<Sync, { table: string }, { rows: number }>()
232 ("Sync") {}
233
234const 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
244Same pattern as
245[Cloudflare.Worker's class form](/blog/2026-04-25-circular-references) —
246the class is a Tag carrying the input/output types; `.make()`
247binds an implementation Layer that resolves the Tag. Stack
248providers include `SyncLive`, the constructor's `Req` picks
249up `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
255In the terraform-style preview, Actions get their own
256symbol — `λ` for a run, `·` for a skip:
257
258```text
259Plan: 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
267The `[action]` tag distinguishes them from resources without
268moving them out of the namespace tree — they sort
269alongside 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
274An Action is not a runtime function. To run code from inside
275a deployed Worker or Lambda, use a
276[Platform](/infrastructure-as-effects/runtime). Actions run at deploy time,
277once per input change, never on a request.
278
279An Action is not a Resource. It has no `read`, no
280`reconcile`, no drift detection. If you need lifecycle
281management of a cloud entity, model that entity as a
282Resource.
283
284An Action is not idempotent for free. The engine guarantees
285the body runs only when the input hash changes, but the
286body itself has to be safe to retry if the run is
287interrupted before it commits — its `running` state is
288persisted, the side effect inside the body is your
289responsibility.
290
291## Where this fits
292
293Resources are the right shape when you're modeling a cloud
294entity that needs to converge to a desired state. Actions
295are the right shape for everything else that wants to
296participate in the same graph — deploy-time scripts,
297notifications, one-shot migrations, derived values
298computed from resource outputs.
299
300The same graph means: upstream Outputs flow in as typed
301inputs, downstream resources can read outputs back, cycles
302are detected at plan time, `--force` works, removal drops
303state cleanly. The same graph means: Actions don't sit
304alongside your stack — they live *inside* it, one node
305among 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.