Skip to main content
Ask your AI

Create Your Own Destination

This guide provides the essentials for building a custom walkerOS destination.

The destination interface​

A destination is an object that implements the Destination.Instance interface from @walkeros/core. The most important property is the push function, which is called for every event.

interface Instance<T extends TypesGeneric = Types> {
  config: Config<T>;
  push: PushFn<T>;
  type?: string;
  env?: Env<T>;
  init?: InitFn<T>;
  pushBatch?: PushBatchFn<T>;
  on?: On.OnFn;
  setup?: SetupFn<Config<T>, Env<T>>;
  destroy?: DestroyFn<Config<T>, Env<T>>;
}

T bundles the destination's own types: Destination.Types<Settings, Mapping, Env>.

The push function​

The push function is where you'll implement the logic to send the event to your desired service. It receives the event and a context object.

type PushFn<T extends TypesGeneric = Types> = (
  event: WalkerOS.Event,
  context: Destination.PushContext<T>,
) => WalkerOS.PromiseOrValue<void | unknown>;

The push context contains:

  • config: Destination configuration with settings
  • env: Environment with window, document, etc.
  • logger: Logger instance
  • id: Unique destination identifier
  • data: Pre-computed data from mapping
  • rule: The matching mapping rule for this event
  • ingest: Optional request metadata from source
  • collector: The collector instance
  • reportError: Reports an error raised outside push, such as an SDK client's 'error' event, optionally for one event

Example: A simple webhook destination​

Here is an example of a simple destination that sends events to a webhook URL.

import type { Destination } from '@walkeros/core';

// 1. Define your settings and bundle your types
interface WebhookSettings {
  url: string;
}

type Types = Destination.Types<WebhookSettings>;

// 2. Create the destination object
export const destinationWebhook: Destination.Instance<Types> = {
  type: 'webhook',
  config: {},

  async push(event, { config }) {
    const { settings } = config;

    // 3. Access your settings
    if (!settings?.url) return;

    // 4. Send the event; a rejection marks the push as failed
    await fetch(settings.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(event),
    });
  },
};

The pushBatch function (optional)​

When the destination's config sets batch (see Batch scheduling), the collector buffers events and calls pushBatch once per flush instead of push per event. batch.entries holds one entry per event with its event, mapped data, rule, ingest and respond. Without pushBatch, every event goes to push and the batch setting has no effect.

If pushBatch throws or rejects, every entry goes to the destination's dead-letter queue and counts as failed. To report single rows, resolve { failed: [{ index, error }] } with indices into batch.entries: only those entries fail. A batch flushes after the elb calls that filled it have resolved, so its outcome shows only in the destination's status and logs. Pending batches are flushed at shutdown: the collector waits up to 5 seconds per flush and logs one that takes longer as failed.

Schemas​

A published destination exports JSON Schemas of its settings and mapping from src/dev.ts, usually written with Zod and converted with zodToSchema from @walkeros/core/dev. walkeros validate, the MCP tools and the docs read them from the package's walkerOS.json (see Package convention). The Meta Pixel destination is a complete example.

The on method (Optional)​

The optional on method lets your destination respond to collector lifecycle events, for example to handle consent changes or session management. Cleanup belongs in destroy.

The collector calls on only after init has run. A destination with on needs an init method, even an empty init() {}, or on is never called.

Available Events​

  • consent, user, globals, custom, config - The matching walker command changed collector state; context.data holds the values the command set
  • run - The collector started or received walker run
  • ready, session - A walker ready or walker session command (the session source sends session); they carry no data

The handler may be async. For consent, user, globals and custom, the collector waits for the handler to settle, up to config.timeout (10 seconds by default), before it pushes events to the destination. If the handler does not settle in that time, or rejects, the destination's events stay queued until a later delivery of the state settles, and are discarded at the next walker run. Lifecycle events are awaited too but hold no events, and they can reach the handler while it is still busy with a state update. The full rules are in State delivery.

export const destinationWithConsent: Destination.Instance<Types> = {
  type: 'webhook-consent',
  config: {},

  // Required for on() to be called
  init() {},

  on(type, context) {
    if (type === 'consent') {
      // context.data holds the consent values the command set
      console.log('Consent updated:', context.data);
    }
  },

  push(event) {
    console.log('Event:', event);
  },
};

Setup lifecycle (optional)​

Components may implement an optional setup() lifecycle to provision external resources (BigQuery datasets, Pub/Sub topics, SQLite tables, webhook registrations) before the runtime ever processes events. Setup is operator-time: it runs only when an operator explicitly invokes walkeros setup <kind>.<name>. The runtime never auto-invokes it.

This separation isolates one-shot infrastructure provisioning from the high-volume event hot path. Production flows can ship with restricted runtime credentials (write-only) while operators run setup with elevated credentials (create) once per environment.

The same lifecycle applies to sources, destinations, and stores. Transformers are pure functions and have no setup.

Behavior​

  • Setup is opt-in via config.setup in the flow config. The CLI invocation requires it to be true or an object; false or omitted means the operator's setup invocation is a narrated skip.
  • Idempotency is the package's responsibility. Re-running setup against a fully provisioned environment is a safe no-op.
  • walkeros setup runs init first and gives setup the config init returns, if any; when init returns false, setup is skipped. destroy runs after setup.
  • Structured JSON output is opt-in via --json (e.g. walkeros setup <kind>.<name> --json). In normal mode the CLI narrates the action and never splices a JSON envelope into the output, even if setup() returns a value. The opt-in setup behavior and idempotency are independent of --json.

Example: a destination with setup​

import type { Destination } from '@walkeros/core';

interface BigQuerySettings {
  projectId: string;
  datasetId: string;
  tableId: string;
}

