/packs / posthog

posthog

PostHog itself, in the workspace: queries, errors and sessions read, a new error heard.

  • integration
  • sources
  • PostHog

As of yardr at commit 67389f7, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agent · crew member.

01 / apply it

yardr pack apply posthog

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