Building workflows
SkillMonitoring & opsThe building-workflows skill lets an AI agent build, edit, test, enable, and monitor PostHog workflows over MCP. It composes a workflow as a graph of actions and edges, creates it as a draft, and tests it one step at a time using test runs and logs. Edits are made with small patch operations rather than rewriting the whole graph, and going live requires the user's explicit approval.
Use Building workflows in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add Building workflows and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the Building workflows skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; Ahel provides instructions and does not run this skill.
No other account needed.
Have a PostHog project and an MCP connection available to your agent.
What your AI can do with it
- Build a PostHog workflow as a graph of actions and edges
- Create the workflow as a draft that opens in the visual editor
- Test a workflow one step at a time with test runs and logs
- Edit drafts surgically using small patch operations
- Enable a workflow only after the user's explicit approval
- Monitor a workflow after it is enabled
Getting started
- Have a PostHog project and an MCP connection available to your agent.
- Ask the agent to build or change a workflow, campaign, or broadcast.
- Review the draft the agent creates and open it in the visual editor.
- Run the test runs and check the logs one step at a time.
- Approve the workflow explicitly when you are ready for it to go live.
What this skill tells your AI
The instructions your AI receives, as published by posthog/skills in skills/omnibus/building-workflows/SKILL.md and read by Ahel’s review.
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.
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": "hi@example.com", "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.
Signals
- GitHub stars
- 72
- Forks
- 7
- Last commit
- Oct 2026
Questions
- Can it change a workflow without rewriting the whole graph?
- Yes. Edits are made with small patch operations rather than rewriting the whole graph.
- Does going live happen automatically?
- No. Going live requires the user's explicit approval.
- How do I test a workflow before enabling it?
- The skill tests it one step at a time using test runs and logs, so you can check each step before going live.
- What does the workflow look like when it is created?
- It is composed as a graph of actions and edges and created as a draft that opens cleanly in the visual editor.
Advanced
- Item type
- skill
- Key
building-workflows- Source
- github.com/posthog/skills
More in Monitoring & ops
Skill · anthropics
More in Monitoring & opsagent-eval
Skill · affaan-m
More in Monitoring & opspricing
Skill · coreyhaines31
More in Monitoring & opslark-okr
Skill · larksuite
More in Monitoring & opsdashboard-builder
Skill · affaan-m
More in Monitoring & opsbabysit
Skill · thedotmack
More in Monitoring & ops