slapflow
v1.5.1
Published
Declarative orchestration runtime for strategy graphs
Downloads
1,088
Maintainers
Readme
Slapflow
When the same business flow can start from a form, an API route, a job, or a WebSocket message, its control flow tends to spread across the application. Slapflow gives that flow one explicit home: a chain of ordinary TypeScript functions.
Slapflow takes care of orchestration, concurrency, cancellation, and diagnostics. Your application keeps ownership of its domain state and side effects.
Why use it?
- Keep the business flow visible. Put a scenario in one chain instead of hiding it among UI handlers, transport callbacks, and service code.
- Test the scenario without the surrounding application. Pass in context and events; actions and conditions are just TypeScript functions.
- Make async behavior deliberate. Choose
parallel,latest,queue,drop, orworkersfor each event source. Actions receive anAbortSignalwhen cancellation matters. - Use the same flow in more than one place. The chain can start from a typed bus, DOM event, API callback, timer, worker, or WebSocket message.
When to use Slapflow
Slapflow orchestrates a control flow that lives and finishes inside a single process. Reach for it when a scenario spans several steps, branches, and failure paths and you want it in one place rather than spread across handlers and callbacks.
What it is not: a web framework (no routing, no middleware stack — pair it with Express, Hono, or Next.js routes) or a job queue (no built-in persistence, no distributed workers). It is also not a durable workflow engine: a chain runs inside a live process and stops with it — a core.loop can spin indefinitely while the process runs, but unlike Temporal there is no state that survives a crash or restart unless your application prevails it. It is closest to a lightweight, in-process state machine: decisions, ordering, concurrency, and cancellation live in a chain, while your application keeps the domain state and side effects.
Installation
npm install slapflowQuick start
This example handles an order submission from a typed event bus. The latest mode cancels a previous submission for the same order when a newer event arrives.
import { createFlow, createPubSub } from 'slapflow'
type Context = {
orders: Map<string, { id: string; status: 'draft' | 'submitted' }>
}
type Events = {
'order.submit': { orderId: string }
}
const bus = createPubSub<Events>()
const context: Context = {
orders: new Map([['order-1', { id: 'order-1', status: 'draft' }]]),
}
const flow = createFlow<Context, unknown, Events>(
{
events: {
'[bus] order.submit': {
entrypoint: 'order.submit',
options: {
concurrency: {
mode: 'latest',
key: ({ orderId }) => orderId,
},
},
},
},
actions: {
'order.submit': ({ context, input }) => {
const order = context.orders.get(input.orderId as string)
if (order) {
order.status = 'submitted'
}
},
},
config: {
entrypoints: { 'order.submit': 'order.submit' },
strategies: { 'order.submit': { fn: 'order.submit' } },
},
},
{ bus, context }
)
flow.start()
bus.emit('order.submit', { orderId: 'order-1' }, { origin: 'api' })Fetch data with cancellation and retries
core.fetch uses the run AbortSignal, retries transient failures, and keeps the response available to the next strategy without coupling the flow to a specific HTTP client.
const config = {
strategies: {
'catalog.load': {
fn: 'core.fetch',
props: {
url: '/api/catalog',
response: 'json',
dataPath: 'catalogResponse',
retry: { maxAttempts: 2, initialDelay: 250, maxDelay: 1_000 },
},
then: ['catalog.apply'],
},
'catalog.apply': { fn: 'catalog.apply' },
},
}
const applyCatalog = ({ runtime }) => {
const { body } = runtime.data.get('catalogResponse')
// Update your application state with body.
}Route branches with compound conditions
Every then and catch target can have its own condition. Combine built-in conditions with and, or, and not to keep branching in the graph:
const config = {
strategies: {
'catalog.load': {
fn: 'core.fetch',
props: {
url: '/api/catalog',
response: 'json',
dataPath: 'catalogResponse',
},
then: [
{
strategy: 'catalog.apply',
when: [
'and',
['typeIs', '$data.catalogResponse.body', 'record'],
['typeIs', '$data.catalogResponse.body.items', 'array'],
['not', ['empty', '$data.catalogResponse.body.items']],
],
},
{
strategy: 'catalog.showEmpty',
when: ['or', ['missing', '$data.catalogResponse.body.items'], ['empty', '$data.catalogResponse.body.items']],
},
],
catch: [
{
strategy: 'catalog.queueRetry',
when: ['and', ['falsy', '$context.network.online'], ['includes', ['startup', 'refresh'], '$input.source']],
},
{
strategy: 'catalog.showError',
when: ['or', ['truthy', '$context.network.online'], ['eq', '$input.source', 'manual']],
},
],
},
'catalog.apply': { fn: 'catalog.apply' },
'catalog.showEmpty': { fn: 'catalog.showEmpty' },
'catalog.queueRetry': { fn: 'catalog.queueRetry' },
'catalog.showError': { fn: 'catalog.showError' },
},
}Fan out work through a keyed pool
A workers binding runs tasks through a shared pool: at most workers runs at once, tasks with the same line key run in FIFO order and never in parallel, and different keys run concurrently. An action fans work into a named pool with runtime.enqueue:
const flow = createFlow<Context, Patch, Events>(
{
config,
actions: {
'world.tick': async ({ input, runtime }) => {
for (const colonyId of Object.keys(context.colonies)) {
await runtime.enqueue('colony.tick', { tick: input.tick, colonyId }, { pool: 'colony', key: colonyId })
}
},
},
events: {
// dispatcher runs outside the pool it feeds
'[bus] game.tick': { entrypoint: 'world.tick', options: { concurrency: { mode: 'parallel' } } },
'[bus] colony.observed': {
entrypoint: 'colony.observed',
options: { concurrency: { mode: 'workers', pool: 'colony', key: '$input.colonyId' } },
},
},
},
{
context: () => store.getState(),
bus,
pools: { colony: { workers: 4, maxQueueSize: 2_000, overflow: 'wait' } },
}
)overflow: 'wait' applies backpressure instead of dropping accepted work. A task cannot enqueue into its own pool (ENQUEUE_SELF_POOL), so the fan-out dispatcher runs outside colony. flow.poolStats('colony') reports active/queued/oldestQueuedMs, and flow.drain({ timeoutMs }) waits for pools and binding lanes to empty on shutdown.
The pool is in-process and in-memory: it bounds concurrency and preserves per-key order, but it is not durable storage.
What Slapflow provides
- Declarative strategies, conditions, error branches, and entrypoints.
- A broad set of built-in conditions for comparisons, type checks, collections, and compound logic.
- Typed PubSub bindings and delegated DOM bindings.
parallel,latest,queue, anddropconcurrency modes with per-entity lanes, plus a keyedworkerspool with named pools, backpressure, andruntime.enqueuefan-out.- A native WebSocket client that proxies socket events into the bus.
core.fetchwith response parsing, cancellation, and retry backoff.core.invoketo fan out athenbranch over the elements of an array or object, exposing each element to the branch as$input.- Normalized results, execution trace, validation, and lifecycle diagnostics such as
slapflow.run.startedandslapflow.run.failed. - Runtime variables for configuration values, templates, and expressions.
Where to go next
- See slapflow-studio, a working application built entirely on Slapflow.
- Read the complete technical specification for the runner API, built-in actions and conditions, expressions, validation, safety limits, transport behavior, and lifecycle semantics.
- Russian documentation: README-RU.md and SPEC-RU.md.
Development
npm test
npm run build
npm run pack:checkAgent skill
The package ships a SKILL.md skill in skills/slapflow/. Point your agent at it —
symlink the folder, not the SKILL.md file.
| Tool | Location | Install |
| --- | --- | --- |
| opencode | opencode.json → skills.paths | config |
| Claude Code | ~/.claude/skills/ (personal) or .claude/skills/ (project) | symlink |
| Codex | ~/.agents/skills/ (user) or .agents/skills/ (repo) | symlink |
opencode — add the packaged path to opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"skills": { "paths": ["node_modules/slapflow/skills"] }
}Claude Code and Codex — symlink the folder (.agents/skills also serves other
Agent Skills clients):
ln -sfn "$PWD/node_modules/slapflow/skills/slapflow" ~/.agents/skills/slapflow
ln -sfn "$PWD/node_modules/slapflow/skills/slapflow" ~/.claude/skills/slapflowRestart opencode and Codex after installing; Claude Code picks up new skills after a restart too, then watches them live. Re-run the symlink if you reinstall the package.
