How to make a Rust scheduled commands plugin (timed console tasks)
A plugin that runs server console commands on a real-world time of day (HH:mm) or every N minutes, with per-entry enable switches, built from config on OnServerInitialized and driven by a single repeating timer.
Server owners routinely need something to happen at a fixed wall-clock time: a nightly server.save, a restart warning at 03:55, a broadcast at midday, or a repeating message every 15 minutes. Doing this with an external cron job means another service to keep alive and no visibility from inside the game server. This plugin keeps the schedule inside Oxide: each entry in the config has a time (either HH:mm or a minute interval), the console command to run, whether it runs as the server console or as a configured player, and an enabled flag. On startup it loads the list into a ScheduledTask list and starts one repeating timer that wakes once per minute. At each wake it checks the clock, runs every due entry, and records the last execution so a restart cannot double-fire the same minute. Console and chat commands let admins list, run and reload the schedule live.
The plugin code
using System.Linq;
using UnityEngine;
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json;
using Oxide.Core;
namespace Oxide.Plugins
{
[Info("Scheduled Commands", "RustPluginGenerator", "1.0.0")]
[Description("Runs configured console commands at fixed times of day or on repeating minute intervals.")]
public class ScheduledCommands : RustPlugin
{
private const string PermAdmin = "scheduledcommands.admin";
private PluginConfig config;
private Timer tickTimer;
private readonly List<ScheduledTask> schedule = new List<ScheduledTask>();
private class PluginConfig
{
[JsonProperty("Tasks")]
public List<TaskEntry> Tasks { get; set; } = new List<TaskEntry>
{
new TaskEntry { Command = "server.save", Time = "03:55", Source = TaskSource.Console, Enabled = false },
new TaskEntry { Command = "say Server will restart in 5 minutes", Time = "03:55", Source = TaskSource.Console, Enabled = false },
new TaskEntry { Command = "say Welcome to the server", Time = "15", Source = TaskSource.Console, Enabled = false }
};
}
private class TaskEntry
{
[JsonProperty("Command")]
public string Command { get; set; }
[JsonProperty("Time")]
public string Time { get; set; }
[JsonProperty("Source")]
public TaskSource Source { get; set; } = TaskSource.Console;
[JsonProperty("PlayerId")]
public string PlayerId { get; set; } = string.Empty;
[JsonProperty("Enabled")]
public bool Enabled { get; set; } = true;
[JsonProperty("LastRun")]
public string LastRun { get; set; } = string.Empty;
}
private enum TaskSource
{
Console,
Player
}
private class ScheduledTask
{
public TaskEntry Entry;
public bool IsInterval;
public TimeSpan DailyTime;
public int IntervalMinutes;
}
protected override void LoadDefaultConfig() => config = new PluginConfig();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<PluginConfig>();
if (config == null) throw new Exception();
}
catch
{
PrintWarning("Configuration file is invalid, using defaults");
LoadDefaultConfig();
}
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config);
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["NoTasks"] = "No scheduled tasks are configured.",
["TaskLine"] = "#{0} [{1}] {2} -> {3} (enabled: {4}, last run: {5})",
["OnlyAdmin"] = "You do not have permission to use this command.",
["BadIndex"] = "Task index {0} does not exist.",
["Reloaded"] = "Schedule reloaded: {0} task(s).",
["RunNow"] = "Task #{0} executed."
}, this);
}
private string Msg(string key, string userId = null, params object[] args) =>
string.Format(lang.GetMessage(key, this, userId), args);
private void Init()
{
permission.RegisterPermission(PermAdmin, this);
}
private void OnServerInitialized()
{
BuildSchedule();
tickTimer = timer.Every(60f, Tick);
}
private void Unload()
{
if (tickTimer != null && !tickTimer.Destroyed)
tickTimer.Destroy();
tickTimer = null;
schedule.Clear();
}
private void BuildSchedule()
{
schedule.Clear();
if (config?.Tasks == null) return;
for (int i = 0; i < config.Tasks.Count; i++)
{
var entry = config.Tasks[i];
if (entry == null || string.IsNullOrEmpty(entry.Command) || string.IsNullOrEmpty(entry.Time))
continue;
var task = new ScheduledTask { Entry = entry };
if (entry.Time.Contains(":"))
{
if (!TimeSpan.TryParseExact(entry.Time, "hh\\:mm", CultureInfo.InvariantCulture, out task.DailyTime) &&
!TimeSpan.TryParse(entry.Time, CultureInfo.InvariantCulture, out task.DailyTime))
{
PrintWarning($"Task #{i} has invalid time '{entry.Time}', skipped");
continue;
}
}
else
{
int minutes;
if (!int.TryParse(entry.Time, out minutes) || minutes <= 0)
{
PrintWarning($"Task #{i} has invalid interval '{entry.Time}', skipped");
continue;
}
task.IsInterval = true;
task.IntervalMinutes = minutes;
}
schedule.Add(task);
}
Puts($"Loaded {schedule.Count} scheduled task(s)");
}
private void Tick()
{
if (schedule.Count == 0) return;
DateTime now = DateTime.Now;
string stamp = now.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
for (int i = 0; i < schedule.Count; i++)
{
var task = schedule[i];
var entry = task.Entry;
if (!entry.Enabled || string.IsNullOrEmpty(entry.Command))
continue;
if (entry.LastRun == stamp)
continue;
bool due = task.IsInterval
? IsIntervalDue(entry, task.IntervalMinutes, now)
: task.DailyTime.Hours == now.Hour && task.DailyTime.Minutes == now.Minute;
if (!due) continue;
entry.LastRun = stamp;
RunTask(entry);
}
SaveConfig();
}
private bool IsIntervalDue(TaskEntry entry, int intervalMinutes, DateTime now)
{
if (string.IsNullOrEmpty(entry.LastRun))
return true;
DateTime last;
if (!DateTime.TryParseExact(entry.LastRun, "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out last))
return true;
return (now - last).TotalMinutes >= intervalMinutes;
}
private void RunTask(TaskEntry entry)
{
BasePlayer caller = null;
if (entry.Source == TaskSource.Player && !string.IsNullOrEmpty(entry.PlayerId))
caller = BasePlayer.FindAwakeOrSleeping(entry.PlayerId);
if (entry.Source == TaskSource.Player && caller != null)
caller.SendConsoleCommand(entry.Command);
else
ConsoleSystem.Run(ConsoleSystem.Option.Server, entry.Command);
Puts($"Executed: {entry.Command}");
}
[ConsoleCommand("scheduledcommands.list")]
private void CmdList(ConsoleSystem.Arg arg)
{
if (!CheckAccess(arg)) return;
if (config?.Tasks == null || config.Tasks.Count == 0)
{
arg.ReplyWith(Msg("NoTasks"));
return;
}
for (int i = 0; i < config.Tasks.Count; i++)
{
var entry = config.Tasks[i];
if (entry == null) continue;
arg.ReplyWith(Msg("TaskLine", null, i, entry.Time, entry.Command, entry.Source, entry.Enabled, string.IsNullOrEmpty(entry.LastRun) ? "-" : entry.LastRun));
}
}
[ConsoleCommand("scheduledcommands.run")]
private void CmdRun(ConsoleSystem.Arg arg)
{
if (!CheckAccess(arg)) return;
int index = arg.GetInt(0, -1);
if (config?.Tasks == null || index < 0 || index >= config.Tasks.Count || config.Tasks[index] == null)
{
arg.ReplyWith(Msg("BadIndex", null, index));
return;
}
RunTask(config.Tasks[index]);
SaveConfig();
arg.ReplyWith(Msg("RunNow", null, index));
}
[ConsoleCommand("scheduledcommands.reload")]
private void CmdReload(ConsoleSystem.Arg arg)
{
if (!CheckAccess(arg)) return;
if (tickTimer != null && !tickTimer.Destroyed)
tickTimer.Destroy();
LoadConfig();
BuildSchedule();
tickTimer = timer.Every(60f, Tick);
arg.ReplyWith(Msg("Reloaded", null, schedule.Count));
}
[ChatCommand("schedule")]
private void CmdSchedule(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermAdmin))
{
SendReply(player, Msg("OnlyAdmin", player.UserIDString));
return;
}
if (config?.Tasks == null || config.Tasks.Count == 0)
{
SendReply(player, Msg("NoTasks", player.UserIDString));
return;
}
for (int i = 0; i < config.Tasks.Count; i++)
{
var entry = config.Tasks[i];
if (entry == null) continue;
SendReply(player, Msg("TaskLine", player.UserIDString, i, entry.Time, entry.Command, entry.Source, entry.Enabled, string.IsNullOrEmpty(entry.LastRun) ? "-" : entry.LastRun));
}
}
private bool CheckAccess(ConsoleSystem.Arg arg)
{
if (arg == null) return false;
if (!arg.IsAdmin) return false;
var player = arg.Player();
if (player != null && !permission.UserHasPermission(player.UserIDString, PermAdmin))
return false;
return true;
}
}
}Hooks used: OnServerInitialized
Config file
Created automatically on first load at oxide/config/ScheduledCommands.json.
{
"Tasks": [
{
"Command": "server.save",
"Time": "03:55",
"Source": 0,
"PlayerId": "",
"Enabled": false,
"LastRun": ""
},
{
"Command": "say Server will restart in 5 minutes",
"Time": "03:55",
"Source": 0,
"PlayerId": "",
"Enabled": false,
"LastRun": ""
},
{
"Command": "say Welcome to the server",
"Time": "15",
"Source": 0,
"PlayerId": "",
"Enabled": false,
"LastRun": ""
}
]
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
scheduledcommands.list | Print every configured task with its time, enabled state and last run time to the console or RCON. | scheduledcommands.admin |
scheduledcommands.run <index> | Run the task at the given zero-based index immediately, ignoring its schedule. | scheduledcommands.admin |
scheduledcommands.reload | Reload the config file and rebuild the internal schedule without restarting the server. | scheduledcommands.admin |
/schedule | In-game shortcut that lists the configured tasks to the calling player. | scheduledcommands.admin |
| Permission | Grants |
|---|---|
scheduledcommands.admin | Grants access to the scheduled commands console commands and the /schedule chat command. |
How the code works
Config model for timed console entries
The nested
PluginConfigholds a singleTaskslist ofTaskEntry. Each entry hasCommand(the console command line to execute),Time(HH:mmin 24-hour server time, or a number such as15meaning every 15 minutes),Source(theTaskSourceenum:ConsoleorPlayer), optionalPlayerIdused as the command caller,Enabledand an internalLastRunstring. Keeping everything in one list makes the plugin easy to edit by hand or reload from disk without touching code.Converting times in BuildSchedule
BuildScheduleconverts each entry into aScheduledTaskonce. If theTimestring contains a colon it is parsed withTimeSpan.TryParseExactusing thehh\:mmformat, falling back toTimeSpan.TryParse. Otherwise it is read as an integer interval in minutes and flagged withIsInterval. Entries with an empty command or time, an unparseable clock value, or an interval that is not a positive integer are skipped and reported withPrintWarningat load time only, so one typo never breaks the whole schedule.One repeating timer instead of many
OnServerInitializedcallsBuildScheduleand starts a singletimer.Every(60f, Tick). On each tickTickcompares the currentDateTime.Nowwith the stored values instead of keeping dozens of timers. Since the finest schedule granularity is one minute, a 60-second period is exact enough and stays cheap on a busy server.Unloaddestroys the timer if it is still alive and clears theschedulelist.Avoiding double runs on restart
Before executing,
Tickwrites the current minute intoEntry.LastRunasyyyy-MM-dd HH:mm, then skips any entry whoseLastRunalready equals that stamp. A plugin reload or server restart inside the same minute therefore does not fire the command twice for daily tasks. Interval tasks useIsIntervalDue, which compares the parsedLastRunagainst the interval in minutes, and runs immediately on first boot whenLastRunis empty.SaveConfigpersists the timestamps inoxide/config.Running a command with the right caller
RunTaskchecksEntry.Source. When it isPlayerandPlayerIdresolves throughBasePlayer.FindAwakeOrSleeping, the command is sent withcaller.SendConsoleCommand. Otherwise it is executed as the server console viaConsoleSystem.Run(ConsoleSystem.Option.Server, ...). Server-console commands such assay,server.saveandrestartshould stay onConsole;Playeris only needed when a command reads the calling player.Admin control and live reload
[ConsoleCommand("scheduledcommands.list")]prints every task with its time, source, enabled state and last run time.scheduledcommands.run <index>callsRunTaskon one entry for testing and saves the config.scheduledcommands.reloaddestroys the current timer, callsLoadConfigandBuildSchedule, then starts a fresh timer, so admins can edit the JSON and apply it without a restart.CheckAccessrequiresarg.IsAdminand, for in-game callers, thescheduledcommands.adminpermission.
Install it on your server
Save ScheduledCommands.cs
Keep the file name exactly as the class name. Oxide matches them.Upload it to the plugins folder
Oxide:oxide/plugins/. Carbon:carbon/plugins/. The server compiles and loads it within a few seconds, no restart needed.Edit the config if you want
On first load the default config is written tooxide/config/ScheduledCommands.json(Carbon:carbon/configs/ScheduledCommands.json). After editing, reload it:oxide.reload ScheduledCommands c.reload ScheduledCommands # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default scheduledcommands.admin c.grant group default scheduledcommands.admin # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Daily restart warning
Run a broadcast at fixed times of day before a scheduled server restart.
Generate this versionRecurring server announcements
Rotate through a list of chat broadcasts on a fixed minute interval.
Generate this versionTimed supply drops and events
Trigger a game event on a wall-clock schedule without cron.
Generate this version
FAQ
Does this use my schedule timezone?
The plugin uses DateTime.Now on the machine that runs the Rust server, so schedules follow the host's local time zone. Set the server time zone in the OS or plan around it; the plugin does not implement its own offset.
Where do I edit the schedule?
Edit oxide/config/ScheduledCommands.json, then run scheduledcommands.reload from the server console or RCON. No restart is required and the running timer is replaced cleanly.
How do I install the plugin on Oxide or Carbon?
Save the code as ScheduledCommands.cs in oxide/plugins, let the server compile it, then edit the generated config and reload. Carbon loads the same .cs file from its plugins folder; no change is needed.
Will a missed minute fire later?
No. Daily tasks fire only when DailyTime matches the current hour and minute. If the server is down at that minute, the run is skipped until the next matching minute the following day.
What does Source Player require?
A PlayerId (64-bit Steam ID) that the plugin resolves through BasePlayer.FindAwakeOrSleeping. If no player is found, the command falls back to the server console. Leave Source on Console for server.save, say or restart.
Customize it for your server
Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More events plugins
- Bradley and heli kill announcerTracks damage dealt to the Bradley APC and patrol helicopter, then announces in chat which player did the most damage and the map grid where theโฆOnEntityTakeDamageOnEntityDeath
- Double gather hourRuns scheduled or admin-triggered double gather windows that multiply dispenser and collectible yields for a fixed duration, with start and endโฆOnServerInitializedOnDispenserGatherOnCollectiblePickup
- First bloodAnnounces the first player kill after a restart as First Blood and pays the killer a configured reward.OnServerInitializedOnNewSaveOnEntityDeath
Last updated Oct 26, 2026.
Customize scheduled commands
Customize