Workflows, steps, sensors, and a registry.

A Loopy system is four pieces that fit together: sensors turn outside signals into events, workflows are directories of steps that act on them, and a registry declares the agents, sandboxes, and events they share.

A workflow is a directory of steps.

Each step is one .md file, and the after: links set the order. Here's the resolve workflow, file by file. Each step runs after the one before it.

workflows/resolve/
---
on:     WorkItem
agent:  Claude
output:
  goal: str
---
Decide the goal for "{{ event.description }}" given
the work item at {{ event.link }}.
---
after:  arbitrate
agent:  Claude
output:
  pr_url: url
  summary: str
---
Implement the goal: {{ arbitrate.goal }}. Open a PR
and summarize the change.
---
after:  fix
agent:  Claude
output:
  verdict: enum[pass, fail]
---
Review {{ fix.pr_url }}: {{ fix.summary }}. Return a
pass/fail verdict.
---
after:  review
agent:  Claude
---
If {{ review.verdict }} is pass, merge the PR and ship
the change.

A step is a markdown file describing a single agent task.

Every step file has two parts. A short header between the --- lines wires the step into the graph. The prose below is the agent's objective.

arbitrate.md
---
on:     WorkItem        # what triggers the step
agent:  Claude      # who runs it
output: { goal: str }   # typed result
---
Decide the goal given the work item at
{{ event.link }}.
on:

The trigger for the workflow's first step: a registered event, or a schedule like cron("0 9 * * *"). Exactly one step has it. A cron step receives a tick as its event, and that tick exposes exactly two fields: {{ event.scheduled_at }} (when this tick fired) and {{ event.last_run }} (the previous tick's time, so the step can scan only what changed since it last ran). Those are the only fields on a tick; naming any other event.* on a cron step fails the build.

after:

Which step this one runs after. The after: links are what build the order. Every step except the first has one.

agent:

Which agent runs the step. Agents are defined in registry.yml (here Claude, Codex, and OpenCode, one per harness: Claude Code, Codex, and OpenCode), and a step references one by name.

output:

The typed result the step returns. Later steps read these fields by name to pass data down the chain.

Optional: emits: puts an event on the bus for other workflows.

Outputs and events: two ways to pass a result

A step can hand off its result two ways, and they are different things. An output stays inside the workflow. An event goes out to the rest of the system. The same value almost never wants both, so the question to ask is: does another workflow need this?

output:

A step's typed, structured result, declared on the step itself. The next step in the same workflow reads it with after: and {{ step.field }}. Outputs never touch the bus, so they stay private to the chain that produced them. The handoff from arbitrate to fix is an output: fix is after: arbitrate and reads {{ arbitrate.goal }}.

emits:

Puts a registered event on the shared bus for a different workflow to pick up with on:. The event has to be declared in registry.yml. Sensors publish events the same way, and on: does not care whether a sensor or a step produced it. Use this where one workflow hands off to another, or to loop a result back to a workflow's entry.

Rule of thumb: a within-workflow handoff (arbitrate → fix) is an output. A cross-workflow handoff (triage → resolve via a WorkItem) is an event.

Sensors turn the outside world into events.

A sensor is a small Python function in the sensors/ directory. It listens for a signal (a webhook or a poll) and returns a typed event onto the bus, where a workflow's first step picks it up with on:.

sensors/sensors.py
from collections.abc import Iterator

from loopy import sensor
from loopy.events import CustomerTicket, PageChanged


# webhook: Zendesk POSTs a new ticket; you shape it into a CustomerTicket
@sensor(webhook="/hooks/zendesk", emits="CustomerTicket")
def zendesk_tickets(req) -> CustomerTicket:
    ticket = req.json["ticket"]
    return CustomerTicket(ticket_id=ticket["id"], subject=ticket["subject"],
                          body=ticket["description"], link=ticket["url"])


# poll: no feed, no webhook, just a page that changes when it changes.
# one tick can yield many events; snapshots is your own store
@sensor(poll="12h", emits="PageChanged")
def page_watch(req) -> Iterator[PageChanged]:
    for url in ("https://vendor.example.com/changelog",
                "https://cloud.example.com/deprecations"):
        old, new = snapshots.swap(url, fetch(url))
        if old and old != new:
            yield PageChanged(url=url, summary_of_change=diff_summary(old, new))

Sensors can live in any .py file under sensors/, so organize them however you like. Loopy scans the whole directory; the file name does not matter. init just puts them in sensors/sensors.py to start. Each sensor is a top-level function marked with @sensor.

webhook=

Register a path and loopy run hosts it. When a service POSTs, your function shapes the payload into a typed event, good for push sources like Zendesk, Linear, or Stripe.

poll=

Give it an interval like "5m" and Loopy calls the function on a timer instead, for sources that don't push to you.

emits=

The event the sensor returns, registered in registry.yml. Because it's typed, everything downstream is checked when you compile.

What a poll interval means

A poll interval is a plain duration: a whole number followed by a unit, no space. The units are s (seconds), m (minutes), h (hours), and d (days), so "30s", "5m", "1h", and "2d" are all valid. It's a duration, not a cron expression. If you need to run on a clock (every day at 9am), put cron("0 9 * * *") in a workflow's on: instead. A malformed interval is caught when you compile.

Common GitHub events are built in

You don't write a sensor for the usual GitHub triggers. A workflow names a built-in event in on: directly, and Loopy registers its contract and the /hooks/github sensor for you. No sensors/ file, no registry.yml entry.

workflows/review/code-review.md
---
on:    Github.PullRequestOpened
agent: Claude
---
Review PR #{{ event.number }} on {{ event.repo }}.

