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
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/posthog/building-workflows/downloadUnzip 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.
Compose the graph. Build
actions+edgesper references/graph-schema.md. For anyfunctionnode, don't guess the template: list the live catalog withcdp-function-templates-listand read its required inputs withcdp-function-templates-retrieve.Create as a draft.
workflows-create. Every workflow is createddraft; it does not execute yet.Test-run it.
workflows-test-runruns one step at a time. Start at the first step (omitcurrent_action_id, or point it at the trigger) with sampleglobals({event, person, groups}), shaped like the trigger's real payload: aneventtrigger needs an event matching its filters, and aninternal-eventtrigger needs an event named in itsfilters.events(for the Slack trigger, a$slack_message_receivedevent with the Slack property bag) and no person (see references/graph-schema.md). Astatus=skippedresult 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 ascurrent_action_idand run again, walking step by step to the end. Skipdelaynodes by jumping to the action after them (delays aren't simulated). Async side effects (HTTP/email/SMS/push) are mocked unless you setmock_async_functions=false. Read each step's trace to confirm the path taken.Read logs while iterating.
workflows-logsshows the per-step execution trace (levels DEBUG to ERROR). This is how you see why a step skipped, branched, or errored.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).Enable (needs the user's explicit sign-off).
workflows-enableflips it toactiveand 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.Dispatch (batch/schedule only). A
batchorscheduleworkflow does not fire on enable alone. Send a one-off broadcast withworkflows-run-batch, or attach a recurring schedule withworkflows-schedule-create. Abatchtrigger fans out to a person audience, so scheduling it needs theworkflows-blast-radiuspreview and its confirm token; ascheduletrigger runs once per occurrence with no audience, so schedule it directly. Confirm withworkflows-getthatstatus=='active'and its read-onlyschedulesfield has an active entry.Monitor. Drill down:
workflows-global-stats(which workflows are failing) toworkflows-stats(one workflow's trend) toworkflows-list-invocations(who it failed for) toworkflows-get-invocation(the triggering payload) toworkflows-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:
Edit.
workflows-patch-graph(orworkflows-updatefor 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-getshows the staged draft indraft; the live config stays inactions/edges. Metadata (name, description) applies live immediately.Test the draft.
workflows-test-runwithuse_draft=trueexecutes the staged draft instead of the live config. Re-test every path you changed.Publish deliberately.
workflows-publishwithoutconfirmreturnsin_flight_runs, aconfirm_token, and animpactsummary: per deleted step, about how many people are parked there and whether they move to a surviving step (moves_to) or exit;empty_variablesthat 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_conflictswhere a schedule overrides a variable the draft removes. Echo the impact to the user and get their go-ahead, then call again withconfirm=trueand thatconfirm_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.Or bail.
workflows-discard-draftthrows 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:
bytecodeon any filter, trigger, condition, conversion, or masking. Compiled server-side from the human-readableproperties/hash. Omit it; sendfilters: {...}, not bytecode.trigger(top-level). Derived from thetriggeraction inactions. 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:
Reuse first. List the library with
workflows-list-email-templatesand pick a template that fits. A drip campaign typically references one base template (say, a branded announcement) from every email step.Create only if nothing fits. Author a new template design-first with the
designing-email-templatesskill: compose thedesign, omithtml(the server renders html from the design).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.Differentiate per step with
workflows-patch-action-emaildesign 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
- ATTRIBUTION.mdAdded by AutoGineer855 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md15.0 KiB
- references/graph-schema.md42.8 KiB
- references/lifecycle-and-debugging.md6.5 KiB
- references/onboarding.md3.8 KiB
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
configEdges
function*inputsDuration 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 byfrom/to.on_error— optional; onlycontinueorabort. Omit to use the default.filters— optional property filters gating the action:{properties: [<cond>]}. Sendproperties, notbytecode.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.
type | config |
|---|---|
trigger | a 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_branchconditions are property-only (person/group<cond>s). Event/action filters are rejected here ("Event filters are not allowed in conditionals").wait_until_conditionis event-aware: itscondition.filtersand eachevents?[].filtersmay also carryevents/actions. An entry naming neither an event nor an action is dropped (it would match everything).sourceis optional (defaults toevents). Never sendbytecode; the server compiles it fromproperties.
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 withtrigger_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-webhookfor bothwebhookandmanual,template-source-webhook-pixelfortracking_pixel. Omitting or guessing it fails the create withTemplate not foundagainst the trigger node. They aresource_webhooktemplates and are not in the destination catalog (cdp-function-templates-lististype=destination), so don't try to discover them there. Each needsinputs.eventandinputs.distinct_idwrapped 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 noevent: use the fixed literal"inputs": { "event": { "value": "$workflow_triggered" }, "distinct_id": { "value": "{request.body.user_id}" } }. Pointingeventat{request.body.event}here makes every manual run return 400 (the source template rejects an emptyevent) while the UI still shows a success toast, so the workflow silently never fires.tracking_pixel— a bodylessGET, 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 withworkflows-run-batch, or make it recurring withworkflows-schedule-create(attaches an RRULE schedule; each firing re-broadcasts to this sameconfig.filters.propertiesaudience). A recurring broadcast is abatchtrigger plus a schedule. Editingconfig.filterspauses 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 withworkflows-schedule-create; no audience preview is needed. Changing this trigger tobatchpauses 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.eventsmust name at least one allowed event id. Runs have no associated person, so person-dependent steps are unavailable. Test-run with an event named infilters.eventsand no person; any other event name is skipped at the trigger. The Slack trigger is this with$slack_message_receivedinfilters.events: the run's event carries propertiesintegration_id,channel,channel_type,slack_team_id,user,bot_id,app_id,subtype,text,ts,thread_ts,is_thread_reply,is_ext_shared_channel, andslack_event(the raw Slack payload). A workflow leaving draft must include one exactchannelproperty filter whose value contains one or more channel IDs; several IDs match any listed channel. The GitHub trigger uses$github_event_receivedand requires exactrepositoryandevent_typefilters.
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 withinttl.Don't send
bytecode— compiled server-side fromhash.
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 aconditional_branch. For await_until_conditionit is themax_wait_durationtimeout path.type: "branch"— requiresindex, matchingconfig.conditions[index]on aconditional_branch. Await_until_conditionresolves (itsconditionmatches or aneventsentry fires) out thebranchedge atindex: 0.Every non-exit node needs a reachable next action via an outgoing edge, or execution fails with "No next action found".
A
conditional_branchwith N conditions typically has Nbranchedges (index: 0..N-1) plus onecontinueedge for the no-match path.A
wait_until_conditionneeds abranchedge atindex: 0(resolution) and acontinueedge (timeout). Without theindex: 0branch 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 declarestemplating: liquid(the email input onfunction_emaildoes; 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:
cdp-function-templates-list(filtertype=destination) to find the right template and itsid.cdp-function-templates-retrievewith that id to read itsinputs_schema— the exact keys, types, and which are required.Build
inputsfrom that schema. Atemplate_idnot 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" }
}
}
}channelsis anintegration_multiinput: itsvalueis an array of integration id numbers (e.g.[6, 7]), not objects. Find the FCM/APNs integration ids withintegrations-list(look forkindfirebase/apns); at least one is required or the send throws "No push channel configured".Templating differs per input.
bodyis liquid — interpolate with{{ person.x }}/{{ event.x }}(double braces) and set"templating": "liquid".titleand 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 theinputs_schemafor the full set).Never hand-author
bytecode— the server compiles it fromvalue. Omitordertoo: the editor lays fields out in the template'sinputs_schemaorder (fixed and consistent), not by theorderon 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" }
}analysisis a literal object, not a template. Never set"templating"on it, and never put{...}expressions in it. Itsvaluetakes one of these shapes:typeRequired keys What the step answers monitorpromptA yes/no verdict on the question in prompt.classifierprompt,tags(1 to 100 category strings)Which of the categories in tagsfit the session.scorerprompt,scale: {"min": <num>, "max": <num>}A score on the scale. maxmust be greater thanmin.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-scannersskill. Find saved scanners with thevision-scannerstools. The step refuses an experiment scanner, because it samples the experiment's variants evenly, which one triggered session can't do.session_iddefaults to{event.properties.$session_id}. Leave it unset unless the session ID is in another property.wait_for_session_enddefaults totrue, which scans the recording after the session has been quiet for 35 minutes. Set it tofalseonly when the trigger event comes after the session ended.The step result is
{"state", "output", "warnings"}.stateissucceeded,failed,skipped,ineligibleorlost.outputis the scanner's answer:verdict,tagsorscorewithreasoningfor a monitor, classifier or scorer, andtitle,summaryandchaptersfor a summarizer. Every type also returnsconfidence. Useoutput_variableto keep the result, then branch on it. Whenoutputis null,warningssays 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_dayandreplay_vision_scan_team_rate_limit_per_day. Past a cap the step returnsstate: "skipped"and the workflow continues.Access. The token needs
session_recording:read(or:write) andreplay_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_idis a literal, not a template. Use a marketing message category'sid(UUID), listed bymessage-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 withmessage-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.identifieris 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
identifieris 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-actionfeature 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, andd≤365 fordelay_durationandmax_delay_durationbutd≤30 for a wait'smax_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_durationanddelay_until. Both together are rejected; so is neither.expressionis HogQL resolving to a datetime. An ISO string, aHogDateTime, 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 (whatDate.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.offsetshifts that instant and is signed:^-?\d*\.?\d+[dhms]$.-1dis a day before the date,2his 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:-48hmeans 48 hours, not 24.max_delay_durationbounds the wait instead.timezone,use_person_timezoneandfallback_timezonework as they do onwait_until_time_window, and decide which zone a date with no offset of its own is read in (a bare2026-03-01, or2026-03-01T09:00:00). A date that states an offset, aHogDateTime, and unix seconds are absolute and ignore them. DefaultUTC, so a date-only value means midnight UTC unless you set a zone.max_delay_durationcaps how far past the step's start the wait may run (default30d, 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_errorsays, 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 isproperties.<key>, with noevent.prefix. The expression runs against the same globals as a filter, whereeventis the event's name, soevent.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
…conversionvariants require aconversiongoal 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 (ornull) for the default 90-day window.There is no minutes form. The server drops a
window_minuteskey without an error, so a window sent that way silently becomes the 90-day default.
An event goal goes in
events, never infilters. An event object stuffed intofiltersis invisible to the conversion matcher and breaks the conversion picker. Without a goal the…conversionexit is a silent no-op. Server compiles the bytecode.
Pre-submit checklist
[ ] Exactly one
type: "trigger"action; usually exactly oneexit.[ ] Every action
typeandconfigmatches a row above (no types outside the supported set).[ ]
on_erroris onlycontinueorabort.[ ]
function_email.template_id == "template-email",function_sms.template_id == "template-twilio",function_push.template_id == "template-native-push".[ ] A
webhook/manualtrigger setstemplate_id == "template-source-webhook"(atracking_pixeluses"template-source-webhook-pixel") — these are built-in source templates, not catalog lookups.[ ] That trigger's
inputs.event/inputs.distinct_idmatch its own type:{request.body.*}forwebhook,$workflow_triggered+{request.body.user_id}formanual,{request.query.ph_*}fortracking_pixel. The wrong pair saves fine and then fails at trigger time.[ ] Every non-exit node has an outgoing edge;
branchedges have anindexmatching a condition.[ ] Every
conditional_branch/wait_until_conditioncondition is wrapped:{filters: {properties: [...]}}, not{properties: [...]}.[ ] All durations match
^\d*\.?\d+[dhms]$and stay within the per-unit caps.[ ] Every
delaysets exactly one ofdelay_durationanddelay_until, and nodelay_untilcarries hand-writtenbytecode.[ ] Function inputs are
{key: {value: ...}}; no hand-writtenbytecodeanywhere; no top-leveltriggerfield set.[ ] A Replay Vision step's
analysisvalue is a literal object of one of the five shapes, with notemplating.
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.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download building-workflows.zip