| name | create-flowneer-plugin |
| description | Design and implement a Flowneer plugin. Use for: writing a FlowneerPlugin object, choosing lifecycle hooks (beforeFlow/beforeStep/wrapStep/afterStep/onError/afterFlow), applying StepFilter to scope hooks, adding TypeScript types via declaration merging, registering a new step type with CoreFlowBuilder.registerStepType(), building a builder method that pushes a step descriptor, composing multiple hooks in one plugin, and packaging a plugin for reuse. |
| argument-hint | Describe what the plugin should do — e.g. rate-limit LLM steps, record per-step metrics, add a sleep step type |
Create a Flowneer Plugin
Decision: which extension mechanism?
| Goal | Mechanism |
|---|
Add a new builder method (e.g. .withMetrics()) that wires hooks | FlowneerPlugin + FlowBuilder.extend() |
Add a completely new step type (e.g. .sleep()) handled by the engine | CoreFlowBuilder.registerStepType() + a builder method that pushes the descriptor |
| Compose reusable step sequences without a new method | fragment() — not a plugin |
Plugins live in plugins/<category>/withXxx.ts and are re-exported from plugins/<category>/index.ts.
Step 1 — Scaffold the file
import type {
FlowneerPlugin,
PluginContext,
StepFilter,
StepMeta,
} from "flowneer";
declare module "flowneer" {
interface FlowBuilder<S, P> {
withMyPlugin(opts?: MyPluginOptions, filter?: StepFilter): this;
}
interface AugmentedState {
__myPluginResult?: string;
}
}
export interface MyPluginOptions {
verbose?: boolean;
}
export const withMyPlugin: FlowneerPlugin = {
withMyPlugin(
this: PluginContext,
opts: MyPluginOptions = {},
filter?: StepFilter,
) {
this._setHooks(
{
beforeStep: (meta: StepMeta, shared: any, params: any) => {
},
afterStep: (meta: StepMeta, shared: any, params: any) => {
},
},
filter,
);
return this;
},
};
Step 2 — Choose lifecycle hooks
Register hooks via this._setHooks(hooks, filter?). Import PluginContext from flowneer and use it as the explicit this parameter — or omit it entirely since FlowneerPlugin already types this as PluginContext.
| Hook | Signature | Fires |
|---|
beforeFlow | (shared, params) => void | Once before the first step |
beforeStep | (meta, shared, params) => void | Before each step body |
wrapStep | (meta, next, shared, params) => Promise<void> | Wraps step body — call next() to execute it |
afterStep | (meta, shared, params) => void | After each step completes |
wrapParallelFn | (meta, fnIndex, next, shared, params) => Promise<void> | Wraps each fn inside .parallel() |
onError | (meta, error, shared, params) => void | When a step throws (before re-throw) |
afterFlow | (shared, params) => void | Once after the last step |
onLoopIteration | (meta, iteration, shared, params) => void | After each loop iteration |
wrapStep is the most powerful hook — it lets you run code before and after, suppress errors, or skip the step:
wrapStep: async (meta, next, shared, params) => {
console.log("before", meta.index);
await next();
console.log("after", meta.index);
},
Multiple wrapStep registrations compose innermost-first (last registered = outermost wrapper).
Step 3 — Scope with StepFilter
StepFilter restricts step-scoped hooks to a subset of steps. beforeFlow / afterFlow are unaffected.
this._setHooks({ beforeStep: myHook }, ["llm:*", "embed:*"]);
this._setHooks(
{ beforeStep: myHook },
(meta) => meta.type === "fn" && meta.label?.startsWith("llm:"),
);
For wrapStep / wrapParallelFn, non-matching steps automatically call next() — the middleware chain is never broken.
Steps get labels via NodeOptions.label:
flow.then(callModel, { label: "llm:callModel" });
Step 4 — Access StepMeta
interface StepMeta {
index: number;
type: string;
label?: string;
}
Step 5 — Common patterns
Observe-only (read shared, no side-effects on flow)
export const withTiming: FlowneerPlugin = {
withTiming(this: PluginContext, filter?: StepFilter) {
const starts = new Map<number, number>();
this._setHooks(
{
beforeStep: (meta: StepMeta) => starts.set(meta.index, Date.now()),
afterStep: (meta: StepMeta, shared: any) => {
if (!shared.__timings) shared.__timings = {};
shared.__timings[meta.index] =
Date.now() - (starts.get(meta.index) ?? Date.now());
starts.delete(meta.index);
},
},
filter,
);
return this;
},
};
Error interception (recover without re-throwing)
export const withFallback: FlowneerPlugin = {
withFallback(this: PluginContext, fn: NodeFn, filter?: StepFilter) {
this._setHooks(
{
wrapStep: async (meta, next, shared, params) => {
try {
await next();
} catch (e) {
if (e instanceof InterruptError) throw e;
shared.__fallbackError = {
stepIndex: meta.index,
message: String(e),
};
await fn(shared, params);
}
},
},
filter,
);
return this;
},
};
Feature flag / skip (dry-run)
export const withDryRun: FlowneerPlugin = {
withDryRun(this: PluginContext) {
this._setHooks({
wrapStep: async (_meta, _next) => {
},
});
return this;
},
};
Per-step side-effect store (audit log pattern)
export const withAuditLog: FlowneerPlugin = {
withAuditLog(this: PluginContext, store: AuditLogStore, filter?: StepFilter) {
const clone = (v: unknown) => JSON.parse(JSON.stringify(v));
this._setHooks(
{
afterStep: async (meta, shared) => {
await store.append({
stepIndex: meta.index,
type: meta.type,
timestamp: Date.now(),
shared: clone(shared),
});
},
onError: (meta, err, shared) => {
store.append({
stepIndex: meta.index,
type: meta.type,
timestamp: Date.now(),
shared: clone(shared),
error: err instanceof Error ? err.message : String(err),
});
},
},
filter,
);
return this;
},
};
Circuit breaker (state machine in closure)
export const withCircuitBreaker: FlowneerPlugin = {
withCircuitBreaker(this: PluginContext, opts: CircuitBreakerOptions = {}) {
const { maxFailures = 3, resetMs = 30_000 } = opts;
let consecutiveFailures = 0;
let openedAt: number | null = null;
this._setHooks({
beforeStep: () => {
if (openedAt !== null) {
if (Date.now() - openedAt >= resetMs) {
consecutiveFailures = 0;
openedAt = null;
} else
throw new Error(
`circuit open after ${consecutiveFailures} failures`,
);
}
},
afterStep: () => {
consecutiveFailures = 0;
},
onError: () => {
if (++consecutiveFailures >= maxFailures) openedAt = Date.now();
},
});
return this;
},
};
Step 6 — Register a new step type (optional)
Use this when you need a first-class step type callable via .myStep() on the builder.
import { CoreFlowBuilder, FlowBuilder } from "flowneer";
import type { StepHandler } from "flowneer";
const sleepHandler: StepHandler = async (step, ctx) => {
await new Promise((r) => setTimeout(r, step.ms));
};
CoreFlowBuilder.registerStepType("sleep", sleepHandler);
export const withSleep: FlowneerPlugin = {
sleep(this: any, ms: number, opts?: { label?: string }) {
this._steps.push({ type: "sleep", ms, ...opts });
return this;
},
};
declare module "flowneer" {
interface FlowBuilder<S, P> {
sleep(ms: number, opts?: { label?: string }): this;
}
}
ctx in the handler provides: shared, params, signal (AbortSignal), hooks, builder.
For step types with nested sub-flows (like loop/batch), use ctx.builder._runSub() to execute the inner flow.
Step 7 — Wire the plugin via FlowBuilder.extend()
import { FlowBuilder } from "flowneer";
import { withMyPlugin } from "./plugins/myCategory/withMyPlugin";
const AppFlow = FlowBuilder.extend([withMyPlugin]);
const flow = new AppFlow<MyState>()
.withMyPlugin({ verbose: true }, ["llm:*"])
.then(step1)
.then(step2);
Layer multiple plugins:
const AppFlow = FlowBuilder.extend([
withCircuitBreaker,
withTiming,
withMyPlugin,
]);
Step 8 — Export from the category index
export { withMyPlugin } from "./withMyPlugin";
export type { MyPluginOptions } from "./withMyPlugin";
Step 9 — Augment AugmentedState for every shared key your plugin writes
Every __* key a plugin writes to shared must be declared inside an
interface AugmentedState block in the same declare module section.
This lets users who write interface MyState extends AugmentedState get all
plugin keys typed and documented automatically — no manual declarations.
declare module "../../Flowneer" {
interface FlowBuilder<S, P> {
withMyPlugin(opts?: MyPluginOptions, filter?: StepFilter): this;
}
interface AugmentedState {
__myPluginResult?: string;
__myPluginError?: { message: string; stepIndex: number };
}
}
Rules:
- One
interface AugmentedState { } block per plugin, inside declare module.
- Every key must be optional (
?) — plugins initialise lazily.
- Add a JSDoc comment describing who writes it and when.
- Keys that are internal plumbing and never useful to read from outside the
plugin should still be declared (document them as internal).
- The merge is compile-time only (triggered by the import) — zero runtime cost.
Validation Checklist