Generated banner for Building Loops
Generated logo for Building Loops

Building Loops

PostHogOfficialPublished by PostHog

  • Skill
  • Workflow
  • Automation and integrations
  • MIT
  • No issues found

From the author

Build a Loop for PostHog Desktop: a workflow that creates an AI task each time its trigger fires, optionally followed by a Slack or email notification with the task's result. Use when asked to create, set up, or change a loop, a recurring agent, a scheduled task, or an automation that runs an AI task when a GitHub, Slack, or PostHog event happens. Covers the exact graph, trigger configs, schedule presets, the notify step, structured output, and the test-run steps.
— the description by PostHog

AutoGineer summary

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

This skill provides the standard procedure for creating Loops in PostHog Desktop. A Loop is a specialized workflow that executes an AI task upon a specific trigger—such as a schedule, GitHub event, Slack message, or PostHog event—and optionally notifies the user via Slack or email. The process involves defining a specific graph structure, configuring trigger filters, setting up the AI task, and performing a test run to ensure the workflow logic is sound before enabling it.

What it does

  • Defines the required graph structure for PostHog Desktop Loops.
  • Configures triggers for schedules, GitHub events, Slack messages, and PostHog events.
  • Sets up AI task execution with specific repository, connector, and skill inputs.
  • Implements optional notification steps for Slack or email delivery.
  • Provides instructions for testing workflows using mocked events.
  • Guides the use of trigger masking to manage task volume and budget.

When to use it

  • When a user wants to automate a recurring AI task.
  • When you need to trigger an AI task based on external events like GitHub activity or Slack messages.
  • When setting up scheduled reporting or automated data analysis tasks.
  • When you need to create a workflow that runs on every occurrence of a specific PostHog event.

Advantages

  • Ensures workflows are compatible with the PostHog Desktop Loop editor.
  • Prevents common errors by using validated trigger configurations and filters.
  • Optimizes task budget usage through proper trigger masking and event filtering.
  • Provides a structured approach to testing workflows before deployment.

Usage examples

“Create a loop that runs every Monday at 9 AM to summarize GitHub issues in my repo.”
The agent will draft a workflow with a schedule trigger, an AI task step to summarize issues, and an exit step, followed by setting up the weekly recurrence.
“Set up a loop that triggers when a new Slack message is sent to the #general channel.”
The agent will configure a Slack trigger with the correct channel ID and filters to ignore bot messages and threads, then link it to an AI task.

Install

  1. Download the skill as a ZIP:

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

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

Documentation by PostHog

Building loops

A Loop is a workflow tagged origin_product: "loops" with one shape: a trigger, one "Create AI task" step, an optional notify step, and an exit. Desktop's Loops screens read this shape back. A workflow with anything else still runs, but Desktop shows it as "changed in the workflow editor" and makes it read-only.

Build it in this order: workflows-create as a draft, workflows-test-run step by step, workflows-schedule-create for schedule loops, then workflows-enable once the user signs off. For anything not covered here (patching a draft, publishing a live workflow, reading logs), use the building-workflows skill.

The graph

Replace only the values in angle brackets. Leave out the notify step and its edges when the user does not want a notification.

{
  "name": "<short name>",
  "description": "",
  "status": "draft",
  "origin_product": "loops",
  "exit_condition": "exit_only_at_end",
  "variables": [{ "key": "task_final_message", "type": "string", "default": "" }],
  "actions": [
    { "id": "trigger", "name": "Trigger", "type": "trigger", "config": <trigger config> },
    {
      "id": "create_task",
      "name": "Create AI task",
      "type": "function",
      "config": {
        "template_id": "template-posthog-create-task",
        "inputs": {
          "prompt": { "value": "<task prompt>" },
          "repository": { "value": ["<owner/name>"] },
          "connectors": { "value": ["<mcp connection id>"] },
          "skills": { "value": ["<skill name>"] },
          "posthog_mcp_scopes": { "value": "read_only" },
          "non_failure_status_codes": { "value": [409] }
        }
      },
      "output_variable": [{ "key": "task_final_message", "result_path": "final_message" }]
    },
    { "id": "notify", "name": "Notify", "type": "function", "config": <notify config> },
    { "id": "exit", "name": "Exit", "type": "exit", "config": { "reason": "Task finished" } }
  ],
  "edges": [
    { "from": "trigger", "to": "create_task", "type": "continue" },
    { "from": "create_task", "to": "notify", "type": "continue" },
    { "from": "notify", "to": "exit", "type": "continue" }
  ]
}

