14 KiB
Triggers
Table of contents
- Overview
- What triggers look like
- Configuration
- Patterns
- Tips and gotchas
- Execution pipeline
- Before-trigger behavior
- After-trigger behavior
- Cascade depth
- The run() action
- Configuration discovery details
- Time triggers
- Startup and error handling
Overview
Triggers are reactive rules that fire when tikis are created, updated, or deleted. A before-trigger can block a mutation with a denial message. An after-trigger can react to a mutation by creating, updating, deleting tikis, or running a shell command.
This page covers how triggers are configured, how they execute at runtime, and common patterns. For the grammar, see Syntax. For structural rules and qualifier scoping, see Semantics. For parse and validation errors, see Validation And Errors.
What triggers look like
A before-trigger guards against unwanted changes:
-- block completing a tiki that has unfinished dependencies
before update
where new.status = "done" and dependsOn any status != "done"
deny "cannot complete tiki with open dependencies"
before update— fires before an update is persistedwhere ...— the guard condition; the trigger only fires when this matchesdeny "..."— the rejection message returned to the caller
An after-trigger automates a reaction:
-- when a recurring tiki is completed, create the next occurrence
after update
where new.status = "done" and old.recurrence is not empty
create title=old.title priority=old.priority tags=old.tags
recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
after update— fires after an update is persistedwhere ...— the guard; the action only runs when this matchescreate ...— the action to perform (can also beupdate,delete, orrun(...))
Triggers without a where clause fire on every matching event:
-- clean up reverse dependencies whenever a tiki is deleted
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
Configuration
Triggers are defined in workflow.yaml under the triggers: key. Each entry has two fields:
ruki— the trigger rule inrukisyntax (required)description— an optional label
triggers:
- description: "block done with open deps"
ruki: >-
before update
where new.status = "done" and dependsOn any status != "done"
deny "resolve dependencies first"
- description: "auto-assign urgent"
ruki: >-
after create
where new.priority <= 2 and new.assignee is empty
update where id = new.id set assignee="booleanmaybe"
- description: "cleanup deps on delete"
ruki: >-
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
workflow.yaml is searched in the standard configuration locations described in Configuration. If multiple files define a triggers: section, the last one wins — cwd overrides project, which overrides user. A file without a triggers: key does not override anything. An explicit empty list (triggers: []) overrides inherited triggers to zero.
Patterns
Limit work in progress
Prevent anyone from having too many in-progress tikis at once:
before update
where new.status = "in progress"
and count(select where assignee = new.assignee and status = "in progress") >= 3
deny "WIP limit reached for this assignee"
The count(select ...) evaluates against the candidate state — the proposed update is already reflected in the count, so the limit fires before persistence.
Auto-assign urgent work
Automatically assign high-priority tikis that arrive without an owner:
after create
where new.priority <= 2 and new.assignee is empty
update where id = new.id set assignee="booleanmaybe"
The after-trigger fires after the tiki is persisted, then updates it with the assignee. This cascades through the mutation gate, so any update validators (like WIP limits) still apply to the auto-assignment.
Recurring task creation
When a recurring tiki is completed, create the next occurrence:
after update
where new.status = "done" and old.recurrence is not empty
create title=old.title priority=old.priority tags=old.tags
recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
The new tiki inherits the original's title, priority, tags, and recurrence pattern. Its due date is set to the next occurrence using next_date().
Dependency cleanup on delete
When a tiki is deleted, remove it from every other tiki's dependsOn list:
after delete
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
This fires after every delete, with no guard condition. The old.id in dependsOn condition finds tikis that depend on the deleted one, and the set clause removes the reference.
Cascade completion
Auto-complete an epic when all its dependencies are done:
after update
where new.status = "done"
update where id in blocks(old.id) and type = "epic"
and dependsOn all status = "done"
set status="done"
When any tiki is marked done, this finds epics that block on it. If all of the epic's other dependencies are also done, the epic is completed automatically. This itself fires further after-update triggers, so cascade chains work naturally (up to the depth limit).
Propagate cancellation
When a tiki is cancelled, cancel downstream tikis that haven't started:
after update
where new.status = "cancelled"
update where id in blocks(old.id) and status in ["backlog", "ready"]
set status="cancelled"
Only tikis in backlog or ready are affected — in-progress work is not cancelled automatically.
Run an external command
Trigger a script when a tiki enters a specific state:
after update
where new.status = "in progress" and "claude" in new.tags
run("claude -p 'implement tiki " + old.id + "'")
The run() action evaluates the expression to a command string, then executes it via sh -c with a 30-second timeout. Command failures are logged but do not block the mutation chain.
Tips and gotchas
- Test your guard condition as a
select where ...statement first. If the select returns unexpected results, the trigger will fire unexpectedly too. - Before-triggers are fail-closed. If the guard expression itself has a runtime error, the mutation is rejected. Keep guard logic straightforward.
- Triggers that modify the same fields they guard on can cascade. For example, an after-update trigger that changes
statuswill fire other after-update triggers. Design triggers to converge — avoid chains that cycle indefinitely. The cascade depth limit (8) prevents runaway loops, but silent termination is rarely what you want. run()commands execute with the permissions of the tiki process. Treat therukifield inworkflow.yamlthe same as any other executable configuration.- A parse error in any trigger definition prevents the app from starting. Validate your
workflow.yamlbefore deploying.
Execution pipeline
When a tiki is created, updated, or deleted, the mutation goes through this pipeline:
- Depth check — reject if the trigger cascade depth exceeds the limit
- Before-validators — run all registered before-triggers for this event; collect rejections
- Persist — write the change to the store
- After-hooks — run all registered after-triggers for this event
Before-triggers are registered as mutation validators. They run before persistence and can block the mutation. After-triggers are registered as hooks. They run after persistence and cannot undo it.
All validators for a given event run — rejections are accumulated, not short-circuited. If any validator rejects, the mutation is blocked and none of the rejection messages are lost.
After-hooks run in definition order. Each hook's errors are logged but do not propagate — the original mutation is unaffected.
Before-trigger behavior
Before-triggers use fail-closed semantics:
- If the guard condition matches, the mutation is rejected with the
denymessage. - If the guard condition evaluation itself errors (e.g. a runtime type error), the mutation is also rejected. This prevents bad triggers from silently allowing mutations they were meant to block.
The context provided to before-triggers depends on the event:
| Event | old |
new |
allTasks |
|---|---|---|---|
| create | nil | proposed task | stored tasks + proposed |
| update | persisted (cloned) | proposed version | stored tasks with proposed applied |
| delete | task being deleted | nil | current stored tasks |
For before-update triggers, allTasks reflects the candidate state — the proposed update is already applied in the task list. This matters for aggregate predicates like WIP limits using count(select ...), which need to see the world as it would look after the update.
After-trigger behavior
After-triggers use fail-open semantics:
- If the guard condition matches, the action executes.
- If the guard condition evaluation itself errors, the trigger is skipped and the error is logged. The mutation chain continues.
- If the action fails, the error is logged. The original mutation is not rolled back.
After-hooks read a fresh task list from the store each time they fire. This means cascaded triggers see the current state of the world, including changes made by earlier triggers in the chain.
After-triggers support two action forms:
- A CRUD action (
create,update, ordelete) — executed through the mutation gate, which fires its own triggers - A
run()command — executed as a shell command (see The run() action)
Cascade depth
After-triggers can cause further mutations, which fire their own triggers, and so on. To prevent infinite loops, cascade depth is tracked:
- The root mutation (user-initiated) runs at depth 0.
- Each triggered mutation increments the depth by 1.
- At depth >= 8, after-hooks are skipped with a warning log.
- At depth > 8, the mutation gate rejects the mutation entirely.
The maximum cascade depth is 8. Termination is graceful — a warning is logged, not a panic. Within a cascade, each after-hook reads the latest store state, so it sees changes from earlier triggers.
The run() action
Note: This section describes trigger run() actions. For the pipe syntax | run(...) and | clipboard() on select statements, see Semantics and Operators And Built-ins. Both run() and clipboard() are pipe-only targets — they cannot appear as trigger actions.
When an after-trigger uses run(...), the command expression is evaluated to a string, then executed:
- The command runs via
sh -c <command-string>. - A 30-second timeout is enforced. If the command exceeds it, the child process is killed.
- Command failure (non-zero exit) is logged but does not block the mutation chain.
- Command success is also logged.
The command string is dynamically evaluated from the trigger's expression, which may reference old.id, new.status, or other fields via string concatenation.
Configuration discovery details
Trigger definitions are loaded from workflow.yaml using the standard configuration precedence. The last file with a triggers: key wins:
| File | triggers: key |
Effect |
|---|---|---|
| user config | yes, 2 triggers | base: 2 triggers |
| project config | absent | no override, user triggers survive |
| cwd config | triggers: [] |
override: 0 triggers |
A file that exists but has no triggers: key expresses no opinion and does not override. An explicit empty list (triggers: []) is an active override that disables inherited triggers.
If two candidate paths resolve to the same absolute path (e.g. when the project root is the current directory), the file is read once.
Time triggers
Time triggers use the every keyword to define a periodic CRUD operation:
every <duration> <statement>
Where <statement> is create, update, or delete (not select or run()). The interval must be a positive duration.
triggers:
- description: stale tasks go back to backlog
ruki: >
every 1hour
update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"
- description: delete expired tasks
ruki: >
every 1day
delete where status = "done" and updatedAt < now() - 30day
Time triggers differ from event triggers in several ways:
- No timing/event pair (
before/after+create/update/delete) — justevery+ duration - No
whereguard at the trigger level — filtering belongs inside the CRUD statement - No
old./new.qualifiers — there is no "old" or "new" task context for a periodic operation - No
denyorrun()— only mutating CRUD statements
Time triggers are parsed and validated at startup alongside event triggers. A parse error in any trigger definition prevents the app from starting.
Note: the time trigger scheduler (executor) is not yet implemented. Time trigger definitions are parsed and stored, but they do not run periodically at this time.
Startup and error handling
Triggers are loaded during application startup, after the store is initialized but before controllers are created.
- Each trigger definition is parsed with the
rukiparser. A parse error in any trigger is fail-fast: the application will not start, and the error message identifies the failing trigger by itsdescription(or by index if no description is set). - If no
triggers:section is found in any workflow file, zero triggers are loaded and the app starts normally. - Successfully loaded triggers are logged with a count at startup.
Recipes
For a catalog of ready-to-use trigger examples — WIP limits, recurring tasks, auto-escalation, and more — see Trigger Ideas.