https://github.com/rook2pawn/picostate
published on npm as https://www.npmjs.com/package/@rook2pawn/picostate
Tiny finite state machine with optional guards and side effects β ideal for driving AI conversations, UI state, async workflows, and beyond.
- Tiny API surface
- Clean state transition logic
- Optional guard conditions before entering a state
- Optional on(state, fn) hooks to trigger effects
- Parallel FSM support (like bold, italic, underline)
- Fully synchronous and testable
- Zero dependencies
npm install picostate
import { PicoState } from "@rook2pawn/picostate";
const fsm = new PicoState("idle", {
idle: { activate: "listening" },
listening: { got_transcript: "thinking" },
thinking: { got_response: "speaking" },
speaking: { done: "idle" },
});
fsm.on("speaking", () => {
console.log("Now speaking...");
});
fsm.guard("activate", () => Date.now() % 2 === 0); // random entry condition
fsm.emit("activate");
console.log(fsm.state); // => maybe 'listening' or stays 'idle' if blockedYou can also provide a payload on state changes, for example
fsm.on("speaking", ({ text }) => {
console.log(`Now speaking...${text}`);
});
fsm.emit("got_response", { text: "Yes please" });npm test
Uses tape for simple test definitions. See tests/test.js.
Creates a finite state machine.
const fsm = new PicoState("idle", {
idle: { activate: "listening" },
listening: { got_transcript: "thinking" },
});.emit(event: string)// Trigger a transition event. If invalid, it throws.
.state
// Get the current state..on(state: string, fn: () => void)
// Trigger a callback when the machine enters a state..onchange(fn: (state, prevState) => void)
// Run a callback any time the state changes..guard(event: string, fn: () => boolean | { ok: boolean; reason?: string })
// Prevents a transition unless the guard passes. Guards can return:
// true (allow) or false (block), or
// an object { ok: boolean, reason?: string } for structured feedback.
// If a guard blocks, the machine emits a guard:blocked event with { eventName, state, reason }.
ps.guard('accelerate', () => {
if (fuel < 5) return { ok: false, reason: 'low fuel' };
return true; // or { ok: true } β equivalent
});
// Optional: observe blocks for logging/metrics
ps.on('guard:blocked', ({ eventName, state, reason }) => {
console.warn(`[guard] blocked ${eventName} @ ${state}: ${reason ?? 'unspecified'}`);
});Probe events allow you to get granular observability on state changes as well as guard events. Example in the following test:
test("FSM test probes events", (t) => {
let didEmit = false;
let didBlock = false;
let didTransition = false;
let locked = true;
const fsm = new PicoState("closed", {
closed: { open: "open" },
open: { close: "closed" },
});
fsm.guard("open", () => {
if (locked) {
return { ok: false, reason: "door is locked" };
} else return true;
});
fsm.on("emit", ({ eventName, state, payload }) => {
// emitted before guard check / state change
t.comment("emit event:", eventName, "from", state);
didEmit = true;
});
let reasonString = "";
fsm.on("guard:blocked", ({ eventName, state, reason, payload }) => {
t.comment("guard:blocked event:", eventName, "from", state);
// emitted when a guard blocks a transition
didBlock = true;
reasonString = reason;
});
fsm.on("transition", ({ eventName, from, to, payload }) => {
t.comment("transition event:", eventName, "from", from, "to", to);
// emitted when a transition occurs
didTransition = true;
});
t.comment("Attempting to open while locked");
fsm.emit("open");
t.ok(didEmit, "emit event fired");
t.ok(didBlock, "guard:blocked event fired");
t.notOk(didTransition, "transition event not fired");
t.equal(fsm.state, "closed", "state remains closed");
t.equal(reasonString, "door is locked", "correct block reason");
t.end();
});MIT Β© 2025 @rook2pawn