The task step

prompt is required. It runs unattended, so write it as a complete brief: what to do, what "done" looks like, and what to report.

Never put event text in the prompt with a {event.properties.<name>} template. Every run already receives the whole triggering event as a separate <triggering_event> block, labelled as data and with its angle brackets escaped so nothing inside it can forge a tag. Name the property instead and let the run read it there:

Read the message from the text property of the triggering event, then...

A template renders the raw value into the instruction part of the prompt, before that block and with no escaping. A Slack poster, an issue author, or a customer's own end user writes that value, so a crafted one reads as instructions to an agent that may hold repository credentials.

Include the other inputs only when the loop needs them:

  • repository: a list of the owner/name repositories the user gives you, when the task works in code. At most 10. The task gets no repository access without it. Never one from memory. Check each is reachable first: integrations-list for GitHub, then integrations-github-repos-retrieve for that exact name.

  • connectors: ids from mcp-connections-list. Only connections shared with everyone in the project are accepted.

  • skills: exact names from skill-list, at most 10.

  • posthog_mcp_scopes: read_only by default. Use full only when the user asks for the task to change things in PostHog.

  • channel: the space the loop belongs to, as <space id>|<space name>. Set it only when the app tells you which space the loop is being created in, never from a name the user types. Each run then shows up in that space's feed, and the loop is listed under that space.

  • reply_in_slack_thread: true (a JSON boolean, not a template string) for a Slack-triggered loop whose result should land back in the thread. That covers the notification, so leave out the notify step.

Keep non_failure_status_codes exactly as the graph has it, on every loop. The API answers 409 when a run hits a task limit. Without this input the step fails with a generic fetch error and the user never reads the limit message.

The workflow waits at this step until the task finishes. The next step then sees final_message, pr_urls, and status on the step result.

Trigger config

Pick the trigger for the source the user names. Schedule and GitHub are the ones Desktop's loop form edits.

Schedule, cadence on a schedule row (below):

{ "type": "schedule" }

GitHub event, one repository, one event type:

{
  "type": "internal-event",
  "filters": {
    "source": "internal-events",
    "events": [{ "id": "$github_event_received", "type": "events" }],
    "properties": [
      { "key": "repository", "value": ["<owner/name>"], "operator": "exact", "type": "event" },
      {
        "key": "event_type",
        "value": ["<issues | issue_comment | pull_request | push>"],
        "operator": "exact",
        "type": "event"
      },
      { "key": "actor_access", "value": ["write"], "operator": "exact", "type": "event" }
    ]
  }
}

Keep the actor_access filter. It stops people without write access to the repository from starting a task. Narrow to one action when the user asks ("when an issue is opened") with one more property filter: { "key": "action", "value": ["<action>"], "operator": "exact", "type": "event" }. A pull_request loop without an action filter fires on every push to every open pull request.

EventActions
issuesopened, reopened, closed, edited, deleted, labeled, unlabeled, assigned, unassigned, pinned, unpinned, transferred
pull_requestopened, reopened, closed, synchronize, edited, ready_for_review, converted_to_draft, review_requested, review_request_removed, labeled, unlabeled, assigned, unassigned
issue_commentcreated, edited, deleted
pushnone, push has no actions

Slack message in one or more channels. channel is required and takes channel IDs. Resolve a name to its id with integrations-channels-retrieve for the project's Slack integration (integrations-list, kind slack). Never guess an id:

{
  "type": "internal-event",
  "filters": {
    "source": "internal-events",
    "events": [{ "id": "$slack_message_received", "type": "events" }],
    "properties": [
      { "key": "channel", "value": ["<channel id>"], "operator": "exact", "type": "event" },
      { "key": "bot_id", "value": "is_not_set", "operator": "is_not_set", "type": "event" },
      { "key": "thread_ts", "value": "is_not_set", "operator": "is_not_set", "type": "event" }
    ]
  }
}

Keep the bot_id and thread_ts filters. They hold the loop to top-level messages a person typed, which is what the workflow editor's Slack trigger creates. Without them the loop starts a task on every alert another app posts and on every reply under any thread, and each one spends the daily task budget. Widen only when the user asks: bot_id with is_set for apps and bots only, an exact filter on user or app_id for named posters, and drop the thread_ts filter to include replies.

PostHog event, every matching occurrence:

{
  "type": "event",
  "filters": {
    "events": [{ "id": "<event>", "name": "<event>", "type": "events", "order": 0, "properties": [] }],
    "properties": [],
    "filter_test_accounts": false
  }
}

