Diagram of win_task_manager.rb: CLI options flow into TriggerBuilder.build, which is pure Ruby and validates time and days, then into a Gem.win_platform? guard and TaskSchedulerClient, which wraps win32-taskscheduler and registers the task with the Windows Task Scheduler COM API

Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

DevOps / System Administration · Windows Server · Ruby 3.x

The Problem

Windows Task Scheduler is the closest thing Windows has to cron, but clicking through taskschd.msc to register a task doesn’t scale — you can’t code-review a GUI, and reproducing the same task across ten servers by hand invites mistakes. schtasks.exe from a batch script gets you scriptable, but its syntax is unforgiving and its errors are cryptic. The win32-taskscheduler gem exposes the same Task Scheduler COM API Windows itself uses, from Ruby, so you can define a scheduled task — daily, weekly, or one-time — in a single readable command and keep the definition in version control.

Prerequisites

  • Ruby 3.0+ for Windows (the RubyInstaller build, not WSL — this gem talks to the native Windows API)
  • The win32-taskscheduler gem: gem install win32-taskscheduler
  • Windows 10, Windows 11, or Windows Server 2016+
  • An elevated (Administrator) shell if the task needs to run as SYSTEM or as a different user than the one creating it

The Code

Save this as win_task_manager.rb:

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_task_manager.rb
#
# Create, list, and remove Windows Scheduled Tasks from Ruby using the
# win32-taskscheduler gem. Designed so a sysadmin can define a task with a
# single command line instead of clicking through taskschd.msc.
#
# Usage examples (run on Windows, in an elevated shell if the task needs to
# run as SYSTEM or another user):
#
#   ruby win_task_manager.rb list
#
#   ruby win_task_manager.rb create --name "NightlyBackup" \
#     --run "C:\scripts\backup.rb" --schedule daily --at 02:00
#
#   ruby win_task_manager.rb create --name "WeeklyReport" \
#     --run "C:\scripts\report.bat" --schedule weekly --days mon,wed,fri --at 07:30
#
#   ruby win_task_manager.rb delete --name "NightlyBackup"
#
# The TriggerBuilder class below has zero Windows dependencies, so its
# logic can be unit-tested on any OS. Only TaskSchedulerClient touches the
# win32-taskscheduler gem, and it is only required when actually running on
# Windows.

require "optparse"

# ---------------------------------------------------------------------------
# TriggerBuilder: pure logic, no OS or gem dependency.
# Converts simple CLI-friendly options into the trigger hash shape expected
# by the win32-taskscheduler gem's TaskScheduler#new_work_item / #trigger=.
# ---------------------------------------------------------------------------
module TriggerBuilder
  DAY_BITS = {
    "mon" => 0x02, "tue" => 0x04, "wed" => 0x08,
    "thu" => 0x10, "fri" => 0x20, "sat" => 0x01, "sun" => 0x40
  }.freeze

  # schedule: "daily" | "weekly" | "once"
  # at:       "HH:MM" 24-hour time the task should first/always fire
  # days:     comma-separated list for weekly schedules, e.g. "mon,wed,fri"
  # date:     "YYYY-MM-DD" required for "once"
  def self.build(schedule:, at:, days: nil, date: nil, now: Time.now)
    hour, minute = parse_time(at)

    case schedule
    when "daily"
      base_trigger(now, hour, minute).merge(
        trigger_type: :TASK_TIME_TRIGGER_DAILY,
        type: { days_interval: 1 }
      )
    when "weekly"
      day_list = (days || "").split(",").map(&:strip).map(&:downcase)
      raise ArgumentError, "weekly schedule requires --days (e.g. mon,wed,fri)" if day_list.empty?

      unknown = day_list - DAY_BITS.keys
      raise ArgumentError, "unknown day(s): #{unknown.join(', ')}" unless unknown.empty?

      mask = day_list.sum { |d| DAY_BITS.fetch(d) }
      base_trigger(now, hour, minute).merge(
        trigger_type: :TASK_TIME_TRIGGER_WEEKLY,
        type: { weeks_interval: 1, days_of_week: mask }
      )
    when "once"
      raise ArgumentError, "once schedule requires --date YYYY-MM-DD" unless date

      y, m, d = date.split("-").map(&:to_i)
      raise ArgumentError, "invalid --date, expected YYYY-MM-DD" unless y && m && d && y > 2000

      {
        start_year: y, start_month: m, start_day: d,
        start_hour: hour, start_minute: minute,
        trigger_type: :TASK_TIME_TRIGGER_ONCE,
        type: { once: nil }
      }
    else
      raise ArgumentError, "unknown --schedule '#{schedule}' (use daily|weekly|once)"
    end
  end

  def self.parse_time(at)
    match = /\A([01]?\d|2[0-3]):([0-5]\d)\z/.match(at.to_s)
    raise ArgumentError, "invalid --at time '#{at}', expected HH:MM (24h)" unless match

    [match[1].to_i, match[2].to_i]
  end

  def self.base_trigger(now, hour, minute)
    {
      start_year: now.year, start_month: now.month, start_day: now.day,
      start_hour: hour, start_minute: minute
    }
  end
