@ellie-ai/runtime
v0.2.0
Published
Minimal Redux-based runtime for agent execution with full lifecycle visibility.
Readme
@ellie-ai/runtime
Minimal Redux-based runtime for agent execution with full lifecycle visibility.
What is this?
A runtime that orchestrates agent execution using Redux patterns. The core building block is the plugin: a small package of optional reducer, initial state, and middleware that you register with the runtime. All framework features (including the agent) are plugins, and your own features should be too.
Need a complete example package? See
packages/agent/README.mdfor a reference implementation that builds on this runtime.
Plugins at a Glance
import { createAction, whenAction, type PayloadAction, type RuntimePlugin } from "@ellie-ai/runtime";
// Define actions using PayloadAction pattern
const costUpdated = createAction<{ cost: number }, "COST_UPDATED">("COST_UPDATED");
const approvalRequested = createAction<{ request: Action }, "APPROVAL_REQUESTED">("APPROVAL_REQUESTED");
const approvalGranted = createAction<{ id: string }, "APPROVAL_GRANTED">("APPROVAL_GRANTED");
// Cost tracking - middleware only
const costTracking: RuntimePlugin = {
middleware: whenAction(
(action): action is ReturnType<typeof modelCompleted> => modelCompleted.match(action),
({ action, api }) => {
api.dispatch(costUpdated({ cost: action.payload.tokens * 0.00001 }));
}
),
};
// Approvals - reducer + middleware with typed plugin
const approvals: RuntimePlugin<"approvals", { pending: Action[] }> = {
key: "approvals",
initialState: { pending: [] },
reducer: (state = { pending: [] }, action) => {
// Use .match() for type-safe action handling
if (approvalRequested.match(action)) {
return { pending: [...state.pending, action.payload.request] };
}
if (approvalGranted.match(action)) {
return {
pending: state.pending.filter((req) => req.id !== action.payload.id),
};
}
return state;
},
middleware: whenAction(
(action) => action.type === "TOOL_REQUESTED" && action.tool === "email",
({ action, api }) => {
api.dispatch(approvalRequested({ request: action }));
}
),
};
// Use as const tuple for typed state inference
const runtime = createRuntime({
plugins: [costTracking, approvals] as const,
});
// State is fully typed - no casts needed!
const state = runtime.getState();
// state.approvals.pending is Action[] - TypeScript knows thisEach plugin is independent and can expose any combination of:
key– the reducer key for mounting state (only needed when you providereducerorinitialState)reducer– manages that slice of stateinitialState– seed state for the slicemiddleware– logic that reacts to actions (single middleware or array)
Action naming
- Core runtime actions exported from
@ellie-ai/runtimeare prefixed withRUNTIME_(for exampleRUNTIME_EXECUTION_STARTED,RUNTIME_WORK_COMPLETED). - Each plugin should use its own prefix to keep logs and diagnostics readable. The built-in agent plugin uses
AGENT_…for its actions. - When building your own plugin, pick a short, descriptive prefix (e.g.
NOTES_,ANALYTICS_) for actions you introduce.
Building a plugin
- Define a
RuntimePluginobject with optionalkey,reducer,initialState, andmiddlewarepieces. - Use middleware helpers like
whenAction,pipeHandlers, and your preferred action prefix to implement behaviour. - Register the plugin via
createRuntime({ plugins: [yourPlugin] }).
Core Concepts
Execution Lifecycle
An execution is started by calling runtime.execute(input). It can have multiple turns if interrupted:
const runtime = createRuntime();
// Start execution, get turn1
const handle1 = runtime.execute("first input");
await handle1.included; // Turn1 accepted
// Interrupt with turn2 (same execution)
const handle2 = runtime.execute("second input");
await handle2.included; // Turn2 accepted
// handle1.completed resolves with { status: 'interrupted' }
// Both turns' work runs in parallel
await handle2.completed; // Resolves when ALL work completesKey insight: Interrupts add turns to the SAME execution. Existing work continues running.
Actions Flow Through Middleware
When you dispatch an action, it flows through the middleware chain, then to the reducer, then bubbles back up:
dispatch(action)
↓
middleware1 (before next)
↓
middleware2 (before next)
↓
middleware3 (before next)
↓
REDUCER (state update)
↑
middleware3 (after next)
↑
middleware2 (after next)
↑
middleware1 (after next)
↑
returnsEach middleware can:
- Before next: Inspect action, modify it, block it, dispatch other actions
- After next: See updated state, dispatch follow-up actions, cleanup
Example middleware:
const loggingMiddleware = (api) => (next) => (action) => {
console.log('Before:', action.type);
const result = next(action); // Call next middleware/reducer
console.log('After:', api.getState());
return result;
};Thunks: Async Work
A thunk is a function that gets executed by middleware. Users dispatch thunks to add async work:
// User dispatches a thunk (function)
runtime.dispatch(async (dispatch, getState, signal) => {
const result = await someAsyncWork();
// Check if aborted
if (signal.aborted) return;
// Dispatch more work
dispatch(async () => {
await moreWork(result);
});
});The thunk middleware:
- Intercepts the function
- Generates a unique workId
- Dispatches
RUNTIME_WORK_STARTEDaction - Executes the thunk
- Dispatches
RUNTIME_WORK_COMPLETEDorRUNTIME_WORK_FAILED
This creates a complete audit trail of all async work.
API
createRuntime(config?)
Creates a runtime instance from plugins and optional additional configuration.
import { createRuntime } from '@ellie-ai/runtime';
const loggingPlugin = {
middleware: whenAction(
action => true,
({ action }) => console.log(`[action] ${action.type}`)
),
};
const runtime = createRuntime({
plugins: [loggingPlugin],
});Matching Execution Actions
The runtime provides helpers for working with execution lifecycle actions:
import {
ExecutionLifecycleActionTypes,
matchExecutionLifecycleAction,
whenAction,
} from "@ellie-ai/runtime";
const lifecycleLogger = {
middleware: whenAction(
matchExecutionLifecycleAction([
ExecutionLifecycleActionTypes.Started,
ExecutionLifecycleActionTypes.Completed,
ExecutionLifecycleActionTypes.Failed,
]),
({ action }) => {
console.log("Lifecycle event:", action.type, action);
}
),
};
const runtime = createRuntime({
plugins: [lifecycleLogger],
});RuntimePlugin
Plugins are plain objects with optional pieces. Only provide what you need:
interface RuntimePlugin<
Key extends string = string,
SliceState = unknown,
PluginAction extends Action = Action,
RootState = unknown
> {
key?: Key; // required if reducer or initialState provided
reducer?: Reducer<SliceState, PluginAction>;
initialState?: SliceState;
middleware?: Middleware<RootState, PluginAction> | Array<Middleware<RootState, PluginAction>>;
}- Reducer mounts under
key. - Initial state seeds the slice when mounted.
- Middleware can be a single function or an array; all are appended in registration order.
IMPORTANT: Use typed plugins for automatic state inference:
// ❌ WRONG - Loses type information
export function myPlugin(): RuntimePlugin {
return { key: "my", initialState: { count: 0 }, reducer: myReducer };
}
// ✅ CORRECT - Preserves type information
export function myPlugin(): RuntimePlugin<"my", { count: number }> {
return { key: "my", initialState: { count: 0 }, reducer: myReducer };
}When you use typed plugins with as const tuples, TypeScript automatically infers the combined state type. See "Typed Plugin Composition" section below.
runtime.execute(input)
Starts or interrupts an execution, returns a handle:
const handle = runtime.execute("user input");
// Wait for turn to be accepted
await handle.included;
// Wait for execution to complete
await handle.completed;
// Cancel execution (aborts all work)
handle.cancel();Returns: TurnHandle
included: Promise<void>- Resolves when turn is acceptedcompleted: Promise<ExecutionResult>- Resolves with{ status: "completed" | "interrupted" | "aborted" }cancel: () => void- Aborts execution
Completion statuses:
{ status: "completed" }– All work finished successfully.{ status: "interrupted", interruptedTurnId, nextTurnId }– Turn was superseded by a newexecute()call.{ status: "aborted" }– Execution was cancelled viahandle.cancel(). Work receives an abort signal.- Work failures still reject the promise with the underlying error.
runtime.dispatch(action | thunk)
Dispatches an action or thunk through the middleware pipeline. Most plugins contribute middleware so you rarely need to dispatch directly, but it remains available for testing or custom logic.
runtime.getState()
Returns current state:
const state = runtime.getState();
console.log(state.execution.state); // "pending" | "completed" | "failed" | "aborted"
console.log(state.execution.turnIds); // All turn IDs in this executionruntime.subscribe(listener)
Subscribe to state changes:
const unsubscribe = runtime.subscribe(() => {
console.log('State changed:', runtime.getState());
});
// Later
unsubscribe();Middleware Helpers
The runtime provides utilities for building declarative middleware:
whenAction(predicate, handler)
Run handler when predicate matches:
import { whenAction, pipeHandlers } from '@ellie-ai/runtime';
const middleware = (api) => {
const handlers = pipeHandlers(
whenAction(
(action) => action.type === "RUNTIME_EXECUTION_STARTED",
({ action, api }) => {
console.log("Execution started:", action.executionId);
}
)
);
return (next) => (action) => {
handlers({ action, api });
return next(action);
};
};pipeHandlers(...handlers)
Compose multiple handlers sequentially:
const handlers = pipeHandlers(
whenAction(isType("RUNTIME_EXECUTION_STARTED"), handleStart),
whenAction(isType("RUNTIME_EXECUTION_COMPLETED"), handleComplete),
whenAction(isType("RUNTIME_WORK_FAILED"), handleError)
);Execution Pipeline Order
createRuntime wires middleware in a specific sequence:
- Thunk middleware – intercepts dispatched functions, tracks work, and emits
RUNTIME_WORK_*actions. - Execution lifecycle middleware – handles
RUNTIME_EXECUTION_*actions (resolving handles, abort signals, cleanup). - Work tracker middleware – counts outstanding work and dispatches
RUNTIME_EXECUTION_COMPLETEDwhen everything finishes. - Plugin middleware – contributed by registered plugins (e.g., agent orchestration, analytics).
- User-supplied middleware – any additional middleware passed via
createRuntime({ middleware }).
Lifecycle middleware only reacts to ExecutionAction; user actions continue to flow through untouched, ensuring execution state stays consistent before plugin logic runs.
isType(type)
Type-safe predicate for a single action type:
whenAction(
isType("RUNTIME_EXECUTION_STARTED"),
({ action }) => {
// action is narrowed to ExecutionStartedAction
console.log(action.executionId, action.input);
}
)isTypes([types])
Type-safe predicate for multiple action types:
whenAction(
isTypes(["RUNTIME_EXECUTION_COMPLETED", "RUNTIME_EXECUTION_FAILED"]),
({ action }) => {
// action is narrowed to ExecutionCompletedAction | ExecutionFailedAction
console.log("Execution ended:", action.executionId);
}
)whenState(predicate)
Predicate based on state condition:
whenAction(
whenState(s => s.execution.state === "pending"),
({ action }) => {
// Only runs when execution is pending
}
)Common Patterns
Pattern: Observing Actions (Before State Update)
Run logic BEFORE the reducer updates state:
const loggingMiddleware = (api) => {
const handlers = pipeHandlers(
whenAction(
(action) => action.type.startsWith("RUNTIME_EXECUTION_"),
({ action }) => {
console.log(`[${action.type}]`, action);
}
)
);
return (next) => (action) => {
handlers({ action, api }); // Log BEFORE state changes
return next(action);
};
};Pattern: Reacting to State Changes (After Update)
Run logic AFTER the reducer updates state:
const alertMiddleware = (api) => {
const handlers = pipeHandlers(
whenAction(
isType("RUNTIME_WORK_FAILED"),
({ action, api }) => {
const state = api.getState();
// State is already updated, can react to it
if (state.execution.failedWorkCount > 3) {
api.dispatch({ type: "TOO_MANY_FAILURES" });
}
}
)
);
return (next) => (action) => {
const result = next(action); // Update state FIRST
handlers({ action, api }); // React AFTER
return result;
};
};Pattern: Blocking Actions
Don't call next() to block an action from reaching the reducer:
const validationMiddleware = (api) => (next) => (action) => {
if (action.type === "DANGEROUS_OPERATION") {
// Validate first
if (!isValid(action)) {
api.dispatch({ type: "VALIDATION_FAILED", action });
return; // DON'T call next - blocked
}
}
return next(action);
};Pattern: Retry Logic
Catch errors in thunks and retry with backoff:
const retryMiddleware = (api) => (next) => (action) => {
const result = next(action);
// Listen for failures and retry
if (action.type === "RUNTIME_WORK_FAILED") {
const { workId, error } = action;
api.dispatch(async (dispatch, getState, signal) => {
let attempts = 0;
while (attempts < 3) {
try {
const result = await retryWork(workId);
dispatch({ type: "RETRY_SUCCESS", workId, result });
return;
} catch (error) {
attempts++;
if (attempts >= 3) throw error;
await sleep(1000 * Math.pow(2, attempts));
}
}
});
}
return result;
};Advanced Patterns
Human-in-the-Loop (HITL)
To pause execution for human approval, use middleware that dispatches approval requests and waits externally:
const approvalMiddleware = (approvalHandler) => (api) => (next) => (action) => {
// Intercept specific actions that need approval
if (action.type === 'TOOL_CALL_REQUESTED') {
// Dispatch approval request (non-blocking)
api.dispatch({
type: 'APPROVAL_REQUIRED',
toolCall: action.toolCall,
callbackId: generateId()
});
// Block this action from proceeding
return; // Don't call next!
}
// Handle approval response
if (action.type === 'APPROVAL_GRANTED') {
// Now dispatch the original action
api.dispatch({
type: 'TOOL_CALL_REQUESTED',
toolCall: action.originalToolCall
});
}
return next(action);
};External code handles the approval UI:
runtime.subscribe(() => {
const state = runtime.getState();
// When approval required
if (state.approvals.pending.length > 0) {
const approval = state.approvals.pending[0];
// Show UI, get user response
showApprovalDialog(approval).then(granted => {
if (granted) {
runtime.dispatch({
type: 'APPROVAL_GRANTED',
originalToolCall: approval.toolCall
});
} else {
runtime.dispatch({ type: 'APPROVAL_REJECTED' });
}
});
}
});Key insight: Middleware can't await (they're synchronous), but they can:
- Block actions from proceeding (don't call next)
- Dispatch request actions
- Wait for response actions from external code
- Then dispatch the original action
Custom State & Reducers
Add state by registering a plugin with key, reducer, and optional initialState. Use .match() for type-safe action handling:
import { createAction, type RuntimePlugin } from "@ellie-ai/runtime";
// Define action with PayloadAction pattern
const analyticsEvent = createAction<
{ type: string; data: unknown },
"ANALYTICS_EVENT"
>("ANALYTICS_EVENT");
interface AnalyticsState {
events: Array<{ type: string; data: unknown }>;
}
// Typed plugin for automatic state inference
const analyticsPlugin: RuntimePlugin<"analytics", AnalyticsState> = {
key: "analytics",
initialState: { events: [] },
reducer: (state = { events: [] }, action) => {
// Use .match() for type-safe action handling
if (analyticsEvent.match(action)) {
return { events: [...state.events, action.payload] };
}
return state;
},
};
// Use as const for typed state
const runtime = createRuntime({
plugins: [analyticsPlugin] as const,
});
// State is fully typed - no casts!
const state = runtime.getState();
// state.analytics.events is Array<{type: string, data: unknown}>Typed Plugin Composition
CRITICAL: Use as const tuples to get automatic state inference.
The runtime uses TypeScript's MergePluginStates<P> type to automatically merge state types from plugins. This only works when TypeScript knows the exact plugin types.
The Problem
// ❌ WRONG - Array annotation loses type information
const plugins: RuntimePlugin[] = [
analyticsPlugin,
costPlugin({ rate: 0.00001 }),
agentPlugin({ model: openAI() }),
];
const runtime = createRuntime({ plugins });
const state = runtime.getState();
// state is: { execution: ExecutionState } - MISSING plugin states!
// You need: const state = runtime.getState() as { analytics: ..., cost: ..., agent: ... }The Solution
// ✅ CORRECT - Tuple preserves exact plugin types
const plugins = [
analyticsPlugin,
costPlugin({ rate: 0.00001 }),
agentPlugin({ model: openAI() }),
] as const;
const runtime = createRuntime({ plugins });
const state = runtime.getState();
// state is FULLY TYPED:
// {
// execution: ExecutionState;
// analytics: AnalyticsState;
// cost: CostState;
// agent: AgentState;
// }
// No casts needed!Why This Works
as consttells TypeScript to infer a readonly tuple instead of an array- Tuple types preserve each element's exact type (not just the union)
MergePluginStates<P>recursively extracts{ [key]: State }from each plugin- Result: TypeScript knows the complete state shape automatically
Best Practices
// ✅ Inline tuple
const runtime = createRuntime({
plugins: [plugin1(), plugin2()] as const,
});
// ✅ Named constant
const myPlugins = [plugin1(), plugin2()] as const;
const runtime = createRuntime({ plugins: myPlugins });
// ✅ Conditional composition
const basePlugins = [analyticsPlugin, agentPlugin(config)] as const;
const plugins = withUI
? [uiPlugin, ...basePlugins] as const
: basePlugins;
// ❌ NEVER annotate as RuntimePlugin[]
const plugins: RuntimePlugin[] = [...]; // Loses type info!When You Still Need Casts
If plugins are dynamically composed, you may need casts:
// Dynamic plugins - type inference won't work
const plugins = config.plugins.map(createPlugin); // RuntimePlugin[]
// Define expected state shape
interface MyAppState {
execution: ExecutionState;
agent: AgentState;
cost?: CostState;
}
const runtime = createRuntime({ plugins });
const state = runtime.getState() as MyAppState;Middleware Composition
Plugin order is the middleware order. When you register plugins, their middleware executes in the sequence they appear:
const loggingPlugin = { middleware: loggingMiddleware };
const metricsPlugin = { middleware: metricsMiddleware };
const validationPlugin = { middleware: validationMiddleware };
const runtime = createRuntime({
plugins: [loggingPlugin, metricsPlugin, validationPlugin],
});Action flow mirrors plugin order:
dispatch(action)
→ loggingMiddleware
→ metricsMiddleware
→ validationMiddleware
→ reducersIf a plugin needs to contribute multiple middleware functions, return an array from middleware.
Work Tracking
All async work is tracked automatically:
runtime.subscribe(() => {
const state = runtime.getState();
// Execution state
console.log(state.execution.state); // "pending" | "completed" | ...
console.log(state.execution.turnIds); // All turns in this execution
});
// Dispatch work
runtime.dispatch(async () => {
// Thunk middleware dispatches RUNTIME_WORK_STARTED
await doWork();
// Thunk middleware dispatches RUNTIME_WORK_COMPLETED
});
// Tracker middleware counts pending work
// When all work completes, dispatches RUNTIME_EXECUTION_COMPLETEDScheduling async work
When middleware needs to kick off asynchronous work, use dispatchWork. It wraps your thunk, guarantees the runtime tracks it, and adds helpful metadata without extra boilerplate:
import { dispatchWork, whenAction } from "@ellie-ai/runtime";
const modelMiddleware = whenAction(
(action) => action.type === "AGENT_MODEL_REQUESTED",
({ action, api }) => {
dispatchWork(api, {
action,
kind: "AGENT_MODEL",
run: async (dispatch, getState) => {
// Call your model provider with whatever state you need
const response = await someModel.generate(getState().conversation, getState().tools);
dispatch({ type: "AGENT_MODEL_RESPONDED", executionId: action.executionId, response });
},
});
}
);dispatchWork automatically records which action scheduled the work. Every RUNTIME_WORK_* event will include the triggering action’s ID and type so you can follow the execution tree without wiring anything by hand.
Work metadata
Work actions include an optional meta payload. The runtime records:
actionId– unique ID for the action that kicked off the work.actionType– the action’s type string.parentActionId– the parent action (if the triggering action was itself dispatched by another).kind– optional label you can provide for easier grouping.
This metadata is surfaced in the logger example so you can trace which action spawned each async job and how it finished.
Looking for a ready-made visualisation?
@ellie-ai/agent-pluginshipscreateAgentLogger, a plugin that renders the action/work tree in the console using this metadata.
Testing
Tests dispatch work directly:
test("execution lifecycle", async () => {
const runtime = createRuntime();
const handle = runtime.execute("test");
await handle.included;
// Dispatch work
runtime.dispatch(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
});
// Wait for completion
await handle.completed;
expect(runtime.getState().execution.state).toBe("completed");
});Architecture
Built on minimal Redux primitives:
┌─────────────────────────────────────────┐
│ Runtime │
│ - execute(input) → TurnHandle │
│ - dispatch(action | thunk) │
│ - getState() → State │
│ - subscribe(listener) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Store (Redux) │
│ - Holds execution state │
│ - Applies actions through reducers │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Middleware Pipeline │
│ - Execution: Handle lifecycle │
│ - Thunk: Execute async work │
│ - Tracker: Track pending work │
│ - Your middleware... │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Reducers │
│ - execution: Update execution state │
│ - Your reducers... │
└─────────────────────────────────────────┘Error Handling
The runtime provides first-class error handling with observable error actions and optional retry support.
Error Flow
When async work (thunks) throw errors:
thunk throws
→ RUNTIME_WORK_ERROR dispatched (with originAction)
→ middleware can intercept/retry
→ if not handled → RUNTIME_WORK_FAILED
→ RUNTIME_EXECUTION_FAILEDRetry Middleware
Automatically retry transient errors (rate limits, network issues) with exponential backoff:
import { createRuntime, createRetryMiddleware } from "@ellie-ai/runtime";
const runtime = createRuntime({
plugins: [...],
middleware: [
createRetryMiddleware({
maxAttempts: 3, // Default: 3
backoff: "exponential", // "fixed" or "exponential"
baseDelayMs: 1000, // Default: 1000ms
maxDelayMs: 30000, // Default: 30s
retryOn: (error) => // Custom predicate
isRateLimitError(error) || isNetworkError(error),
}),
],
});When a retryable error occurs, RUNTIME_WORK_RETRY is dispatched for observability, then the original action is re-dispatched.
Error Classification Helpers
Built-in helpers to classify errors:
import {
isRateLimitError, // 429, "rate limit", "too many requests"
isNetworkError, // ECONNREFUSED, fetch failed, socket hang up
isAuthError, // 401, 403, "API key", "unauthorized"
isTimeoutError, // timeout, timed out, deadline exceeded
isServerError, // 5xx errors
isRetryableError, // rate limit || network || timeout || server
classifyError, // Returns { type, retryable }
} from "@ellie-ai/runtime";
// Example: custom retry predicate
createRetryMiddleware({
retryOn: (error) => {
const { type, retryable } = classifyError(error);
// Retry rate limits and network errors, but not auth errors
return retryable && type !== "auth";
},
});Manual Error Handling
To catch errors in thunks and prevent execution failure:
runtime.dispatch(async (dispatch, getState, signal) => {
try {
const result = await riskyOperation();
dispatch({ type: "OPERATION_SUCCESS", result });
} catch (error) {
// Caught here - execution continues
dispatch({ type: "OPERATION_ERROR", error: error.message });
}
});Observing Errors
Listen for error actions in middleware:
const errorMiddleware = (api) => {
const handlers = pipeHandlers(
whenAction(
isType("RUNTIME_WORK_ERROR"),
({ action }) => {
// Error occurred, may be retried
console.warn("Work error:", action.payload.error);
}
),
whenAction(
isType("RUNTIME_EXECUTION_FAILED"),
({ action }) => {
// Execution permanently failed
console.error("Execution failed:", action.payload.error);
}
)
);
return (next) => (action) => {
handlers({ action, api });
return next(action);
};
};Debugging
Redux DevTools
The runtime is Redux-compatible. Connect Redux DevTools to:
- See every action dispatched
- Inspect state at each step
- Time-travel to any point in execution
Action-Based Debugging
Instead of console.logs, dispatch debug actions:
api.dispatch({
type: "DEBUG_CHECKPOINT",
location: "afterModelCall",
data: relevantInfo
});These show up in DevTools and create an audit trail.
Trace Execution Flow
When debugging, gather:
- Action history - Sequence of all actions
- State at failure - What state looked like when it broke
- Expected vs actual - What action should have happened
Trace through the action sequence to find where the flow diverged.
Design Principles
- Observable by default - Every action flows through the store, every state change is visible
- No magic - Explicit actions, reducers, middleware. You see exactly what happens when
- Composable - Bring your own state, reducers, middleware
- Testable - Pure functions, deterministic state updates, no hidden side effects
- Minimal - ~500 LOC. Redux primitives + execution lifecycle + work tracking
Middleware Anti-Patterns and Best Practices
When building middleware, it's critical to follow Redux conventions and keep state observable. This section documents common mistakes and the correct patterns.
The Golden Rules
- All state belongs in reducers - Never use closure variables for state
- Each
whenActionhandler must be independent - No shared closure state between handlers - Middleware functions only pipe handlers - No logic in the main middleware function
- Use
api.getState()to read state - Always check reducer state, not closure variables - Use
dispatchWorkfor async operations - Never useasync/awaitin middleware functions - Action-based coordination - Don't block by skipping
next()without proper action coordination
Anti-Pattern 1: Closure Variables for State
❌ WRONG
export function createMyMiddleware(config) {
let isConnecting = false; // ❌ Hidden state!
let toolsReady = false; // ❌ Not observable!
return (api: MiddlewareAPI) => {
const handleStart = whenAction(
(action) => action.type === "START",
({ api }) => {
if (isConnecting) return; // ❌ Reading closure variable
isConnecting = true; // ❌ Mutating closure variable
dispatchWork(api, {
action,
run: async () => {
await connectToServer();
isConnecting = false; // ❌ Hidden state mutation
toolsReady = true; // ❌ Hidden state mutation
},
});
}
);
return (next) => (action) => {
const result = next(action);
runHandlers({ action, api });
return result;
};
};
}Problems:
- State is hidden in closures, not observable in Redux DevTools
- Can't test state transitions
- Can't serialize/deserialize state
- Multiple instances share no state visibility
✅ RIGHT
export function createMyMiddleware(config) {
return (api: MiddlewareAPI) => {
const handleStart = whenAction(
(action) => action.type === "START",
({ api }) => {
const state = api.getState(); // ✅ Read from reducer
if (state.myPlugin.connecting) return; // ✅ Check reducer state
api.dispatch({ type: "MY_CONNECTING_STARTED" }); // ✅ Dispatch action
dispatchWork(api, {
action,
run: async () => {
await connectToServer();
api.dispatch({ type: "MY_CONNECTED" }); // ✅ Update via action
},
});
}
);
const runHandlers = pipeHandlers(handleStart);
return (next) => (action) => {
const result = next(action);
runHandlers({ action, api });
return result;
};
};
}
// Reducer uses .match() for type-safe action handling
import { createAction } from "@ellie-ai/runtime";
const myConnecting = createAction<void, "MY_CONNECTING_STARTED">("MY_CONNECTING_STARTED");
const myConnected = createAction<void, "MY_CONNECTED">("MY_CONNECTED");
function myReducer(state = { connecting: false, ready: false }, action) {
if (myConnecting.match(action)) {
return { ...state, connecting: true };
}
if (myConnected.match(action)) {
return { ...state, connecting: false, ready: true };
}
return state;
}Benefits:
- State visible in Redux DevTools
- Can time-travel debug
- Fully testable
- Serializable state
Anti-Pattern 2: Dependent whenAction Handlers
❌ WRONG
export function createMyMiddleware(config) {
const pendingActions: Action[] = []; // ❌ Shared closure state
let ready = false; // ❌ Shared between handlers
return (api: MiddlewareAPI) => {
const handleInit = whenAction(
(action) => action.type === "INIT",
({ api }) => {
// ... initialization ...
ready = true; // ❌ Mutates shared closure variable
// Re-dispatch pending actions
while (pendingActions.length > 0) { // ❌ Reads shared closure array
const pending = pendingActions.shift()!;
api.dispatch(pending);
}
}
);
const handleOtherAction = whenAction(
(action) => action.type === "OTHER",
({ api, action }) => {
if (!ready) { // ❌ Depends on shared closure variable
pendingActions.push(action); // ❌ Mutates shared closure array
return;
}
// ... process action ...
}
);
const runHandlers = pipeHandlers(handleInit, handleOtherAction);
return (next) => (action) => {
const result = next(action);
runHandlers({ action, api });
return result;
};
};
}Problems:
- Handlers are coupled via shared closure state
- Hidden dependencies between handlers
- Can't test handlers in isolation
- Hard to debug - state mutations happen in multiple places
✅ RIGHT
export function createMyMiddleware(config) {
return (api: MiddlewareAPI) => {
// Each handler is INDEPENDENT - only reads state, only dispatches actions
const handleInit = whenAction(
(action) => action.type === "INIT",
({ api }) => {
api.dispatch({ type: "MY_READY" }); // ✅ Just dispatch
}
);
const handleReady = whenAction(
(action) => action.type === "MY_READY",
({ api }) => {
const state = api.getState(); // ✅ Read from reducer
// Re-dispatch pending actions from STATE
state.myPlugin.pendingActions.forEach((pendingAction) => {
api.dispatch(pendingAction);
});
api.dispatch({ type: "MY_PENDING_CLEARED" });
}
);
const handleOtherAction = whenAction(
(action) => action.type === "OTHER",
({ api, action }) => {
const state = api.getState(); // ✅ Read from reducer
if (!state.myPlugin.ready) {
// Store in STATE via action
api.dispatch({ type: "MY_ACTION_PENDING", action });
return;
}
// ... process action ...
}
);
const runHandlers = pipeHandlers(handleInit, handleReady, handleOtherAction);
return (next) => (action) => {
const result = next(action);
runHandlers({ action, api });
return result;
};
};
}
// Reducer uses .match() for type-safe action handling
const myReady = createAction<void, "MY_READY">("MY_READY");
const myActionPending = createAction<{ action: Action }, "MY_ACTION_PENDING">("MY_ACTION_PENDING");
const myPendingCleared = createAction<void, "MY_PENDING_CLEARED">("MY_PENDING_CLEARED");
function myReducer(state = { ready: false, pendingActions: [] }, action) {
if (myReady.match(action)) {
return { ...state, ready: true };
}
if (myActionPending.match(action)) {
return { ...state, pendingActions: [...state.pendingActions, action.payload.action] };
}
if (myPendingCleared.match(action)) {
return { ...state, pendingActions: [] };
}
return state;
}Benefits:
- Each handler is independently testable
- State transitions are explicit and observable
- No hidden dependencies
- Easy to debug - all state changes go through reducer
Anti-Pattern 3: Logic in Middleware Function
❌ WRONG
export function createMyMiddleware(config) {
const pendingActions: Action[] = [];
let ready = false;
return (api: MiddlewareAPI) => {
const handleInit = whenAction(/* ... */);
const runHandlers = pipeHandlers(handleInit);
return (next) => (action) => {
// ❌ WRONG - Logic in middleware function!
if (action.type === "OTHER" && !ready) {
pendingActions.push(action);
return; // Don't call next - blocked
}
// ❌ WRONG - Another check in middleware function!
if (action.type === "READY") {
ready = true;
while (pendingActions.length > 0) {
api.dispatch(pendingActions.shift()!);
}
}
const result = next(action);
runHandlers({ action, api });
return result;
};
};
}Problems:
- Logic is split between middleware function and handlers
- Harder to test and debug
- Breaks the pattern - middleware function should ONLY pipe handlers
- Mixes concerns (gating vs. handling)
✅ RIGHT
export function createMyMiddleware(config) {
return (api: MiddlewareAPI) => {
// ✅ All logic in independent whenAction handlers
const handleInit = whenAction(
(action) => action.type === "INIT",
({ api }) => {
api.dispatch({ type: "MY_READY" });
}
);
const handleReady = whenAction(
(action) => action.type === "MY_READY",
({ api }) => {
const state = api.getState();
state.myPlugin.pendingActions.forEach((a) => api.dispatch(a));
api.dispatch({ type: "MY_PENDING_CLEARED" });
}
);
const handleOther = whenAction(
(action) => action.type === "OTHER",
({ api, action }) => {
const state = api.getState();
if (!state.myPlugin.ready) {
api.dispatch({ type: "MY_ACTION_PENDING", action });
} else {
// ... process ...
}
}
);
const runHandlers = pipeHandlers(handleInit, handleReady, handleOther);
// ✅ Middleware function ONLY pipes handlers
return (next) => (action) => {
const result = next(action);
runHandlers({ action, api });
return result;
};
};
}Benefits:
- Single responsibility - middleware function only pipes
- All logic in testable, independent handlers
- Follows consistent pattern
- Easy to understand and maintain
Anti-Pattern 4: Async Middleware with Await
❌ WRONG
export function createMyMiddleware(config) {
const connectionPromise = connectToServer(); // ❌ Async operation in closure
return (api: MiddlewareAPI) => {
return (next) => async (action) => { // ❌ async middleware function!
if (action.type === "SOME_ACTION") {
await connectionPromise; // ❌ BLOCKS entire middleware chain!
}
return next(action);
};
};
}Problems:
- Blocks the entire middleware chain
- No other middleware can process actions while waiting
- Violates Redux middleware conventions (must be synchronous)
- Can't track or cancel the async work
✅ RIGHT
export function createMyMiddleware(config) {
return (api: MiddlewareAPI) => {
const handleStart = whenAction(
(action) => action.type === "START",
({ action, api }) => {
const state = api.getState();
if (state.myPlugin.connecting) return;
api.dispatch({ type: "MY_CONNECTING" });
// ✅ Use dispatchWork for async operations
dispatchWork(api, {
action,
run: async () => {
await connectToServer();
api.dispatch({ type: "MY_CONNECTED" });
},
});
}
);
const runHandlers = pipeHandlers(handleStart);
return (next) => (action) => { // ✅ Synchronous!
const result = next(action);
runHandlers({ action, api });
return result;
};
};
}Benefits:
- Middleware chain stays synchronous and fast
- Async work is tracked in action tree
- Work can be cancelled via abort signal
- Follows Redux conventions
Reference Implementation
The canonical example of correct middleware patterns is in packages/agent-plugin/src/middleware/agent.ts:
export function createAgentMiddleware(config = {}) {
const { maxLoops } = config;
return (api: MiddlewareAPI<AgentState, AllActions>) => {
// ✅ Each whenAction is independent
// ✅ Only reads from api.getState()
// ✅ Only dispatches actions
// ✅ No shared closure state
const handleExecutionStart = whenAction(
(action) => action.type === "RUNTIME_EXECUTION_STARTED",
({ action, api }) => {
const state = api.getState(); // ✅ Read state
if (maxLoops !== undefined && state.loop.loopCount >= maxLoops) {
return;
}
api.dispatch({ type: "AGENT_MODEL_REQUESTED", executionId }); // ✅ Dispatch
}
);
const handleModelResponded = whenAction(/* ... */);
const handleToolCompletion = whenAction(/* ... */);
const runHandlers = pipeHandlers(
handleExecutionStart,
handleModelResponded,
handleToolCompletion
);
// ✅ Middleware function ONLY pipes handlers
return (next) => (action) => {
const result = next(action);
runHandlers({ action, api });
return result;
};
};
}Key principles:
- Each
whenActionis a pure function (only reads state, only dispatches actions) - No closure variables for state
- Middleware function only pipes handlers together
- All state lives in the reducer
When You Need Action Gating
If you need to gate actions (like waiting for tools to register before allowing model requests), use action-based coordination:
- Store gating state in reducer (e.g.,
toolsRegistered: boolean) - Check state in handler via
api.getState() - Dispatch request actions when not ready
- Store pending actions in reducer state (not closure)
- Re-dispatch pending actions when ready
See packages/agent-plugin/src/middleware/agent.ts for the reference pattern.
FAQ
Q: Why Redux instead of simpler state management?
Redux provides:
- Time-travel debugging (replay actions)
- Complete audit trail (all actions logged)
- Predictable state updates (pure reducers)
- Middleware for side effects (clean separation)
- Excellent testing story
Q: When should I use middleware vs. dispatching directly?
- Middleware: Cross-cutting concerns (logging, metrics, approvals, validation)
- Direct dispatch: Application logic, async work (thunks), testing
Users dispatch thunks. Middleware orchestrates them into tracked work.
Q: Can middleware be async?
No. Middleware functions are synchronous. But they can:
- Dispatch thunks (which are async)
- Dispatch actions, wait externally, dispatch response actions (HITL pattern)
- Use
setTimeout,queueMicrotaskfor deferred work
Q: How do interrupts work?
- Interrupt adds a NEW turn to the SAME execution
- Previous turn's handle resolves with
{ status: "interrupted" } - All work from ALL turns continues running in parallel
- Execution completes when ALL work finishes
Q: What's the difference between interrupt and abort?
- Interrupt:
execute()while pending → Adds turn, old work continues - Abort:
handle.cancel()→ Fires abort signal, all work should stop, execution aborts
Q: How do I handle errors without stopping execution?
Catch errors inside your thunks:
runtime.dispatch(async (dispatch) => {
try {
await riskyWork();
} catch (error) {
// Handle it - execution continues
dispatch({ type: "RUNTIME_WORK_FAILED", error });
}
});If an error escapes a thunk, the entire execution fails.
