Skip to main content

Streaming output

The output argument type plus props:Output() lets a command stream status messages into its own panel, line by line, with timestamps and severity colors. This is the right shape for:

  • Audits that walk a large selection and flag problems.
  • Bulk operations that report success/failure per item.
  • Anything where "what just happened" is more interesting than "did it succeed."

This recipe walks a complete example end-to-end.

The shape

┌─ Audit Selection ────────────────────────┐
│ ▸ output Log │
│ ┌────────────────────────────────────┐ │
│ │ [10:42:15] Auditing 14 parts... │ │
│ │ [10:42:15] [WARN] Part12: not │ │
│ │ anchored, will fall. │ │
│ │ [10:42:15] [ERROR] Part19: missing │ │
│ │ CollisionGroup. │ │
│ │ [10:42:15] Done. 1 warning, 1 err. │ │
│ └────────────────────────────────────┘ │
│ ┌─ Execute ─────────────────────────┐ │
│ └───────────────────────────────────┘ │
└──────────────────────────────────────────┘

A single read-only panel that the command writes to as it runs.

Schema

return {
Description = "Audit the current selection for common authoring issues.",
AutoRecordChanges = false, -- read-only command — don't pollute undo history
Tags = { "selection", "audit" },

Arguments = {
Log = {
Type = "output",
Default = "",
PlaceholderText = "Press Execute to audit the selection.",
LayoutOrder = 1,
},
} :: Types.ArgumentType,

Run = function(props: Types.Props, arguments: Types.ArgumentResult)
-- next section
end,
} :: Types.ScriptModule

Two things to note:

  • AutoRecordChanges = false. Auditing doesn't change the place; we don't want to open a ChangeHistoryService recording for it.
  • PlaceholderText. Shows when the panel is empty. Replaced the moment your first append lands.

The body

Run = function(props: Types.Props, arguments: Types.ArgumentResult)
local log = props:Output("Log")
log:clear()

local parts = {}
for _, inst in props.Selected do
if inst:IsA("BasePart") then
table.insert(parts, inst)
end
end

if #parts == 0 then
log:warn("Select at least one BasePart.")
return
end

log:print(string.format("Auditing %d parts...", #parts))

local warnings, errors = 0, 0

for _, part in parts do
if not part.Anchored and part.Massless == false then
log:warn(string.format("%s: not anchored, will fall.", part.Name))
warnings += 1
end

if part.CollisionGroup == "" or part.CollisionGroup == "Default" then
log:print(string.format("%s: default collision group.", part.Name))
end

if part.Transparency == 1 and part.CanCollide then
log:error(string.format("%s: invisible but collidable.", part.Name))
errors += 1
end
end

log:print(string.format(
"Done. %d warnings, %d errors.",
warnings, errors
))
end,

What's happening, in order:

  1. Bind the handle once. props:Output("Log") returns a chainable handle. We hold it in a local so we can call methods like log:print(...) instead of typing props:Output("Log"):print(...) on every line.
  2. Clear before writing. Without log:clear(), every Execute press appends to the previous run's output. Clearing first gives you a fresh panel each time.
  3. Print, warn, error. :print is a plain line. :warn adds a yellow [WARN] prefix and wraps the line in a colored <font> tag. :error does the same with red.
  4. All of the methods chain. log:print(...):warn(...):error(...) is valid — each method returns the handle. Use it when it reads cleaner; here we wrote one log call per loop iteration so it didn't matter.

What the user sees

Run on a selection of three parts where one is unanchored and one is invisible-but-collidable:

[10:42:15] Auditing 3 parts...
[10:42:15] [WARN] Cube: not anchored, will fall.
[10:42:15] [ERROR] Glass: invisible but collidable.
[10:42:15] Done. 1 warnings, 1 errors.

[WARN] lines render yellow, [ERROR] lines render red. [10:42:15] is the timestamp the plugin prepends automatically.

Output panel showing one print line, one yellow [WARN] line, and one red [ERROR] line — each with a [HH:MM] timestamp prepended

Variations

Drop the timestamps

For commands where each line is self-contained and timestamps add noise, set Timestamp = false on the schema:

Log = {
Type = "output",
Default = "",
Timestamp = false,
LayoutOrder = 1,
},

Now appended lines render bare:

Auditing 3 parts...
[WARN] Cube: not anchored, will fall.
[ERROR] Glass: invisible but collidable.
Done. 1 warnings, 1 errors.

Append without binding a handle

If you only need a single line and don't want the handle ergonomics, props:AppendOutput writes directly:

props:AppendOutput("Log", "Started job")
-- ... later ...
props:AppendOutput("Log", "Finished job")

AppendOutput always uses the plain (non-severity) style. There's no AppendOutput(name, "WARN", text) form — for severity, use the handle.

Preserve the previous run's log

If you'd rather keep a rolling history across runs, just skip log:clear(). New lines append to the bottom; the user can scroll up to see prior runs. Each run's first log:print(...) line acts as a natural section break thanks to its timestamp.

Multiple output panels

A command can declare more than one output argument and stream to each independently:

Arguments = {
Progress = { Type = "output", Default = "", LayoutOrder = 1 },
Errors = { Type = "output", Default = "", Timestamp = false, LayoutOrder = 2 },
} :: Types.ArgumentType,

Run = function(props)
local progress = props:Output("Progress")
local errors = props:Output("Errors")

-- ...

progress:print("Step 1 of 3...")
errors:warn("Skipped one item")
end,

Useful when you want a fast-scrolling progress feed and a separate "things that went wrong" summary the user can read at the end without scrolling.

When to reach for output (and when not to)

Use output when:

  • The command produces enough text that a label would feel cramped.
  • You want the user to scan results, not just see "done."
  • Severity matters — some lines are warnings, some are hard errors.

Skip it when:

  • The command is one-shot and the result is a single sentence. Use props:SendNotification(...) and a label instead.
  • The command doesn't emit text at all. An empty output panel just takes up space.