# Scripting (Starlark)

> Scripts are inline Starlark code that runs in response to triggers. They can read/write attributes, post and edit comments, and schedule themselves to fire again later.

Canonical HTML page: https://conclude.io/doc/reference/scripting/
Last updated: 2026-11-02

---

Scripts are inline [Starlark](https://github.com/google/starlark-go) code
that runs in response to triggers. They can read/write attributes, post and
edit comments, and schedule themselves to fire again later.

## Skeleton

```yaml
scripts:
  - name: route
    source: |
      def onTrigger(activity, conclude, trigger):
          category = activity.get("category")
          if category == "billing":
              activity.set("routed_to", "finance-team")
          elif category == "outage":
              activity.set("severity", "high")
              conclude.comment("Routed to oncall.")

triggers:
  - onCreate:
      - script: route
```

Every script defines an **entry function** named `onTrigger` that takes
three arguments: `activity`, `conclude`, `trigger`.

## Enabling scripting

Scripts only run when `Organization.Settings.Scripting` is true (set in the
DB; no UI toggle). If scripting is off, the script trigger notification is
dropped with a logged warning.

## The `activity` argument

Read-only view of the activity at trigger time, plus mutators.

| Attribute | Type | Purpose |
|---|---|---|
| `activity.id` | int | Activity ID |
| `activity.title` | string | Activity title |
| `activity.attrs` | dict | Frozen snapshot of all attributes |
| `activity.get(name)` | string | Get one attribute value |
| `activity.set(name, value)` | None | Queue an attribute change (applied after the script returns) |

## The `conclude` argument

Side-effects from the script.

| Call | Returns | Effect |
|---|---|---|
| `conclude.comment(text)` | int (action ID) | Post a comment as the bot, immediately. Returns the new action's ID. |
| `conclude.editComment(handle, text)` | None | Replace the text of a bot-posted comment. `handle` is an action ID returned by `conclude.comment`. |
| `conclude.schedule(delaySeconds, state)` | None | Schedule another invocation of `onTrigger` after `delaySeconds`. `state` is a dict carried into the next invocation. |
| `conclude.log(text)` | None | Debug log (stderr). |

### Limits

| Limit | Value |
|---|---|
| Max script execution steps | 1,000,000 |
| Wall-clock timeout | 10 s |
| Max `conclude.comment` calls per run | 10 |
| `conclude.schedule` delay | 0.1 – 3600 s |
| `conclude.schedule` state size | ≤ 64 KB JSON |
| Schedule chain length | ≤ 1000 ticks |

Exceeding any limit returns an error from the builtin or drops the
scheduled re-fire.

## The `trigger` argument

Tells the script *why* it ran.

```python
trigger.event       # "onCreate" | "onComment" | "onEdit" | "onConclude" | "onSchedule"
trigger.comment     # struct or None — present only for "onComment"
trigger.comment.text   # the comment text
trigger.comment.isBot  # True if the comment was posted by an agent/script
trigger.state       # dict or None — present only for "onSchedule"
```

Branching:

```python
def onTrigger(activity, conclude, trigger):
    if trigger.event == "onComment":
        handle_comment(activity, conclude, trigger.comment.text)
    elif trigger.event == "onSchedule":
        tick(conclude, trigger.state)
```

## Scheduled re-entry

`conclude.schedule(delaySeconds, state)` enqueues another invocation of
`onTrigger` after a delay, carrying `state` (a JSON-serializable dict).

```python
def onTrigger(activity, conclude, trigger):
    if trigger.event == "onComment":
        # start
        handle = conclude.comment("Working on it (0/10)...")
        conclude.schedule(1, {"h": handle, "n": 0})
    elif trigger.event == "onSchedule":
        s = trigger.state
        n = s["n"] + 1
        conclude.editComment(s["h"], "Working on it (%d/10)..." % n)
        if n < 10:
            conclude.schedule(1, {"h": s["h"], "n": n})
```

Delivered the same way as activity reminders. Chain length capped at
1000 ticks per top-level invocation.

State must round-trip through JSON: only None, bool, int, float, string,
list, tuple, and dict (string-keyed) are supported.

## State JSON gotchas

- **Whole-number floats coerce to int** on the way back (`1.0` → `1`).
  Don't assume `trigger.state["n"]` is float just because you stored it as
  one.
- **Tuples become lists** through the JSON round-trip.
- **Large state slows down state-JSON serialization** every tick; keep
  state compact (bit-pack large grids, etc.).

## Loop protection

Comments and attribute changes posted by a script carry the script's
trigger call stack. When those changes fire follow-up notifications
(`onComment`, `onEdit`), Conclude blocks script and agent dispatch on those follow-ups
— preventing infinite loops.

Scheduled re-entries bypass this check. The chain-length cap is the only
bound on a runaway schedule.

Declarative cascades (`setAttribute`, `message`, `email`) still chain up to
8 levels deep.

## Editing rules

`conclude.editComment(handle, text)` only succeeds when:

- `handle` is the ID of an action on **this activity**, AND
- the action was posted by the bot (i.e. you got `handle` from a previous
  `conclude.comment` in this app).

Cross-activity edits and edits of human-authored comments are rejected with
an error log.

## Posting rules

`conclude.comment(text)` runs **synchronously**: by the time the call
returns, the comment is in the database and the platform has been notified.
That's why it can return the action ID. Each call hits the DB + platform
once, so prefer one initial post + many `editComment` calls for animations.

## Full example: Conway's Life

See `tests/apps/calculator.yaml` (comment-driven), `tests/apps/invaders.yaml`
(animated via `conclude.editComment` + `conclude.schedule`).

A minimal animation pattern:

```yaml
scripts:
  - name: tick
    source: |
      def onTrigger(activity, conclude, trigger):
          if trigger.event == "onComment":
              h = conclude.comment("Frame 0")
              conclude.schedule(0.5, {"h": h, "n": 0})
          elif trigger.event == "onSchedule":
              s = trigger.state
              n = s["n"] + 1
              conclude.editComment(s["h"], "Frame %d" % n)
              if n < 20:
                  conclude.schedule(0.5, {"h": s["h"], "n": n})

triggers:
  - onComment:
      - script: tick
        ifMatch: "^(?i)go$"
```

