Generated banner for Building Workflows
Generated logo for Building Workflows

Building Workflows

PostHogOfficialPublished by PostHog

  • Skill
  • Workflow
  • Data and AI
  • MIT
  • No issues found

From the author

Build, edit, test, enable, and monitor PostHog workflows over MCP. Author the action/edge graph so it runs and opens cleanly in the visual editor, then change drafts surgically with patch operations. Use when asked to build, set up, automate, change, fix, or debug a workflow, campaign, broadcast, drip sequence, or event-triggered automation in the workflows product, including first-time messaging setup (email channel, sending domain, frequency cap) before the first workflow.
— the description by PostHog

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a systematic lifecycle for creating and maintaining PostHog workflows. It guides users through composing directed graphs of actions and edges, testing them step-by-step with sample payloads, and managing the transition from draft to active status. It emphasizes structural integrity to ensure compatibility with the visual editor and provides methods for surgical updates to workflows and email content.

What it does

  • Author workflow graphs as JSON structures containing triggers, actions, and edges.
  • Validate workflow structure to prevent visual editor errors.
  • Execute step-by-step test runs using sample event or person payloads.
  • Manage workflow lifecycles from draft creation to active deployment.
  • Apply surgical updates to active workflows using atomic patch operations.
  • Monitor workflow execution through logs and per-step trace analysis.

When to use it

  • When you need to build or automate event-triggered sequences, drip campaigns, or broadcasts.
  • When setting up initial messaging infrastructure like email channels and sending domains.
  • When debugging or fixing existing workflow logic or execution paths.
  • When modifying active workflows while ensuring minimal disruption to in-flight runs.

Advantages

  • Ensures structural consistency between JSON definitions and the visual editor.
  • Allows for granular testing of individual workflow steps before full activation.
  • Supports atomic updates to drafts without replacing the entire workflow configuration.
  • Provides clear visibility into execution traces and failure points.

Usage examples

“Set up a new workflow that sends a reminder email one day after a user signs up.”
The agent will verify the project's messaging setup, compose the workflow graph with a trigger and delay node, and guide you through testing the draft.
“I need to change the subject line of an email in my active 'Welcome' workflow.”
The agent will stage a draft of the workflow, apply the email patch to the specific action, and help you preview the impact before publishing.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/posthog/building-workflows/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip building-workflows.zip -d .agents/skills

Documentation by PostHog

Building workflows

A PostHog workflow is a directed graph: a list of action nodes (actions) wired by edges (edges), with exactly one trigger node that starts every run. You author that graph as JSON and ship it over MCP. Always call it a "workflow" to the user. "Hog flow" is the internal code name (HogFlow), not a user-facing term.

The single biggest failure mode is getting the graph JSON structurally wrong. The backend stores actions/config as loose JSON, but the visual editor parses every node against a strict schema, so a malformed node saves but then breaks the editor view for the whole workflow. Before composing or editing any graph, read references/graph-schema.md. It is the contract; do not improvise node shapes from these examples alone.

A one-off email that the user's code sends to one person, such as a password reset, a sign-in code or a receipt, is a transactional email, not a workflow. Use the sending-transactional-emails skill for it.

Before the first messaging workflow

When the user asks to set up workflows or messaging, or asks for an email, SMS or push workflow in a project that may not send yet, call workflows-setup-status before composing anything. It says which setup steps are open and links each one in the app. Walk the user through the open steps, set the sending rules, then offer suggested workflows. Read references/onboarding.md for the flow and the defaults to recommend.

Skip this for automations that only post to Slack or call webhooks: they need no email setup. If workflows-setup-status is not available, go straight to the lifecycle below.

The lifecycle

