Skip to main content

Create Your Own Destination

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

What is a destination?

A destination is a function that receives events from walkerOS and sends them to an external service, such as an analytics platform, an API, or a database.

The destination interface

A destination is an object that implements the Destination interface. The most important property is the push function, which is called for every event.

interface Destination<Settings = unknown> {
config: {};
push: PushFn<Settings>;
type?: string;
init?: InitFn<Settings>;
on?(
event: 'consent' | 'session' | 'ready' | 'run',
context?: unknown,
): void | Promise<void>;
}

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<Settings> = (
event: WalkerOS.Event,
context: Destination.PushContext<Settings>,
) => void;

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

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 interface
interface WebhookSettings {
url: string;
}

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

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

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

// 4. Send the event
fetch(settings.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
}).catch(console.error);
},
};

Schema validation (optional)

Destinations can export Zod schemas to provide runtime validation and TypeScript IDE support for configuration options. Export a schemas namespace containing SettingsSchema and MappingSchema to enable validation, autocomplete, and JSON Schema generation for tools like MCP and Explorer.

See the Destination Schemas Guide for implementation details, or reference existing destinations like Meta Pixel for complete examples.

The on method (Optional)

The optional on method allows your destination to respond to collector lifecycle events. This is useful for handling consent changes, session management, or cleanup tasks.

Available Events

  • consent - Called when user consent changes, with consent state as context
  • session - Called when a new session starts, with session data as context
  • ready - Called when the collector is ready to process events
  • run - Called when the collector starts or resumes processing
export const destinationWithConsent: Destination<WebhookSettings> = {
type: 'webhook-consent',
config: {},

on(event, context) {
if (event === 'consent') {
console.log('Consent updated:', context);
// React to consent changes - maybe clear cookies if consent withdrawn
}
},

push(event, { config }) {
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

  • Triggered only by walkeros setup <kind>.<name> (per-component, explicit). Never by push, simulate, deploy, or the runtime.
  • 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.
  • 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<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.
},
};

The corresponding flow config opts in via config.setup:

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

Then the operator runs:

walkeros setup destination.bigquery

See also

Conditional activation with require

Destinations can use require to delay registration until specific events fire. This prevents SDK loading and network requests before conditions are met:

await startFlow({
destinations: {
  ga4: {
    code: ga4Destination,
    config: {
      require: ['session'],
      consent: { marketing: true },
    },
  },
},
});

require and consent compose: require gates initialization before session information is available, consent gates every push (filter events by consent state).

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: {
destination: destinationWebhook,
config: {
settings: {
  url: 'https://api.example.com/events',
},
},
},
},
});

Advanced example: Session management

Here's a more advanced example that demonstrates session handling and cleanup:

export const destinationWithSession: Destination<WebhookSettings> = {
type: 'webhook-session',
config: {},

on(event, context) {
switch (event) {
case 'session':
// New session started
console.log('New session:', context);
// Could initialize session-specific tracking
break;

case 'consent':
// Handle consent changes
const consent = context as { marketing?: boolean; analytics?: boolean };
if (!consent?.marketing) {
  // Clear marketing-related data if consent withdrawn
  console.log('Marketing consent withdrawn, clearing data');
}
break;

case 'ready':
// Collector is ready
console.log('Starting destination services');
break;

case 'run':
// Collector resumed processing
console.log('Collector resumed, processing queued events');
break;
}
},

push(event, { config }) {
// Regular event processing
const { settings } = config;
if (!settings?.url) return;

fetch(settings.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
}).catch(console.error);
},
};

TypeScript integration

To get full TypeScript support for your destination's configuration, you can extend the WalkerOS.Destinations interface.

// types.ts
import type { Destination } from '@walkeros/core';
import type { WebhookSettings } from './destinationWebhook';

declare global {
namespace WalkerOS {
interface Destinations {
webhook: Destination.Config<WebhookSettings>;
}
}
}

Environment dependencies (testing)

The env parameter enables dependency injection for external APIs and SDKs. This allows you to test your destination 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

Use the 3rd generic parameter for type safety, then access env in init or push:

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 };
}

// Add Env as 3rd generic parameter for proper typing
export const destination: DestinationWeb.Destination<Settings, Mapping, Env> = {
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';

export const push: Env = {
window: {
customAPI: jest.fn(),
},
};

Export from your examples index:

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

Using in Tests

import { clone } from '@walkeros/core';
import type { Destination, Collector } from '@walkeros/core';
import { createMockLogger } from '@walkeros/core';
import { examples } from './index';

// Helper to create push context for testing
function createPushContext(
overrides: Partial<Destination.PushContext<Settings>> = {},
): Destination.PushContext<Settings> {
return {
  config: {},
  env: examples.env.push,
  logger: createMockLogger(),
  id: 'test-destination',
  collector: {} as Collector.Instance,
  data: {},
  rule: undefined,
  ...overrides,
};
}

describe('My Destination', () => {
it('calls custom API', async () => {
  // Clone the example env to avoid mutations
  const testEnv = clone(examples.env.push);

  const context = createPushContext({ env: testEnv });
  await destination.push(event, context);

  expect(testEnv.window.customAPI).toHaveBeenCalledWith('page view');
});
});

Key Points:

  • Production: No env needed, uses real APIs (window, fetch, SDKs)
  • Testing: Provide env with mocks for isolated testing
  • Type Safety: 3rd generic parameter gives full autocomplete
  • Fallback: getEnv(env) automatically uses real APIs if env not provided
  • Reusable: Store mock environments in examples/env.ts for consistency
  • Context helper: Use createPushContext() to standardize test context with id and rule

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, enabling MCP tools and the CLI to validate configurations without installing your package.

Optional: Hints

Packages can export a hints record from src/dev.ts to provide lightweight, actionable context beyond schemas and examples, 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
  • 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)