skills/effect-machine/SKILL.md
Type-safe state machines for Effect. Use when building state machines with effect-machine — defining states/events, transition handlers, spawn effects, timeouts, postpone, actors, typed ask/reply, testing, recovery/durability lifecycle. Triggers on effect-machine imports, Machine.make, Machine.spawn, actor.start, Machine.replay, Machine.reply, Event.reply, State/Event definitions, ActorRef usage, Recovery, Durability, Lifecycle.
npx skillsauth add cevr/effect-machine effect-machineInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
What are you building?
├─ Defining states/events → §Schema-First
├─ Writing transition handlers → §Transitions
├─ Adding side effects → §Effects
├─ Testing machines → §Testing
├─ Running actors → §Actors
├─ Typed ask/reply → §Ask / Reply
├─ Recovery/durability → §Lifecycle
├─ Timeouts / postpone → §Timeouts, §Postpone
└─ Slots (guards/effects) → §Slots
States and events ARE schemas. State({}) and Event({}) produce tagged unions with constructors.
import { Schema } from "effect";
import { State, Event } from "effect-machine";
const S = State({
Idle: {}, // empty → plain value: S.Idle
Loading: { url: Schema.String }, // non-empty → constructor: S.Loading({ url })
});
const E = Event({
Start: { url: Schema.String },
Done: { data: Schema.Unknown },
GetCount: Event.reply({}, Schema.Number), // reply-bearing event
GetInfo: Event.reply({ id: Schema.String }, Schema.String), // payload + reply
});
derive — construct from existing state, picks overlapping fields:
S.Active.derive(state); // pick target fields from source
S.Active.derive(state, { count: n + 1 }); // pick + override
Type guards / matching:
S.$is("Loading")(value) // boolean type guard
S.$match(value, { Loading: (s) => ..., _: () => ... }) // pattern match
const machine = Machine.make({ state: S, event: E, initial: S.Idle })
// Single state → event → handler
.on(S.Idle, E.Start, ({ event }) => S.Loading({ url: event.url }))
// Multi-state source
.on([S.Loading, S.Retrying], E.Done, ({ event }) => S.Active({ data: event.data }))
// Wildcard — any state (specific .on wins)
.onAny(E.Cancel, () => S.Cancelled)
// Reenter same state (re-triggers spawn effects + timeouts)
.reenter(S.Active, E.Refresh, ({ state }) => S.Active.derive(state))
// Mark final states (actor stops, postpone buffer settles)
.final(S.Done)
.final(S.Cancelled);
Handler return types:
// Pure: return new state
({ state, event }) => S.Next({ ... })
// Effectful: return Effect<State>
({ state }) => Effect.gen(function* () { ... return S.Next({ ... }) })
// With reply (for actor.ask — event must use Event.reply()):
({ state }) => Machine.reply(S.Same.derive(state), state.count)
machine.spawn(S.Loading, ({ state, self }) =>
Effect.gen(function* () {
const data = yield* fetchData(state.url);
yield* self.send(E.Done({ data }));
}),
);
machine.task(S.Loading, ({ state }) => fetchData(state.url), {
onSuccess: (data) => E.Done({ data }),
onFailure: () => E.Error,
});
machine.background(({ self }) =>
Stream.fromSchedule(Schedule.spaced("10 seconds")).pipe(
Stream.runForEach(() => self.send(E.Heartbeat)),
),
);
Unified parameterized slots via Slot.define + Slot.fn. Handlers take only params:
import { Slot } from "effect-machine";
const MySlots = Slot.define({
canRetry: Slot.fn({ max: Schema.Number }, Schema.Boolean),
notify: Slot.fn({ msg: Schema.String }),
});
const machine = Machine.make({
state: S,
event: E,
slots: MySlots,
initial: S.Idle,
})
.on(S.Error, E.Retry, ({ slots, state }) =>
Effect.gen(function* () {
if (yield* slots.canRetry({ max: 3 })) return S.Loading.derive(state);
return S.Failed;
}),
)
.spawn(S.Done, ({ slots, state }) => slots.notify({ msg: `Done: ${state.id}` }));
// Provide at spawn time — handlers take only params
const actor =
yield *
Machine.spawn(machine, {
slots: {
canRetry: ({ max }) => attempts < max,
notify: ({ msg }) => Effect.log(msg),
},
});
yield * actor.start;
Slots are accepted everywhere: Machine.spawn, Machine.replay, simulate, createTestHarness.
Events declare reply schemas via Event.reply(fields, schema). Handlers must use Machine.reply():
const E = Event({
GetCount: Event.reply({}, Schema.Number), // askable
Reset: {}, // not askable
});
// Handler — Machine.reply() required for reply-bearing events
machine.on(S.Active, E.GetCount, ({ state }) => Machine.reply(S.Active.derive(state), state.count));
// Caller — return type inferred from schema
const count = yield * actor.ask(E.GetCount); // number
// actor.ask(E.Reset) — TYPE ERROR (no reply schema)
Rules:
Event.reply({}, schema) — empty payload + reply; Event.reply({ id: Schema.String }, schema) — payload + replyMachine.reply(state, value) — plain state return is a type errorMachine.reply() — type error.onAny() handlers cannot provide replies — use specific .on() for reply eventsAsk RPC propagates replies through entity boundarygen_statem-style. Timer starts on state entry, cancels on exit:
machine.timeout(S.Loading, {
duration: Duration.seconds(30),
event: E.Timeout,
});
// Dynamic duration from state
machine.timeout(S.Retrying, {
duration: (state) => Duration.seconds(state.backoff),
event: E.GiveUp,
});
.reenter() restarts the timer with fresh state values.
gen_statem-style. Buffered events drain FIFO on state change, looping until stable:
machine.postpone(S.Connecting, E.Data).postpone(S.Connecting, [E.Data, E.Command]);
Multi-stage: if a drained event causes another state change, postponed events re-drain.
Machine.spawn returns a cold actor. Call actor.start to fork the event loop. Events sent before start() are queued.
const actor = yield * Machine.spawn(machine);
yield * actor.start;
// With options
const actor = yield * Machine.spawn(machine, { id: "my-id", hydrate: savedState });
yield * actor.start;
Auto-cleans up if Scope is present. Otherwise call actor.stop manually.
| Method | Description |
| ---------------------- | ------------------------------------------------------------------- |
| start | Fork event loop + effects (required after Machine.spawn) |
| send(event) | Fire-and-forget |
| cast(event) | Alias for send |
| call(event) | Request-reply → ProcessEventResult |
| ask(event) | Typed reply (event must have Event.reply() schema) |
| snapshot | Current state |
| changes | Stream<State> (SubscriptionRef-backed) |
| transitions | Stream<{ fromState, toState, event }> (PubSub-backed edge stream) |
| waitFor(S.X) | Wait for state |
| sendAndWait(ev, S.X) | Send + wait |
| awaitFinal | Wait for final state |
| sync.* | Sync variants for non-Effect boundaries |
system.spawn auto-starts — no actor.start needed.
const system = yield * ActorSystemService;
const actor = yield * system.spawn("id", machine); // auto-started
const maybe = yield * system.get("id"); // Option<ActorRef>
yield * system.stop("id"); // boolean
system.actors; // ReadonlyMap snapshot
system.events; // Stream<SystemEvent>
machine.spawn(S.Active, ({ self }) =>
Effect.gen(function* () {
const child = yield* self.spawn("worker", workerMachine).pipe(Effect.orDie);
yield* child.send(WorkerEvent.Start);
// auto-stopped when parent exits Active
}),
);
Recovery + Durability hooks for persistence. Passed via lifecycle option on Machine.spawn / system.spawn.
const actor =
yield *
Machine.spawn(machine, {
lifecycle: {
recovery: {
// Runs during actor.start. Return Some to override initial state, None for cold start.
resolve: ({ actorId, generation, machineInitial }) =>
storage.get(actorId).pipe(Effect.map(Option.fromNullable)),
},
durability: {
// Runs after each committed transition
save: ({ actorId, generation, previousState, nextState, event }) =>
storage.set(actorId, nextState),
// Optional sync filter — skip uninteresting transitions
shouldSave: (state, prev) => state._tag !== prev._tag,
},
},
});
yield * actor.start;
| Interface | When it runs | Receives |
| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- |
| Recovery<S> | During actor.start (and supervision restart) | { actorId, generation, machineInitial } |
| Durability<S, E> | After each state commit, before reply settlement | { actorId, generation, previousState, nextState, event } |
generation — 0 = cold start, 1+ = supervision restarthydrate overrides recovery — Machine.spawn(machine, { hydrate: state }) skips resolve entirelyLifecycle<S, E> = { recovery?, durability? } — both optional// Restore from snapshot
const actor = yield * Machine.spawn(machine, { hydrate: loadedState });
yield * actor.start;
// Restore from event log
const state = yield * Machine.replay(machine, events);
const actor = yield * Machine.spawn(machine, { hydrate: state });
yield * actor.start;
// Restore from snapshot + tail events
const state = yield * Machine.replay(machine, tailEvents, { from: snapshot });
const actor = yield * Machine.spawn(machine, { hydrate: state });
yield * actor.start;
Machine.replay semantics:
self.send/self.spawn are no-ops (stubbed)import { simulate, assertPath, assertReaches, createTestHarness } from "effect-machine";
// Simulate — run events, get all states
const { states, finalState } = yield * simulate(machine, [E.Start, E.Done]);
// Assertions
yield * assertPath(machine, events, ["Idle", "Loading", "Done"]);
yield * assertReaches(machine, events, "Done");
yield * assertNeverReaches(machine, events, "Error");
// Test harness — step-by-step
const harness = yield * createTestHarness(machine);
yield * harness.send(E.Start);
expect(harness.state._tag).toBe("Loading");
Both simulate and createTestHarness accept Machine directly.
Machine.spawn returns unstarted actor — must call yield* actor.start. system.spawn auto-starts.Machine.spawn(machine, { slots: { ... } })S.Idle vs S.Loading({ url })Machine.spawn({ hydrate }) re-runs spawn effects for the hydrated state (timers, scoped resources)hydrate overrides recovery — resolve() is never called when hydrate is settransitions is observational — PubSub-backed, late subscribers miss edges. Not a durability guarantee.self/system. Side effects through self.send are no-ops.ask() requires reply schema — only events with Event.reply() accepted; non-reply events are type errors"effect-machine/v3" for Effect v3 projectsdevelopment
# effect-machine Skill Quick reference for AI agents working with effect-machine. ## What It Is Type-safe state machines for Effect. Schema-first API. ## Core Pattern ```ts import { Machine, State, Event, Slot } from "effect-machine"; // 1. Define schemas const MyState = State({ Idle: {}, Loading: { url: Schema.String }, Done: { data: Schema.Unknown }, }); const MyEvent = Event({ Start: { url: Schema.String }, Complete: { data: Schema.Unknown }, }); // 2. Build machine const ma
development
Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
development
Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
development
Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.