Lifecycle hooks
Beyond Run, a command module can declare any of five optional hooks. Every hook is plugin-managed: no :Connect, no :Disconnect, no signal cleanup. Define the field on the returned table and CommandRunner calls it at the right moment.
| Hook | Signature | Fires when |
|---|---|---|
Validate | (props) -> boolean, string? | Before Run (Execute button or RunCommand). |
OnExecuted | (props, arguments, result) | After Run returns successfully. |
OnError | (props, arguments, err) | After Run throws. |
OnSelected | (props) | The user picks the command in the sidebar. |
OnDeselected | (props) | The user picks another command, deletes this one, or closes the widget. |
All five are optional. All five are caught in a pcall — an error inside a hook is logged with warn and never propagates.
Validate and OnError apply to the main Run path only — they do not fire for per-argument Run callbacks (the ones declared on individual button / switch / etc. arguments).
Validate
Pre-flight gate. Return true to proceed, false to abort. The optional second return is shown as a toast.
Validate = function(props)
if not (props.FirstSelected and props.FirstSelected:IsA("BasePart")) then
return false, "Select a part first"
end
return true
end,
If Validate returns false:
Rundoes not fire.- No
ChangeHistoryServicerecording is opened (so no empty undo step). OnErrordoes not fire (this isn't an error, it's a gate).- If a string was returned, it shows as a brief toast.
If Validate itself throws, the abort still happens (fail-closed) and the throw is logged via warn. A broken validator should never silently let Run proceed.
Use Validate instead of an early return props:SendNotification(...) inside Run. It's declarative, keeps the Run body focused on the work, and makes the precondition obvious to anyone reading the module.
OnExecuted
Fires after Run returns successfully.
OnExecuted = function(props, arguments, result)
print("Just finished, args were:", arguments)
end,
| Parameter | Type | What it is |
|---|---|---|
props | Props | Same props table that was passed to Run. |
arguments | {[string]: any} | Same as props.Arguments, included for ergonomics. |
result | any | Whatever Run returned. nil if Run had no return. |
Triggers regardless of how Run was invoked:
- The user clicked Execute in the args panel.
- Another command called
props:RunCommand("YourCommand", args).
It does not fire when other commands run. There's no global "after any command" hook. If you want to do something after running a sibling command, just put the code after the RunCommand call — RunCommand is synchronous, the next line doesn't execute until the inner command's Run (and its OnExecuted) have both finished:
Run = function(props)
props:RunCommand("AnchorAll")
print("AnchorAll done") -- runs after AnchorAll's OnExecuted
end
By the time OnExecuted fires the ChangeHistoryService recording is already committed. Anything you do inside OnExecuted is its own undo step. If your post-step needs to be inside the same undo, do it inside Run.
OnError
Symmetric pair with OnExecuted for the failure case. Fires when Run throws.
OnError = function(props, arguments, err)
props:SendNotification(string.format("RecolorParts failed: %s", err))
end,
| Parameter | Type | What it is |
|---|---|---|
props | Props | Same props that was passed to the failed Run. |
arguments | {[string]: any} | Args snapshot at the time of execute. |
err | string | The error message Run threw. |
The plugin still emits its own warn for the underlying error, so the Output console always shows the failure. OnError is for command-side reaction: a custom toast, rolling back partial state, telemetry, retry. The ChangeHistoryService recording is committed regardless of success or failure (Roblox's recording API doesn't have a discard path); any mutations Run made before throwing are part of the undo step.
OnSelected / OnDeselected
Pair of hooks for the command's selection lifecycle. Useful for live previews — apply a highlight on select, remove it on deselect.
OnSelected = function(props)
if props.FirstSelected then
local hl = Instance.new("Highlight")
hl.Name = "__RecolorPreview"
hl.Adornee = props.FirstSelected
hl.Parent = props.FirstSelected
end
end,
OnDeselected = function(props)
for _, inst in workspace:GetDescendants() do
if inst.Name == "__RecolorPreview" then
inst:Destroy()
end
end
end,
OnSelected fires when:
- The user clicks the command in the sidebar.
- A different command was active and the user switched (the previous command's
OnDeselectedfires first, then the new command'sOnSelected).
OnDeselected fires when:
- The user picks a different command from the sidebar.
- The user deletes the active command.
- The user closes the widget (toggles it off via the toolbar button).
Both hooks receive the same props shape as Run. props.Arguments reflects the latest persisted argument values; props.Selected is the Studio selection snapshot at the moment the hook fires. The hook is called synchronously — don't yield. If you need long-running work, use task.spawn and clean it up in OnDeselected.
A few details that surprise people:
- No automatic re-selection on widget reopen. Closing the widget calls
OnDeselected. Reopening doesn't auto-fireOnSelected; the user has to click a command. Plan your cleanup so the world is in a sane state without anOnSelectedto "rebuild" anything. OnDeselectedruns against a freshly-loaded module. If the user edited the script between select and deselect,OnDeselectedruns the latest source — not whatever code was active whenOnSelectedran. Don't rely on closure state shared across the pair; persist anything you need (Instance attributes,props.Configuration).- Module deletion is a deselect. If the active command's ModuleScript is destroyed externally (Explorer drag-out, scripted removal), the plugin runs
OnDeselectedif the script is still resolvable. If the script is already gone the hook silently no-ops — there's no module to load to call the hook on.
Errors inside hooks
All five hooks are wrapped in pcall. An error inside any hook is logged via warn and discarded. Hooks are best-effort, not load-bearing. If your hook must succeed, audit it the same way you'd audit Run — log + recover, don't assume.
A worked example
A "duplicate selection" command that uses every hook:
return {
Arguments = {
Count = { Type = "number", Default = 3, Min = 1, Max = 50 },
},
Validate = function(props)
local source = props.FirstSelected
if not (source and source:IsA("BasePart")) then
return false, "Select a part first"
end
return true
end,
Run = function(props, arguments)
local source = props.FirstSelected
local copies = {}
for i = 1, arguments.Count do
local copy = source:Clone()
copy.Position = source.Position + Vector3.new(0, 4 * i, 0)
copy.Parent = source.Parent
table.insert(copies, copy)
end
return copies
end,
OnExecuted = function(props, args, copies)
props:SetSelection(copies)
props:SendNotification(string.format("Created %d copies", #copies))
end,
OnError = function(props, args, err)
props:SendNotification("Duplicate failed, see Output for details")
end,
OnSelected = function(props)
print("Duplicate command armed; pick a part and Execute")
end,
OnDeselected = function(props)
print("Duplicate command stowed")
end,
}
Validate keeps Run short and readable. OnExecuted does the "after" work that doesn't belong in the undo recording. OnError gives the user a friendlier message than the bare warn. OnSelected / OnDeselected are toy here, but they're where you'd attach the live-preview highlight described above.