Work the workflow through these stages. Don't jump straight to enabling it.

  1. Compose the graph. Build actions + edges per references/graph-schema.md. For any function node, don't guess the template: list the live catalog with cdp-function-templates-list and read its required inputs with cdp-function-templates-retrieve.

  2. Create as a draft. workflows-create. Every workflow is created draft; it does not execute yet.

  3. Test-run it. workflows-test-run runs one step at a time. Start at the first step (omit current_action_id, or point it at the trigger) with sample globals ({event, person, groups}), shaped like the trigger's real payload: an event trigger needs an event matching its filters, and an internal-event trigger needs an event named in its filters.events (for the Slack trigger, a $slack_message_received event with the Slack property bag) and no person (see references/graph-schema.md). A status=skipped result means this payload would not fire the trigger: if you fabricated the payload, fix it to match the trigger; if it came from a real past run (workflows-get-invocation), the trigger's filter is wrong, not the payload. The result includes the next step's id (nextActionId). Feed that back as current_action_id and run again, walking step by step to the end. Skip delay nodes by jumping to the action after them (delays aren't simulated). Async side effects (HTTP/email/SMS/push) are mocked unless you set mock_async_functions=false. Read each step's trace to confirm the path taken.

  4. Read logs while iterating. workflows-logs shows the per-step execution trace (levels DEBUG to ERROR). This is how you see why a step skipped, branched, or errored.

  5. Edit, then re-test. Patch the graph with workflows-patch-graph (see Editing a draft). Every edit invalidates your earlier test — re-run the affected path before moving on. On a draft workflow, edits apply directly; on an active one they stage a draft (see Changing a live workflow).

  6. Enable (needs the user's explicit sign-off). workflows-enable flips it to active and an event/webhook/manual trigger starts firing on matching activity. From then on it runs on real people, and every change goes through the draft → test → publish cycle before taking effect — so finish testing, then get the user's explicit go before enabling. Don't enable on your own initiative.

  7. Dispatch (batch/schedule only). A batch or schedule workflow does not fire on enable alone. Send a one-off broadcast with workflows-run-batch, or attach a recurring schedule with workflows-schedule-create. A batch trigger fans out to a person audience, so scheduling it needs the workflows-blast-radius preview and its confirm token; a schedule trigger runs once per occurrence with no audience, so schedule it directly. Confirm with workflows-get that status=='active' and its read-only schedules field has an active entry.

  8. Monitor. Drill down: workflows-global-stats (which workflows are failing) to workflows-stats (one workflow's trend) to workflows-list-invocations (who it failed for) to workflows-get-invocation (the triggering payload) to workflows-logs (the failing step).

Full tool catalog, grouped by job: references/lifecycle-and-debugging.md.

Editing a draft

Patch, don't replace. Edit a draft with workflows-patch-graph: a small, ordered list of id-addressed operations (update_action, add_action, remove_action, add_edge, remove_edge, replace_action_edges). update_action deep-merges its patch, so changing one email subject is a few lines, not the whole graph. The ops apply atomically server-side (read, apply in order, validate, save only if valid), and the response echoes the full updated graph, so you never re-fetch before the next edit. This keeps each round-trip tiny instead of re-transmitting every action and edge.

workflows-update covers only what a graph patch can't express: top-level fields like name, description, exit_condition, conversion, trigger_masking, and variables. It rejects actions/edges outright - a partial list would silently drop every step it omits - so every graph change goes through workflows-patch-graph.

After any patch, re-test the path you changed (step 3). A patch that validates structurally can still route the wrong way.

Email content follows the same rule. The email inside a function_email step is edited with workflows-patch-action-email: the same id-addressed design ops as the template patch, plus an email_patch merge for subject/preheader/text/recipients, with the HTML re-rendered server-side so it always matches the design. Prefer it over workflows-patch-graph update_action for email content - an update_action that changes design leaves the stored html stale. Library templates are edited with workflows-patch-email-template, not workflows-update-email-template (which resends the entire design JSON). Compose and edit email designs with the designing-email-templates skill.

Changing a live workflow

Editing an active workflow stages a draft instead of changing what's running: nothing reaches real people until you publish. Work the cycle:

  1. Edit. workflows-patch-graph (or workflows-update for content fields) on the active workflow writes to its draft — the first edit copies the live graph into the draft, later edits compose onto it. workflows-get shows the staged draft in draft; the live config stays in actions/edges. Metadata (name, description) applies live immediately.

  2. Test the draft. workflows-test-run with use_draft=true executes the staged draft instead of the live config. Re-test every path you changed.

  3. Publish deliberately. workflows-publish without confirm returns in_flight_runs, a confirm_token, and an impact summary: per deleted step, about how many people are parked there and whether they move to a surviving step (moves_to) or exit; empty_variables that may render empty for people already past their new producer when they reach a reference (a structural warning — it can fire even when everyone in-flight is still upstream of the producer); schedule_conflicts where a schedule overrides a variable the draft removes. Echo the impact to the user and get their go-ahead, then call again with confirm=true and that confirm_token. A 409 means the draft changed since the preview and a 400 means the token expired (15 minutes) — preview again and re-confirm either way. Publish revalidates everything, so an invalid draft is rejected and live config stays untouched.

  4. Or bail. workflows-discard-draft throws the staged draft away.

In-flight runs follow the live config: once published, people mid-flow continue from their current step on the new version. Steps they already passed don't re-run; people parked on a step the publish deletes skip forward to its next surviving step (or exit at a dead end), exactly as the impact preview reported.

Timing edits apply to parked runs gradually, not instantly. Publishing a shortened delay (or a moved wait window) reschedules the runs parked on it via a rate-limited sweep. Runs already due to wake soon keep their original earlier wake untouched; only wakes that the sweep moves earlier are affected, and those land spread out, no sooner than a few minutes after publish (and never later than their original wake). Runs still parked shortly after publishing are expected - tell the user this rather than re-publishing or treating it as a failure.

Rolling back

Every live-content change appends a snapshot to the workflow's revision history. workflows-list-revisions lists versions (newest first); workflows-get-revision returns one version's full content. To roll back (or forward), workflows-restore-revision copies that version's content into the draft — it never touches the live config — then the normal publish cycle applies: test with use_draft=true, preview, confirm. The preview shows exactly what the rollback does to people in-flight, same as any publish.

A restore returns 409 when a draft is already open; publish or discard it, or pass overwrite=true to replace it. Two things a rollback cannot undo: runs that already moved or exited while the newer version was live keep their positions (their side effects happened), and a publish that shortened a delay may have pulled parked wake times earlier — rolling back doesn't push them later again.

What the server owns, never send it

The server compiles and manages these. Authoring them by hand is the fastest way to a broken workflow:

  • bytecode on any filter, trigger, condition, conversion, or masking. Compiled server-side from the human-readable properties/hash. Omit it; send filters: {...}, not bytecode.

  • trigger (top-level). Derived from the trigger action in actions. Read-only. Set the trigger by adding the trigger node, not by setting this field.

  • billable_action_types, version, id, created_*. Computed/managed.

Minimal worked example

Event trigger, wait 1 day, send email, exit. Note: exactly one trigger, every non-exit node has an outgoing edge, ids are referenced consistently by edges, and no bytecode is sent.

{
  "name": "Nudge after signup",
  "description": "One day after signup, send a reminder.",
  "exit_condition": "exit_only_at_end",
  "actions": [
    {
      "id": "trigger_node",
      "name": "Signed up",
      "type": "trigger",
      "config": {
        "type": "event",
        "filters": { "events": [{ "id": "user signed up", "name": "user signed up", "type": "events", "order": 0 }] }
      }
    },
    {
      "id": "delay_1",
      "name": "Wait 1 day",
      "type": "delay",
      "config": { "delay_duration": "1d" }
    },
    {
      "id": "email_1",
      "name": "Reminder email",
      "type": "function_email",
      "config": {
        "template_id": "template-email",
        "template_uuid": "<uuid returned by workflows-create-email-template>",
        "message_category_type": "marketing",
        "inputs": {
          "email": {
            "value": {
              "to": { "email": "{person.properties.email}", "name": "" },
              "from": { "email": "[email protected]", "name": "Example" }
            }
          }
        }
      }
    },
    {
      "id": "exit_node",
      "name": "Exit",
      "type": "exit",
      "config": { "reason": "Done" }
    }
  ],
  "edges": [
    { "from": "trigger_node", "to": "delay_1", "type": "continue" },
    { "from": "delay_1", "to": "email_1", "type": "continue" },
    { "from": "email_1", "to": "exit_node", "type": "continue" }
  ]
}

Email bodies come from the template library, not hand-written html:

  1. Reuse first. List the library with workflows-list-email-templates and pick a template that fits. A drip campaign typically references one base template (say, a branded announcement) from every email step.

  2. Create only if nothing fits. Author a new template design-first with the designing-email-templates skill: compose the design, omit html (the server renders html from the design).

  3. Reference it by putting the template's UUID in each step's config.template_uuid, as above. The save snapshots the template's subject, text, html, and design into the step.

  4. Differentiate per step with workflows-patch-action-email design operations - each step's snapshot is edited independently, so five steps from one base template can each carry their own content.

The snapshot is one-way: editing the library template later does not change steps that already referenced it, and patching a step never touches the library template. If a user expects a template edit to flow into their workflows, correct that - the steps keep their copies, and each one is updated with workflows-patch-action-email.

Always give templates a real plain-text text alongside the design: clients that block rich content show only text, so filler like "placeholder" reaches real inboxes.

Hard rules to surface to the user, not work around

  • Behavioral targeting is unsupported. "Did event X at least N times over the last M days" can't be expressed as a trigger or a batch/schedule audience. If asked, reject it and explain; don't approximate it with a broken filter. (The backend rejects behavioral cohorts in batch audiences outright.)

  • Batch audiences target who a person is, not what they did. Person properties and/or static/property-based cohorts only. Event/action filters in a batch audience are silently dropped, so they're rejected.

  • Prefer re-evaluating audiences. For batch, inline person-property conditions or a dynamic (filter-based) cohort re-evaluate as people qualify; a static cohort is a frozen list, use only for an explicit given set.

Files

references/graph-schema.md

Workflow graph schema

The contract for actions and edges. The stored workflow is loose JSON, but the visual editor validates every node against a strict schema keyed on type. A node that saves successfully but doesn't match this contract will break the editor view for the whole workflow when someone opens it. Treat the shapes below as required, not advisory.

Contents

  • Node (action) shape

  • Action types and their config

  • Edges

  • function* inputs

  • Duration strings (delay_duration, max_wait_duration)

  • Waiting until a date (delay_until)

  • Conversion & exit condition

  • Pre-submit checklist

Node (action) shape

Every action object has these common fields plus a type-specific config:

{
  "id": "unique_within_workflow",
  "name": "Human label",
  "description": "",
  "type": "<see action types>",
  "config": {},
  "on_error": "continue",
  "filters": null,
  "output_variable": null
}
  • id — unique within the workflow; edges reference it by from/to.

  • on_error — optional; only continue or abort. Omit to use the default.

  • filters — optional property filters gating the action: {properties: [<cond>]}. Send properties, not bytecode.

  • output_variable — optional; store a step result into a workflow variable. {key, result_path?, spread?}.

Action types and their config

Use only these type values — they are the complete supported set. An unknown or unsupported type breaks the editor's parse for the entire graph.

typeconfig
triggera trigger config (see below). Exactly one trigger node per workflow.
delay{ "delay_duration": "30m" }, or { "delay_until": {"expression": "<HogQL date>", "offset?": "-1d"}, "max_delay_duration?": "30d" } to wait for a per-person date. Exactly one of the two. See duration rules and delay_until below.
conditional_branch{ "conditions": [ { "filters": {"properties": [<cond>]}, "name?": "" } ] }. Index N pairs with the branch edge index: N.
random_cohort_branch{ "cohorts": [ { "percentage": 50, "name?": "A" } ] }. Percentages are relative weights and should sum to 100, but a total above or below 100 still splits traffic in the given proportions.
wait_until_condition{ "condition?": {"filters": {"properties": [<cond>]}}, "events?": [{"filters": {...}, "name?": ""}], "max_wait_duration": "7d" }. condition is optional: an events-only wait is valid (server seeds a missing condition as {filters: null}). Duration rules as delay.
wait_until_time_window{ "timezone": "UTC", "use_person_timezone?": false, "day": <"weekday" / "weekend" / "any" / ["monday",...]>, "time": <"any" / ["10:00","11:00"]> }.
function{ "template_id": "<live template id>", "inputs": { ... }, "mappings?": [] }. Don't guess the id or its inputs — discover them live (see below).
function_email{ "template_id?": "template-email", "template_uuid?": "<saved template UUID>", "inputs": {"email": {"value": {...}}}, "message_category_type?": <"marketing" / "transactional">, "tracking_enabled?": <bool> }. template_id is the literal template-email; omit it and the server infers it from the step type. Reference a saved library template (from workflows-list-email-templates) by putting its UUID in template_uuid, never in template_id — a UUID sent as template_id is moved into template_uuid automatically. When template_uuid is set and the value has no body keys (subject/text/html/design), the server copies the template's body into inputs.email.value at save (a snapshot — later template edits don't propagate); you still supply from and to. Set from to {integrationId: <sender id>} for one sender. To rotate across up to 10 senders, also set integrationIds: [<sender id>, ...]; keep integrationId as the first sender for compatibility. Workflow runs using the same sender list resolve to the same sender, including across multiple email steps. If you author any body key inline, your body wins and template_uuid is provenance only. tracking_enabled defaults to true; when false, no open pixel is injected and links are not rewritten, so opens/clicks are not recorded for this step (delivery/bounce/unsubscribe still are).
function_sms{ "template_id?": "template-twilio", "inputs": { ... }, "message_category_type?": "..." }. template_id is the literal template-twilio; omit it and the server infers it.
function_push{ "template_id?": "template-native-push", "inputs": { ... }, "message_category_type?": <"marketing" / "transactional"> }. template_id is the literal template-native-push; omit it and the server infers it. Sends a mobile push notification via FCM/APNs. Its inputs are richer than email's — title, body, and a channels list of the FCM/APNs integration ids to send through — so retrieve the template-native-push inputs_schema (as with function) for the exact keys, and use the project's push integration ids for channels.
exit{ "reason?": "Done" }. Usually one terminal exit node.
Branch and wait condition filters (the filters wrapper is mandatory)

conditional_branch and wait_until_condition gate on a filters object, the action-filter shape ({properties?, events?, actions?, source?, filter_test_accounts?}). The wrapper is not optional:

  • Write { "filters": { "properties": [<cond>] } } on each condition, never { "properties": [<cond>] } directly on the condition object. The bare form saves but the visual editor flags it and the branch compiles to a constant, so it never evaluates your condition.

  • conditional_branch conditions are property-only (person/group <cond>s). Event/action filters are rejected here ("Event filters are not allowed in conditionals").

  • wait_until_condition is event-aware: its condition.filters and each events?[].filters may also carry events/actions. An entry naming neither an event nor an action is dropped (it would match everything).

  • source is optional (defaults to events). Never send bytecode; the server compiles it from properties.

Trigger config (the trigger node)

Discriminated on config.type:

  • event — { "type": "event", "filters": { "events": [{ "id": "<event>", "name": "<event>", "type": "events", "order": 0, "properties": [<cond>] }], "properties": [<cond>], "filter_test_accounts": false } }. Fires on every matching occurrence. Throttle repeats with trigger_masking (dedup/sampling — not behavioral filtering).

  • webhook / manual / tracking_pixel — { "type": "webhook", "template_id": "<literal>", "inputs": { ... } }. These use a fixed built-in source template, not one you look up: template-source-webhook for both webhook and manual, template-source-webhook-pixel for tracking_pixel. Omitting or guessing it fails the create with Template not found against the trigger node. They are source_webhook templates and are not in the destination catalog (cdp-function-templates-list is type=destination), so don't try to discover them there. Each needs inputs.event and inputs.distinct_id wrapped in {value: ...}, and the right values differ per trigger type because a different request reaches the template:

    • webhook — a POST whose body you control: "inputs": { "event": { "value": "{request.body.event}" }, "distinct_id": { "value": "{request.body.distinct_id}" } }.

    • manual — the "trigger manually" button POSTs {user_id, $variables}, so the body carries no event: use the fixed literal "inputs": { "event": { "value": "$workflow_triggered" }, "distinct_id": { "value": "{request.body.user_id}" } }. Pointing event at {request.body.event} here makes every manual run return 400 (the source template rejects an empty event) while the UI still shows a success toast, so the workflow silently never fires.

    • tracking_pixel — a bodyless GET, so read query params: "inputs": { "event": { "value": "{request.query.ph_event}" }, "distinct_id": { "value": "{request.query.ph_distinct_id}" } }. Body references resolve empty here and the pixel still returns its 200 GIF, so every hit is dropped with no error to retry on.

  • batch — { "type": "batch", "filters": { "properties": [<cond>] } }. The audience: person-property conditions and/or cohort references. No event/action filters (silently dropped, so rejected). Does not fire on enable — dispatch a one-off broadcast with workflows-run-batch, or make it recurring with workflows-schedule-create (attaches an RRULE schedule; each firing re-broadcasts to this same config.filters.properties audience). A recurring broadcast is a batch trigger plus a schedule. Editing config.filters pauses any schedule attached to it, since the confirmed recipient count no longer holds - preview again and re-create the cadence.

  • schedule — { "type": "schedule" }. One person-less run per occurrence, for jobs that act on their own (for example a "Create AI task" step). Attach the cadence with workflows-schedule-create; no audience preview is needed. Changing this trigger to batch pauses any schedule already attached, because that cadence was never sized against an audience - re-create it after the blast-radius preview.

  • internal-event (flag-gated) — { "type": "internal-event", "filters": { "source": "internal-events", "events": [{ "id": "<event>", "type": "events" }], "properties": [<cond>] } }. Fires once for each matching event on the internal-events stream; filters.events must name at least one allowed event id. Runs have no associated person, so person-dependent steps are unavailable. Test-run with an event named in filters.events and no person; any other event name is skipped at the trigger. The Slack trigger is this with $slack_message_received in filters.events: the run's event carries properties integration_id, channel, channel_type, slack_team_id, user, bot_id, app_id, subtype, text, ts, thread_ts, is_thread_reply, is_ext_shared_channel, and slack_event (the raw Slack payload). A workflow leaving draft must include one exact channel property filter whose value contains one or more channel IDs; several IDs match any listed channel. The GitHub trigger uses $github_event_received and requires exact repository and event_type filters.

Trigger masking (throttling an event trigger)

trigger_masking is a top-level workflow field (not an action) that throttles an already-matching event trigger — it dedups/samples firings, it does not decide who enters.

"trigger_masking": { "hash": "{person.id}", "ttl": 3600, "threshold": null }
  • hash — HogQL template defining the dedup key. "{person.id}" = once per person.

  • ttl — seconds to suppress repeats of the same hash (60–94608000).

  • threshold? — fire once per N matches of the same hash (a sampler: N=3 fires on the 1st, 4th, 7th…). Omit to fire once then suppress within ttl.

  • Don't send bytecode — compiled server-side from hash.

Condition shape (<cond>)

Property conditions used in trigger/action filters, branch conditions, and conversion:

{ "key": "plan", "value": ["pro"], "operator": "exact", "type": "person" }

type is event | person | group. Never include bytecode — the server compiles it.

Edges

{ "from": "source_id", "to": "target_id", "type": "continue", "index": 0 }
  • type: "continue" — fall-through: the sequential next step, or the no-match path out of a conditional_branch. For a wait_until_condition it is the max_wait_duration timeout path.

  • type: "branch" — requires index, matching config.conditions[index] on a conditional_branch. A wait_until_condition resolves (its condition matches or an events entry fires) out the branch edge at index: 0.

  • Every non-exit node needs a reachable next action via an outgoing edge, or execution fails with "No next action found".

  • A conditional_branch with N conditions typically has N branch edges (index: 0..N-1) plus one continue edge for the no-match path.

  • A wait_until_condition needs a branch edge at index: 0 (resolution) and a continue edge (timeout). Without the index: 0 branch it only ever advances on timeout, never on the event/condition firing.

function* inputs

Inputs are keyed by the template's input schema, each wrapped in {value: ...}:

"inputs": { "url": { "value": "https://example.com/hook" } }
  • Wrap values in {value: ...}. A flat string won't enable templating.

  • Templating uses single-curly {person.x} / {event.x} inside the value string. Liquid-style {{ ... }} is rejected on hog-templated fields ("Placeholders are not allowed in this context") — the only fields that accept Liquid are ones whose input schema declares templating: liquid (the email input on function_email does; most others don't).

  • Dictionary input values are template strings too — write booleans/numbers as single-expression templates: "{true}", "{42}", which evaluate to the typed value.

  • Required inputs must be present, or create fails with "This field is required".

Discovering function templates (do this, don't guess)

The set of available function templates and their required inputs is live data, not something to hardcode — it changes as integrations are added. For a function node:

  1. cdp-function-templates-list (filter type=destination) to find the right template and its id.

  2. cdp-function-templates-retrieve with that id to read its inputs_schema — the exact keys, types, and which are required.

  3. Build inputs from that schema. A template_id not in the live list fails with "Template not found".

function_email, function_sms, and function_push are the exception — their template_id is the fixed literal template-email / template-twilio / template-native-push, so you don't look the template_id up, and you can omit it entirely: the server infers the literal from the step type. A saved email template's UUID goes in template_uuid alongside the literal, never in template_id (a UUID sent as template_id is moved into template_uuid automatically). For function_email, referencing a template means you only author from/to: the server materializes the template's body at save (see the action-types table). function_push still has variable inputs (notably channels), so retrieve its inputs_schema even though the id is fixed.

function_push worked example

Retrieve template-native-push with cdp-function-templates-retrieve for the full inputs_schema (it has many optional Android/iOS keys), but the core shape is:

{
  "id": "push_1",
  "name": "Re-engagement push",
  "type": "function_push",
  "config": {
    "template_id": "template-native-push",
    "inputs": {
      "distinctId": { "value": "{event.distinct_id}" },
      "channels": { "value": [6, 7] },
      "title": { "value": "Notification from {event.event}" },
      "body": { "value": "Hi {{ person.properties.first_name }}, come finish setting up.", "templating": "liquid" }
    }
  }
}
  • channels is an integration_multi input: its value is an array of integration id numbers (e.g. [6, 7]), not objects. Find the FCM/APNs integration ids with integrations-list (look for kind firebase / apns); at least one is required or the send throws "No push channel configured".

  • Templating differs per input. body is liquid — interpolate with {{ person.x }} / {{ event.x }} (double braces) and set "templating": "liquid". title and the other string inputs are hog — use {event.x} / {person.x} (single braces). The wrong brace style leaves the expression as a literal.

  • Required: distinctId, channels, title. Optional: body, image, data, ttlSeconds, android_*, ios_* (retrieve the inputs_schema for the full set).

  • Never hand-author bytecode — the server compiles it from value. Omit order too: the editor lays fields out in the template's inputs_schema order (fixed and consistent), not by the order on your inputs, so leaving it off doesn't change the form. Push has no delivered/opened/clicked signal (FCM/APNs respond synchronously), so a successful send means "accepted for delivery", nothing more.

Replay Vision step

Replay Vision is PostHog's AI that watches session recordings. It replays a session as video, together with its events, and answers a question about it. Each scan uses credits from the project's Replay Vision quota.

A function node with template_id: "template-posthog-replay-vision" scans the trigger event's session and waits for the answer. Use it when the next step depends on what happened in the session, which event properties alone can't tell, for example to add session context to a Slack alert or to branch on whether the user struggled. The project must record sessions, and the trigger event must carry a $session_id.

{
  "id": "vision_1",
  "name": "Did checkout fail?",
  "type": "function",
  "config": {
    "template_id": "template-posthog-replay-vision",
    "inputs": {
      "analysis": { "value": { "type": "monitor", "prompt": "Did the user hit an error during checkout?" } }
    }
  },
  "output_variable": { "key": "replay_vision" }
}
  • analysis is a literal object, not a template. Never set "templating" on it, and never put {...} expressions in it. Its value takes one of these shapes:

    typeRequired keysWhat the step answers
    monitorpromptA yes/no verdict on the question in prompt.
    classifierprompt, tags (1 to 100 category strings)Which of the categories in tags fit the session.
    scorerprompt, scale: {"min": <num>, "max": <num>}A score on the scale. max must be greater than min.
    summarizerpromptA title, a summary, and chapters.
    scannerscanner_id (a saved scanner's UUID)The saved scanner's answer, with its own type and prompt.
  • The inline types take the same optional settings as a saved scanner of that type. See the creating-replay-vision-scanners skill. Find saved scanners with the vision-scanners tools. The step refuses an experiment scanner, because it samples the experiment's variants evenly, which one triggered session can't do.

  • session_id defaults to {event.properties.$session_id}. Leave it unset unless the session ID is in another property.

  • wait_for_session_end defaults to true, which scans the recording after the session has been quiet for 35 minutes. Set it to false only when the trigger event comes after the session ended.

  • The step result is {"state", "output", "warnings"}. state is succeeded, failed, skipped, ineligible or lost. output is the scanner's answer: verdict, tags or score with reasoning for a monitor, classifier or scorer, and title, summary and chapters for a summarizer. Every type also returns confidence. Use output_variable to keep the result, then branch on it. When output is null, warnings says why: used-up credits, a session too short or too long to scan, or a failed scan.

  • Main environment only. The server refuses the step in a child environment of a project.

  • Daily caps. Each workflow can start 100 scans a day, and the project 500, unless the project settings change replay_vision_scan_rate_limit_per_day and replay_vision_scan_team_rate_limit_per_day. Past a cap the step returns state: "skipped" and the workflow continues.

  • Access. The token needs session_recording:read (or :write) and replay_scanner:write, and the user needs access to recordings and to the saved scanner. Without them, the save fails with a message that names what is missing.

Opt out of messages step

A function node with template_id: "template-posthog-opt-out-of-messages" adds the run's recipient to the opt-out list for one message category, or for all marketing messages. Later email, SMS and push steps for that category skip the recipient. Use it whenever a workflow has to opt someone out. Don't build this with a webhook step that calls messaging_preferences/add_opt_out/ with an API key: that call breaks when the key's owner leaves or the key rotates, and its failures only show as webhook errors.

{
  "id": "opt_out_1",
  "name": "Opt out of product updates",
  "type": "function",
  "on_error": "abort",
  "config": {
    "template_id": "template-posthog-opt-out-of-messages",
    "inputs": {
      "category_id": { "value": "<message category id>" },
      "identifier": { "value": "{person.properties.email}" }
    }
  }
}
  • category_id is a literal, not a template. Use a marketing message category's id (UUID), listed by message-categories-list, or "$all" for all marketing messages. If the category doesn't exist yet and the user's request covers creating it, for example an import that brings its own categories, create it with message-categories-create. Otherwise ask the user for it rather than guess. Never put {...} expressions in it. A save with a category the project doesn't have fails.

  • identifier is who to opt out. It defaults to {person.properties.email}. Use the same value as the recipient of the email step it should suppress, because opt-outs match the recipient exactly.

  • Only marketing categories work, because transactional messages ignore opt-outs. A save with a transactional category fails.

  • The step fails, and the workflow log says why, when identifier is empty or the category was deleted. It never passes silently.

  • Set "on_error": "abort" on the node. Without it, a failed opt-out continues to the next step, which may send the message the person asked to stop.

  • Running it again for someone who is already opted out succeeds and logs "Already opted out".

  • The step is behind the workflow-opt-out-of-messages-action feature flag. Without the flag, the save fails with "Template not found".

Duration strings (delay_duration, max_wait_duration)

Must match ^\d*\.?\d+[dhms]$ — a number plus unit s | m | h | d. Examples: 30s, 30m, 2h, 1d, 1.5d (=36h).

  • No ISO-8601. Fractions are allowed in every unit.

  • Per-unit caps: s≤60, m≤60, h≤24, and d≤365 for delay_duration and max_delay_duration but d≤30 for a wait's max_wait_duration. A save over a cap is rejected, so use the larger unit (90m → 1.5h). Workflows saved before the check may still store an over-cap value, which runs at the cap.

Waiting until a date (delay_until)

A delay waits either a fixed span or until a date carried by the person or the event. Use delay_until when the date differs per person, which a duration cannot express (a trial expiry, a renewal date, a booked appointment).

{
  "id": "delay_1",
  "type": "delay",
  "config": {
    "delay_until": { "expression": "person.properties.trial_expiration_at", "offset": "-1d" },
    "max_delay_duration": "30d"
  }
}
  • Exactly one of delay_duration and delay_until. Both together are rejected; so is neither.

  • expression is HogQL resolving to a datetime. An ISO string, a HogDateTime, and unix seconds all resolve to the same instant, so a stored date works whichever shape the customer set it in. A bare number is read as seconds: a millisecond timestamp (what Date.now() and most SDKs produce) is rejected rather than read as a date tens of thousands of years out. Divide it by 1000 in the expression, or store the date as an ISO string.

  • offset shifts that instant and is signed: ^-?\d*\.?\d+[dhms]$. -1d is a day before the date, 2h is two hours after. Omit it to wait for the date itself. Keep the arithmetic here rather than in the expression, so the builder can still read the step back as a property plus an offset.

  • Unlike delay_duration, an offset is not clamped per unit: -48h means 48 hours, not 24. max_delay_duration bounds the wait instead.

  • timezone, use_person_timezone and fallback_timezone work as they do on wait_until_time_window, and decide which zone a date with no offset of its own is read in (a bare 2026-03-01, or 2026-03-01T09:00:00). A date that states an offset, a HogDateTime, and unix seconds are absolute and ignore them. Default UTC, so a date-only value means midnight UTC unless you set a zone.

  • max_delay_duration caps how far past the step's start the wait may run (default 30d, same duration rules as above). It exists so a far-future date cannot park a run indefinitely.

  • Never send bytecode. The server compiles the expression at save time and discards anything the client sent. A broken expression fails the save with the parse error.

  • The expression is re-read on every wake, not only on entry, and the only wake a parked delay schedules is the instant it computed. So a date that moves further out is honored: the run wakes at the old instant and parks again to the new one. A date that moves closer is not: nothing wakes the run early, so it waits for the original instant and then continues at once, later than the new date asked for.

  • A date that cannot be resolved aborts the run whatever on_error says, because a wait that cannot work out when to act has nothing safe to fall through to.

  • A person property is person.properties.<key>; an event property is properties.<key>, with no event. prefix. The expression runs against the same globals as a filter, where event is the event's name, so event.properties.<key> resolves to nothing and aborts the run.

  • The builder writes those two shapes (bracketed when the key is not a bare identifier). Any other expression saves fine but shows in the editor as a read-only custom expression instead of a property pick.

Conversion & exit condition

  • exit_condition: exit_only_at_end (default), exit_on_conversion, exit_on_trigger_not_matched, exit_on_trigger_not_matched_or_conversion.

  • The …conversion variants require a conversion goal with two slots plus a window:

    • filters — property conditions only, an array [{key, value, operator, type}, ...] (empty array = any event in the window converts).

    • events — event-based goals, [{ "filters": { "events": [{ "id": "<event>", "name": "<event>", "type": "events" }] } }].

    • window — how long after entry a conversion still counts, as a duration string ("7d", "12h", "30m", "45s"), max "365d". Omit it (or null) for the default 90-day window.

    • There is no minutes form. The server drops a window_minutes key without an error, so a window sent that way silently becomes the 90-day default.

  • An event goal goes in events, never in filters. An event object stuffed into filters is invisible to the conversion matcher and breaks the conversion picker. Without a goal the …conversion exit is a silent no-op. Server compiles the bytecode.

Pre-submit checklist

  • [ ] Exactly one type: "trigger" action; usually exactly one exit.

  • [ ] Every action type and config matches a row above (no types outside the supported set).

  • [ ] on_error is only continue or abort.

  • [ ] function_email.template_id == "template-email", function_sms.template_id == "template-twilio", function_push.template_id == "template-native-push".

  • [ ] A webhook / manual trigger sets template_id == "template-source-webhook" (a tracking_pixel uses "template-source-webhook-pixel") — these are built-in source templates, not catalog lookups.

  • [ ] That trigger's inputs.event / inputs.distinct_id match its own type: {request.body.*} for webhook, $workflow_triggered + {request.body.user_id} for manual, {request.query.ph_*} for tracking_pixel. The wrong pair saves fine and then fails at trigger time.

  • [ ] Every non-exit node has an outgoing edge; branch edges have an index matching a condition.

  • [ ] Every conditional_branch / wait_until_condition condition is wrapped: {filters: {properties: [...]}}, not {properties: [...]}.

  • [ ] All durations match ^\d*\.?\d+[dhms]$ and stay within the per-unit caps.

  • [ ] Every delay sets exactly one of delay_duration and delay_until, and no delay_until carries hand-written bytecode.

  • [ ] Function inputs are {key: {value: ...}}; no hand-written bytecode anywhere; no top-level trigger field set.

  • [ ] A Replay Vision step's analysis value is a literal object of one of the five shapes, with no templating.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Node.js

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
PostHog
Publisher
PostHog (Official)
Source
PostHog/skills · skills/omnibus/building-workflows · commit a5d30e7
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by PostHog or PostHog.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download building-workflows.zip