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

## The destination interface[​](#the-destination-interface "Direct link to 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.

```typescript
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 "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<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[​](#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 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)[​](#the-pushbatch-function-optional "Direct link to the-pushbatch-function-optional")

When the destination's config sets `batch` (see [Batch scheduling](/preview/pr-749/docs/mapping/rule.md#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[​](#schema-validation-optional "Direct link to 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](#package-convention)). The [Meta Pixel](https://github.com/elbwalker/walkerOS/tree/main/packages/web/destinations/meta) destination is a complete example.

## The `on` method (Optional)[​](#the-on-method-optional "Direct link to 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[​](#available-events "Direct link to 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](/preview/pr-749/docs/collector/commands.md#state-delivery).

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

```typescript
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)[​](#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")

* 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[​](#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.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`:

```json
{
  "destinations": {
    "bigquery": {
      "package": "@walkeros/server-destination-gcp",
      "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](/preview/pr-749/docs/apps/cli.md#setup-command) for the operator-facing command, options, and exit codes.
* [Create your own source](/preview/pr-749/docs/sources/create-your-own.md) for source setup.
* [Stores](/preview/pr-749/docs/stores.md) for store setup and lifecycle ordering.

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

## Environment dependencies (testing)[​](#environment-dependencies-testing "Direct link to 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[​](#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")

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

```typescript
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[​](#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';

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:

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

### Using in Tests[​](#using-in-tests "Direct link to 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](/preview/pr-749/docs/getting-started/flow/step-examples.md#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[​](#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, 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[​](#optional-hints "Direct link to 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[​](#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
