Building loops

SkillCommunication

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Building loops skill

What this skill tells your AI

The instructions your AI receives, as published by posthog/skills in skills/omnibus/building-loops/SKILL.md and read by ahel’s review.

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: the owner/name the user gives you, when the task works in code. Never one from memory. Check it 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.

Signals

GitHub stars
62
Forks
6
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
building-loops
Source
github.com/posthog/skills