A PostHog event trigger creates a task on every matching occurrence, and the workflow editor's volume warning does not run on this path. Bound it before you create the loop: narrow the event with a property filter, and throttle a common event with trigger_masking, a top-level field beside actions and edges.

"trigger_masking": { "hash": "{person.id}", "ttl": 3600, "threshold": null }

hash is a HogQL template for the dedup key, so {person.id} fires once per person. ttl is how long to suppress repeats of that hash, in seconds, from 60 to about three years. threshold fires once per N matches of the same hash instead; leave it out for plain dedup. Never send bytecode; the server compiles it from hash. A workflow can create 100 tasks a day and a project 500 across all its workflows, so an unbounded loop on a busy event spends the project's budget and the next workflow to fire is refused. Editing the loop in Desktop keeps trigger_masking.

Manual, run from the "trigger manually" button:

{
  "type": "manual",
  "template_id": "template-source-webhook",
  "inputs": { "event": { "value": "$workflow_triggered" }, "distinct_id": { "value": "{request.body.user_id}" } }
}

Schedule row

Create it with workflows-schedule-create after the workflow exists, with the workflow id as workflow_id. rrule is one of these, exactly. No BYHOUR or BYMINUTE.

Cadencerrule
Every hourFREQ=HOURLY;INTERVAL=1
Every dayFREQ=DAILY;INTERVAL=1
WeekdaysFREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR
One day a weekFREQ=WEEKLY;INTERVAL=1;BYDAY=<MO..SU>
OnceFREQ=DAILY;COUNT=1

starts_at is the first run, as ISO 8601 with a UTC offset. The clock time comes from it, so pick the next occurrence at the time the user wants. timezone is the user's IANA timezone. Hourly schedules start on the hour.

Notify step

The task's result reaches the notify step through the output_variable on the task step: {variables.task_final_message} is the agent's closing message. Add { "key": "task_pr_urls", "result_path": "pr_urls" } to the list, and a matching variables entry, when the message should link the pull request.

Slack, template_id template-slack. slack_workspace is the Slack integration id from integrations-list. Resolve channel with integrations-channels-retrieve and read that channel's is_member before you summarize. False means the PostHog Slack app is not in the channel: the first real run fails to post and the result reaches nobody, so ask the user to invite the app rather than building the loop:

{
  "template_id": "template-slack",
  "inputs": {
    "slack_workspace": { "value": <integration id> },
    "channel": { "value": "<channel id>" },
    "text": { "value": "<loop name> finished: {variables.task_final_message}" }
  }
}

Email: set the step's type to function_email instead of function. from is a sender id from integrations-list, and to is an object holding the user's address. Send html as an empty string: the runtime requires the key, and an empty one sends a text-only email. The email input accepts Liquid, so use {{ variables.task_final_message }} here:

{
  "template_id": "template-email",
  "inputs": {
    "email": {
      "value": {
        "from": { "integrationId": <sender id> },
        "to": { "email": "<user email>", "name": "" },
        "subject": "<loop name> finished",
        "text": "{{ variables.task_final_message }}",
        "html": ""
      }
    }
  }
}

Structured output

When the notify step or the user needs a specific field from the task, such as a verdict or a count, ask the task for it instead of parsing prose. Add an output variable per field with result_path output.<name> and a matching variables entry with the type (string, number, boolean). The task is told which fields to return, and {variables.<name>} holds the value afterwards. Skip this when final_message is enough.

Test run

Test the draft before you schedule or enable it. workflows-test-run runs one step at a time and mocks every outbound call, so nothing real is created. That covers the notify step as well as the task, so a passing test run says the graph is wired up, not that Slack or email delivery works.

  1. Run with no current_action_id and globals { "event": { "event": "$scheduled", "properties": {} } } for a schedule loop. For a GitHub or Slack loop, send the trigger's event name with properties that match the filters, and no person. For a PostHog event loop, send that event with a person.

  2. Expect nextActionId = create_task. Run again with current_action_id: "create_task".

  3. Expect the mocked task call and nextActionId = notify (or exit). Continue until the exit step.

A status=skipped on step 1 means the sample event does not match the trigger filters. Fix the sample, not the trigger.

Not available in Loops

In-app or push notifications, and any other step type. If the user asks for one, say Loops does not support it yet and offer the closest loop that fits. Do not add actions, edges, or inputs to work around it.

Files

LICENSE

MIT License

Copyright (c) 2025

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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.

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-loops · 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-loops.zip