npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.md for 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 this

Each plugin is independent and can expose any combination of:

  • key – the reducer key for mounting state (only needed when you provide reducer or initialState)
  • reducer – manages that slice of state
  • initialState – seed state for the slice
  • middleware – logic that reacts to actions (single middleware or array)

Action naming

  • Core runtime actions exported from @ellie-ai/runtime are prefixed with RUNTIME_ (for example RUNTIME_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

  1. Define a RuntimePlugin object with optional key, reducer, initialState, and middleware pieces.
  2. Use middleware helpers like whenAction, pipeHandlers, and your preferred action prefix to implement behaviour.
  3. 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 completes

Key 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)
  ↑
returns

Each 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:

  1. Intercepts the function
  2. Generates a unique workId
  3. Dispatches RUNTIME_WORK_STARTED action
  4. Executes the thunk
  5. Dispatches RUNTIME_WORK_COMPLETED or RUNTIME_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 accepted
  • completed: 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 new execute() call.
  • { status: "aborted" } – Execution was cancelled via handle.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 execution

runtime.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:

  1. Thunk middleware – intercepts dispatched functions, tracks work, and emits RUNTIME_WORK_* actions.
  2. Execution lifecycle middleware – handles RUNTIME_EXECUTION_* actions (resolving handles, abort signals, cleanup).
  3. Work tracker middleware – counts outstanding work and dispatches RUNTIME_EXECUTION_COMPLETED when everything finishes.
  4. Plugin middleware – contributed by registered plugins (e.g., agent orchestration, analytics).
  5. 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:

  1. Block actions from proceeding (don't call next)
  2. Dispatch request actions
  3. Wait for response actions from external code
  4. 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

  1. as const tells TypeScript to infer a readonly tuple instead of an array
  2. Tuple types preserve each element's exact type (not just the union)
  3. MergePluginStates<P> recursively extracts { [key]: State } from each plugin
  4. 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
        → reducers

If 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_COMPLETED

Scheduling 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-plugin ships createAgentLogger, 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_FAILED

Retry 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:

  1. Action history - Sequence of all actions
  2. State at failure - What state looked like when it broke
  3. Expected vs actual - What action should have happened

Trace through the action sequence to find where the flow diverged.

Design Principles

  1. Observable by default - Every action flows through the store, every state change is visible
  2. No magic - Explicit actions, reducers, middleware. You see exactly what happens when
  3. Composable - Bring your own state, reducers, middleware
  4. Testable - Pure functions, deterministic state updates, no hidden side effects
  5. 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

  1. All state belongs in reducers - Never use closure variables for state
  2. Each whenAction handler must be independent - No shared closure state between handlers
  3. Middleware functions only pipe handlers - No logic in the main middleware function
  4. Use api.getState() to read state - Always check reducer state, not closure variables
  5. Use dispatchWork for async operations - Never use async/await in middleware functions
  6. 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 whenAction is 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:

  1. Store gating state in reducer (e.g., toolsRegistered: boolean)
  2. Check state in handler via api.getState()
  3. Dispatch request actions when not ready
  4. Store pending actions in reducer state (not closure)
  5. 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, queueMicrotask for 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.