end

# ---------------------------------------------------------------------------
# TaskSchedulerClient: thin wrapper around win32-taskscheduler.
# Only ever instantiated on Windows.
# ---------------------------------------------------------------------------
class TaskSchedulerClient
  def initialize
    require "win32/taskscheduler"
    @ts = Win32::TaskScheduler.new
  end

  def list
    @ts.tasks
  end

  def create(name:, command:, args: nil, trigger:, run_as: nil)
    @ts.new_work_item(name, trigger)
    @ts.activate(name)
    @ts.application_name = command
    @ts.parameters = args if args
    @ts.set_account_information(run_as, nil) if run_as
    @ts.save
    true
  end

  def delete(name)
    return false unless @ts.tasks.include?(name)

    @ts.delete(name)
    true
  end

  def status(name)
    @ts.activate(name)
    @ts.status
  end
end

# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_cli(argv)
  options = { args: nil, run_as: nil }
  subcommand = argv.shift

  parser = OptionParser.new do |opts|
    opts.banner = "Usage: win_task_manager.rb [list|create|delete] [options]"
    opts.on("--name NAME", "Task name") { |v| options[:name] = v }
    opts.on("--run PATH", "Executable/script to run") { |v| options[:command] = v }
    opts.on("--args ARGS", "Arguments passed to the executable") { |v| options[:args] = v }
    opts.on("--schedule TYPE", "daily | weekly | once") { |v| options[:schedule] = v }
    opts.on("--at HH:MM", "Time of day to run") { |v| options[:at] = v }
    opts.on("--days LIST", "Comma-separated days for weekly (mon,tue,...)") { |v| options[:days] = v }
    opts.on("--date YYYY-MM-DD", "Date for a one-time task") { |v| options[:date] = v }
    opts.on("--run-as USER", "Account to run the task as") { |v| options[:run_as] = v }
  end
  parser.parse!(argv)

  [subcommand, options, parser]
end

if $PROGRAM_NAME == __FILE__
  subcommand, options, parser = parse_cli(ARGV)

  unless %w[list create delete].include?(subcommand)
    puts parser
    exit 1
  end

  unless Gem.win_platform?
    warn "win_task_manager.rb only operates against the real Task Scheduler on Windows."
    warn "(TriggerBuilder logic can still be exercised on any OS for testing.)"
    exit 1
  end

  client = TaskSchedulerClient.new

  case subcommand
  when "list"
    client.list.each { |t| puts t }
  when "create"
    trigger = TriggerBuilder.build(
      schedule: options[:schedule], at: options[:at],
      days: options[:days], date: options[:date]
    )
    client.create(
      name: options.fetch(:name), command: options.fetch(:command),
      args: options[:args], trigger: trigger, run_as: options[:run_as]
    )
    puts "Created task '#{options[:name]}'"
  when "delete"
    if client.delete(options.fetch(:name))
      puts "Deleted task '#{options[:name]}'"
    else
      puts "No task named '#{options[:name]}' found"
    end
  end
end

How It Works

Diagram of win_task_manager.rb: CLI options flow into TriggerBuilder.build, which is pure Ruby and validates time and days, then into a Gem.win_platform? guard and TaskSchedulerClient, which wraps win32-taskscheduler and registers the task with the Windows Task Scheduler COM API

1. Separate the OS-independent logic from the Windows-only logic

The most important structural decision here is splitting TriggerBuilder from TaskSchedulerClient. TriggerBuilder.build is plain Ruby — string parsing, a day-of-week bitmask, date math — with zero dependency on Windows or the gem. That means you (or a CI pipeline running on Linux) can unit-test every schedule permutation and every error case without ever touching a Windows box. Only TaskSchedulerClient#initialize calls require "win32/taskscheduler", and it’s the only class that talks to the real COM API.

2. Simple flags map to the gem’s trigger hash

