Skip to main content
Ask your AI

Using Step Examples

Overview​

Step examples are structured { in, out } pairs (with optional mapping for destinations) that define the expected input/output behavior of each step in a flow. They serve as:

  • Test fixtures for automated it.each testing
  • Simulation input for walkeros push --simulate (pass an example's in as --event; the CLI does not compare the result against out)
  • MCP context for AI-assisted development
  • Documentation showing real-world usage

Every source, transformer, and destination can ship step examples alongside its code.

The Three Type Zones​

Each step in a flow sits at a boundary between arbitrary external formats and the walkerOS event model:

Source Transformer Destination
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ in: arbitrary │ │ in: walkerOS │ │ in: walkerOS │
│ (HTTP req, DOM) │ │ Event │ │ Event │
│ │ │ │ │ │ │ │ │
│ ▼ │ │ ▼ │ │ ▼ │
│ out: walkerOS │ │ out: walkerOS │ │ out: arbitrary │
│ Event │ │ Event | false │ │ (gtag, API) │
└─────────────────┘ └─────────────────┘ └─────────────────┘

arbitrary ──────────── walkerOS.Event ──────────── arbitrary
  • Source converts platform-specific input into a walkerOS event
  • Transformer receives and returns walkerOS events (or false to filter)
  • Destination converts a walkerOS event into vendor-specific output

The in/out/trigger Format​

Every step example is an object with in (input), out (array of observable effects), and optional trigger (how to invoke):

type StepEffect = readonly [callable: string, ...args: unknown[]];
type StepOut = readonly StepEffect[];

interface StepExample {
in: unknown; // Platform-specific input
out: StepOut; // Array of effect tuples - see shape rules below
trigger?: { type?: string; options?: unknown }; // How to invoke
mapping?: unknown; // Destination mapping rule
command?: 'config' | 'consent' | 'user' | 'run'; // Route to walker command
}

out is always an array of effect tuples, even for a single effect. Each tuple is [callable, ...args]. The first element is the callable's public name (the SDK function users would write). Remaining elements are the arguments.

Callable conventions​

Component typeCallableNotes
Destination (SDK function)'gtag', 'fbq', 'ttq.track'Public SDK name. Dotted paths render literally.
Destination (method on global)'analytics.track', 'dataLayer.push'Method notation.
Destination (HTTP)'fetch', 'sendServer'The actual call users would make. NOT env.* prop names - those are internal plumbing.
Source'elb'The walker public push API.
Transformer'return'Reserved keyword. Renders as return <value> (no parens).

'return' is reserved. Don't use it for anything else.

Empty out: [] means the step produced no observable effect (filtered input, transformer passthrough, validator rejection). Reserved only for cases where the destination/source deliberately emits nothing.

Source Step Example - Server (Express)​

in is an HTTP request shape, out is a tuple of the elb() call the source makes:

export const checkoutPost: Flow.StepExample = {
trigger: { type: 'POST' },
in: {
method: 'POST',
path: '/collect',
body: { name: 'order complete', data: { id: 'ORD-123', total: 149.97 } },
},
out: [
['elb', { name: 'order complete', data: { id: 'ORD-123', total: 149.97 } }],
],
};

Source Step Example - Commands (CMP, session)​

A source that issues walker commands records them in its own call shape: the first argument verbatim, callable 'elb'. Commands come first, in call order, then the events. A CMP source:

out: [['elb', 'walker consent', { functional: true, marketing: true }]],

The session source (env.command('user' | 'session', ...), then its event):

out: [
['elb', 'user', { session: 's3ss10n-id', device: 'd3v1c3-id' }],
['elb', 'session', { id: 's3ss10n-id', isStart: true /* ... */ }],
['elb', { name: 'session start', data: { id: 's3ss10n-id' /* ... */ } }],
],

A source simulation (walkeros push --simulate source.X, MCP flow_simulate) returns exactly these calls: call elb("walker consent",{...}). The collector's own commands (starting consent from --consent, globals, custom, shutdown) and the wiring commands on, hook, destination are never part of a source's out. A consent-gated source needs --consent (MCP state.consent) to emit anything.

Source Step Example - Browser​

in is an HTML string, out is a tuple of the elb() call:

export const clickEvent: Flow.StepExample = {
trigger: { type: 'click', options: 'button' },
in: '<button data-elb="cta" data-elb-cta="label:Sign Up" data-elbaction="click:click">Sign Up</button>',
out: [
[
'elb',
{
name: 'cta click',
data: { label: 'Sign Up' },
trigger: 'click',
entity: 'cta',
action: 'click',
},
],
],
};

Transformer Step Example​

in is a walkerOS event, out is a ['return', value] tuple. An empty array means the transformer passed the event through unchanged:

export const step = {
orderPasses: {
in: { name: 'order complete', data: { id: 'ORD-123' } },
out: [['return', { name: 'order complete', data: { id: 'ORD-123' } }]],
},
debugFiltered: {
in: { name: 'debug test', data: { message: 'noise' } },
out: [['return', false]], // Transformer rejects
},
passthrough: {
in: { name: 'page view' },
out: [], // No modification
},
};

Destination Step Example​

in is a walkerOS event, mapping is the mapping rule that transforms it, out is an array of call tuples - one per observable effect the destination produces. Multi-call events (e.g., GA4 + Ads + GTM for a single walker event) flatten into a single array in execution order:

import type { Flow } from '@walkeros/core';
import { getEvent } from '@walkeros/core';

export const purchase: Flow.StepExample = {
in: getEvent('order complete', { timestamp: 1700000000 }),
mapping: {
name: 'Purchase',
data: { map: { value: 'data.total', currency: { value: 'EUR' } } },
},
out: [
[
'fbq',
'track',
'Purchase',
{ value: 555, currency: 'EUR' },
{ eventID: '1700000000-gr0up-1' },
],
],
};

Multi-tool example (one walker event produces GA4, Ads, GTM in order):

out: [
['gtag', 'event', 'purchase', { transaction_id: 'o1', value: 555 }],
['gtag', 'event', 'conversion', { send_to: 'AW-123', value: 555 }],
['dataLayer.push', { event: 'purchase', ecommerce: { ... } }],
],

Each export is a self-contained Flow.StepExample - no intermediate variables, no all aggregation. The mapping field ties the mapping rule to the example so tests can register it dynamically: { [event.entity]: { [event.action]: example.mapping } }.

Consumers iterate all examples via Object.entries(examples.step) - export * as step exposes every named export directly.

Command Step Example​

Some destinations need to respond to walker commands (walker consent, walker user, walker run), not events. Set the command field to route in through elb('walker <command>', in) instead of pushing it as an event:

export const consentGranted: Flow.StepExample = {
command: 'consent',
in: { marketing: true, functional: true },
out: [
'consent',
'update',
{ ad_storage: 'granted', analytics_storage: 'granted' },
],
};

Supported commands: config, consent, user, run.

  • When command is absent (default), in is pushed as a regular event via elb(in).

  • When command is set, mapping is not applied - commands don't flow through event mapping.

  • The out format is destination-specific. For gtag it's [action, subAction, params] matching gtag(...) calls.

  • Simulate a command example with --command <name> (MCP: command): the destination simulation runs collector.command(name, in) instead of a push and records the calls it makes. Add --consent when the destination needs starting consent; it applies first, then the command:

    walkeros push flow.json -e '{"functional":true,"marketing":true}' \
    --simulate destination.ga4 --command consent

Writing Examples​

Best Practices​

  1. Use camelCase names -- descriptive of the scenario (checkoutPost, debugFiltered, pageViewBasic)
  2. Cover happy path and edge cases -- include at least one positive and one negative case for transformers
  3. Use realistic data -- real-looking event names, IDs, and values
  4. Keep examples minimal -- only include fields relevant to the step's logic; omit empty/default-value fields (context: {}, nested: [], user: {}, etc.)
  5. Export from examples/step.ts -- follow the existing dev.ts structure
  6. Title and describe public examples -- add title (2-5 words) and description (one short sentence, 10-25 words) for every example that should appear in docs. Mark test-only fixtures with public: false.

File Structure​

// src/examples/step.ts - only Flow.StepExample exports, nothing else
import type { Flow } from '@walkeros/core';
import { getEvent } from '@walkeros/core';

export const purchase: Flow.StepExample = {
in: getEvent('order complete', { timestamp: 1700000000 }),
mapping: { name: 'Purchase', data: { map: { value: 'data.total' } } },
out: ['track', 'Purchase', { value: 555 }, { eventID: '1700000000-gr0up-1' }],
};

// src/examples/index.ts
export * as env from './env';
export * as step from './step';

The file exports only Flow.StepExample objects. No intermediate variables, no all, no config. Consumers iterate via Object.entries(examples.step).

Rule: examples live only on the ./dev subpath​

Examples (and schemas) must be exported only from src/dev.ts (the ./dev package subpath), never from a production entry (src/index.ts / the package main). Production bundles tree-shake on the import path: if examples is reachable from the main entry, the bundler inlines every example (and its getEvent fixtures) into the shipped JS, bloating consumer bundles with test-only data. Importing them from ./dev keeps them available for simulation and testing while leaving production output clean.

A no-restricted-syntax ESLint rule enforces this, so a export ... examples from a production entry fails lint.

Multi-export packages: exportExamples and exportSchemas​

A package whose package.json walkerOS.exports lists more than one export adds a map of EVERY export, the default included, to src/dev.ts:

export const exportExamples = {
destinationBigQuery: bigqueryExamples,
destinationPubSub: pubsubExamples,
};

The step's export name comes from resolveExportName (exported from @walkeros/cli): the step's import, else bundle.packages[pkg].imports[0], else the default. When the module has the map, map[exportName] is used and a missing name resolves to nothing (never a fallback to examples); without a map, examples is used. selectDevExamples(devModule, exportName) implements this. The CLI simulate, MCP flow_examples and package_get, core cdn.ts and the tsup walkerOS.json (exportExamples key) all follow it. Existing named exports (pubsubExamples, sqsExamples, ...) stay for back compat.

The same package also exports exportSchemas, every export to that export's schemas in the shape of a single-export schemas (settings required, mapping, setup where they exist). Reuse the very objects behind schemas.settings for the default entry, so a toBe test pins it:

export const exportSchemas = {
destinationBigQuery: {
settings: bigquerySchemas.settings,
mapping: bigquerySchemas.mapping,
},
destinationPubSub: {
settings: pubsubSchemas.settings,
mapping: pubsubSchemas.mapping,
setup: pubsubSchemas.setup,
},
};

walkeros validate and MCP package_get pick the entry for the step's export with the same rule. The tsup build (buildDev) fails when either map does not list exactly the walkerOS.exports names, when an exportSchemas entry has no settings, and when any source, transformer, destination or store package publishes no schemas.settings (a package without settings publishes z.object({})). Prefixed keys in schemas (pubsubSettings, ...) stay.

Mock env and simulation paths​

src/examples/env.ts exports push (the mock env simulate injects) and simulation (the call paths to record). Simulate refuses a package destination whose export has no push mock ("No mock env for ..."), so it never calls the real vendor.

  • A simulation path names the call that CARRIES the vendor request (sendServer, fetch, call:PubSub.topic.publishMessage, call:JSONWriter.appendRows, call:AWS.SNSClient.send, call:fs.createWriteStream.write), never only a constructor.

  • init is not the request. Do not record an init call (it carries the API key and sends nothing); a path may pass THROUGH init's result (call:Mixpanel.init.track). Amplitude and Mixpanel record their tracking calls, not init.

  • Grammar (parseCallPath, shared by the recorder): an optional call: prefix, then .-separated segments; an empty segment makes the path unresolvable. The one recorder is observeEnv(env, paths) in @walkeros/core (collector wrapEnv and web-core getEnv delegate to it). It returns a Proxy view; nothing on the env objects or prototypes is mutated.

  • Mid-path resolution: on an object, a segment is looked up as a property. After a function, a segment is the function's own property or a parent class's static; anything else (including call, bind, name, length) navigates the call result (or the constructed instance, for new). Frozen objects and frozen call results are recorded through a view; only a static on a frozen function stays unresolved. A mid-path Promise (async factory) is followed with .then. Only the leaf call is recorded: fn is the path without call:, args the leaf call's arguments. instanceof, statics and #private fields keep working, and a chain returning this records once.

  • Pin every list in the package tests with the async expectSimulationResolves(examples.env) from @walkeros/core/dev: it walks each path through the push mock (constructing, calling, awaiting) and fails on a missing segment or a non-function leaf.

    import { expectSimulationResolves } from '@walkeros/core/dev';
    it('declares simulation paths that resolve', () =>
    expectSimulationResolves(examples.env));
  • Not recordable: anonymous values such as SQLite prepared-statement rows.

  • Sources: a source package that declares simulation runs on its mock client in a source simulation, and those calls are recorded. SQS records call:AWS.SQSClient.send (one receive per 100 ms while running); Pub/Sub pull records call:PubSub.subscription (messages arrive as events). Their module-mock tests import moduleMockEnv, the same env without the client.

Metadata: title, description, public​

Every Flow.StepExample accepts three optional metadata fields that control how it surfaces in docs and MCP output:

  • title?: string - overrides the default camelCase-to-spaced heading in website docs. Keep it short (2-5 words), human-readable.
  • description?: string - one short sentence (10-25 words) rendered above each example in docs and surfaced in MCP flow_examples output. Explains what the example demonstrates, not how.
  • public?: boolean - defaults to true. When false, the example is excluded from the website docs render and from default MCP flow_examples output. It still runs in tests and remains callable via CLI/MCP flow_simulate.
export const purchase: Flow.StepExample = {
title: 'Purchase',
description:
'A completed order mapped to the Meta Pixel Purchase standard event.',
in: getEvent('order complete', { timestamp: 1700000000 }),
mapping: { name: 'Purchase', data: { map: { value: 'data.total' } } },
out: [['fbq', 'track', 'Purchase', { value: 555, currency: 'EUR' }]],
};

// Internal fixture - runs in tests, hidden from docs and default MCP output.
export const debugFiltered: Flow.StepExample = {
public: false,
in: { name: 'debug test', data: { message: 'noise' } },
out: [['return', false]],
};

When to mark public: false​

Mark public: false when:

  • The example is a transformer rejection (out: [['return', false]]) that only proves filtering works.
  • It's an error-path or malformed-input case.
  • It's a redundant variant kept only for regression coverage.
  • It's an internal fixture used by cross-package tests.

Keep public (omit the field) when:

  • It's the happy path.
  • It teaches a distinct mapping or usage pattern.
  • It's the only example for some entity/action the package handles.

Source Trigger Metadata​

Source step examples can include a trigger field for simulation:

{
in: '<button data-elb="cta">Sign Up</button>',
trigger: { type: 'click' },
out: { name: 'cta click', data: { label: 'Sign Up' } }
}

When simulating via CLI or MCP, the step example maps to SourceInput:

  • in -> content (the actual source input)
  • trigger -> trigger (which mechanism to fire)

Destination and transformer examples don't use trigger.

Simulating with Step Examples​

Use --simulate <kind>.<name> to target a specific step, then provide the input with --event. For a source step, that is SourceInput ({ content, trigger? }):

# Simulate a source step with trigger metadata
walkeros push flow.json --simulate source.browser --event '{"content":"<html>...","trigger":{"type":"click"}}'

The CLI does not compare the result against out. For each simulated step it prints the matched mapping key and every recorded call (--json: under simulations), and exits 1 only when the push fails with an error; a mapping that produces the wrong output still exits 0. Assert on out in your own tests (see Testing with Examples below). Add --ingest to give a transformer, collector or destination step a request context, --consent for the collector consent it starts from, --command for a command example, and --page-url for the page of a web source (default: the trigger's options.url, else http://localhost). See using-cli.

The MCP flow_examples tool returns trigger metadata alongside in/out, and mapping for destination examples, giving full visibility into how input events are transformed to vendor-specific output.

Validating Examples​

Cross-step example validation is included automatically when validating a flow. It does not run any step:

# Validate flow config including step example compatibility
walkeros validate flow.json

For steps connected through next or a destination's before chain, flow validation checks that:

  • At least one upstream out is structurally compatible with a downstream in: same type; two objects share at least half of the keys of the object with fewer keys; any two arrays are compatible regardless of content. No compatible pair is an error. This is a shape check, not an equality check.
  • A connection is only checked when both steps declare examples; a step without an examples key produces no warning.
  • When both steps declare examples but one side has no usable value, the connection produces a warning. An upstream out counts when it is a non-empty array, string, or object (such as a walkerOS event); out: false and empty values are skipped. A downstream in counts unless its example sets command.
  • A StepOut out (every entry an array with a string head) contributes the events it passes on: elb gives args[0] when it is an object; return gives a.event for { event }, a for a bare event, each item for an array (fan-out); false, message.ack, message.nack, response, respond and vendor calls give nothing. A StepOut with no events counts as no usable out.
  • Contracts bind only where a @walkeros/transformer-validate step links them, as at runtime. Each validate step's own examples run through that step's resolved settings.contract, mode, format and output, and the verdict must agree with its out (valid: passed on; invalid: ["return", false] in strict, passed on with the isValid flag false in pass). A disagreement is CONTRACT_VIOLATION (warning, error with --strict). No other example is checked against the config-level contract block, and a flow without a validate step gets no contract checks.

Testing with Examples​

The primary use of step examples is automated testing with it.each.

Source Tests (createTrigger)​

Sources export createTrigger from their examples. It follows the unified Trigger.CreateFn interface: receives Collector.InitConfig, lazily starts the flow, and returns a trigger function that simulates real-world invocations.

Each package implements createTrigger differently:

  • Browser: Injects HTML into DOM, starts flow, dispatches native events
  • Express: Boots real HTTP server, sends fetch() requests

Use a spy destination to capture events:

import type { Destination, WalkerOS } from '@walkeros/core';
import { sourceExpress } from '@walkeros/server-source-express';
import { examples } from '@walkeros/server-source-express/dev';

describe('Step Examples', () => {
it.each(Object.entries(examples.step))('%s', async (name, example) => {
const events: WalkerOS.Event[] = [];
const spy: Destination.Instance = {
type: 'spy',
config: { init: true },
push: jest.fn((event) => {
events.push(JSON.parse(JSON.stringify(event)));
}),
};

const instance = await examples.createTrigger({
consent: { functional: true },
sources: {
express: { code: sourceExpress, config: { settings: { port: 0 } } },
},
destinations: { spy: { code: spy } },
});

await instance.trigger(example.trigger?.type)(example.in);

const found = events.find((e) => e.name === example.out.name);
expect(found).toBeDefined();
});
});

Browser source note: The browser source's elbLayer processes events via a detached promise chain (fire-and-forget). For interactive triggers (click, submit), poll until events arrive:

while (!events.length) await Promise.resolve();

CMP sources push walker consent commands, not regular events. Assert on collector consent state instead of using a spy destination:

const instance = await examples.createTrigger({
consent: {},
sources: {
usercentrics: {
code: sourceUsercentrics,
config: { settings: {} },
},
},
});

await instance.trigger(
example.trigger?.type,
example.trigger?.options,
)(example.in);

// Yield for detached elb('walker consent') chain
while (!Object.keys(instance.flow!.collector.config.consent || {}).length)
await Promise.resolve();

expect(instance.flow!.collector.config.consent).toEqual(
expect.objectContaining(expected),
);

Server Function Handler Tests (collector.sources)​

Server function handlers (fetch, AWS Lambda, GCP CloudFunction) don't own servers. Their createTrigger accesses the source instance from collector.sources after startFlow and calls source.push() with platform-native types:

function findSource(collector) {
for (const source of Object.values(collector.sources || {})) {
if (source.type === 'fetch') return source;
}
}

// In trigger:
const source = findSource(flow.collector);
const response = await source.push(request);

Destination Functional Tests​

For destinations, use startFlow + elb() to run events through the real collector pipeline. This verifies the full flow including mapping:

import { startFlow } from '@walkeros/collector';
import { clone } from '@walkeros/core';
import { examples } from './dev';

describe('Step Examples', () => {
it.each(Object.entries(examples.step))('%s', async (name, example) => {
const event = example.in as WalkerOS.Event;
const mapping = example.mapping as Rule | undefined;

const mockFn = jest.fn();
const env = clone(examples.env.push);
env.window.fbq = mockFn;

const dest = jest.requireActual('.').default;
const { elb } = await startFlow({});

// Build mapping config from event entity/action
const mappingConfig = mapping
? { [event.entity]: { [event.action]: mapping } }
: undefined;

elb('walker destination', {
code: { ...dest, env },
config: {
settings: { pixelId: '1234567890' },
mapping: mappingConfig,
},
});

await elb(event);
expect(mockFn).toHaveBeenLastCalledWith(...(example.out as unknown[]));
});
});

Transformer Tests​

For transformers that can return false:

import { examples } from '../dev';

describe('transformer', () => {
it.each(Object.entries(examples.step))(
'%s',
async (name, { in: input, out: expected }) => {
const result = await transformer.push(input, context);
if (expected === false) {
expect(result).toBe(false);
} else {
expect(result).toEqual(expected);
}
},
);
});

Checklist​

When adding step examples to a package or flow:

  • Create src/examples/step.ts with Flow.StepExample exports
  • Export from src/examples/index.ts
  • Use camelCase, descriptive example names
  • Include at least one happy-path example
  • For transformers, include a false (filtered) case
  • Add it.each test using step examples
  • Verify examples compile: npm run build
  • Run tests: npm run verify:touched -- <pkg> (L1)
  • Add title and description to every public example.
  • Mark internal/test-only examples with public: false.
  • examples/env.ts exports push and a simulation list naming the request call, pinned with expectSimulationResolves(examples.env).
  • Multi-export package: exportExamples and exportSchemas in dev.ts list every export (the build fails otherwise).
  • In a flow file, add examples sparingly: they are mainly internal and for demos (for example a client flow's source with public: false examples showing its default behaviour).
💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)