Skip to content

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

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 are configured in automations.json at the root of your workspace:

~/.craft-agent/workspaces/{workspaceId}/automations.json
Terminal window
vorno-cli automation list
vorno-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 20
vorno-cli automation last-executed <id>
vorno-cli automation test <id> --match "..."
vorno-cli automation lint
vorno-cli automation validate
{
"version": 2,
"automations": {
"EventName": [
{
"name": "Optional display name",
"matcher": "regex-pattern",
"actions": [
{ "type": "prompt", "prompt": "Check for updates and report status" }
]
}
]
}
}
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: TodoStateChange is a deprecated alias for SessionStatusChange. 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 — LabelAdded instead of LabelAdd, 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.

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 -

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.

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 @mentions to 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.

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 to http:// or https:// — other protocols are rejected.

Body format:

  • json (default) — Body is serialized as JSON. Content-Type: application/json is set automatically unless you override it in headers.
  • form — Body object keys are URL-encoded as application/x-www-form-urlencoded. Useful for OAuth token endpoints, Stripe, and legacy APIs. Each value supports $VAR expansion.
  • raw — Body is sent as a plain string. Set Content-Type in headers yourself.

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

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" } }
{
"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
}
{
"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).

{
"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-unreachable and moves on.

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.

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

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.

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.

Both prompt and webhook actions support variable expansion using $VAR or ${VAR} syntax.

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)

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

Terminal window
# In your shell profile
export 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 $HOME or $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.

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" }
]
}

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.

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 minutes
  • 0 9 * * * - Daily at 9:00 AM
  • 0 9 * * 1-5 - Weekdays at 9:00 AM
  • 30 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 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." }
]
}

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.

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

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

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:

  • onFailure runs are never themselves reconciled and never trigger onFailure — 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 onFailure write no history entry. If a webhook action omits its body, a JSON body with the failure context is sent automatically: { automationId, failureKind: "dispatch" | "outcome" | "missed", ok: false, sessionId?, errorCount?, expectedTs?, error? }.
  • Failures of the onFailure actions themselves are logged and swallowed.

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_key error still recorded ok: true because 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 sessionAction field) are shown in the run list with their outcome spelled out, so a refused set-status reads differently from an applied one. A skipped: outcome renders as blocked.
  • A dispatch record with ok: true means the action was dispatched, not that it achieved anything. A prompt action records ok: true as soon as the session is created; if the model’s tool call is then rejected, that shows up in the outcome record (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.

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 operations
  • allow-all - Session auto-approves all operations

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.

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.

If you haven’t paired a supergroup yet:

  1. 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.
  2. Add the bot to the supergroup. Group name → Add members → search for your bot’s username → add.
  3. 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.
  4. 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.

{
"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" }
]
}
]
}
}

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" }
]
}
]
}
}

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}*" }
}
]
}
]
}
}
{
"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." }
]
}
]
}
}
{
"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." }
]
}
]
}
}
{
"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." }
]
}
]
}
}

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"
}
}
]
}
]
}
}

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."
}
]
}
]
}
}
{
"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}"
}
}
]
}
]
}
}
{
"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}"
}
}
]
}
]
}
}

Automations are validated when:

  1. The workspace is loaded
  2. You edit automations.json (via PreToolUse hook)
  3. You run config_validate with target automations or all

Using config_validate:

Ask Vorno to validate your automations configuration:

Validate my automations configuration

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

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 (setSessionStatusset-status, LabelAddedLabelAdd).

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:

Terminal window
# Check automations.json syntax
cat automations.json | jq .

Webhook actions have two levels of automatic retry:

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.

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.

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.

  1. Validate first — run config_validate with target: "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.
  2. 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.
  3. Check event name — must be exact (e.g., LabelAdd, not labeladd or LabelAdded)
  4. Check matcher — regex must match the event value. Note an absent matcher matches everything, so a rule firing too often usually means a mis-keyed filter.
  5. Check enabled"disabled": true does nothing; the real key is "enabled": false
  6. Check cron — for SchedulerTick, verify the cron expression with an online tool
  7. Check logs — look for [automations], [AutomationSystem], or [Scheduler]

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 outcome record and open the spawned session. A common case: the prompt asks the model to close a task, and the set_session_status tool refuses — correctly, since models may never close tasks. Use a set-status action with allowClosed: true instead.
  • 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 often skipped:self-trigger or skipped:rate-limited.
  1. Check that the prompt is not empty
  2. Verify @mentions reference valid sources/skills
  1. Check URL — Must be a valid http:// or https:// URL. Other protocols (ftp, ws, etc.) are rejected at runtime with a clear error.
  2. Check env vars — Ensure CRAFT_WH_* variables are set in your shell profile and Vorno was restarted after adding them. URLs using $VAR templates are validated after variable expansion — if the variable is empty or unset, the URL will be invalid.
  3. Use the Test button — Tests connectivity to the URL (note: env vars are not expanded during test)
  4. Check method — Some endpoints require specific HTTP methods (POST, PUT, etc.)
  5. Check response — The automation history shows HTTP status codes for webhook executions

When a webhook execution fails (shown with a red indicator in the timeline), you can retry it:

  1. Open the automation’s detail page
  2. In the “Recent Activity” timeline, failed webhook entries show a Retry button
  3. Click “Retry” to re-execute the webhook actions immediately
  4. 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).

  1. Start simple - Test with a basic prompt before building complex workflows
  2. Use labels - Tag scheduled sessions for easy filtering
  3. Be specific - Use matchers to avoid triggering on every event
  4. Test cron - Use crontab.guru to verify expressions
  5. Keep secrets out of config - Use CRAFT_WH_* env vars for webhook URLs and tokens instead of hardcoding them in automations.json
  6. Combine actions - Use both webhook and prompt actions in a single automation for notification + AI response workflows