The win32-taskscheduler gem expects a trigger hash with keys like trigger_type and a type sub-hash whose shape depends on the trigger type (daily wants a days_interval, weekly wants days_of_week as a bitmask, and so on). Rather than making callers construct that hash by hand, TriggerBuilder.build takes friendly flags — --schedule weekly --days mon,wed,fri --at 07:30 — and does the bitmask math (DAY_BITS) and validation for you, raising a clear ArgumentError the moment something doesn’t parse.

3. The client wraps create / list / delete around three gem calls

TaskSchedulerClient#create calls new_work_item to register the task under a name and trigger, then sets application_name (the executable) and parameters (its arguments) before save. set_account_information lets you specify which account the task runs as — useful for running as a dedicated service account rather than the interactive user, and required if you want the task to run whether or not anyone is logged in.

4. A platform guard, not a platform assumption

Running this script on Linux or macOS by mistake (easy to do if it’s checked into a cross-platform repo) prints a clear message and exits 1 instead of failing deep inside a LoadError for a gem that can’t exist on that platform. That one Gem.win_platform? check is what makes the rest of the script safe to keep in a shared scripts repo alongside Linux tooling.

Example Output

The TriggerBuilder logic was exercised directly (bypassing the Windows-only TaskSchedulerClient) to confirm the hash shapes and validation are correct:

daily OK: {:start_year=>2026, :start_month=>7, :start_day=>10, :start_hour=>2,
  :start_minute=>0, :trigger_type=>:TASK_TIME_TRIGGER_DAILY, :type=>{:days_interval=>1}}

weekly OK: {:start_year=>2026, :start_month=>7, :start_day=>10, :start_hour=>7,
  :start_minute=>30, :trigger_type=>:TASK_TIME_TRIGGER_WEEKLY,
  :type=>{:weeks_interval=>1, :days_of_week=>42}}

once OK: {:start_year=>2026, :start_month=>12, :start_day=>25, :start_hour=>23,
  :start_minute=>15, :trigger_type=>:TASK_TIME_TRIGGER_ONCE, :type=>{:once=>nil}}

correctly raised: weekly schedule requires --days (e.g. mon,wed,fri)
correctly raised: invalid --at time '25:99', expected HH:MM (24h)
ALL TESTS PASSED

Note the weekly bitmask: mon (0x02) | wed (0x08) | fri (0x20) = 42, exactly what Win32::TaskScheduler expects for days_of_week. On an actual Windows host, ruby win_task_manager.rb create --name "WeeklyReport" --run "C:\scripts\report.bat" --schedule weekly --days mon,wed,fri --at 07:30 would print Created task 'WeeklyReport' and the task would appear in taskschd.msc immediately.

Troubleshooting

  • LoadError: cannot load such file -- win32/taskscheduler: you’re either not on Windows, or the gem isn’t installed for the Ruby you’re running. Confirm with gem list win32-taskscheduler and that ruby -v reports a mingw/msvcrt build, not WSL’s Linux Ruby.
  • “Access is denied” on save: creating a task that runs as SYSTEM or another user requires an elevated shell. Right-click your terminal and “Run as administrator,” or invoke the script from an already-elevated scheduled process.
  • Task creates but never fires: check whether the account it runs as has “Log on as a batch job” rights (secpol.msc → Local Policies → User Rights Assignment) — a common gotcha when using --run-as with a low-privilege service account.
  • days_of_week mask looks wrong: the bit values in DAY_BITS follow the Task Scheduler API’s own convention (Sunday is 0x40, not bit 0) — don’t assume it maps to Ruby’s Date#wday numbering.
  • Want to confirm a task was actually created: run the list subcommand, or cross-check with schtasks /query /tn "TaskName" /v from an ordinary command prompt.

Extending This Script

  • Add a --from-file tasks.yml mode that reads a batch of task definitions and reconciles them — create missing ones, update changed ones, delete ones no longer in the file — so your scheduled tasks become declarative and diffable in version control.
  • Add an update subcommand that reads the existing trigger via @ts.trigger(index), merges in changes, and re-saves, instead of requiring delete-then-recreate.
  • Extend TriggerBuilder with a monthly schedule type using TASK_TIME_TRIGGER_MONTHLYDATE.
  • Wire the status method into a monitoring check that alerts if a critical scheduled task’s last run result was non-zero.
  • Pair this with the ssh_fleet_runner.rb script from this series (adapted for WinRM) to roll the same scheduled task out across a whole fleet of Windows servers at once.

Leave a Reply

Your email address will not be published. Required fields are marked *

Click to access the login or register cheese