Automations Configuration Guide
This guide explains how to configure automations in Vorno to automate workflows based on events.
CLI-first workflow (recommended): Use
vorno-cli automation ...commands instead of editing JSON directly.
vorno-cli automation --help- Canonical command reference: vorno-cli.md
What Are Automations?
Section titled “What Are Automations?”Automations allow you to trigger actions automatically when specific events occur in Vorno. You can:
- Send prompts to create agent sessions based on events
- Send webhook HTTP requests to external services (Slack, Discord, custom APIs, etc.)
- Execute actions on a schedule using cron expressions
- Automate workflows based on permission mode changes, flags, or session status changes
automations.json Location
Section titled “automations.json Location”Automations are configured in automations.json at the root of your workspace:
~/.craft-agent/workspaces/{workspaceId}/automations.jsonRecommended CLI Commands
Section titled “Recommended CLI Commands”vorno-cli automation listvorno-cli automation get <id>vorno-cli automation create --event UserPromptSubmit --prompt "..."vorno-cli automation update <id> --json '{...}'vorno-cli automation enable <id>vorno-cli automation disable <id>vorno-cli automation duplicate <id>vorno-cli automation history [<id>] --limit 20vorno-cli automation last-executed <id>vorno-cli automation test <id> --match "..."vorno-cli automation lintvorno-cli automation validateBasic Structure
Section titled “Basic Structure”{ "version": 2, "automations": { "EventName": [ { "name": "Optional display name", "matcher": "regex-pattern", "actions": [ { "type": "prompt", "prompt": "Check for updates and report status" } ] } ] }}Supported Events
Section titled “Supported Events”App Events (triggered by Vorno)
Section titled “App Events (triggered by Vorno)”| Event | Trigger | Match Value |
|---|---|---|
LabelAdd |
Label added to session | Label ID (e.g., bug, not Bug) |
LabelRemove |
Label removed from session | Label ID (e.g., bug, not Bug) |
LabelConfigChange |
Label configuration changed | Always matches |
PermissionModeChange |
Permission mode changed | New mode name |
FlagChange |
Session flagged/unflagged | true or false |
SessionStatusChange |
Session status changed | New status (e.g., done, in_progress) |
SchedulerTick |
Runs every minute | Uses cron matching |
WebhookReceived |
An inbound HTTP request hit a registered hook | Configurable via matchField (default: the whole body) |
Note:
TodoStateChangeis a deprecated alias forSessionStatusChange. Existing configs using the old name will continue to work but will show a deprecation warning during validation.
Event names are case-sensitive and exact. A block filed under a name that isn’t in these tables —
LabelAddedinstead ofLabelAdd, say — is discarded in full at load, taking every matcher inside it with it. Validation reports this as an error naming the block and how many matchers were lost.
Agent Events (passed to Claude SDK)
Section titled “Agent Events (passed to Claude SDK)”| Event | Trigger | Match Value |
|---|---|---|
PreToolUse |
Before a tool executes | Tool name |
PostToolUse |
After a tool executes successfully | Tool name |
PostToolUseFailure |
After a tool execution fails | Tool name |
Notification |
Notification received | - |
UserPromptSubmit |
User submits a prompt | - |
SessionStart |
Session starts | - |
SessionEnd |
Session ends | - |
Stop |
Agent stops | - |
SubagentStart |
Subagent spawned | - |
SubagentStop |
Subagent completes | - |
PreCompact |
Before context compaction | - |
PermissionRequest |
Permission requested | - |
Setup |
Initial setup | - |
Action Types
Section titled “Action Types”There are exactly six action types. Anything else is not a valid action — see Session Actions below, and Validation for what happens if you invent one.
type |
Does |
|---|---|
prompt |
Creates a session and sends it a prompt |
webhook |
Sends an outbound HTTP request |
set-status |
Changes a session’s status |
set-labels |
Adds/removes labels on a session |
send-message |
Injects a message into a live session |
apply-context |
Activates a named context profile on a session |
All six work on any event.
Prompt Actions
Section titled “Prompt Actions”Send a prompt to Vorno (creates a new session for scheduled prompts).
{ "type": "prompt", "prompt": "Run the @weather skill and summarize the forecast"}| Property | Type | Default | Description |
|---|---|---|---|
type |
"prompt" |
Required | Action type |
prompt |
string | Required | Prompt text to send |
llmConnection |
string | Workspace default | LLM connection slug (configured in AI Settings) |
model |
string | Workspace default | Model ID for the created session |
Features:
- Use
@mentionsto reference sources or skills - Environment variables are expanded (e.g.,
$CRAFT_LABEL)
LLM Connection & Model: Optionally specify which AI provider and model to use for the created session. If omitted, the workspace default connection and model are used.
{ "type": "prompt", "prompt": "Quick code review of recent changes", "llmConnection": "my-copilot-connection", "model": "gemini-2.5-flash"}The llmConnection value is the slug of an LLM connection configured in AI Settings. The model value is a model ID supported by the provider. If either is invalid or not found, it gracefully falls back to the workspace default. Both can be used independently or together.
Webhook Actions
Section titled “Webhook Actions”Send an HTTP request to an external endpoint when an event fires. Useful for notifications (Slack, Discord), logging to external services, or triggering external workflows.
{ "type": "webhook", "url": "https://hooks.slack.com/services/${CRAFT_WH_SLACK_PATH}", "method": "POST", "body": { "text": "Session ${CRAFT_SESSION_NAME} status changed to ${CRAFT_NEW_STATE}" }}| Property | Type | Default | Description |
|---|---|---|---|
type |
"webhook" |
Required | Action type |
url |
string | Required | Target URL (http or https) |
method |
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" |
"POST" |
HTTP method |
headers |
Record<string, string> |
{} |
HTTP headers as key-value pairs |
bodyFormat |
"json" | "form" | "raw" |
"json" |
Body serialization format |
body |
object or string | - | Request body (omitted for GET requests) |
auth |
object | - | Authentication shorthand (see below) |
captureResponse |
boolean | false |
Capture response body in result (truncated to 4KB) |
URL validation: Literal URLs are validated at config load time. Templated URLs (containing
$VAR) are validated at runtime after variable expansion. Both must resolve tohttp://orhttps://— other protocols are rejected.
Body format:
json(default) — Body is serialized as JSON.Content-Type: application/jsonis set automatically unless you override it inheaders.form— Body object keys are URL-encoded asapplication/x-www-form-urlencoded. Useful for OAuth token endpoints, Stripe, and legacy APIs. Each value supports$VARexpansion.raw— Body is sent as a plain string. SetContent-Typeinheadersyourself.
Authentication:
Instead of manually constructing Authorization headers, you can use the auth shorthand:
Bearer token:
{ "type": "webhook", "url": "https://api.example.com/events", "auth": { "type": "bearer", "token": "${CRAFT_WH_API_TOKEN}" }, "body": { "event": "$CRAFT_EVENT" }}Basic auth (username/password):
{ "type": "webhook", "url": "https://legacy.example.com/webhook", "auth": { "type": "basic", "username": "${CRAFT_WH_USER}", "password": "${CRAFT_WH_PASS}" }}The auth field is applied before custom headers, so you can override the generated Authorization header if needed. All auth field values support $VAR expansion.
Response capture: By default, webhook response bodies are discarded after reading (to release connections). Set captureResponse: true to capture the response body (truncated to 4KB). The captured body is included in the execution result and recorded in automation history (truncated to 500 chars).
{ "type": "webhook", "url": "https://api.example.com/status", "method": "GET", "captureResponse": true}Note: Response capture adds memory overhead proportional to the response size. Only enable it for endpoints where you need to inspect the response.
Variable expansion: The url, headers values, body, and auth fields all support $VAR and ${VAR} syntax for environment variable expansion. See Environment Variables below.
Security: Webhook actions only have access to CRAFT_* system variables and CRAFT_WH_* user-defined secrets. They do not have access to your full system environment (e.g., $HOME, $PATH, or other process variables).
Session Actions
Section titled “Session Actions”Four action types mutate an existing session rather than creating one:
set-status, set-labels, send-message, and apply-context.
They work on any event. Earlier builds restricted them to WebhookReceived;
that restriction was a scope limitation, not a security property, and it is gone.
Nothing about the webhook transport made a status change safer — what it was
providing incidentally was loop safety, which is now explicit. See
Loop safety below.
The closure rule is unchanged: set-status still refuses a closed status unless
the rule declared allowClosed: true, and the agent-facing set_session_status
tool still refuses closed statuses unconditionally.
All four take a session selector naming the session to act on. Set exactly
one of id or label:
{ "session": { "id": "260804-deft-leaf" } }{ "session": { "label": "deploy-target" } }Both fields support $VAR environment expansion and $.jsonpath extraction
from the webhook body, so the target can come from the request itself:
{ "session": { "id": "$.body.session_id" } }set-status
Section titled “set-status”{ "type": "set-status", "session": { "label": "release-watch" }, "status": "needs-review"}| Property | Type | Default | Description |
|---|---|---|---|
type |
"set-status" |
Required | Action type |
session |
selector | Required | Exactly one of id / label |
status |
string | Required | A status ID from statuses/config.json |
allowClosed |
boolean | false |
Permit moving into a closed-category status |
allowClosed is the closure gate, and it is deliberate. Moving a session
into a closed status (done, cancelled) is refused unless allowClosed: true
is written in the config. The refusal is recorded in history as
rejected:closed-status:<status>.
The rule behind it: a model may never close a task from inside a turn. The
agent-facing set_session_status tool refuses closed statuses unconditionally
and no flag changes that. allowClosed is not a way around it — it is a
human declaring, at registration time, that this specific rule closes tasks.
The distinction is who decided: a person editing a config file, not a model
mid-turn.
{ "type": "set-status", "session": { "id": "$.body.session_id" }, "status": "done", "allowClosed": true}set-labels
Section titled “set-labels”{ "type": "set-labels", "session": { "id": "$.body.session_id" }, "add": ["deployed"], "remove": ["deploying"]}| Property | Type | Default | Description |
|---|---|---|---|
type |
"set-labels" |
Required | Action type |
session |
selector | Required | Exactly one of id / label |
add |
string[] | — | Label IDs to add |
remove |
string[] | — | Label IDs to remove |
At least one of add / remove is required. Labels that don’t exist in the
workspace are dropped silently — create them in labels/config.json first.
Valued labels use the id::value form (e.g. priority::3).
send-message
Section titled “send-message”{ "type": "send-message", "session": { "label": "ops-inbox" }, "message": "Deploy $.body.version finished with status $.body.result"}| Property | Type | Default | Description |
|---|---|---|---|
type |
"send-message" |
Required | Action type |
session |
selector | Required | Exactly one of id / label |
message |
string | Required | Message text (supports $VAR and $.jsonpath) |
Desktop only. The standalone trigger server cannot inject into a session it does not host; it records
deferred:host-unreachableand moves on.
apply-context
Section titled “apply-context”Activates a named context profile on a session — its working directory, sources, and permission mode, in one action.
{ "type": "apply-context", "session": { "id": "$CRAFT_SESSION_ID" }, "profile": "steward"}| Property | Type | Default | Description |
|---|---|---|---|
type |
"apply-context" |
Required | Action type |
session |
selector | Required | Exactly one of id / label |
profile |
string | Required | An id from context-profiles/config.json (supports $VAR and $.jsonpath) |
Pair it with a LabelAdd rule and a label becomes a context activator —
add steward to a session and it moves to the Steward checkout with the right
sources on:
{ "automations": { "LabelAdd": [ { "id": "steward-context", "matcher": "^steward$", "actions": [ { "type": "apply-context", "session": { "id": "$CRAFT_SESSION_ID" }, "profile": "steward" } ] } ] }}Desktop only. The standalone trigger server records
deferred:host-unreachable. It could write the session header, but sources and permission mode only take effect by re-plumbing a live agent — so writing the header alone would report success while the running session kept its old context.
Context profiles
Section titled “Context profiles”Profiles live in context-profiles/config.json in your workspace folder:
{ "version": 1, "profiles": [ { "id": "steward", "name": "Steward repo", "workingDirectory": "/Users/you/dev/steward", "sources": ["dev"], "permissionMode": "ask" } ]}| Field | Type | Description |
|---|---|---|
id |
string | Required. What apply-context references. |
name |
string | Optional display name. |
workingDirectory |
string | Absolute path. Validated when applied. |
sources |
string[] | Replaces the session’s enabled sources — it is not a merge. |
permissionMode |
safe | ask | allow-all |
See the escalation rule below. |
allowEscalation |
boolean | Required to raise permissionMode. Default false. |
Every knob is optional; an omitted one is left alone. A profile must set at least one — a profile that changes nothing is a rule that silently does nothing.
Why a profile instead of one action per knob. addWorkingDirectory,
enableSkill, enableSource are the names people reach for, and none of them
exist. One action type per knob multiplies without bound: N knobs × M rules,
with every rule needing its own review. A profile is reviewed once, reused
everywhere, and auditable in one file — and new knobs become fields here rather
than new action types. If you find yourself wanting a per-knob action, that is
the signal to add a field instead.
Permission-mode escalation. Lowering the mode is always allowed.
Raising it above the session’s current mode requires "allowEscalation": true
on the profile — otherwise the action is refused and recorded as
rejected:permission-escalation:<mode>. Without this, adding a label would be a
silent privilege escalation. The flag sits on the profile rather than on the
rule so that the file declaring "permissionMode": "allow-all" is the same file
that says whether that is authorized.
apply-context cannot close a session. There is no status knob on a
profile, and permission mode is not an input to either closure rule — an
automation still needs allowClosed on a set-status action, and the
agent-facing set_session_status tool still refuses closed statuses
unconditionally.
Skills are not a profile field. Skills activate per message, via a
[skill:<slug>] mention — there is no session-level skill state to set. Put the
mention in the rule’s prompt action instead. Writing "skills" in a profile
is a config error with that explanation, not a silent no-op.
If context-profiles/config.json is missing, unparseable, or has any invalid
profile, no profiles load — one bad entry does not leave its siblings
half-usable — and apply-context records
rejected:unknown-profile:<id>.
Outcomes
Section titled “Outcomes”Session actions record what actually happened, not merely that they were attempted:
| History outcome | Meaning |
|---|---|
set-status:<status> / set-labels / send-message |
Applied |
apply-context:<profile> |
Applied; the record lists which knobs actually changed |
deferred:target-not-found |
The selector matched no session |
rejected:invalid-status:<status> |
No such status in statuses/config.json |
rejected:closed-status:<status> |
Closed status without allowClosed: true |
rejected:unknown-profile:<id> |
No such profile in context-profiles/config.json |
rejected:permission-escalation:<mode> |
Profile raises the mode without allowEscalation: true |
deferred:host-unreachable |
send-message / apply-context on the standalone server |
skipped:unhandled-action:<type> |
This host has no implementation for that action type |
error:<message> |
The mutation threw |
skipped:self-trigger |
Refused: the rule would re-enter on an event its own action caused |
skipped:depth-exceeded |
Refused: the automation chain hit the depth cap |
skipped:rate-limited |
Refused: the rule exceeded its per-minute session-action budget |
skipped:unknown-action |
Refused: one of the rule’s actions names a type no handler dispatches |
The three prefixes are not interchangeable. rejected: and error: mean the
action reached an executor; deferred: means it was admitted but did not apply
here or now; skipped: means a guard refused it before execution, so nothing
was attempted at all. A skipped: record is shown in the run history with the
same blocked treatment as a dead-rule diagnostic — visible, but never counted as
a successful run.
skipped:unknown-action is the narrow sibling of the config-diagnostic
dead-rule record. The diagnostic covers a rule that can never run at all and is
written once per config load; this covers a rule that fires normally while one of
its actions is unrunnable, and is written when that half-fire actually happens.
Loop safety
Section titled “Loop safety”Session actions mutate session state, and session state changes emit events. So
a set-status action on SessionStatusChange, set-labels on LabelAdd, or
apply-context on PermissionModeChange, feeds itself by construction: the rule’s own effect looks exactly like the thing
that triggers it. Three guards keep that bounded. You do not configure any of
them, and none can be turned off.
1. Self-trigger suppression. A matcher never runs again on an event its own
action caused — at any depth. A rule that flips a session’s status on
SessionStatusChange fires once and stops.
2. Depth cap. Every event carries provenance: which rule caused it, and how many automation hops deep the chain already is. A chain is refused past 3 hops. This catches loops self-trigger suppression cannot see — two rules ping-ponging (A’s action triggers B, B’s triggers A) never re-enter themselves, but each hop still counts.
Provenance is exact, not inferred. Only automation-caused mutations carry it, so a change made by you in the UI, by an agent, or by an external edit to the session file starts a fresh chain at depth 0 — which is correct, because it is genuinely a new cause.
3. Rate gate. A matcher may run session actions at most 5 times per minute. This bounds the case the other two structurally cannot: one rule firing a fresh, depth-1 action on every event of a chatty type. Webhook deliveries are exempt here because they are already rate-limited per hook at the receiver.
A refusal is never silent — it is logged with the matcher, the reason, and the specifics (the depth reached, the offending rule). If a rule seems to have stopped firing, that log is the first place to look.
Environment Variables
Section titled “Environment Variables”Both prompt and webhook actions support variable expansion using $VAR or ${VAR} syntax.
System Variables (CRAFT_*)
Section titled “System Variables (CRAFT_*)”These are automatically set by the automation system based on the triggering event:
| Variable | Description | Available For |
|---|---|---|
$CRAFT_EVENT |
Event name (e.g., LabelAdd) |
All events |
$CRAFT_EVENT_DATA |
Full event payload as JSON | All events |
$CRAFT_SESSION_ID |
Session ID | Events with session context |
$CRAFT_SESSION_NAME |
Session name | Events with session context |
$CRAFT_WORKSPACE_ID |
Workspace ID | All events |
Per-event variables:
| Event | Variable | Description |
|---|---|---|
LabelAdd / LabelRemove |
$CRAFT_LABEL |
The label that was added/removed |
PermissionModeChange |
$CRAFT_OLD_MODE, $CRAFT_NEW_MODE |
Previous and new permission mode |
FlagChange |
$CRAFT_IS_FLAGGED |
true or false |
SessionStatusChange |
$CRAFT_OLD_STATE, $CRAFT_NEW_STATE |
Previous and new status |
SchedulerTick |
$CRAFT_LOCAL_TIME, $CRAFT_LOCAL_DATE |
Current time (14:30) and date (2026-03-09) |
User-Defined Webhook Secrets (CRAFT_WH_*)
Section titled “User-Defined Webhook Secrets (CRAFT_WH_*)”For webhook actions, you can define your own secrets by setting environment variables with the CRAFT_WH_ prefix in your shell profile (e.g., ~/.zshrc, ~/.bashrc):
# In your shell profileexport CRAFT_WH_SLACK_URL="https://hooks.slack.com/services/T.../B.../xxx"export CRAFT_WH_DISCORD_URL="https://discord.com/api/webhooks/123/abc"export CRAFT_WH_API_TOKEN="your-secret-token"Then reference them in automations.json:
{ "type": "webhook", "url": "${CRAFT_WH_SLACK_URL}", "method": "POST", "body": { "text": "Hello from Vorno!" }}{ "type": "webhook", "url": "https://api.example.com/events", "headers": { "Authorization": "Bearer ${CRAFT_WH_API_TOKEN}" }, "body": { "event": "${CRAFT_EVENT}", "session": "${CRAFT_SESSION_NAME}" }}This keeps secrets out of automations.json (which may be shared or committed to version control).
Note: Only variables prefixed with
CRAFT_WH_are injected into webhook actions. Other environment variables (like$HOMEor$DATABASE_URL) are not accessible to webhooks.
Note: Environment variables are not expanded during test runs (the “Test” button in the UI). Tests send the raw URL/body as configured.
Matcher Configuration
Section titled “Matcher Configuration”Display Name
Section titled “Display Name”Use the optional name field to give an automation a human-readable display name. If omitted, the name is automatically derived from the first action.
{ "name": "Morning Weather Report", "cron": "0 8 * * *", "actions": [ { "type": "prompt", "prompt": "Run the @weather skill" } ]}Regex Matching (for most events)
Section titled “Regex Matching (for most events)”Use the matcher field to filter which events trigger your automations:
{ "matcher": "^urgent$", "actions": [ { "type": "prompt", "prompt": "An urgent label was added. Review the session and summarise the issue." } ]}If matcher is omitted, the automation triggers for all events of that type.
Cron Matching (for SchedulerTick)
Section titled “Cron Matching (for SchedulerTick)”For SchedulerTick events, use cron expressions instead of regex:
{ "cron": "0 9 * * 1-5", "timezone": "America/New_York", "actions": [ { "type": "prompt", "prompt": "Give me a morning briefing" } ]}Cron format: minute hour day-of-month month day-of-week
| Field | Values |
|---|---|
| Minute | 0-59 |
| Hour | 0-23 |
| Day of month | 1-31 |
| Month | 1-12 |
| Day of week | 0-6 (0 = Sunday) |
Examples:
*/15 * * * *- Every 15 minutes0 9 * * *- Daily at 9:00 AM0 9 * * 1-5- Weekdays at 9:00 AM30 14 1 * *- 1st of each month at 2:30 PM
Timezone: Use IANA timezone names (e.g., Europe/Budapest, America/New_York). Defaults to system timezone if not specified.
Conditions
Section titled “Conditions”Conditions are optional filters that run after the matcher/cron matches but before actions fire. All conditions in the array must pass (implicit AND). If the array is empty or omitted, actions fire unconditionally.
{ "cron": "0 9 * * *", "timezone": "Europe/Budapest", "conditions": [ { "condition": "time", "weekday": ["mon", "tue", "wed", "thu", "fri"] } ], "actions": [ { "type": "prompt", "prompt": "Good morning! Here's your daily briefing." } ]}Time Conditions
Section titled “Time Conditions”Check time-of-day and day-of-week in a given timezone.
{ "condition": "time", "after": "09:00", "before": "17:00", "weekday": ["mon", "tue", "wed", "thu", "fri"], "timezone": "Europe/Budapest"}| Property | Type | Description |
|---|---|---|
after |
"HH:MM" |
Start of time window (inclusive) |
before |
"HH:MM" |
End of time window (exclusive) |
weekday |
string[] |
Allowed days: mon, tue, wed, thu, fri, sat, sun |
timezone |
string | IANA timezone. Falls back to matcher timezone, then system local |
Overnight ranges: If after is later than before (e.g., "after": "22:00", "before": "06:00"), the range wraps across midnight.
State Conditions
Section titled “State Conditions”Check fields from the event payload. Useful for filtering on specific transitions or values.
{ "condition": "state", "field": "permissionMode", "from": "safe", "to": "allow-all"}| Property | Type | Description |
|---|---|---|
field |
string | Payload field name (e.g., permissionMode, sessionStatus, labels, isFlagged) |
value |
any | Exact match |
from |
any | Previous value (for transition events) |
to |
any | New value (for transition events) |
contains |
string | Array membership check (e.g., check if a label is present) |
not_value |
any | Matches anything except this value |
Transition fields: For permissionMode and sessionStatus, from/to automatically resolve to the correct payload keys (oldMode/newMode, oldState/newState).
Logical Composition
Section titled “Logical Composition”Combine conditions with and, or, and not:
{ "condition": "and", "conditions": [ { "condition": "time", "weekday": ["mon", "tue", "wed", "thu", "fri"] }, { "condition": "time", "after": "09:00", "before": "17:00" } ]}{ "condition": "or", "conditions": [ { "condition": "state", "field": "permissionMode", "value": "allow-all" }, { "condition": "state", "field": "isFlagged", "value": true } ]}{ "condition": "not", "conditions": [ { "condition": "time", "weekday": ["sat", "sun"] } ]}| Type | Behaviour |
|---|---|
and |
All sub-conditions must pass |
or |
At least one sub-condition must pass |
not |
None of the sub-conditions may pass |
Nesting depth: Conditions can be nested up to 8 levels deep. A simplification warning is emitted at depth 4. Unknown condition types fail closed (evaluate to false).
Failure Handling (onFailure)
Section titled “Failure Handling (onFailure)”Add an optional onFailure list to any matcher to run follow-up actions when a
run for that automation fails. It fires when a not-ok history record is
appended for the matcher (see Execution History Records):
- a dispatch failure (the session couldn’t be created), or
- an outcome failure (the spawned session’s turn produced error-role messages — e.g. an invalid API key), or
- a missed cron fire (the app was down across a scheduled fire).
Only prompt and webhook actions are allowed inside onFailure. Any other
action type is rejected at validation time.
{ "name": "Nightly backup", "cron": "0 2 * * *", "actions": [ { "type": "prompt", "prompt": "Run the @backup skill and report the result" } ], "onFailure": [ { "type": "webhook", "url": "${CRAFT_WH_SLACK_URL}", "method": "POST", "body": { "text": ":rotating_light: Nightly backup automation failed" } }, { "type": "prompt", "prompt": "The nightly backup failed. Investigate and summarise why." } ]}Semantics:
onFailureruns are never themselves reconciled and never triggeronFailure— there is no recursion. Their sessions are created without a matcher id, so they produce no history records and no fire-count churn.- Webhook actions in
onFailurewrite no history entry. If a webhook action omits itsbody, a JSON body with the failure context is sent automatically:{ automationId, failureKind: "dispatch" | "outcome" | "missed", ok: false, sessionId?, errorCount?, expectedTs?, error? }. - Failures of the
onFailureactions themselves are logged and swallowed.
Execution History Records
Section titled “Execution History Records”Automation runs are recorded in automations-history.jsonl at the workspace
root. Records come in four kinds, distinguished by an optional kind field:
kind |
Meaning | Example fields |
|---|---|---|
| (absent) | Dispatch — an automation actually fired (a prompt session was created or a webhook was sent). This is what the “Recent Activity” run list shows and what “last executed” is based on. | ok, sessionId?, prompt?, webhook? |
"outcome" |
Outcome reconciliation — written right after a prompt dispatch record once the spawned session’s turn completes. ok is true only if the turn produced no error-role messages. |
ok, sessionId, errorCount |
"missed" |
Missed fire — written on scheduler startup when an enabled cron matcher’s most recent expected fire (within the last 24h) has no dispatch record. Always ok: false. |
ok: false, expectedTs |
"config-diagnostic" |
Dead rule — written at load/reload when a rule is structurally unable to run at all (see Validation). Not a failed run; nothing was attempted. Shown in the run history with a warning treatment. | ok: false, reason, detail, event |
Notes:
- Outcome and missed records make semantic failures visible: previously a run
whose session hit an
invalid_api_keyerror still recordedok: truebecause the session was created and the turn threw no unhandled exception. - Only dispatch records (no
kind) count toward “last executed” and appear in the Recent Activity run list. Outcome/missed records are reconciliation metadata and are filtered out of the run list. Config diagnostics are shown — a rule that can never run is exactly what you need to see when you go looking for why it never ran — but they are marked as blocked rather than counted as runs. - Session-action records (dispatch records carrying a
sessionActionfield) are shown in the run list with their outcome spelled out, so a refusedset-statusreads differently from an applied one. Askipped:outcome renders as blocked. - A dispatch record with
ok: truemeans the action was dispatched, not that it achieved anything. A prompt action recordsok: trueas soon as the session is created; if the model’s tool call is then rejected, that shows up in theoutcomerecord (or not at all, for a rejection the session swallows). When checking whether a rule is working, read the outcome, not the dispatch. - Retention is per-kind: outcome and missed records never evict dispatch records (each kind keeps its own last-20-per-automation window).
- Test runs (the Test button) and
onFailure-spawned sessions produce no outcome records.
Permission Mode
Section titled “Permission Mode”The permissionMode field controls the permission level of sessions created by prompt actions.
{ "cron": "*/10 * * * *", "permissionMode": "allow-all", "actions": [ { "type": "prompt", "prompt": "Check system health and log the results" } ]}Permission modes:
safe- Session runs in Explore mode (default)ask- Session prompts for approval before write operationsallow-all- Session auto-approves all operations
Labels for Prompt Actions
Section titled “Labels for Prompt Actions”Prompt actions can specify labels that will be applied to the session they create:
{ "cron": "0 9 * * *", "labels": ["Scheduled", "morning-briefing"], "actions": [ { "type": "prompt", "prompt": "Give me today's priorities" } ]}This creates a session with the “Scheduled” and “morning-briefing” labels applied automatically.
Telegram Topic Routing
Section titled “Telegram Topic Routing”When a Telegram supergroup is paired in Settings → Messaging → Telegram, set
telegramTopic on a matcher to route its spawned sessions into a dedicated
forum topic. The topic is created on first use and reused thereafter.
{ "matcher": "^urgent$", "telegramTopic": "Urgent Alerts", "actions": [ { "type": "prompt", "prompt": "Look at the urgent issue: $LABEL" } ]}| Field | Type | Description |
|---|---|---|
telegramTopic |
string (1–128 chars) | Topic name. Created on first use, reused thereafter. Multiple matchers using the same value share one topic. |
Activation requirements (all must hold; otherwise the field is silently ignored):
- A Telegram supergroup is paired in Settings → Messaging → Telegram
- The Telegram bot is connected
- The bot has the Manage Topics admin permission
Names are case-sensitive: "Reports" and "reports" create separate topics.
Setting up the Telegram supergroup
Section titled “Setting up the Telegram supergroup”If you haven’t paired a supergroup yet:
- Create / convert a supergroup with Topics enabled. In Telegram, open the group → tap the group name → Edit (pencil icon) → toggle Topics on → Save. The group must be a forum supergroup; regular groups can’t host topics.
- Add the bot to the supergroup. Group name → Add members → search for your bot’s username → add.
- Promote the bot to admin with “Manage Topics”. Group name → Edit → Administrators → Add Administrator → pick the bot → toggle on Manage Topics → Save. This is the step most people miss; without it, topic creation fails with
400: not enough rights to create a topic. - Pair the supergroup. In Vorno: Settings → Messaging → Telegram → Pair Supergroup. Copy the 6-digit code, then in any topic of the supergroup type
/pair <code>. The bot confirms and the Settings row updates with the group’s title.
Verify by checking the supergroup row in Settings shows the group title. If automation runs fail later, ~/.craft-agent/logs/messaging-gateway.log will show automation_topic_bind_failed with the underlying Telegram error.
Complete Examples
Section titled “Complete Examples”Daily Weather Report
Section titled “Daily Weather Report”{ "version": 2, "automations": { "SchedulerTick": [ { "name": "Daily Weather Report", "cron": "0 8 * * *", "timezone": "Europe/Budapest", "labels": ["Scheduled", "weather"], "actions": [ { "type": "prompt", "prompt": "Run the @weather skill and give me today's forecast" } ] } ] }}Weekday-Only AI News (with Conditions)
Section titled “Weekday-Only AI News (with Conditions)”Use a time condition to restrict a daily schedule to weekdays only:
{ "version": 2, "automations": { "SchedulerTick": [ { "name": "Morning AI news", "cron": "0 9 * * *", "timezone": "Europe/Budapest", "conditions": [ { "condition": "time", "weekday": ["mon", "tue", "wed", "thu", "fri"], "timezone": "Europe/Budapest" } ], "labels": ["Scheduled", "ai-news"], "actions": [ { "type": "prompt", "prompt": "Run the @ai-news skill and summarize today's AI developments" } ] } ] }}Permission Mode Gate (with Conditions)
Section titled “Permission Mode Gate (with Conditions)”Only notify when permission mode changes specifically from safe to allow-all:
{ "version": 2, "automations": { "PermissionModeChange": [ { "conditions": [ { "condition": "state", "field": "permissionMode", "from": "safe", "to": "allow-all" } ], "actions": [ { "type": "webhook", "url": "${CRAFT_WH_SLACK_URL}", "method": "POST", "body": { "text": ":warning: Permission escalated from safe to allow-all in *${CRAFT_SESSION_NAME}*" } } ] } ] }}Log Label Changes
Section titled “Log Label Changes”{ "version": 2, "automations": { "LabelAdd": [ { "actions": [ { "type": "prompt", "prompt": "The label $CRAFT_LABEL was added. Log this change with a timestamp." } ] } ], "LabelRemove": [ { "actions": [ { "type": "prompt", "prompt": "The label $CRAFT_LABEL was removed. Log this change with a timestamp." } ] } ] }}Urgent Label Notification
Section titled “Urgent Label Notification”{ "version": 2, "automations": { "LabelAdd": [ { "matcher": "^urgent$", "actions": [ { "type": "prompt", "prompt": "An urgent label was added to this session. Triage the session and summarise what needs immediate attention." } ] } ] }}Permission Mode Change Notification
Section titled “Permission Mode Change Notification”{ "version": 2, "automations": { "PermissionModeChange": [ { "matcher": "allow-all", "actions": [ { "type": "prompt", "prompt": "The permission mode was changed to allow-all. Log the change and note any security implications." } ] } ] }}Slack Notification on Status Change
Section titled “Slack Notification on Status Change”Sends a Slack message when a session is marked as done. Requires CRAFT_WH_SLACK_URL in your shell profile.
{ "version": 2, "automations": { "SessionStatusChange": [ { "name": "Notify Slack on Done", "matcher": "^done$", "actions": [ { "type": "webhook", "url": "${CRAFT_WH_SLACK_URL}", "method": "POST", "body": { "text": ":white_check_mark: Session *${CRAFT_SESSION_NAME}* marked as done" } } ] } ] }}Mixed Actions (Prompt + Webhook)
Section titled “Mixed Actions (Prompt + Webhook)”A single automation can have both prompt and webhook actions. They execute in order.
{ "version": 2, "automations": { "LabelAdd": [ { "name": "Urgent: Notify and Triage", "matcher": "^urgent$", "actions": [ { "type": "webhook", "url": "${CRAFT_WH_SLACK_URL}", "method": "POST", "body": { "text": ":rotating_light: Urgent label added to *${CRAFT_SESSION_NAME}*" } }, { "type": "prompt", "prompt": "An urgent label was added. Triage the session and summarise what needs immediate attention." } ] } ] }}Form-Encoded Request (OAuth / Stripe)
Section titled “Form-Encoded Request (OAuth / Stripe)”{ "version": 2, "automations": { "SchedulerTick": [ { "name": "Refresh API Token", "cron": "0 */6 * * *", "actions": [ { "type": "webhook", "url": "https://auth.example.com/oauth/token", "method": "POST", "bodyFormat": "form", "body": { "grant_type": "client_credentials", "client_id": "${CRAFT_WH_CLIENT_ID}", "client_secret": "${CRAFT_WH_CLIENT_SECRET}" } } ] } ] }}Webhook with Custom Headers
Section titled “Webhook with Custom Headers”{ "version": 2, "automations": { "SessionStatusChange": [ { "name": "Log to External API", "actions": [ { "type": "webhook", "url": "https://api.example.com/craft-events", "method": "POST", "headers": { "Authorization": "Bearer ${CRAFT_WH_API_TOKEN}", "X-Source": "vorno-cli" }, "body": { "event": "${CRAFT_EVENT}", "session_id": "${CRAFT_SESSION_ID}", "old_status": "${CRAFT_OLD_STATE}", "new_status": "${CRAFT_NEW_STATE}" } } ] } ] }}Validation
Section titled “Validation”Automations are validated when:
- The workspace is loaded
- You edit automations.json (via PreToolUse hook)
- You run
config_validatewith targetautomationsorall
Using config_validate:
Ask Vorno to validate your automations configuration:
Validate my automations configurationOr use the config_validate tool directly with target: "automations".
Common validation errors:
- Invalid JSON syntax
- Unknown event names
- Empty actions array
- Invalid cron expression
- Invalid timezone
- Invalid regex pattern
- Potentially unsafe regex patterns (nested quantifiers)
- Unknown action type (see below)
- A known action type missing its required fields (see below)
Rules that validate but can never run
Section titled “Rules that validate but can never run”Config parsing is deliberately lenient so that a file written by a newer build
still opens on an older one. The cost is that a few mistakes used to parse
cleanly and then do nothing, forever, with no error and no history — a rule that
looked healthy and wasn’t. Those are now reported explicitly, both as validation
errors and as config-diagnostic history records written at load and on every
reload.
Three ways a rule can be dead:
| Mistake | Example | What actually happens |
|---|---|---|
| Invented action type | "type": "setSessionStatus" |
No handler exists; the action is skipped silently |
| Malformed known action | {"type": "set-status", "status": "done"} with no session |
Fails its own schema, then throws when the rule fires |
| Typo’d event name | "LabelAdded": [...] |
The entire block is discarded at load |
Validation names the offending value and suggests the real one where it can
(setSessionStatus → set-status, LabelAdded → LabelAdd).
Unknown matcher keys are warnings, not errors — but read them. The dangerous case is a mis-keyed filter:
{ "labelId": "auto-close", "actions": [ ... ] }labelId is not a real key, so it is stripped — which leaves matcher unset,
and an unset matcher matches every event of that type. The rule doesn’t
fail closed; it fires on everything. Filtering is done with matcher, a regex:
{ "matcher": "^auto-close$", "actions": [ ... ] }Similarly, "disabled": true is not a real key and does not turn a rule off.
The real key is enabled, and the value flips: use "enabled": false.
Disabled rules are exempt from all of these checks. A rule you have parked
with "enabled": false isn’t claiming to do anything, so it won’t hold your
config in a failing state — but the report comes back the moment you re-enable
it.
To validate manually:
# Check automations.json syntaxcat automations.json | jq .Retry Behavior
Section titled “Retry Behavior”Webhook actions have two levels of automatic retry:
Immediate retry (transient failures)
Section titled “Immediate retry (transient failures)”When a webhook fails with a server error (5xx), timeout, or connection error, it is automatically retried up to 2 times with exponential backoff (1s → 2s → 4s). Client errors (4xx) are not retried — they indicate a configuration problem.
Deferred retry (extended outages)
Section titled “Deferred retry (extended outages)”If all immediate retries fail, the webhook is added to a persistent retry queue. The queue retries at increasing intervals:
| Attempt | Delay | Cumulative |
|---|---|---|
| 1st deferred | 5 minutes | 5 min |
| 2nd deferred | 30 minutes | 35 min |
| 3rd deferred | 1 hour | ~1.5 hours |
After the final deferred attempt fails, the webhook is marked as permanently failed in the history. Deferred retries survive app restarts.
Note: Only transient failures (5xx, timeouts, connection errors) are retried. Client errors (4xx) indicate a configuration problem and should be fixed in
automations.json.
Retry and rate limiting: Retried webhook requests count toward the per-endpoint rate limit (30/min per origin). If a retry would exceed the limit, it is deferred to the next retry window.
Rate Limits
Section titled “Rate Limits”To protect against runaway automations (e.g., an automation that indirectly triggers itself in a loop), the event bus enforces per-event-type rate limits:
| Event | Max fires / minute |
|---|---|
SchedulerTick |
60 (1/sec) |
All others (LabelAdd, FlagChange, PreToolUse, etc.) |
10 |
When a limit is hit, further events of that type are silently dropped for the remainder of the 60-second window. A warning is logged. The window resets automatically.
Example: If you have a LabelAdd task that triggers a prompt which adds a label back to a session, it will fire at most 10 times before being rate-limited — preventing infinite session creation.
This bus limit is a blunt backstop: it drops events for every rule on that event type, silently, for the rest of the window. Session actions have their own narrower guards that engage first and report why — see Loop safety.
Troubleshooting
Section titled “Troubleshooting”Automation not firing
Section titled “Automation not firing”- Validate first — run
config_validatewithtarget: "automations". If the rule can never run (invented action type, malformed action, typo’d event name), this says so directly and the rest of this list is moot. See Rules that validate but can never run. - Check the run history — open the automation’s detail page. A blocked entry
means either that the rule is structurally dead (
config-diagnostic) or that a guard refused it (skipped:*) — not that it fired and failed. The entry text names which. - Check event name — must be exact (e.g.,
LabelAdd, notlabeladdorLabelAdded) - Check matcher — regex must match the event value. Note an absent
matchermatches everything, so a rule firing too often usually means a mis-keyed filter. - Check
enabled—"disabled": truedoes nothing; the real key is"enabled": false - Check cron — for SchedulerTick, verify the cron expression with an online tool
- Check logs — look for
[automations],[AutomationSystem], or[Scheduler]
Automation fires but nothing happens
Section titled “Automation fires but nothing happens”A dispatch record with ok: true only means the action was dispatched.
- Prompt actions: the session was created; whether the model did what you
asked is a separate question. Check the
outcomerecord and open the spawned session. A common case: the prompt asks the model to close a task, and theset_session_statustool refuses — correctly, since models may never close tasks. Use aset-statusaction withallowClosed: trueinstead. - Session actions: check the recorded outcome (
rejected:closed-status,deferred:target-not-found,skipped:rate-limited, …) in Session Actions → Outcomes. A rule that worked and then quietly stopped is most oftenskipped:self-triggerorskipped:rate-limited.
Prompt not creating session
Section titled “Prompt not creating session”- Check that the prompt is not empty
- Verify @mentions reference valid sources/skills
Webhook not working
Section titled “Webhook not working”- Check URL — Must be a valid
http://orhttps://URL. Other protocols (ftp, ws, etc.) are rejected at runtime with a clear error. - Check env vars — Ensure
CRAFT_WH_*variables are set in your shell profile and Vorno was restarted after adding them. URLs using$VARtemplates are validated after variable expansion — if the variable is empty or unset, the URL will be invalid. - Use the Test button — Tests connectivity to the URL (note: env vars are not expanded during test)
- Check method — Some endpoints require specific HTTP methods (POST, PUT, etc.)
- Check response — The automation history shows HTTP status codes for webhook executions
Retrying failed webhooks
Section titled “Retrying failed webhooks”When a webhook execution fails (shown with a red indicator in the timeline), you can retry it:
- Open the automation’s detail page
- In the “Recent Activity” timeline, failed webhook entries show a Retry button
- Click “Retry” to re-execute the webhook actions immediately
- The retry result is recorded as a new history entry
Note: Retries execute the webhook actions as currently configured. If you’ve changed the URL or headers since the original failure, the retry uses the updated configuration. Environment variables are not expanded during replay (same as the Test button).
Best Practices
Section titled “Best Practices”- Start simple - Test with a basic prompt before building complex workflows
- Use labels - Tag scheduled sessions for easy filtering
- Be specific - Use matchers to avoid triggering on every event
- Test cron - Use crontab.guru to verify expressions
- Keep secrets out of config - Use
CRAFT_WH_*env vars for webhook URLs and tokens instead of hardcoding them in automations.json - Combine actions - Use both webhook and prompt actions in a single automation for notification + AI response workflows