posthog/develop-extension
Author a new PostHog browser extension, or port a posthog-js v1 extension, against the @posthog/browser-common Client/Extension contract. Use when adding or porting an extension (autocapture, pageview, surveys, replay, exceptions, web-vitals, campaign-params, feature flags, …).
npx skills add https://github.com/PostHog/posthog-js --skill develop-extension
A browser extension is an opt-in feature that implements Extension and talks to its host SDK exclusively through the
single Client adapter passed to setup and shared by extensions on that SDK instance. The contract is designed for
extensions shared across browser generations; concrete host adapters and loading integrations remain owned by their SDK
packages.
Prefer a class when porting a posthog-js v1 extension that is already a class. Retaining its method boundaries makes the
port easier to review and keeps future fixes comparable with v1.
import type { Client, Extension } from '@posthog/browser-common'
export interface MyExtensionOptions {
enabled?: boolean
}
export class MyExtension implements Extension {
readonly name = 'myExtension'
private _client: Client | undefined
constructor(private readonly _options: MyExtensionOptions = {}) {}
setup(client: Client): void | Promise<void> {
this._client = client
this.startIfEnabled()
}
startIfEnabled(): void {
// Install listeners and patches here; retain every Disposable.
}
stop(): void {
// Release resources owned by this instance.
}
dispose(): void {
this.stop()
this._client = undefined
}
}
name is unique within one client and is used for diagnostics and de-duplication.setup(client) may be async when it needs KV or other asynchronous state.dispose() is optional, synchronous, idempotent, and best-effort.Client.| Need | Use |
| ----------------------------- | ---------------------------------------------------------- |
| current identity | client.distinctId, client.anonymousId, client.groups |
| current session | client.session |
| record an event | await client.capture(event, properties?, options?) |
| add properties to every event | client.registerDynamicEventProperties(() => ({ … })) |
| react to finalized events | client.onEvent(({ event, properties }) => …) |
| call a PostHog endpoint | await client.sendRequest(path, init?) |
| react to server config | client.onRemoteConfig(…) |
| persist small state | client.kv |
| log | client.logger |
Create an extension-named child logger with client.logger.createLogger('[myExtension]') when its messages need a
prefix. onRemoteConfig immediately replays the latest known outcome; narrow on result.ok before reading
result.config and define safe behavior for { ok: false }.
sendRequest is a low-level transport bridge. Select the configured origin with target (api, flags, or
assets) and construct endpoint authentication using client.projectToken in the required query parameter, body,
header, or path.
registerDynamicEventProperties runs inline while the host builds an event. Read anyasync state during setup and close over it.
onEvent observes finalized events andcannot mutate them.
patch wrappers. Use createDisposable(teardown) for idempotent synchronous cleanup.
projectToken are synchronous. Capture, requests, andKV are awaitable; remote-config outcomes are delivered through onRemoteConfig.
Guard work after each await so disposal cannot be followed by late installation.
client.kv, not globals. Browser-v1 keys are passed verbatim to persistence. Unknown keys may becaptured as event properties, collisions can overwrite SDK state, and reset clears them. Use stable extension-owned
keys and define their exposure policy.
setup(client) andoptional dispose(); they do not wrap or subclass extension implementations.
Use Publisher for an event stream exposed by an extension. Keep the publisher private and expose only its listener:
import { Publisher, type Extension, type Listener } from '@posthog/browser-common'
interface FeatureFlagsChange {
flag: string
value: string | boolean | undefined
}
export class FeatureFlagsExtension implements Extension {
readonly name = 'featureFlags'
private readonly _changes = new Publisher<FeatureFlagsChange>()
readonly onChange: Listener<FeatureFlagsChange> = this._changes.listener
setup(): void {}
dispose(): void {
this._changes.dispose()
}
}
| v1 | Shared extension |
| ------------------------------------------------------------- | ------------------------------------------------------- |
| instance.capture(e, p) | client.capture(e, p) |
| instance.get_distinct_id() | client.distinctId |
| instance.get_property(k) / persistence | client.kv.get(k) |
| instance.config.X (static) | constructor option |
| instance.config.X (server-driven) | client.onRemoteConfig(...) |
| instance.sessionManager.checkAndGetSessionAndWindowId(true) | client.session |
| _addCaptureHook / observing events | client.onEvent(...) |
| registering an enricher | client.registerDynamicEventProperties(fn) |
| requestRouter.endpointFor(...) + _send_request | client.sendRequest(path, init?) |
| snapshot/keepalive send on unload | client.sendRequest(path, { transport: 'sendBeacon' }) |
dispose().Take posthog/develop-extension from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.