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.
| 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.
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 assumetrigger.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:
handleis the ID of an action on this activity, AND- the action was posted by the bot (i.e. you got
handlefrom a previousconclude.commentin 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$"