# Workflows, steps, sensors, and a registry.

> Markdown mirror of /docs/concepts.html

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/arbitrate.md`**

```yaml
---
on:     WorkItem
agent:  Claude
output:
  goal: str
---
Decide the goal for "{{ event.description }}" given
the work item at {{ event.link }}.
```

**`workflows/resolve/fix.md`**

```yaml
---
after:  arbitrate
agent:  Claude
output:
  pr_url: url
  summary: str
---
Implement the goal: {{ arbitrate.goal }}. Open a PR
and summarize the change.
```

**`workflows/resolve/review.md`**

```yaml
---
after:  fix
agent:  Claude
output:
  verdict: enum[pass, fail]
---
Review {{ fix.pr_url }}: {{ fix.summary }}. Return a
pass/fail verdict.
```

**`workflows/resolve/ship.md`**

```yaml
---
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`**

```yaml
---
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`**

```python
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`**

```yaml
---
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](/docs/integrations.md#github) 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`**

```yaml
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 runtime configuration 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`, `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`, `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.
