Reference

Scripting (Starlark)

On this page

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.

Skeleton

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.

AttributeTypePurpose
activity.idintActivity ID
activity.titlestringActivity title
activity.attrsdictFrozen snapshot of all attributes
activity.get(name)stringGet one attribute value
activity.set(name, value)NoneQueue an attribute change (applied after the script returns)

The conclude argument

Side-effects from the script.

CallReturnsEffect
conclude.comment(text)int (action ID)Post a comment as the bot, immediately. Returns the new action’s ID.
conclude.editComment(handle, text)NoneReplace the text of a bot-posted comment. handle is an action ID returned by conclude.comment.
conclude.schedule(delaySeconds, state)NoneSchedule another invocation of onTrigger after delaySeconds. state is a dict carried into the next invocation.
conclude.log(text)NoneDebug log (stderr).

Limits

LimitValue
Max script execution steps1,000,000
Wall-clock timeout10 s
Max conclude.comment calls per run10
conclude.schedule delay0.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.

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:

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).

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:

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$"