The Visor scheduler provides a generic, frontend-agnostic system for executing workflows and reminders at specified times. It supports both static schedules defined in YAML configuration and dynamic schedules created via AI tool at runtime.
The scheduler operates in two modes:
- Workflow Schedules: Execute a named workflow/check from your configuration
- Simple Reminders: Post a message or run it through the visor pipeline (e.g., for AI-powered responses)
Output destinations (Slack, GitHub, webhooks) are handled by output adapters, making the scheduler truly frontend-agnostic. When running with Slack, the SlackOutputAdapter automatically posts results back to the appropriate channel or DM.
Storage & Cloud Databases: For detailed database configuration (PostgreSQL, MySQL, MSSQL, SSL/TLS, connection strings, cloud provider examples), see Scheduler Storage.
Add scheduler settings to your .visor.yaml:
scheduler:
enabled: true
storage:
path: .visor/schedules.json
default_timezone: America/New_York
check_interval_ms: 60000 # How often to check for due schedules
# Limits for dynamic schedules (created via AI tool)
limits:
max_per_user: 25
max_recurring_per_user: 10
max_global: 1000
# Permissions for dynamic schedule creation
permissions:
allow_personal: true # Allow schedules via DM or CLI
allow_channel: true # Allow channel schedules (Slack)
allow_dm: true # Allow DM schedules to other users
allowed_workflows: # Glob patterns for allowed workflows
- "report-*"
- "status-*"
denied_workflows: # Glob patterns for denied workflows
- "admin-*"
- "dangerous-*"
# Static cron jobs (always allowed, bypass permissions)
cron:
daily-standup:
schedule: "0 9 * * 1-5" # Weekdays at 9am
workflow: daily-standup
timezone: America/New_York
output:
type: slack
target: "#engineering"
weekly-report:
schedule: "0 10 * * 1" # Mondays at 10am
workflow: weekly-report
inputs:
team: platform
output:
type: slack
target: "#platform-team"Message triggers allow workflows to be executed reactively based on Slack messages, without requiring an @mention. This is useful for monitoring channels (e.g., CI/CD failure notifications) and automatically triggering workflows in response.
Add on_message alongside cron in the scheduler section:
scheduler:
enabled: true
on_message:
cicd-watcher:
description: "React to CI/CD failure notifications"
channels: ["C0CICD"] # Channel IDs (wildcard suffix supported, e.g., "CENG*")
from: ["U123BOT"] # Optional: only from these user IDs
from_bots: true # Allow bot messages (default: false)
contains: ["failed", "error"] # Any keyword match, case-insensitive (OR)
match: "build.*failed" # Regex pattern (optional)
threads: root_only # root_only | thread_only | any (default: any)
workflow: handle-cicd-failure
inputs:
source: slack
output:
type: slack
enabled: true
thread-responder:
description: "Respond in threads about deployments"
channels: ["C0DEPLOY"]
threads: thread_only # Only react to thread replies
contains: ["help", "stuck"]
workflow: deploy-help| Option | Type | Default | Description |
|---|---|---|---|
channels |
string[] | (all) | Channel IDs to monitor. Supports wildcard suffix (e.g., CENG*) |
from |
string[] | (all) | Only trigger on messages from these Slack user IDs |
from_bots |
boolean | false |
Allow bot messages to trigger. When false, bot messages are ignored |
contains |
string[] | (all) | Keyword match — any keyword triggers (case-insensitive, OR logic) |
match |
string | (none) | Regex pattern to match against message text (case-insensitive) |
threads |
string | any |
Thread scope: root_only, thread_only, or any |
workflow |
string | (required) | Workflow/check ID to execute when triggered |
inputs |
object | {} |
Workflow inputs passed to the execution |
output |
object | (none) | Output destination (type, target, thread_id) |
description |
string | (none) | Description for logging |
enabled |
boolean | true |
Enable/disable this trigger |
- All specified filters must pass (AND logic between filters)
- Within
contains, any keyword match suffices (OR logic) - Omitted filters are not checked (they match everything)
| Value | Behavior |
|---|---|
root_only |
Only react to root channel messages (no thread_ts or thread_ts === ts) |
thread_only |
Only react to thread replies (thread_ts exists and differs from ts) |
any |
React to both root messages and thread replies (default) |
When a triggered message is a thread reply, full thread conversation context is fetched via SlackAdapter and passed to the workflow as slack_conversation. When the message is a root channel message, only the single message is available.
Slack WebSocket Event
→ Parse JSON, ACK
→ Filter subtypes (edited, etc.)
→ Filter self-bot messages
→ Filter guests
→ Evaluate message triggers ← NEW
→ For each match: dispatch workflow async
→ Mention gate (existing @mention path)
→ Thread/allowlist/dedup gates
→ Dispatch all checks (existing mention-based path)
Both paths can fire for the same message. A message that matches a trigger AND contains an @mention will dispatch the triggered workflow AND go through the normal mention-based dispatch. Each triggered workflow gets its own engine instance.
The triggered workflow receives via webhookData:
| Field | Description |
|---|---|
event.text |
Message text |
event.user |
Sender user ID |
event.channel |
Channel ID |
event.ts / event.thread_ts |
Message timestamps |
trigger.id |
Which trigger matched |
trigger.type |
on_message |
slack_conversation |
Full thread context (if thread reply) |
The event type is slack_message, which can be used in step on: filters:
steps:
handle-cicd-failure:
type: ai
on: [slack_message]
prompt: "Analyze this CI/CD failure and suggest fixes."Each trigger match is deduplicated using the key trigger:${triggerId}:${channel}:${ts}. This prevents the same trigger from firing multiple times for duplicate Slack events.
Message triggers are rebuilt when configuration is hot-reloaded via updateConfig(). Adding, removing, or modifying triggers in .visor.yaml takes effect on the next config reload without restarting the bot.
scheduler:
on_message:
cicd-failures:
channels: ["C0CICD"]
from_bots: true
contains: ["failed", "error", "broken"]
match: "build.*#\\d+.*(failed|error)"
threads: root_only
workflow: analyze-failure
inputs:
notify_team: true
steps:
analyze-failure:
type: ai
on: [slack_message]
schema:
type: object
properties:
text:
type: string
severity:
type: string
enum: [low, medium, high, critical]
required: [text, severity]
prompt: |
A CI/CD failure was detected. Analyze the failure message and provide:
1. Root cause analysis
2. Suggested fix
3. Severity assessment
Failure message: {{ event.text }}Static cron jobs are defined in your configuration file and always run regardless of permission settings. They're ideal for recurring organizational tasks:
scheduler:
cron:
security-scan:
schedule: "0 2 * * *" # Daily at 2am
workflow: security-scan
output:
type: slack
target: "#security-alerts"
backup-status:
schedule: "0 6 * * *" # Daily at 6am
workflow: backup-check
inputs:
notify_on_failure: trueStandard 5-field cron expressions:
* * * * *- minute, hour, day of month, month, day of week0 9 * * *- Every day at 9:00 AM0 9 * * 1-5- Weekdays at 9:00 AM*/15 * * * *- Every 15 minutes0 0 1 * *- First day of every month at midnight
Users can create schedules dynamically through the AI tool. The AI is responsible for:
- Extracting timing: Converting natural language to cron expressions or ISO timestamps
- Determining targets: Using the current channel context (channel ID from conversation)
- Identifying recurrence: One-time vs recurring schedules
User in DM: "remind me to check builds every day at 9am"
AI: [calls schedule tool with action=create, reminder_text="check builds",
cron="0 9 * * *", target_type="dm", target_id="D09SZABNLG3"]
User in #security: "run security-scan every Monday at 10am"
AI: [calls schedule tool with action=create, workflow="security-scan",
cron="0 10 * * 1", target_type="channel", target_id="C05ABC123"]
User in DM: "remind me in 2 hours to review the PR"
AI: [calls schedule tool with action=create, reminder_text="review the PR",
run_at="2026-02-08T18:00:00Z", target_type="dm", target_id="D09SZABNLG3"]
The AI generates structured parameters:
| Parameter | Description |
|---|---|
reminder_text |
What to say when the schedule fires |
workflow |
Alternatively, a workflow to execute |
target_type |
"channel", "dm", "thread", or "user" |
target_id |
Slack channel ID (C... or D...) |
cron |
For recurring: cron expression |
run_at |
For one-time: ISO 8601 timestamp |
is_recurring |
Boolean flag |
Dynamic schedules respect the permissions configuration:
- allow_personal: Controls personal schedules (DM context or CLI)
- allow_channel: Controls channel schedules (Slack channels)
- allow_dm: Controls DM schedules to other users
- allowed_workflows: Glob patterns that workflows must match
- denied_workflows: Glob patterns that block workflows (checked first)
permissions:
allow_personal: true
allow_channel: false # Disable channel schedules
allow_dm: false # Disable DM schedules
allowed_workflows:
- "report-*" # Only allow report workflowsThe scheduler determines schedule type based on context and enforces restrictions:
| Context | Allowed Schedule Type |
|---|---|
| CLI | personal only |
| Slack DM | personal only |
| Slack channel | channel only |
| Slack group DM | dm only |
Context-Based Enforcement: When creating a schedule from a DM, you can only create personal schedules. When in a channel, you can only create channel schedules. This prevents cross-context leakage (e.g., personal reminders shouldn't appear when listing schedules in a public channel).
When listing schedules, only schedules matching the current context type are shown:
- In a DM: Only personal schedules
- In a channel: Only channel schedules
- In a group DM: Only dm/group schedules
This protects privacy - personal reminders created in a DM won't be visible when someone lists schedules in a public channel.
visor schedule start [--config .visor.yaml]Runs the scheduler daemon that checks for and executes due schedules.
visor schedule list [--user <userId>] [--status <status>] [--json]Shows schedules. Use --json for machine-readable output.
visor schedule create <workflow> --at "<expression>" [--inputs key=value] [--output-type slack] [--output-target #channel]Examples:
# One-time schedule
visor schedule create daily-report --at "tomorrow at 9am"
# Recurring schedule
visor schedule create standup --at "every weekday at 9am" --output-type slack --output-target "#team"
# With inputs
visor schedule create backup-check --at "every day at 2am" --inputs environment=productionvisor schedule cancel <id>visor schedule pause <id>
visor schedule resume <id>When a schedule executes, results can be routed to different destinations:
output:
type: slack
target: "#channel-name" # or @username for DM
thread_id: "1234567890.123456" # Optional threadoutput:
type: github
target: "owner/repo"output:
type: webhook
target: "https://example.com/webhook"output:
type: noneWhen running the Slack bot, the scheduler automatically starts and integrates with the Slack frontend:
// In socket-runner.ts
import { Scheduler, createSlackOutputAdapter } from '../scheduler';
// Create scheduler
this.genericScheduler = new Scheduler(this.visorConfig, schedulerConfig);
// Set execution context so scheduled reminders can use the Slack client
this.genericScheduler.setExecutionContext({
slack: this.client,
slackClient: this.client,
});
// Register Slack output adapter for posting results
this.genericScheduler.registerOutputAdapter(
createSlackOutputAdapter(this.client)
);
await this.genericScheduler.start();The scheduler uses an execution context to pass runtime dependencies to workflow executions:
slackClient: The Slack API client for posting messagescliMessage: For simple reminders, this bypasseshuman-inputprompts
For simple reminders, the scheduler seeds the PromptStateManager so that human-input checks can consume the reminder text as if the user sent it:
const mgr = getPromptStateManager();
mgr.setFirstMessage(channel, threadTs, reminderText);This ensures reminders run through chat workflows smoothly without blocking for user input.
The scheduler understands various time expressions:
- "in 2 hours"
- "in 30 minutes"
- "tomorrow at 9am"
- "next Monday at 3pm"
- "Friday at noon"
- "every day at 9am"
- "every Monday at 9am"
- "every weekday at 9am"
- "every hour"
- "every 30 minutes"
- "every month on the 1st at midnight"
- Created: Schedule is stored with status
active - Due: When current time >= nextRunAt, schedule is picked up
- Executing: Workflow runs with schedule context
- Completed:
- One-time: status changes to
completed - Recurring:
nextRunAtis updated, status staysactive
- One-time: status changes to
- Paused: Schedule is skipped during checks
- Failed: Increments
failureCount, may be retried
When a schedule has no workflow specified but includes workflowInputs.text, it runs as a "simple reminder":
- The reminder text is treated as if the user sent it as a new message
- It runs through the full visor pipeline (all configured checks)
- The AI processes it and posts the response back via the Slack frontend
- The
SlackOutputAdapterdetects when the pipeline handled output and avoids double-posting
This allows reminders like "check how many Jira tickets were created this week" to get an AI-generated response rather than just echoing the reminder text.
# Example: Schedule created via AI tool
# When this fires, it runs through the pipeline and posts the AI response
{
"workflowInputs": { "text": "How many PRs were merged today?" },
"outputContext": { "type": "slack", "target": "D09SZABNLG3" }
}For recurring simple reminders, the scheduler saves the AI response after each execution. On subsequent runs, the previous response is included in the context, allowing the AI to reference or build upon its earlier answer.
How it works:
- Reminder fires and runs through the visor pipeline
- AI generates a response, which is posted to Slack
- The response text is saved as
previousResponsein the schedule store - On the next run, the reminder text includes the previous response:
<original reminder text> --- **Previous Response (for context):** <AI's previous response> --- Please provide an updated response based on the reminder above.
Example use case:
User: "Every day at 9am, tell me how many Jira tickets were created"
Day 1: AI responds with "5 tickets were created yesterday"
Day 2: AI sees previous response and can say "8 tickets today (up from 5 yesterday)"
This feature enables continuity for status updates, progress tracking, and any recurring reminder where historical context is valuable. The AI can:
- Compare current data to previous runs
- Track trends over time
- Provide delta/change information
- Reference what was said before
Note: One-time schedules do not save previousResponse since they only execute once.
src/
├── scheduler/ # Generic scheduler module
│ ├── index.ts # Public exports
│ ├── schedule-store.ts # Schedule persistence facade
│ ├── schedule-parser.ts # Natural language parsing utilities
│ ├── scheduler.ts # Generic scheduler daemon
│ ├── schedule-tool.ts # AI tool for schedule management
│ ├── message-trigger.ts # Slack message trigger evaluator
│ ├── cli-handler.ts # CLI command handlers
│ └── store/ # Storage backends
│ ├── index.ts # Backend factory
│ ├── types.ts # Backend interface & config types
│ └── sqlite-store.ts # SQLite backend (OSS)
│
├── enterprise/
│ └── scheduler/
│ └── knex-store.ts # PostgreSQL/MySQL/MSSQL backend (Enterprise)
│
└── slack/
└── slack-output-adapter.ts # Posts results to Slack
| Component | Purpose |
|---|---|
Scheduler |
Main daemon that checks for due schedules and executes them |
ScheduleStore |
Singleton for persisting schedules to JSON |
MessageTriggerEvaluator |
Evaluates Slack messages against on_message triggers |
ScheduleOutputAdapter |
Interface for output destinations |
SlackOutputAdapter |
Implements output posting for Slack |
schedule-tool |
AI tool definition and handler |
- User creates schedule via AI tool or CLI
ScheduleStore.create()persists scheduleSchedulereither sets up cron job (recurring) or setTimeout (one-time)- When schedule fires:
- With workflow: Runs named workflow via
StateMachineExecutionEngine - Without workflow: Runs reminder text through full visor pipeline
- With workflow: Runs named workflow via
SlackOutputAdapter.sendResult()posts results (unless pipeline already handled it)
- Check scheduler is running:
visor schedule start - Verify schedule status:
visor schedule list - Check workflow exists in config
- Verify permissions allow the schedule type
- Verify the execution context includes Slack client:
scheduler.setExecutionContext({ slack: client, slackClient: client });
- Check that
SlackOutputAdapteris registered:scheduler.registerOutputAdapter(createSlackOutputAdapter(client));
- For simple reminders, verify the pipeline has a
human-inputcheck and AI check - Check logs for
[SlackOutputAdapter] Skipping post- this means the pipeline already handled output
If personal reminders appear when listing in a channel, ensure:
- The
allowedScheduleTypecontext is being set correctly based on channel type - The schedule's
outputContext.targetcorrectly identifies the channel type
- Check
permissionsconfig matches schedule type - Verify workflow matches
allowed_workflowspatterns - Ensure workflow doesn't match
denied_workflows
- Set explicit timezone in config:
default_timezone: America/New_York - User timezone is captured when schedule is created
- All times stored as UTC internally
interface Schedule {
id: string; // UUID v4
creatorId: string; // User who created
creatorName?: string; // User display name (for messages)
creatorContext?: string; // "slack:U123", "github:user", "cli"
timezone: string; // IANA timezone
schedule: string; // Cron expression (empty for one-time)
runAt?: number; // Unix timestamp (one-time only)
isRecurring: boolean;
originalExpression: string; // Natural language input (for display)
workflow?: string; // Workflow/check ID (undefined for simple reminders)
workflowInputs?: Record<string, unknown>; // For reminders: { text: "..." }
outputContext?: ScheduleOutputContext;
status: 'active' | 'paused' | 'completed' | 'failed';
nextRunAt?: number;
lastRunAt?: number;
runCount: number;
failureCount: number;
lastError?: string; // Last error message if failed
previousResponse?: string; // AI response from last run (recurring only)
createdAt: number; // Creation timestamp
}interface ScheduleOutputContext {
type: 'slack' | 'github' | 'webhook' | 'none';
target?: string;
threadId?: string;
metadata?: Record<string, unknown>;
}