The built-in events are Github.PullRequestOpened, Github.PullRequestMerged, Github.IssueOpened, Github.IssueCommentCreated, and Github.Push. They fire for any repository your GitHub App delivers. See GitHub in the integrations docs for each event's payload and setup. For a source the built-ins don't cover, write your own @sensor as above.

Key abstractions live in YAML.

One file declares the reusable pieces your steps refer to by name: the agents that run steps, the sandboxes their code runs in, and the events that move between workflows.

registry.yml
agents:                       # define one per harness, then reference any by name from a step
  Claude:                 # driven by Claude Code
    sandbox: BaseSandbox
    model:   claude-opus-4-8
    harness: claude-code
    skills:  [code-review, testing]

  Codex:                  # same project, driven by Codex
    sandbox: BaseSandbox
    model:   gpt-5.5
    harness: codex
    skills:  [code-review]

  OpenCode:               # same project, driven by OpenCode
    sandbox: BaseSandbox
    model:   claude-sonnet-4-6
    harness: opencode
    skills:  [code-review]

sandboxes:                    # where an agent's code runs: image + env
  BaseSandbox:
    provider: daytona
    image:    { debian_slim: "3.12", apt: [git], workdir: /home/loopy, user: loopy }
    env_file: secrets/base.env

events:                       # typed messages on the bus; a step may only trigger `on:` one registered here
  Incident: { source: enum[sentry, pagerduty, datadog], issue_id: str, title: str, link: url }
  CustomerTicket: { ticket_id: str, subject: str, body: str, link: url }
  WorkItem: { link: url, description: str }
  PageChanged: { url: url, summary_of_change: str }

Names: one namespace, Capitalized

The three sections (agents:, sandboxes:, events:) are each optional, but their entries share one namespace: a sandbox, an agent, and an event can never share a name. Every name is Capitalized, with one exception: default is the one reserved lowercase name, and you can define it for a sandbox or an agent (it names the one used when none is picked explicitly). No other lowercase names are allowed. The Github. prefix belongs to the built-in GitHub events, so your own events can't use it. All of this is checked when you compile.

Agent keys

An agent is a named configuration (model, harness, sandbox, skills) that a step picks with agent:.

sandbox:

Required. The sandbox the agent runs in, named under sandboxes:. Where an agent runs is never inferred. If most agents share one, set defaults.agent.sandbox once instead of repeating it (see defaults below).

model:

Required. The model the agent runs on, named explicitly. It is never inferred and there is no fallback to a harness's own default, so a manifest always records exactly which model each agent uses. Each harness drives one provider's models:

claude-code runs Anthropic Claude: claude-sonnet-5, claude-fable-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-opus-4-5, claude-haiku-4-5, and claude-sonnet-4-5. codex runs OpenAI: gpt-5.5, gpt-5.4-pro, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano, gpt-5.3-chat-latest, gpt-5.2-pro, gpt-5.2-chat-latest, gpt-5.2, gpt-5.1-codex-mini, gpt-5.1-codex, gpt-5.1-chat-latest, gpt-5.1, gpt-5-pro, gpt-5, gpt-5-mini, gpt-5-nano, gpt-5-codex, and gpt-5-chat-latest. opencode runs either provider, written as a bare id or in provider/model form (anthropic/claude-sonnet-5, openai/gpt-5.5).

A cross-provider pairing (say gpt-5.5 on claude-code) is rejected by loopy compile (LOOPY-E508), so it never reaches a running system.

harness:

Required. The runner that drives the agent: claude-code, codex, or opencode. Like the model, it is always declared, never inferred, and an unknown harness name is a compile error.

skills:

Optional. A list of skill names to load into the agent. Each must exist as a directory under skills/ in your project.

A top-level defaults.agent block fills in any of these for agents that don't set them: model, harness, and sandbox are plain overrides (the agent's own value wins), and an agent's own skills list replaces the default list entirely (no merging). There are no built-in agents: every agent a step names is declared in the registry, so its model and harness pairing is always visible in the file.

Sandbox keys

A sandbox is where an agent's code runs: a provider, an image, and what the box may reach.

provider:

Required. One of local, docker, or daytona. Every sandbox declares its provider explicitly; there is no fallback.

image:

Optional. What to build the box from. Pick at most one base: debian_slim (a Python version, the default), base (an existing image name), dockerfile (a path), or snapshot (a prebuilt snapshot; excludes all other build keys). On top of the base you can layer apt, pip, pip_requirements, env, workdir, run, user, entrypoint, and cmd. Unknown keys are rejected.

network:

Optional. An egress allowlist. Egress is open by default; set this to restrict the sandbox to the hosts you list, like [github.com].

env_file:

Optional. A path (or list of paths) to env files supplying the sandbox's secrets. It's a reference: the compiler records the path and never reads the file, and values resolve at run time.

repos:

Optional. GitHub repos to clone into the workspace when the sandbox is acquired. Each entry is an owner/name string (or full https URL), or a mapping with a required url plus optional ref (branch, tag, or SHA; the default branch when omitted), path (a subdirectory, defaulting to the repo name), and depth (clone depth, default 1; null for full history).

Event fields

An event's body is its field map: EventName: { key: type, ... }, with no wrapper key. Fields are optional (an event with none is a bare signal). Each field's type is one of str, int, float, bool, url, or enum[a, b, c], or an inline JSON Schema object when the shorthands don't fit. A step's on: or emits: may only name an event registered here, plus the built-in Github.* events, which come pre-registered.