01 / apply it
yardr pack apply posthogyardr carries this pack in its binary; the name is enough. yardr pack check posthog shows what it would change first. It goes into the workspace itself, and into no repo · depot.
yardr pack apply https://yardr.dev/packs/posthog/From this site, as the files below.
02 / the files
Every file, as it ships.
# PostHog as a pack of kind source: the integration, imported into the yard
# and into no depot. What PostHog is: HogQL queries, error tracking
# issues, session recordings and Replay Vision observations an agent reads
# through `yardr call`, and the webhook that hears a new error issue.
# One call writes: the destination, made once by the hook card.
#
# yardr pack apply posthog
#
# The source is named posthog: yardr knows PostHog as this pack alone.
name: posthog
kind: source
setup: SETUP.md
sources:
posthog:
declaration: posthog.yaml
#!/bin/sh
# A verified PostHog webhook destination body to the yard's envelope:
# stdin {"headers":{…},"body":{…}}, stdout the envelope. Needs jq.
# internal/hooks feeds it the bodies its tests recorded and compares the
# envelopes with the shapes below.
#
# The body is the destination's JSON body as the setup has it: the
# template's default {event, person} with the project added
# ({"event": "{event}", "person": "{person}", "project": "{project}"}),
# since the default names no project. An $error_tracking_issue_created
# event becomes type error_issue in the thread of its issue, with the
# triggering exception's session and its replay link in its fields when
# the event carries one; a `survey sent` event becomes type feedback
# with the answer as its text, the event's uuid as its thread, and the
# survey, the sender's email, the footplate version and the OS in its
# fields when the delivery carries them; any other body becomes type
# event, so a delivery the destination was not matched for (a test from
# its page) is recorded and routed as today, and never refused with a
# 400 the service retries. Nothing of the body is ever printed to stderr.
exec jq -c '
def obj: if type == "object" then . else {} end;
def str($v): if ($v | type) == "string" then $v elif $v == null then "" else "\($v)" end;
# trunc is hooks.truncate: whitespace collapsed, at most 500 runes. The jq
# POSIX space class includes the Unicode spaces split by Go strings.Fields.
def trunc: (gsub("^[[:space:]]+|[[:space:]]+$"; "") | gsub("[[:space:]]+"; " ")) | if length > 500 then .[0:499] + "…" else . end;
(.body | if type == "object" then . else error("posthog webhook body is not an object") end) as $b
| ($b.event | obj) as $e
| (($b.project | obj).id | str(.)) as $project
| ((($b.project | obj).url | str(.)) | sub("/+$"; "")) as $base
| (if $project != "" then {project: [$project]} else null end) as $fields
| if ($e.event // "") == "$error_tracking_issue_created" then
# The issue is the distinct_id; its properties carry the same id as
# $exception_issue_id, which stands in where no distinct_id came.
((($e.distinct_id // "") | if type == "string" then . else "" end)
| if . != "" then . else ((($e.properties | obj)."$exception_issue_id" // "") | if type == "string" then . else "" end) end) as $id
| (($e.properties | obj).name | str(.)) as $name
| (($e.properties | obj).description | str(.) | trunc) as $text
# The link is the one place body data leaves as an address a reader
# follows, so it is made only as PostHog makes it: an https project
# URL, and the UUID of the issue in its own segment (the id the issue
# call takes). Another scheme, or an id carrying a slash, query or
# fragment, makes no link; the issue is still heard, unlinked.
| (if ($base | test("^https://[^[:space:]]+$")) and ($id | test("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"))
then "\($base)/error-tracking/\($id)" else "" end) as $url
# The session is the $session_id of the triggering exception, which
# the event carries under exception_props when the SDK set one (and
# nowhere when the message was too large to carry it). The replay
# link is made under the same guard as the issue link: an https
# project URL and a UUID session. Another shape is still heard as
# the session, with no link for a reader to follow.
| ((($e.properties | obj).exception_props | obj)["$session_id"] | if type == "string" then . else "" end) as $session
| (if ($base | test("^https://[^[:space:]]+$")) and ($session | test("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"))
then "\($base)/replay/\($session)" else "" end) as $replay
| ((if $fields == null then {} else $fields end)
| (if $session != "" then . + {session: [$session]} else . end)
| (if $replay != "" then . + {replay: [$replay]} else . end)) as $all
# No mentions: the description is an error message, and an @word in
# one names nobody. The entity is the issue, whose description
# persists across its deliveries.
| {source: "posthog", type: "error_issue", action: "created",
actor: {}, thread: {id: $id, title: $name, url: $url},
text: $text, url: $url, entity: $id, mentions: []}
| if ($all | length) == 0 then . else . + {fields: $all} end
elif ($e.event // "") == "survey sent" then
# Footplate feedback arrives as the survey shape: $survey_id names
# the survey the subscription filters on, and the answer
# is the bare $survey_response of a one-question capture, else the
# one $survey_response_<question id> value the delivery carries (the
# first in key order, where several came). An empty bare answer falls
# back to the keyed one, so the message is never dropped for a blank.
# No mentions: a message naming @word names nobody here. No URL is
# made from body data, and the submission stands alone, with no
# entity: each delivery is heard once, in the thread of its own uuid.
(($e.properties | obj)) as $props
| ($props."$survey_response") as $bare
| ([($props | to_entries[] | select(.key | test("^\\$survey_response_.+")) | .key)] | sort) as $keyed
| (if ($keyed | length) > 0 then $props[$keyed[0]] else null end) as $keyedval
| (if ($bare | type) == "string" and $bare != "" then $bare
elif $bare != null and ($bare | type) != "string" then str($bare)
else str($keyedval) end | trunc) as $text
| (($props."$survey_id" | if type == "string" then . else "" end)) as $survey
| (($props.email | if type == "string" then . else "" end)) as $email
| (($props.footplate_version | if type == "string" then . else "" end)) as $version
| (($props.os | if type == "string" then . else "" end)) as $os
| ((if $fields == null then {} else $fields end)
| (if $survey != "" then . + {survey: [$survey]} else . end)
| (if $email != "" then . + {email: [$email]} else . end)
| (if $version != "" then . + {footplate_version: [$version]} else . end)
| (if $os != "" then . + {os: [$os]} else . end)) as $all
| ((($e.uuid // "") | if type == "string" then . else "" end)) as $uuid
| {source: "posthog", type: "feedback",
actor: {}, thread: {id: $uuid},
text: $text, mentions: []}
| if ($all | length) == 0 then . else . + {fields: $all} end
else
{source: "posthog", type: "event", actor: {}, thread: {}, mentions: []}
| if $fields == null then . else . + {fields: $fields} end
end
'
# PostHog as a declared source: what an agent may ask of it through
# `yardr call` (a HogQL query, the error tracking issues, one issue, the
# session recordings, one recording, a session's Replay Vision
# observations), and what it sends in (a new error issue). One call
# writes: the destination, made once by the hook card; the pack has no
# thread, notify or link key, so the yard writes nothing else to PostHog.
#
# Checked against PostHog's docs and source on 20 October 2026: the query
# endpoint (posthog.com/docs/api/queries, and the generated query_create),
# the issues endpoints
# (products/error_tracking/backend/presentation/views/issues.py, routes.py,
# pagination.py), the users endpoint (posthog/api/user.py), the recordings
# endpoints (posthog/session_recordings/session_recording_api.py, the
# RecordingsQuery in posthog/schema.py), the observations endpoint
# (products/replay_vision/backend/api/observations.py, routes in
# products/replay_vision/backend/routes.py), the personal API key scopes
# (posthog/scopes.py, frontend/src/lib/scopes.tsx), the webhook destination
# template (nodejs/src/cdp/templates/_destinations/webhook/
# webhook.template.ts), the issue-created event, whose exception_props carry
# the triggering exception's $session_id
# (products/error_tracking/backend/temporal/lifecycle/side_effects.py and
# event_properties.py, products/cdp/backend/internal_events.py), the
# destination globals (posthog.com/docs/cdp/destinations/
# customizing-destinations) and the issue's and the replay's routes
# (products/error_tracking/manifest.tsx, products/replay/manifest.tsx).
#
# The destination call beside them, checked on 23 October 2026 at
# posthog/posthog f1c6477: the template's id and inputs (nodejs/src/cdp/
# templates/_destinations/webhook/webhook.template.ts), the functions
# endpoint, its create shape and its scope (products/cdp/backend/api/
# hog_function.py, posthog/scopes.py), the inputs' {value} shape and the
# filter shape (products/cdp/backend/validation.py), the issue-created
# internal event (products/error_tracking/backend/temporal/lifecycle/
# issue_created/activities.py) and the consumer that runs only internal
# destinations for internal events (nodejs/src/cdp/consumers/
# cdp-internal-event.consumer.ts, nodejs/src/cdp/schema/hogflow.ts).
#
# The register check beside it, checked on 24 October 2026 at the same
# pin: the list with its type filter (HogFunctionFilterSet), the full
# answers that carry the inputs (get_serializer_class), the search over
# the name and the description (safely_get_queryset, posthog/helpers/
# trigram_search.py: a literal match is exact, and exact is kept over
# similar) and the read scope (scope_object_read_actions,
# posthog/scopes.py), all in products/cdp/backend/api/hog_function.py
# unless said. A read answers secrets masked (mask_config_secrets), so
# the check matches the URL, never the secret.
#
# A command is a list, run without a shell. A path that does not start
# with / is a file beside this one, which `yardr source add` copies into
# the yard.
name: posthog
about: PostHog analytics and error tracking. Answers HogQL queries, lists error issues and session recordings, reads a session's Replay Vision observations, hears a new error issue.
# The name of the variable, never the value. A personal API key with the
# scopes query:read, error_tracking:read, user:read, session_recording:read,
# replay_scanner:read, hog_function:read and hog_function:write, sent as
# a Bearer token (posthog.com/docs/api/personal-api-keys). The write scope is
# for the destination call alone, which the hook card makes once, and the
# read scope beside it for the check that lists what stands before a
# retry registers again. hook.normalize is handed no credential at all.
credential:
env: POSTHOG_API_KEY
header: "Authorization: Bearer {credential}"
# ---- what it sends us ----------------------------------------------------
hook:
# Adapter.Verify stays data, checked by the core: the secret is handed
# to no command, and an unverified body reaches none. The destination
# signs nothing yardr can check (its signing secret follows the Standard
# Webhooks spec, which is base64 over id.timestamp.body, and yardr's
# hmac-sha256 is hex over body or timestamp and body), so the secret
# goes in a header of the destination's secret headers instead, compared
# as it arrives. The delivery id is the event's uuid, one per transition,
# so a retry is told however it re-serializes the body.
verify:
scheme: secret
signature: { header: X-Yardr-Secret }
delivery: { body: event.uuid }
bodies: unique
# PostHog's deliveries follow its alerts, not a rhythm, so silence is never quiet.
untimed: true
# The yard registers the destination itself on the hook card: it makes
# the secret, asks for the project id in the clear, and POSTs the
# destination with the public URL and the secret in its secret headers.
# A retry first lists what stands and adopts the destination already
# aimed at the hook, so a call PostHog took but never answered is not
# made twice.
register:
call: destination
url: url
secret: secret
ask: [project]
existing: { call: destinations, in: results, field: inputs.url.value }
# Adapter.Normalize. stdin {"headers":{…},"body":{…}}, stdout the
# envelope. The core sets source and delivery itself.
normalize: [posthog/normalize]
# The keys its events' fields may carry: a subscription's --where may
# name them beside type, action, team, project, repo and label. project
# is the id the destination's body carries under project (the setup adds
# it beside the default {event, person}, which names no project). session
# is the triggering exception's $session_id, which the issue-created
# event carries under exception_props when the SDK set one, and replay
# the replay link built from it, on the host that sent the delivery.
# survey, email, footplate_version and os are what a `survey sent`
# event carries ($survey_id, the sender's email, the footplate version
# and the OS), which a feedback subscription filters on.
keys: [project, session, replay, survey, email, footplate_version, os]
# ---- (today) what an agent may ask of it, through `yardr call` ------------
# Declared HTTPS requests, granted one by one. A command is never something
# an agent calls. Every call's project is the id the project's settings
# show, the team id the /api/projects/ routes take.
# The cloud the calls go to: the US base, unless the yard set the EU one
# (`yardr source set posthog base=eu`, or `yardr pack apply posthog
# --source-base posthog=eu`, which a re-apply keeps). The hook needs no
# such setting: the issue's URL is built from the project.url the
# delivery carries, on whichever host sent it.
base: https://us.posthog.com
bases:
us: https://us.posthog.com
eu: https://eu.posthog.com
calls:
# The hook card's own call, granted to none: it makes the webhook
# destination the hook hears through, from the webhook template, as an
# internal destination (only those run for internal events), enabled,
# firing on the issue-created event alone, with the delivery's body the
# setup's {event, person, project} and the secret in its secret headers.
# The operator deletes the destination in PostHog when the hook goes.
destination:
kind: update
about: Make the webhook destination the hook hears through
params:
project: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
url: { type: string, max: 2048 }
secret: { type: string, max: 256 }
# The delivery's body, fixed by the file: PostHog's templating
# globals for the event, the person and the project. They are
# parameters only because a request body writes no literal {name}:
# each default is sent as it stands, and the hook card asks for
# none of them. project stays the global (normalize reads the URL
# it renders), not the project id above.
event_global: { type: string, default: "{event}" }
person_global: { type: string, default: "{person}" }
project_global: { type: string, default: "{project}" }
request:
method: POST
path: /api/projects/{project}/hog_functions/
body: {
name: "yardr",
description: "Sends new error issues to the yard's hook.",
type: "internal_destination",
template_id: "template-webhook",
enabled: true,
inputs: {
url: { value: "{url}" },
method: { value: "POST" },
body: { value: { event: "{event_global}", person: "{person_global}", project: "{project_global}" } },
secret_headers: { value: { X-Yardr-Secret: "{secret}" } },
},
filters: { source: "internal-events", events: [{ id: "$error_tracking_issue_created", type: "events" }] },
}
# The hook card's check before it registers again: the internal
# destinations named yardr, with their inputs, of which a retry adopts
# the one already aimed at the hook instead of making another. A list
# answers without inputs unless full is asked, and the search holds
# the answer to the name; the card matches the URL. A read, granted
# with the rest.
destinations:
kind: read
about: List the internal destinations named yardr, with their inputs
params:
project: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
request:
method: GET
path: /api/projects/{project}/hog_functions/
query: { type: internal_destination, full: "true", search: yardr }
# The page's results, each with its inputs: what the check scans. A
# full destination carries its inputs' schema beside its inputs, and
# a handful run past the 64 KB a call otherwise returns.
answer: { select: [results], max: 262144 }
query:
kind: read
about: Run a HogQL query, answered with its columns and rows
params:
project: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
hogql: { type: string, max: 8192 } # free text: its value is in no event
request:
method: POST
path: /api/projects/{project}/query/
body: { query: { kind: "HogQLQuery", query: "{hogql}" }, name: "yardr" }
# Results and their columns; a name the query_log shows the query
# under, as the docs ask. A result table runs past the 64 KB a call
# otherwise returns, and a larger one is refused, not cut.
answer: { select: [results, columns], max: 262144 }
issues:
kind: read
about: List the error tracking issues, 25 by default
params:
project: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
limit: { type: string, pattern: "^([1-9][0-9]?|100)$", default: "25" } # 1 to 100, always sent, 25 unless given (PostHog's own default is 100)
offset: { type: string, pattern: "^[0-9]{1,9}$", optional: true }
request:
method: GET
path: /api/projects/{project}/error_tracking/issues/
query: { limit: "{limit}", offset: "{offset}" }
# The standard {count, next, previous, results} page; a page of issues
# runs past the 64 KB a call otherwise returns. The list takes no
# status filter, so every status comes back and the caller keeps what
# it wants.
answer: { select: [count, next, results], max: 262144 }
issue:
kind: read
about: Read one error tracking issue
params:
project: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
id: { type: string, pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", max: 36, log: true } # the issue's UUID
request:
method: GET
path: /api/projects/{project}/error_tracking/issues/{id}/
# The whole issue, as the read serializer has it: id, status,
# severity, name, description, first_seen, assignee, external_issues
# and cohort. One issue never nears the 64 KB a call returns.
recordings:
kind: read
about: List the session recordings, 50 by default
params:
project: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
person_uuid: { type: string, pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", max: 36, optional: true } # one person's UUID
session_ids: { type: string, max: 2048, optional: true } # a JSON list of session UUIDs, ["<id>"]: the list reads each query value as JSON
date_from: { type: string, max: 64, optional: true } # ISO or relative (-3d, the default when left out)
date_to: { type: string, max: 64, optional: true }
limit: { type: string, pattern: "^([1-9]|[1-4][0-9]|50)$", optional: true } # 1 to 50; PostHog's own default is 50
after: { type: string, max: 512, optional: true } # the cursor the previous answer's next_cursor holds
request:
method: GET
path: /api/projects/{project}/session_recordings/
query:
person_uuid: "{person_uuid}"
session_ids: "{session_ids}"
date_from: "{date_from}"
date_to: "{date_to}"
limit: "{limit}"
after: "{after}"
# {results, has_next, next_cursor}: the recordings' metadata (id,
# distinct_id, person, duration, times, start_url, counts, ongoing),
# whether more follow, and the cursor for them. A page runs past the
# 64 KB a call otherwise returns.
answer: { select: [results, has_next, next_cursor], max: 262144 }
recording:
kind: read
about: Read one session recording's metadata
params:
project: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
id: { type: string, pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", max: 36, log: true } # the session's UUID
request:
method: GET
path: /api/projects/{project}/session_recordings/{id}/
# The whole recording, as the read serializer has it: id, distinct_id,
# person, duration, times, start_url, counts, ongoing and the rest.
# One recording never nears the 64 KB a call returns.
observations:
kind: read
about: Read a session's Replay Vision observations, in words
params:
project: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
session_id: { type: string, pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", max: 36, log: true } # the session's UUID
request:
method: GET
path: /api/projects/{project}/vision/observations/
query: { session_id: "{session_id}" }
# The standard {count, next, previous, results} page; each observation
# carries its scanner's result in words (scanner_result.model_output's
# title and summary) beside its one-line summary_line. A page runs
# past the 64 KB a call otherwise returns. Only sessions a Replay
# Vision scanner has run on have any.
answer: { select: [count, next, results], max: 262144 }
whoami:
kind: read
about: Whose personal API key the credential is
request:
method: GET
path: /api/users/@me/
answer: { select: [email] }
check: whoami
# Setting up PostHog
The pack wires PostHog into the yard as the declared source `posthog`:
an agent reads it through `yardr call` (HogQL queries, error tracking
issues, session recordings, a session's Replay Vision observations),
and webhooks hear a new error issue, and footplate feedback. One call
writes: the destination, made once by the hook card; resolving or
assigning an issue stays in PostHog's pages.
1. **A personal API key.** In PostHog, your user settings → Personal API
keys (Settings → Personal API keys,
`https://us.posthog.com/settings/user-api-keys#personal-api-keys`, or
`https://eu.posthog.com/settings/user-api-keys#personal-api-keys` on
EU Cloud): create one with the scopes `query:read`,
`error_tracking:read`, `user:read`, `session_recording:read`,
`replay_scanner:read`, `hog_function:read` and `hog_function:write`,
for the queries, the issues, the check, the recordings, the
observations, the register check and the destination below. The yard
sends it as `Authorization: Bearer`, read from the variable
`POSTHOG_API_KEY`, the name and never the value.
2. **The project id.** In PostHog, the project's settings → Project ID
(`https://us.posthog.com/settings/project#variables`, or
`https://eu.posthog.com/settings/project#variables` on EU Cloud):
the number, which every call below takes as `project`.
3. **Apply the pack.** As the operator:
```sh
yardr pack apply posthog
# source posthog is declared: 9 calls under https://us.posthog.com (yardr source show posthog)
```
4. **Give the yard the key.** No file is written by hand: the command
asks for the key and shows nothing as it is typed or pasted, and the
yard's server writes it to a file only you can read
(`~/.yardr/sources/posthog.env`).
```sh
yardr source key posthog # POSTHOG_API_KEY, the personal API key: the operator's to give
```
Or have a crew member ask for it, and answer on its card, in
footplate or in `yardr chat` (`ctrl+o`), where the field shows dots:
```sh
yardr bead ask <bead> "PostHog's personal API key" --secret posthog --context "Settings → Personal API keys, scopes query:read, error_tracking:read, user:read, session_recording:read, replay_scanner:read, hog_function:read, hog_function:write."
```
The key goes nowhere else: not into mail, chat, a bead or a pack.
5. **Grant the calls and test them.** Reading is the operator's to
grant, every read call with `read`:
```sh
yardr source grant posthog read --group builders
yardr source check posthog
# posthog: check call whoami (GET /api/users/@me/): 200 in …ms, … bytes
# posthog: email: <the key owner's address>
```
In a session of a granted group, with the project id from step 2:
```sh
yardr call posthog issues project=<id>
yardr call posthog issue project=<id> id=<an issue's id>
yardr call posthog query project=<id> hogql="select event, count() from events group by event order by count() desc limit 10"
yardr call posthog recordings project=<id>
yardr call posthog recording project=<id> id=<a session's id>
yardr call posthog observations project=<id> session_id=<a session's id>
```
The issues come 25 by default (`limit`, at most 100, `offset` pages);
the list takes no status filter, so every status comes back. A query
answers its columns and rows; PostHog refuses `OFFSET` on API keys,
so page on `timestamp` instead. The recordings come 50 by default
(`limit`, at most 50, `after` pages with the previous answer's
`next_cursor`); `person_uuid`, `session_ids` (a JSON list, such as
`["<a session's id>"]`), `date_from` and `date_to` narrow them.
An issue's sessions come through `query`: every `$exception` carries
its issue's id, so one query names the sessions behind an issue:
```sh
yardr call posthog query project=<id> hogql="select distinct properties.\$session_id from events where event='\$exception' and properties.\$exception_issue_id='<an issue's id>'"
```
Then `observations` reads what PostHog already wrote about one of
those sessions in words, and the replay link is where a person
watches it:
```sh
yardr call posthog observations project=<id> session_id=<a session's id>
# https://us.posthog.com/project/<id>/replay/<a session's id>
```
(`?t=<seconds>` starts the replay at an offset.) Observations exist
only for sessions a Replay Vision scanner has run on: a summarizer
scanner is set up in PostHog, where running it costs PostHog
credits, and reading what it wrote costs nothing here. The pack
never asks PostHog to run one.
6. **The hook and its destination.** The yard registers the destination
itself: a crew member asks for the hook, and the operator approves the
card, giving the project id from step 2 in the clear. The yard makes
the signing secret, keeps it in the hook's file, and makes the
destination in PostHog from the webhook template, enabled, firing on
a new error issue alone. The card needs the yard's public hook
address first (`yardr settings set hook_public_url
https://<host>/hooks`).
```sh
yardr bead ask <bead> "Hook PostHog's new error issues up?" --run "hook add posthog --source posthog"
yardr bead answer <bead> <ask> --approve --value project=<id>
# hook posthog saved: POST /hooks/posthog (posthog); registered with POST /api/projects/{project}/hog_functions/ at https://us.posthog.com (201)
```
If the approval's answer is lost, approving again first lists what
stands and adopts the destination already aimed at the hook instead
of making another.
Removing the hook later removes nothing in PostHog: delete the
destination in error tracking's alerting settings too.
7. **Without the write scope, by hand.** Where the operator will not
give the key `hog_function:write`, step 6's card cannot register,
and the secret and the destination are made by hand instead. The
reads above work either way. Make the secret first: PostHog holds
whatever the destination is given, and the yard keeps the same in
the file the hook names, so make one and put it in both places. A
new file takes its mode from the shell's umask, so narrow it first.
Each command below is one thing to type, in a block of its own:
```sh
umask 077
```
```sh
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' > ~/.yardr/posthog.secret
```
```sh
chmod 600 ~/.yardr/posthog.secret
```
```sh
yardr hook add posthog --source posthog --secret-file ~/.yardr/posthog.secret
# hook posthog saved: POST /hooks/posthog (posthog)
```
Then the destination, in PostHog: Error tracking → Configuration →
Alerting → New notification → Go back to traditional editor (under
"Need more control?"), and pick "HTTP Webhook on issue created" —
the webhook template with the issue-created filter already set, not
the plain "HTTP Webhook", which never fires on it. On its page:
- **Webhook URL**: `https://<public host>/hooks/posthog` — the path
ends in the hook's name, the one `yardr hook add` was given above.
- **JSON Body**: the default names the event and the person but no
project, so add the project beside them:
```json
{"event": "{event}", "person": "{person}", "project": "{project}"}
```
- **Secret headers**: add `X-Yardr-Secret` with the secret made
above. The destination sends this header as-is; the yard verifies
it instead of a signature. Secret headers, not Headers: a secret
stays encrypted and out of the logs there.
- **Method** stays POST, and **Filters** already match the
issue-created event (`$error_tracking_issue_created`) alone: the
yard hears a new issue, not a reopened or spiking one. Leave them.
Create & Enable. Then prove the issue path before a real issue
arrives: on the destination's page open Testing (Start testing)
and replace the `event` object in the globals editor with an
issue-created event, leaving the rest of the globals as shown:
```json
{
"uuid": "<a fresh uuid>",
"event": "$error_tracking_issue_created",
"distinct_id": "<a fresh uuid>",
"timestamp": "2026-10-23T12:00:00.000000+00:00",
"properties": {
"name": "PostHog destination test issue",
"description": "Sent from the destination's Testing panel; not a real issue.",
"exception_props": {
"$session_id": "<a fresh uuid>"
}
}
}
```
Make each uuid fresh (`uuidgen`): the delivery id is the event's
uuid, so a second run with the same one is kept as a retry of
the first. Leave Make real HTTP requests on, and Test function:
the destination POSTs the pasted event through the JSON body
above, the hook answers 200, and a subscription with `--where
type=error_issue` hears it, with the session and its replay link
in its fields. It arrives as a genuine error_issue event — that
is the point — marked as a test only by its name and
description. If the panel reports the function was skipped, the
pasted event did not match the destination's filters. A bare
Test function without the paste proves only the URL and the
secret: its example event is a `$pageview`, which the yard
records as type `event`.
The panel posts whatever globals the editor holds, and the test
run delivers them when they match the destination's filters,
with a real fetch while mocking is off (checked against PostHog
at f1c6477, 23 Oct 2026:
frontend/src/scenes/hog-functions/configuration/HogFunctionTest.tsx
and hogFunctionTestLogic.tsx, whose test form defaults
mock_async_functions to false; nodejs/src/cdp/cdp-api.ts,
postFunctionInvocation and buildHogExecutorAsyncOptions;
products/cdp/backend/api/hog_function.py, which names the
globals "Mock global variables available during test
invocation"; posthog.com/docs/cdp/destinations/
customizing-destinations for the `{event}` template).
If you later give the key the write scope and add the hook through
step 6's card instead, delete this destination first: the card adopts
a destination already aimed at the hook's URL, and a hand-made one's
secret is not the hook's.
8. **Hear new issues, and file beads for them.** A subscription hears
them in an inbox:
```sh
yardr subscribe <hearer> posthog --scope all --where type=error_issue
```
When the delivery carries the triggering exception's session, the
event's fields carry the session and its replay link beside the
project, for a subscription's `--where` to name. A makes line on the
hook files a bead for each new issue instead, with the issue's title,
its description and a link to it. `hook add` replaces the definition,
so the secret file is given again — the hook's own, which `yardr hook
show posthog` names (with step 6's card the yard made it; with step
7's fallback it is `~/.yardr/posthog.secret`) — and the line names the
depot, since the event names no repository:
```sh
yardr hook add posthog --source posthog --secret-file <the hook's file> --makes "action=created event=error_issue -> task:new@<depot>"
# hook posthog saved: POST /hooks/posthog (posthog)
# its events make a bead for a thread none tracks:
# action=created event=error_issue -> task:new@<depot>
```
9. **Hear footplate feedback, and read it.** Beside the
issue destination, a second webhook destination in the same EU
project hears the `survey sent` events footplate's Feedback button
sends as answers to the API-type survey 'footplate feedback' (one
open-text question). The yard does not register this one: the hook
card's register stays the error-issue destination's alone, and the
operator makes the feedback destination by hand, aimed at the same
hook. In PostHog: Data pipelines → Destinations → + New →
Destination, search "Webhook" and + Create — the plain "HTTP
Webhook", which fires on whatever event its filters match. On its
page:
- **Webhook URL**: `https://<public host>/hooks/posthog` — the
same hook as step 6's, the path ending in the hook's name.
- **JSON Body**: the same body as the issue destination, so the
event arrives with its properties and the project beside them:
```json
{"event": "{event}", "person": "{person}", "project": "{project}"}
```
- **Secret headers**: add `X-Yardr-Secret` with the hook's
secret — the file `yardr hook show posthog` names (with step
6's card the yard made it; with step 7's fallback it is
`~/.yardr/posthog.secret`). Secret headers, not Headers: a
secret stays encrypted and out of the logs there.
- **Method** stays POST, and under **Match event and actions**
select `survey sent` alone: the yard hears every survey
response and reads only its own survey's, by the id in the
line below.
Create & Enable. (Checked 28 Oct 2026 against
posthog.com/docs/data-pipelines/destinations/webhook,
posthog.com/docs/surveys/destinations and
posthog.com/docs/surveys/implementing-custom-surveys, and at
posthog/posthog 86ba897:
nodejs/src/cdp/templates/_destinations/webhook/webhook.template.ts —
the "HTTP Webhook" template's inputs, their defaults, and secret
headers sent as entered; the survey shape of a one-question
capture, `$survey_id` with the bare `$survey_response`, and
`$survey_response_<question id>` per question.)
Then prove the feedback path before real feedback arrives: on
the destination's page open Testing (Start testing) and replace
the `event` object in the globals editor with a survey sent
event, leaving the rest of the globals as shown:
```json
{
"uuid": "<a fresh uuid>",
"event": "survey sent",
"distinct_id": "<a fresh uuid>",
"timestamp": "2026-10-28T12:00:00.000000+00:00",
"properties": {
"$survey_id": "<the feedback survey's id>",
"$survey_response": "Sent from the destination's Testing panel; not real feedback.",
"footplate_version": "0.0.0",
"os": "test"
}
}
```
Make each uuid fresh (`uuidgen`): the delivery id is the event's
uuid, so a second run with the same one is kept as a retry of
the first. Leave Make real HTTP requests on, and Test function:
the destination POSTs the pasted event through the JSON body
above, the hook answers 200, and the yardmaster subscription
below (`--where type=feedback --where survey=<the feedback
survey's id>`) hears it, with the version and the OS in its
fields. It arrives as
genuine feedback — that is the point — marked as a test only by
its text. If the panel reports the function was skipped, the
pasted event did not match the destination's filters.
Each feedback delivery is heard through a subscription on its
type and its survey, delivering to the yardmaster's inbox, with
the feedback survey's id in the survey field — for this yard,
the one mckean's mail names; it lives here, never in pack code:
```sh
yardr subscribe yardmaster posthog --scope all --where type=feedback --where survey=<the feedback survey's id>
```
Each feedback is an inbox line there, read as mail is read.
Feedback is read, never filed: a makes line
files no bead for feedback,
because the yard files beads only for deliveries about a
thread with a URL (`internal/dispatch/makes.go`), and a
feedback submission carries its uuid alone, with no link made
from it.
On EU Cloud the calls go to `https://eu.posthog.com` instead of
`https://us.posthog.com`: the pack declares both bases, and the yard
picks one by name, in step 3 (`yardr pack apply posthog --source-base
posthog=eu`, which a re-apply keeps) or any time after (`yardr source
set posthog base=eu`). The hook needs no such setting: the issue's and
the replay's URLs are built from the `project.url` the delivery carries,
on whichever host sent it.