> Part of the walkerOS documentation. Project overview and full index: <https://www.walkeros.io/llms.txt>

# Create Your Own Destination

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

## What is a destination?[​](#what-is-a-destination "Direct link to 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[​](#the-destination-interface "Direct link to 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.

```typescript
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 "Direct link to 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.

```typescript
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[​](#example-a-simple-webhook-destination "Direct link to Example: A simple webhook destination")

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

```typescript
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)[​](#schema-validation-optional "Direct link to 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](https://github.com/elbwalker/walkerOS/blob/main/DESTINATION_SCHEMAS.md) for implementation details, or reference existing destinations like [Meta Pixel](https://github.com/elbwalker/walkerOS/tree/main/packages/web/destinations/meta) for complete examples.

## The `on` method (Optional)[​](#the-on-method-optional "Direct link to 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[​](#available-events "Direct link to 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

The handler may be async. The collector awaits every call, lifecycle events such as `run` included, bounded by `config.timeout` (10 seconds by default). For `consent`, `user`, `globals` and `custom`, the destination receives the current state before its first push, and the collector waits for the handler to settle before it pushes events to the destination. If such a handler does not settle in that time, or rejects, the destination receives no events until a later delivery of the state settles; its events stay queued meanwhile and are discarded at the next `walker run`. State updates reach a handler one at a time: one that arrives while the handler is still busy, even past its timeout, is applied as the current state once the handler finishes, before any held event, so the newest state is always applied last. Lifecycle events such as `run`, `ready` and `session` still reach a handler that is busy with a state update. A lifecycle handler that does not settle holds no events. Handlers of different destinations run independently, so a slow handler in one destination does not delay events to other destinations.

An async state handler must settle: until it settles or reaches `config.timeout`, that destination receives no events. `elb` commands and `startFlow` resolve only after the destinations' `on()` handlers for that command have settled or timed out; a command sent while a handler is still busy with an earlier state update resolves at once, and its update is applied right after the running one, while that destination's events wait for it.

### Example: Consent-Aware Destination[​](#example-consent-aware-destination "Direct link to Example: Consent-Aware Destination")

```typescript
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)[​](#setup-lifecycle-optional "Direct link to 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[​](#behavior "Direct link to 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[​](#example-a-destination-with-setup "Direct link to Example: a destination with setup")

```typescript
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`:

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

Then the operator runs:

```bash
walkeros setup destination.bigquery
```

### See also[​](#see-also "Direct link to See also")

* [walkeros setup CLI command](https://www.walkeros.io/docs/apps/cli.md#setup-command) for the operator-facing command, options, and exit codes.
* [Create your own source](https://www.walkeros.io/docs/sources/create-your-own.md) for source setup.
* [Stores](https://www.walkeros.io/docs/stores.md) for store setup and lifecycle ordering.

## Conditional activation with `require`[​](#conditional-activation-with-require "Direct link to 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:

```typescript
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[​](#using-your-destination "Direct link to Using your destination")

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

```typescript
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',
  },
},
},
},
});
```

## Advanced example: Session management[​](#advanced-example-session-management "Direct link to Advanced example: Session management")

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

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

on(event, context) {
switch (event) {
case 'session':
  // Session detected: on every run, or on every consent change when the
  // session source is consent-gated. Carries no payload, read the session
  // from event.user or the session start event instead.
  console.log('Session detected');
  // 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[​](#typescript-integration "Direct link to TypeScript integration")

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

```typescript
// 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)[​](#environment-dependencies-testing "Direct link to 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[​](#defining-an-environment "Direct link to Defining an Environment")

Define the external dependencies your destination needs:

```typescript
// 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[​](#using-environment-in-your-destination "Direct link to Using Environment in Your Destination")

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

```typescript
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[​](#creating-test-environments "Direct link to Creating Test Environments")

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

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

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

Export from your examples index:

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

### Using in Tests[​](#using-in-tests "Direct link to Using in Tests")

```typescript
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
* **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
* **Context helper**: Use `createPushContext()` to standardize test context with `id` and `rule`

## Package convention[​](#package-convention "Direct link to Package convention")

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

### `walkerOS` field in package.json[​](#walkeros-field-in-packagejson "Direct link to walkeros-field-in-packagejson")

```json
{
  "walkerOS": { "type": "destination", "platform": "web" },
  "keywords": ["walkeros", "walkeros-destination"]
}
```

| Field      | Required | Description                                |
| ---------- | -------- | ------------------------------------------ |
| `walkerOS` | Yes      | Object with `type` and `platform` metadata |

### Build-time generation[​](#build-time-generation "Direct link to Build-time generation")

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

```typescript
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.

`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[​](#optional-hints "Direct link to 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[​](#publishing-checklist "Direct link to 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
