| name | walkeros-create-destination |
| description | Use when creating a new walkerOS destination to send events to a vendor or |
Create a New Destination
Prerequisites
Before starting, read these skills:
Choose Your Template
| Complexity | Template | When to Use |
|---|
| Simple | plausible/ | Single SDK call, minimal config |
| Complex | gtag/ | Multiple services, sub-destinations |
| Server | gcp/ | Server-side, batching, SDK init |
Process Overview
1. Research → Deeply understand vendor SDK, API, and event taxonomy
2. Classify → Determine vendor taxonomy type and integration approach
3. Examples → Define in/out pairs FIRST (start with the end result)
4. Mapping → Define walkerOS → vendor transformation
5. Scaffold → Copy template and configure
6. Convention → Add walkerOS.json metadata and buildDev
7. Implement → Build using examples as test fixtures
8. Test → Verify against example variations
9. Document → Write README
Phase 1: Research
Goal: Deeply understand the vendor SDK before writing any code. Research
quality determines implementation quality.
1.1 Find and Install Official SDK
Always prefer the vendor's official SDK package over raw HTTP API calls. The SDK
handles transport, batching, retries, and plugin ecosystems — don't reinvent
these.
npm search [vendor-name]
npm search @[vendor]
npm install @vendor/analytics-browser
ls node_modules/@vendor/analytics-browser/lib/esm/
1.2 Understand SDK Architecture
1.3 Identify All Event Methods
Go beyond just track(). Most SDKs have specialized methods:
| Method Category | Examples | walkerOS Handling |
|---|
| Event tracking | track(), logEvent() | Default push() |
| User properties | identify(), setUserProperties() | mapping.settings |
| Revenue/purchase | revenue(), purchase() | mapping.settings |
| Groups/accounts | setGroup(), groupIdentify() | mapping.settings |
| Identity setters | setUserId(), setDeviceId() | settings.identify |
| Opt-out | setOptOut(), consent() | on('consent') |
| Cleanup | flush(), reset() | destroy() |
1.4 Check Existing Patterns
Review similar destinations in the codebase:
ls packages/web/destinations/
Gate: Research Complete
Checkpoint: Research Review (Optional)
If working with human oversight, pause here to confirm:
- SDK integration approach correct?
- Event mapping makes sense for the use case?
- Any vendor quirks or limitations to handle?
Phase 2: Classify Vendor Taxonomy
Goal: Understand what the vendor expects, which determines destination
complexity.
2.1 Taxonomy Types
| Type | Description | Mapping Needed | Example Vendors |
|---|
| Free-form | Any event name accepted, no prescribed schema | Minimal — names pass through | Most modern analytics |
| Rigid | Prescribed event names unlock specific reports | Essential — must map to exact names | Some legacy analytics |
| Schema-based | Self-describing events with formal schemas | Structural — must build schema objects | Data warehouse tools |
2.2 Determine Integration Approach
| Approach | When to use | Pattern |
|---|
| Vendor SDK as host | SDK has plugins, batching, retries | Load SDK, disable what walkerOS replaces, use as transport |
| Script + command queue | SDK loaded via script tag | Load script, use global function queue |
| HTTP API | No SDK available, or server-side | Direct HTTP calls via sendWeb() or fetch |
Prefer the vendor SDK — it handles transport, retries, and plugin
orchestration. HTTP API is a fallback when no SDK exists.
When using the vendor SDK:
- Disable features walkerOS replaces (autocapture, identity storage) by
default — but allow users to re-enable via settings
- Pass through all SDK init options for full configurability
- Import types from the SDK package directly
Gate: Classification Complete
Phase 3: Create Examples (BEFORE Implementation)
Mandatory. Examples are the test fixtures for Phase 8. Define expected in
/ out / mapping triples FIRST — start with the end result in mind. Without
examples, you cannot test. Even for free-form vendors where no mapping is
strictly "required," step examples serve as the single source of truth for
tests, simulations, and documentation.
Authoritative pattern: See
using-step-examples for the Three
Type Zones and lifecycle. This skill reuses that contract — do not diverge.
3.1 Scaffold Directory Structure
mkdir -p packages/web/destinations/[name]/src/examples
mkdir -p packages/web/destinations/[name]/src/{schemas,types}
3.2 Required Files (3 files total)
All seven reference web destinations (gtag, meta, snowplow, plausible, piwikpro,
api, demo) use exactly three files in src/examples/. Match this structure — no
events.ts, outputs.ts, or standalone mapping.ts.
| File | Purpose |
|---|
examples/env.ts | Mock environment for testing (no real network calls) |
examples/step.ts | Flow.StepExample entries with in / out / mapping? triples |
examples/index.ts | Barrel exports: env and step |
The step.ts file embeds the input event, the mapping config, and the expected
vendor output together in one Flow.StepExample — subsuming what older skills
described as separate events.ts / outputs.ts / mapping.ts files.
3.3 Typing Rules (strict)
No any. Every example value must be explicitly typed.
- Inputs use
WalkerOS.Event (via getEvent() from @walkeros/core) —
never hand-roll event literals.
- Outputs use vendor SDK types imported from the official package
whenever the vendor publishes them. Do not invent local output types for
payloads the vendor already types (e.g. use Meta Pixel's
fbq argument types,
not a local FbqCall interface).
- Step entries are typed
Flow.StepExample from @walkeros/core.
- Mock env is typed against the destination's local
Env type from
../types. No as any, no untyped {}.
- Vendor SDK types come from the SDK you installed in Phase 1 — reuse them
rather than duplicating shapes.
3.4 Code Template — examples/step.ts
import type { Flow, WalkerOS } from '@walkeros/core';
import { getEvent } from '@walkeros/core';
export const purchase: Flow.StepExample = {
in: getEvent('order complete', { timestamp: 1700000100 }),
mapping: {
name: 'purchase',
data: {
map: {
transaction_id: 'data.id',
value: 'data.total',
currency: { key: 'data.currency', value: 'EUR' },
},
},
},
out: [
'event',
'purchase',
{
transaction_id: '0rd3r1d',
value: 555,
currency: 'EUR',
},
],
};
export const pageView: Flow.StepExample = {
in: getEvent('page view', { timestamp: 1700000102 }),
mapping: undefined,
out: ['event', 'page_view', { send_to: 'G-XXXXXX-1' }],
};
export const consentGranted: Flow.StepExample = {
command: 'consent',
in: { marketing: true, functional: true },
out: ['consent', 'update', { ad_storage: 'granted' }],
};
Init step example
Every destination ships an examples.step.init entry — the init is a
first-class step example, not a hidden side effect.
in mirrors the real Destination.Config shape users copy-paste — typically
{ loadScript: true, settings: { /* vendor-specific */ } }. Whatever a user
would configure in their flow goes here verbatim.
out is the ordered list of vendor calls the init() lifecycle produces
(script tags, SDK initializers, queue setup). Each effect tuple follows the
standard [callable, ...args] shape.
Test pattern. Call
destination.init({ id, config, env, logger, collector }) directly in the test
— no capture helpers, no capture.ts, no allowlists. Assert the captured vendor
calls equal examples.step.init.out:
const calls: unknown[][] = [];
const env = wrapEnv(examples.env.init, (call) => calls.push(call));
await destination.init({
id: 'test',
config: examples.step.init.in as Destination.Config,
env,
logger,
collector,
});
expect(calls).toEqual(examples.step.init.out);
Event step tests bootstrap once with examples.step.init.in, then slice the
shared capture buffer to isolate push effects from init effects:
const pushCalls = calls.slice(examples.step.init.out.length);
expect(pushCalls).toEqual(example.out);
There are no hand-maintained allowlists or isInitEffect filters — the init
example's out.length is the single source of truth for how many effects belong
to init.
Multi-tool packages (like gtag, which drives GA4, Google Ads, and GTM)
ship per-tool init examples — examples.step.ga4Init, adsInit, gtmInit
— instead of a single init. The docs render each on its own page via
<StepExample example={data.examples.step.ga4Init} />, and tests pick the right
init per sub-tool.
For destinations, the Three Type Zones collapse to:
in = walkerOS event (WalkerOS.Event)
out = vendor output (typed against vendor SDK)
mapping = rule under test (optional)
3.5 examples/index.ts (barrel)
export * as env from './env';
export * as step from './step';
3.6 examples/env.ts
Mock the vendor SDK surface and any DOM touchpoints. Never reach real network,
real cookies, or real globals. Type the exports against your local Env:
import type { Env } from '../types';
export const init: Env | undefined = {
};
export const push: Env = {
};
3.7 Test Fixture Contract (hard rule)
The examples authored here are the Phase 8 test fixtures. No parallel
fixtures allowed.
src/index.test.ts (or src/__tests__/stepExamples.test.ts) MUST iterate
examples via it.each(Object.entries(examples.step)).
- Tests must NOT contain hardcoded payloads, vendor configs, or expected
outputs.
- If a test needs a value that is not in
examples.step, add it to step.ts
first, then consume it from the test. Never inline test data.
- The only per-test setup allowed is deriving destination
settings from the
example's mapping.settings (e.g. enabling the right sub-tool).
See packages/web/destinations/gtag/src/__tests__/stepExamples.test.ts for a
canonical reference.
3.8 Export via dev.ts
export * as schemas from './schemas';
export * as examples from './examples';
Phase 3 Acceptance Checklist
Phase 4: Define Mapping
Goal: Document transformation from walkerOS events to vendor format.
Mapping rules live inside each Flow.StepExample entry in step.ts — no
separate mapping.ts file. Each step example embeds the exact mapping rule
under test alongside its in event and expected out output.
Verify Mapping Logic
For each entry in step.ts, trace:
Input: examples.step.purchase.in (WalkerOS.Event)
↓ Apply examples.step.purchase.mapping
↓ name transforms, data.map applied
Output: Should match examples.step.purchase.out
Gate: Mapping Verified
Phase 5: Scaffold
Template destination: packages/web/destinations/plausible/
cp -r packages/web/destinations/plausible packages/web/destinations/[name]
cd packages/web/destinations/[name]
Directory structure:
packages/web/destinations/[name]/
├── src/
│ ├── index.ts # Main destination (init + push)
│ ├── index.test.ts # Tests against examples
│ ├── dev.ts # Exports schemas and examples
│ ├── examples/
│ ├── schemas/
│ └── types/
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── jest.config.mjs
└── README.md
Transformer Chain Integration
Destinations can wire to transformer chains via before in the init config:
destinations: {
myDestination: {
code: destinationMyDestination,
config: { settings: { } },
before: 'redact'
}
}
Phase 6: walkerOS.json Convention
Every walkerOS package ships a walkerOS.json file for CDN-based schema
discovery.
Add walkerOS field to package.json
{
"walkerOS": { "type": "destination", "platform": "web" },
"keywords": ["walkerOS", "walkerOS-destination", ...]
}
Use buildDev() in tsup.config.ts
Replace buildModules({ entry: ['src/dev.ts'] }) with buildDev():
import { buildDev } from '@walkeros/config/tsup';
buildDev(),
This auto-generates dist/walkerOS.json from your Zod schemas at build time.
Hints (Optional)
If your destination has capabilities, behaviors, or troubleshooting patterns not
obvious from schemas alone, add hints. See walkeros-writing-documentation
skill for full guidelines.
Create src/hints.ts:
import type { Hint } from '@walkeros/core';
export const hints: Hint.Hints = {
'auth-methods': {
text: 'Supports X, Y, and Z auth methods. See settings schema for all options.',
code: [{ lang: 'json', code: '{ "settings": { ... } }' }],
},
};
Export from src/dev.ts:
export * as schemas from './schemas';
export * as examples from './examples';
export { hints } from './hints';
Guidelines:
- Expand awareness — describe capabilities ("supports X, Y, Z"), don't prescribe
one path
- Reference schemas and examples, don't duplicate them
- Verify every claim against actual implementation before publishing
- Key naming: kebab-case, group with prefixes (auth-*, storage-*,
troubleshoot-*)
- Most destinations don't need hints — schemas and examples cover the common
case
Gate: Convention Met
Runtime-only npm dependencies
If your package wraps a third-party npm dep that cannot be ESM-bundled (uses
__dirname, ships a .node binary, etc.), declare it under
walkerOS.bundle.external in your package.json. See
walkeros-using-cli → Bundle externals
for the complete contract.
Phase 7: Implement
Now write code to produce the outputs defined in Phase 3.
Template Files
Use these templates as your starting point:
| File | Purpose | Template |
|---|
types/index.ts | Type definitions | types.ts |
index.ts | Main destination | index.ts |
Key Patterns
- Init receives context: Destructure
config, env, logger, id from
context
- Push receives context: Includes
data, rule (renamed from mapping),
ingest
- Use
getEnv<Env>(env): Never access window/document directly, and
never cast them. Declare your SDK global
(declare global { interface Window { vendorSdk?: ... } }) and narrow
Env['window'] to the SDK shape in types.ts. The generic
getEnv<Env>(env) then returns your narrowed env intersected with the real
DOM globals, so window.vendorSdk and document.createElement are fully
typed with zero casts. No as any, as unknown, as Window, as Document,
@ts-ignore, or @ts-expect-error is allowed in production or test
code, ever. If something will not type check, fix the types, do not cast.
- Return config from init: Allows updating config during initialization
- Optional
destroy method: Implement if the destination holds resources
(DB connections, SDK clients, timers) that need cleanup on shutdown. Call
flush() or equivalent on the vendor SDK.
- Mapping-based settings (recommended): Use standard walkerOS mapping
values (
map, loop, key, value, condition) in mapping.settings.*
for vendor-specific operations. Resolve via getMappingValue() in push()
and interpret the resolved object's keys as SDK method instructions. This
keeps config agnostic and reuses the mapping engine.
- Consent two-layer:
config.consent gates walkerOS event delivery.
on('consent') controls vendor SDK internals (opt-out, pause capture, etc.).
Both needed for complete consent compliance.
- For step-example tests, use
command: 'consent' on Flow.StepExample to
invoke the on('consent') handler. Do not push consent data as an event.
Reserved fields
config.setup is reserved for the setup lifecycle (see
Adding setup (optional)). Do not use the key setup
for unrelated package metadata or hint keys. The framework wires this field to
SetupFn via resolveSetup, and walkeros setup destination.<name> reads it.
Repurposing the name will collide with that wiring.
Gate: Implementation Compiles
Adding setup (optional)
A destination package can implement an optional setup() function to provision
external resources idempotently: BigQuery datasets and tables, Pub/Sub topics,
SQLite tables, warehouse schemas, S3 buckets, webhook registrations on
downstream platforms. Setup runs only when an operator explicitly types
walkeros setup destination.<name>. The runtime never auto-invokes it from
init(), push(), or destroy().
The framework provides the slot, the CLI command, and a resolveSetup helper.
The package owns: what setup means, idempotency, error handling, return value.
For background on how setup fits the destination lifecycle, see
understanding-destinations.
Types
import type { CoreDestination } from '@walkeros/core';
export interface Settings {
}
export interface InitSettings {
}
export interface Mapping {
}
export interface Env {
}
export interface Setup {
location?: string;
partitioning?: { field: string; type: 'DAY' | 'HOUR' };
}
export type Credentials =
CoreDestination.Credential<CoreDestination.ServiceAccount>;
export type Types = CoreDestination.Types<
Settings,
Mapping,
Env,
InitSettings,
Setup,
Credentials
>;
Where credentials belong. Put service-account / key-pair credentials (the
kind paired with $env.NAME to inject a secret) on the strictly-typed top-level
config.credentials, not inside settings. Read them via
config.credentials ?? settings.credentials ?? ADC so existing flows keep
working, and omit them to fall back to Application Default Credentials. The raw
settings.<sdk> passthrough (e.g. settings.bigquery) stays as the lower-level
escape hatch for SDK-specific auth options. The package-specific
settings.credentials still works but is deprecated.
Implementation
import type { CoreDestination, SetupFn } from '@walkeros/core';
import { resolveSetup } from '@walkeros/core';
import type { Setup, Types } from './types';
const DEFAULT_SETUP: Setup = {
location: 'EU',
};
export const setup: SetupFn<
CoreDestination.Config<Types>,
CoreDestination.Env<Types>
> = async ({ config, env, logger }) => {
const options = resolveSetup(config.setup, DEFAULT_SETUP);
if (!options) return;
};
Wire it in your default export:
import { setup } from './setup';
export default {
type: 'my-destination',
push: ,
setup,
};
When to implement
Implement setup() when your destination needs first-time provisioning of
external resources before events can flow: warehouse tables, Pub/Sub topics, S3
buckets, schema bindings, IAM roles, webhook registrations on downstream
platforms. Skip it when your destination only consumes credentials or HTTP
endpoints the user already provisioned.
Contract
- Triggered only by
walkeros setup <kind>.<name>. Never by runtime push, init,
or destroy.
- Idempotency is your responsibility. Re-running setup against a fully
provisioned environment must be a safe no-op. Use try-create-catch-409 on REST
APIs,
IF NOT EXISTS on SQL, native idempotent operations where available.
The framework does not retry, track state, or detect drift.
- Return structured data from
setup() when useful for operator scripting. The
CLI emits non-undefined return values as JSON to stdout.
- For packages where
setup: true (boolean form) is meaningless because
mandatory fields have no safe defaults (e.g., Kafka numPartitions, GitHub
webhook webhookUrl), reject the boolean form with a clear runtime error
listing required fields:
if (config.setup === true) {
throw new Error(
'kafka destination setup requires explicit options: ' +
'{ topic, numPartitions, replicationFactor }. There is no safe default.',
);
}
Phase 8: Test Against Examples
Tests verify implementation against the examples from Phase 3. If examples are
incomplete, tests will be incomplete.
See testing-strategy for the shared
env / dev-examples conventions this phase depends on.
Verify implementation produces expected outputs.
Test Template
Use the test template: index.test.ts.
Reference canonical implementation:
packages/web/destinations/gtag/src/__tests__/stepExamples.test.ts.
Key Test Patterns
it.each(Object.entries(examples.step)) is mandatory — one iteration per
step example. Do not write per-feature tests with hand-rolled payloads.
- Use
createPushContext() helper — standardizes context creation.
- Include
id field — required in context.
- Use
rule instead of mapping — property renamed in PushContext.
- Zero hardcoded payloads — every input, vendor config, and expected output
comes from
examples.step or examples.env. If you need something new, add
it to examples first.
- Clone env per test —
clone(examples.env.push) so vendor mocks don't
leak across iterations.
Gate: Tests Pass
Phase 9: Document
Follow the writing-documentation
skill for:
- README structure and templates
- Example validation against
apps/quickstart/
- Quality checklist before publishing
Key requirements for destination documentation:
Validation Checklist
Beyond
understanding-development
requirements (build, test, lint, no any):
Reference Files
| What | Where |
|---|
| Simple template | packages/web/destinations/plausible/ |
| Complex example | packages/web/destinations/gtag/ |
| Types | packages/web/core/src/types/destination.ts |
Related Skills