export const destinationBigquery: Destination.Instance<
  Destination.Types<BigQuerySettings>
> = {
  type: 'bigquery',
  config: {},

  async setup({ config }) {
    const { settings } = config;
    if (!settings) return;
    // Create dataset and table if missing. Idempotent: re-running is a no-op.
    // Return any structured result the operator may want to inspect.
    return { datasetCreated: settings.datasetId, tableCreated: settings.tableId };
  },

  push(event, { config }) {
    // Hot path: only writes rows, never creates resources.
  },
};

A flow config opts in via config.setup, here for the BigQuery destination of @walkeros/server-destination-gcp:

{
  "destinations": {
    "bigquery": {
      "package": "@walkeros/server-destination-gcp",
      "config": {
        "setup": true,
        "settings": { "projectId": "my-proj", "datasetId": "events", "tableId": "raw" }
      }
    }
  }
}

Then the operator runs:

walkeros setup destination.bigquery

See also​

Using your destination​

To use your custom destination, add it to the destinations object in your collector configuration.

import { startFlow } from '@walkeros/collector';
import { destinationWebhook } from './destinationWebhook';

const { elb } = await startFlow({
  destinations: {
    myWebhook: {
      code: destinationWebhook,
      config: {
        settings: {
          url: 'https://api.example.com/events',
        },
      },
    },
  },
});

Environment dependencies (testing)​

The env parameter injects external APIs and SDKs into your destination, so you can test its logic without making actual API calls or requiring real browser globals.

Use cases:

  • Mock external SDKs (Google Analytics, Facebook Pixel, AWS SDK)
  • Test without network requests
  • Simulate different API responses
  • Run tests in any environment (Node.js, browser, CI)

Defining an Environment​

Define the external dependencies your destination needs:

// types.ts - Web destination
import type { DestinationWeb } from '@walkeros/web-core';

export interface Env extends DestinationWeb.Env {
  window: {
    gtag: (command: string, ...args: unknown[]) => void;
  };
}

// types.ts - Server destination
import type { DestinationServer } from '@walkeros/server-core';
import type { BigQuery } from '@google-cloud/bigquery';

export interface Env extends DestinationServer.Env {
  BigQuery?: typeof BigQuery;
}

Using Environment in Your Destination​

Add Env as the third type of your Types bundle for type safety, then access env in init or push:

import type { Destination } from '@walkeros/core';
import type { DestinationWeb } from '@walkeros/web-core';
import { getEnv } from '@walkeros/web-core';

interface Settings {
  /* ... */
}
interface Mapping {
  /* ... */
}
interface Env extends DestinationWeb.Env {
  window: { customAPI: (event: string) => void };
}

// Env is the third type of the bundle
type Types = Destination.Types<Settings, Mapping, Env>;

export const destination: DestinationWeb.Destination<Types> = {
  type: 'custom',
  config: {},

  async init({ config, env }) {
    // Initialize SDK using env, falls back to real APIs
    const { window } = getEnv<Env>(env);
    window.customAPI('init');
    return config;
  },

  push(event, { config, env }) {
    const { window } = getEnv<Env>(env);
    window.customAPI(event.name);
  },
};

Creating Test Environments​

Create reusable mock environments in an examples/env.ts file:

// examples/env.ts
import type { Env } from '../types';

const noop = () => {};

export const push: Env = {
  window: {
    customAPI: noop,
  },
};

// Call paths that simulate records
export const simulation = ['call:window.customAPI'];

Export from your examples index:

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

Using in Tests​

Tests push events through startFlow with this env and compare the recorded calls with your step examples, as in Using examples in tests.

Key points:

  • Production: No env needed, uses real APIs (window, fetch, SDKs)
  • Testing: Provide env with mocks for isolated testing
  • Type Safety: Env in your Types bundle types env in init and push
  • Fallback: getEnv(env) automatically uses real APIs if env not provided
  • Reusable: Store mock environments in examples/env.ts for consistency
  • Simulate: walkeros push --simulate runs the destination with examples.env.push and refuses a destination that has none, so it never calls the real vendor. Next to it, export simulation, the call paths that carry the vendor request (for example ['sendServer'] or ['call:PubSub.topic.publishMessage']), which simulate records and prints
  • Keep injected clients: when init builds an SDK client, reach it through env and create one only when none is injected, so the mock env reaches the request call

Package convention​

Every walkerOS package includes machine-readable metadata for tooling and discovery.

walkerOS field in package.json​

{
  "walkerOS": { "type": "destination", "platform": "web" },
  "keywords": ["walkerOS", "walkerOS-destination"]
}
FieldRequiredDescription
walkerOSYesObject with type and platform metadata

Build-time generation​

Use buildDev() from the shared tsup config to auto-generate walkerOS.json:

import { buildDev } from '@walkeros/config/tsup';

This file contains your package's JSON Schemas and examples, so MCP tools and the CLI can validate configurations without installing your package.

buildDev() fails the build when the package publishes no schemas.settings. A package with two or more walkerOS.exports also exports exportExamples and exportSchemas from src/dev.ts, each listing exactly those exports, the default included, with settings in every exportSchemas entry; a package with fewer exports sets neither.

Optional: Hints​

Packages can export a hints record from src/dev.ts with short notes that schemas and examples do not cover, such as authentication methods, storage behavior, or troubleshooting tips. Hints are serialized into walkerOS.json and surfaced via MCP tools. See the walkeros-create-destination skill for details.

Publishing checklist​

  • walkerOS field in package.json
  • Keywords include walkerOS and walkerOS-destination
  • buildDev() in tsup.config.ts
  • schemas.settings exported from src/dev.ts (per export for multi-export packages)
  • dist/walkerOS.json generated on build
  • npm run test passes
  • npm run lint passes
💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)