Services
How a program reaches a service, what a call gives back, and the services for time, JSON, your own pages, web requests and the run.
use TimeServiceTimeServiceJsonServiceFileServiceTimeService:Now()A service is an instance named after itself, with Parent nil, living as long as the run. A System project has nine: ProcessService, FileService, JsonService, TimeService, PageService, HttpService, RunService, ProgramService and EncodingService (the last two are in the class reference). Call members with a colon: TimeService:Now() (Instances).
What a service hands you (a started program, a timer, a page server) is a handle, an instance that is a child of that service. Destroy ends it; the end of the run destroys whatever is left.
Each example is Main.shot of a one-file project, Demo.ssp (shot write takes the text on stdin), and checks with 0 errors unless it shows them:
shot new Demo.ssp
shot write Main.shot --project Demo.ssp
shot check --project Demo.ssp
shot run Demo.sspReach a service
In a .shot file, one line:
use ProcessServiceIn any file, plain Luau included, one require:
local ProcessService = require("@shotsystem/ProcessService")use tries the project's own files first: a project file called ProcessService wins. A name that is no service and no project file is an error in shot check:
Demo.ssp/Main.shot:1 use NopeService: no NopeService in this folder or any folder above it (use looks at each folder's own files and folders, up to the project root)and stops a run there (exit code 1):
error: Main.shot:1: use NopeService: no NopeService in this folder or any folder above it (use looks at each folder's own files and folders, up to the project root)
at Main.shot:1Both forms are typed: shot check reports a dot call, a missing member and a result that may be nil:
use ProcessService
use TimeService
local now = TimeService.Now()
local timer = TimeService:After(5, function() end)
timer:Cancel()
print(ProcessService:StartAsync("python").Pid)gives
Demo.ssp/Main.shot:4:13 TypeError: This function must be called with self. Did you mean to use a colon instead of a dot?
Demo.ssp/Main.shot:6:1 TypeError: Key 'Cancel' not found in external type 'Timer'
Demo.ssp/Main.shot:7:7 TypeError: Value of type 'ProcessHandler?' could be nilRun anyway, the dot call stops it:
error: Main.shot:4: TimeService.Now is a method: call it with a colon, TimeService:Now(...)
at Main.shot:4- A service can't be destroyed:
TimeService can't be destroyed: a service lives as long as the run. - Any member of a destroyed handle is an error:
ProcessHandler "python" was destroyed.
What a call gives back
- A member that waits is named
...Async; the others never wait. - Each callback you hand a service runs in its own thread, so it may call
...Asyncmembers. - A mistake in the code is an error: a wrong argument, a value JSON can't hold, an unknown time code.
- A failure of the world returns
nil(orfalse) and why: one line starting with the operation and what it was about.
use JsonService
use ProcessService
-- a failure of the world: nil and why
local result, why = ProcessService:RunAsync("no-such-program")
print(tostring(result) .. ", " .. tostring(why))
-- a mistake in the code: an error
local ok, problem = pcall(function()
return JsonService:Encode({ Run = print })
end)
print(tostring(ok) .. ", " .. tostring(problem))prints
nil, StartAsync no-such-program: program not found
false, Main.shot:10: JsonService:Encode: unsupported value type `function`RunAsync's why names StartAsync, which it calls. Uncaught, a mistake stops the run with exit code 1:
use JsonService
local reading = { Run = print }
print(JsonService:Encode(reading))prints
error: Main.shot:4: JsonService:Encode: unsupported value type `function`
at Main.shot:4To stop on a failure, wrap the call in assert; the error says why:
use ProcessService
local child = assert(ProcessService:StartAsync("no-such-program"))stops the run with
error: Main.shot:3: StartAsync no-such-program: program not found
at Main.shot:3An error in a callback is printed and the program goes on, but the run ends with exit code 1:
use TimeService
TimeService:After(0.1, function()
error("broken in a timer")
end)
TimeService:WaitAsync(0.3)
print("the program went on")prints
error: Main.shot:4: broken in a timer
at Main.shot:4
the program went on
1 error in callbacksSignals (Line, Exited, Connected, Message, Stopping, ...) have Connect, Once and WaitAsync(timeout?), nil on a timeout (Instances, EVENTS AND SIGNALS).
What keeps a run going
A run ends when the code is done and nothing it started is live. Live until they end or are destroyed: timers, page servers, and started programs you listen to (Line, Exited or WaitAsync).
use TimeService
TimeService:After(1, function()
print("the timer, a second after the last line")
end)
print("the last line of code")prints
the last line of code
the timer, a second after the last line- An Every or a page server you never destroy keeps a run going for good.
RunService:Quitends a run at once. - When a run ends, programs it started that still run are stopped, with everything they started.
- Relative paths and working folders start at the project file's folder (
RunService.ProjectFolder), wherever you ranshot run.
Files and folders
FileService reads, writes, lists, copies, moves and recycles files and folders through PathHandlers, opens files for ranged work and follows a growing file: see FileService.
Programs
ProcessService starts programs on this computer, streams their lines, writes their input, waits for them and stops them with everything they started. StartAsync gives a ProcessHandler named after its program; RunAsync runs one to its end. See ProcessService.
Time and timers
TimeService: the time, waits, timers and times as text. A time is seconds since 1970, milliseconds as the fraction.
TimeService:Now() -> number seconds since 1970
TimeService:Clock() -> number seconds since an arbitrary start, for measuring
TimeService:WaitAsync(seconds : number) -> number waits (at least one frame), gives how long it waited
TimeService:After(seconds : number, callback : () -> ()) -> Timer calls it once
TimeService:Every(seconds : number, callback : () -> ()) -> Timer calls it every period, the first one period from now
TimeService:Format(time : number?, format : string?) -> string local time as text; nil time: now
TimeService:Iso(time : number?) -> string UTC to the second, 1970-01-01T00:00:00Z; nil time: now
TimeService:CreateTimer(interval : number, repeats : boolean?) -> Timer stopped until its Start
TimeService:CreateAlarm(at : number) -> Timer fires once at a clock timeA Timer is a child of TimeService named "After 0.2" or "Every 0.1". Destroy stops it.
timer.Interval : number seconds to the next fire
timer.Repeats : boolean
timer.Running : boolean
timer.TimesFired : number
timer.Fired : signal (at : number) at: the clock time it fired
timer:Start(), timer:Stop()- A timer has no Cancel:
Cancel is not a valid member of Timer "TimeService.After 5". Destroy it. - A fired After timer is destroyed by TimeService: reading it is an error (the example's last line).
- Format writes local time, Iso UTC. Format's default is "%Y-%m-%d %H:%M:%S". Its codes are below, and a
-,_or0after the%works on any of them; any other code is an error,TimeService:Format: %q is not a time code:
%Y %C %y %m %b %B %h %d %e %a %A %w %u %U %W %G %g %V %j %D %x %F %v %H %k %I %l %P %p %M %S %f %R %T %X %r %Z %z %c %s %t %n
%-d %_d %0d %.f %.3f %.6f %.9f %:z %::z %:::z %+ %%- A program has
task.spawn,task.deferandtask.cancelbut notask.wait,waitortick; shot check names the replacement:
Demo.ssp/Main.shot:1:1 task.wait is Roblox's: write TimeService:WaitAsync(seconds) (use TimeService)Example:
use TimeService
print("time 0 in UTC: " .. TimeService:Iso(0))
print("now, local: " .. TimeService:Format())
local once = TimeService:After(0.2, function()
print("After: fired once")
end)
print(once.ClassName .. " " .. once.Name .. ", a child of TimeService: " .. tostring(once.Parent == TimeService))
local cancelled = TimeService:After(0.2, function()
print("never printed")
end)
cancelled:Destroy()
local ticks = 0
local every = TimeService:Every(0.1, function()
ticks += 1
end)
local waited = TimeService:WaitAsync(0.55)
every:Destroy()
print(string.format("waited %.1f s: Every ticked %d times", waited, ticks))
print("the fired timer: " .. tostring(select(2, pcall(function()
return once.Parent
end))))prints (the second line is your date and time)
time 0 in UTC: 1970-01-01T00:00:00Z
now, local: 2026-10-20 01:18:51
Timer After 0.2, a child of TimeService: true
After: fired once
waited 0.6 s: Every ticked 5 times
the fired timer: Main.shot:23: Timer "After 0.2" was destroyedJSON
JsonService turns a value into JSON text and back. Neither call waits.
JsonService:Encode(value : any, pretty : boolean?) -> string pretty: two spaces, one member per line
JsonService:Decode(text : string) -> (any, string?)- Keys come out sorted.
- A table with exactly the keys 1..n is an array; any other is an object with string keys, so a gap stays:
{ [1] = "a", [3] = "c" }is{"1":"a","3":"c"}. - An empty table is
{}, so an empty list you send is{}(WEB REQUESTS has one). Only a decoded[]is written back as[]. - null decodes to nil: the field is missing.
- Text that is not JSON comes from outside: Decode gives nil and why.
- A value JSON can't hold is an error:
JsonService:Encode: unsupported value type `function`
JsonService:Encode: a table inside itself (a cycle)
JsonService:Encode: a key that is a boolean (keys are strings or numbers)Example:
use JsonService
local reading = { Station = "north", Celsius = 21.5, Hours = { 9, 12 } }
print(JsonService:Encode(reading))
print(JsonService:Encode(reading, true))
print(JsonService:Encode({ [1] = "a", [3] = "c" }) .. " " .. JsonService:Encode({}))
print(JsonService:Encode((JsonService:Decode("[]"))) .. " " .. JsonService:Encode((JsonService:Decode("{}"))))
local back = JsonService:Decode('{"Celsius": 18, "Note": null}')
print("Celsius " .. back.Celsius .. ", Note " .. tostring(back.Note))
local bad, why = JsonService:Decode("{ not json")
print(tostring(bad) .. ", " .. tostring(why))prints
{"Celsius":21.5,"Hours":[9,12],"Station":"north"}
{
"Celsius": 21.5,
"Hours": [
9,
12
],
"Station": "north"
}
{"1":"a","3":"c"} {}
[] {}
Celsius 18, Note nil
nil, Decode: key must be a string at line 1 column 3Your own pages
PageService serves your program's own pages on this computer only, with a live channel to every open page.
PageService:ServeAsync(options : PageOptions?) -> (Page?, string?)
PageOptions = { Folder : string?, Pages : {[string] : string}?, Port : number? }
PageRequest = { Method : string, Path : string, Query : {[string] : string}, Body : string }
PageResponse = { Status : number?, Body : string?, Type : string? }A Page, a child of PageService named after its port ("page 47200"), serves until destroyed or the run ends. Below, <page> stands for its address, which shot prints in full with the port.
page.Url : string <page>/?k=<the page's key>
page.Port : number
page.Connected : signal (client : PageClient) a page opened the channel
page.Message : signal (client : PageClient, text : string)
page:Send(text : string) to every open page
page:Clients() -> {PageClient}
page:Handle(route : string, callback : (request : PageRequest) -> (PageResponse | string))
page:Destroy() stops serving
PageClient = { Id : number, Send : (self, text : string) -> (), Closed : signal () }- Folder serves a folder's files (
index.htmlfor/). Pages serves texts by route;/and routes without an extension are HTML, others go by extension. Port is the first port tried (47200 when nil), then the next free one: readpage.Port. /ui/common/serves the project window's look (theme.css, ...), so a page can match the window (The window).Handleanswers an exact path ("/notes"), any method (request.Method). A route ending in/*answers what under it nothing else does, with the whole path inrequest.Path.- Its callback returns a string (200, text/plain) or
{ Status?, Body?, Type? }. An error in it answers 500 and is a callback error (WHAT A CALL GIVES BACK). page.Urlends in?k=and the page's key. Handle routes and the live channel answer a request without it with 401,this route needs the page's key (X-Shot-Key, or ?k= from page.Url); files and Pages texts don't. Send it as theX-Shot-Keyheader or?k=, and build addresses from the part ofpage.Urlbefore?.- A PageClient has no Close: a channel closes when its page is closed or goes, and
client.Closedfires.
The page's side of the channel is one script:
<script src="/shotsystem/live.js"></script>
ShotLive.on(fn) hears each text the program sends
ShotLive.send(text) sends one back
ShotLive.onState(fn) hears "open" and "closed"It reconnects by itself and adds the key to the page's requests; open the page by page.Url.
Example: open the address it prints in a browser on the same computer.
use PageService
local HTML = [[<!doctype html>
<html>
<head><meta charset="utf-8"><title>Station</title></head>
<body>
<pre class="lines"></pre>
<script src="/shotsystem/live.js"></script>
<script>
const lines = document.querySelector(".lines");
ShotLive.on((text) => {
lines.textContent += text + "\n";
ShotLive.send("got " + text);
});
</script>
</body>
</html>
]]
local page = assert(PageService:ServeAsync({ Pages = { ["/"] = HTML } }))
print(page.ClassName .. " " .. page.Name .. ", a child of PageService: " .. tostring(page.Parent == PageService))
print("open " .. page.Url)
local client = page.Connected:WaitAsync(60)
if client then
print("a page opened the channel")
client:Send("hello page")
local _, answer = page.Message:WaitAsync(10)
print("the page answered: " .. tostring(answer))
else
print("no page opened in a minute")
end
page:Destroy()
print("stopped")prints, once a browser opened the address (the key differs each run):
Page page 47200, a child of PageService: true
open <page>/?k=28eb1255ec5090421ae3600a59f25134
a page opened the channel
the page answered: got hello page
stoppedWeb requests
HttpService sends web requests, with a method, headers and a body.
HttpService:FetchAsync(request : HttpRequest) -> (HttpResponse?, string?)
HttpService:GetTextAsync(url : string, headers : {[string] : string}?) -> (string?, string?)
HttpService:PostTextAsync(url : string, body : string, contentType : string?, headers : {[string] : string}?) -> (string?, string?)
HttpRequest = { Url : string, Method : string?, Headers : {[string] : string}?, Body : string? }
HttpResponse = { StatusCode : number, StatusMessage : string, Success : boolean, Headers : {[string] : string}, Body : string }- FetchAsync gives any status as an answer (Success true for a 2xx), and nil and why only when nothing answered or the URL is bad:
FetchAsync not a url: bad argument #1: relative URL without a base. Method defaults to GET. - GetTextAsync and PostTextAsync give the body of a 2xx answer, else nil and why with the status and the body's start:
GetTextAsync <page>/nowhere: status 404: no page /nowhere. - PostTextAsync sends
application/jsonunless you give contentType. - Roblox's RequestAsync, GetAsync and PostAsync throw on a failure; these return nil and why, hence the other names. The answer's fields are Roblox's.
Example: a page server of the same program is the server, so nothing leaves this computer.
use HttpService
use JsonService
use PageService
use TimeService
-- the server: a page of this program with one route of its own
local page = assert(PageService:ServeAsync())
local notes : {string} = {}
page:Handle("/notes", function(request)
if request.Method == "POST" then
table.insert(notes, request.Body)
return { Status = 201, Body = "saved note " .. #notes }
end
return { Body = JsonService:Encode(notes), Type = "application/json" }
end)
-- page.Url ends in ?k=<key>: a route needs the key
local base = string.match(page.Url, "^[^?]*") or ""
local key = { ["X-Shot-Key"] = string.match(page.Url, "k=(%w+)") or "" }
print("before: " .. tostring((HttpService:GetTextAsync(base .. "notes", key))))
for _, note in { "rain at noon", "wind from the west" } do
print(HttpService:PostTextAsync(base .. "notes", note, "text/plain", key))
end
print("after: " .. tostring((HttpService:GetTextAsync(base .. "notes", key))))
local answer = HttpService:FetchAsync({ Url = base .. "notes", Method = "GET", Headers = key })
if answer then
print(answer.StatusCode .. " " .. answer.StatusMessage .. ", Success " .. tostring(answer.Success) .. ", " .. answer.Headers["content-type"])
end
local refused = HttpService:FetchAsync({ Url = base .. "notes" })
if refused then
print(refused.StatusCode .. ": " .. refused.Body)
end
local missing, why = HttpService:GetTextAsync(base .. "nowhere")
print(tostring(missing) .. ", " .. tostring(why))
page:Destroy()
TimeService:WaitAsync(0.2)
local gone, goneWhy = HttpService:GetTextAsync(base .. "notes", key)
print(tostring(gone) .. ", " .. tostring(goneWhy))prints (print puts a space between values)
before: {}
saved note 1 nil
saved note 2 nil
after: ["rain at noon","wind from the west"]
200 OK, Success true, application/json
401: this route needs the page's key (X-Shot-Key, or ?k= from page.Url)
nil, GetTextAsync <page>/nowhere: status 404: no page /nowhere
nil, GetTextAsync <page>/notes: No connection could be made because the target machine actively refused it. (os error 10061)The empty list of notes came out as {} (see JSON).
The run itself
RunService: the run's arguments, the project's folder, the environment, and ending the run.
RunService.Arguments : {string} read only: what came after `--` on the shot run line
RunService.ProjectFolder : string read only: the folder that holds the project file, with / slashes
RunService.Stopping : signal () the run is ending
RunService:GetEnv(name : string) -> string? an environment variable, or nil
RunService:Quit(code : number?) ends the run with that exit code (0 when nil); never returnsshot run Demo.ssp -- north "b c"gives two arguments, the second with its space.- Quit runs every Stopping callback first (up to 3 seconds in all), then stops everything the run started. Code after Quit never runs.
Example, run from the folder above the project's folder weather, as shot run weather/Demo.ssp -- north "b c":
use RunService
print(#RunService.Arguments .. " arguments: " .. table.concat(RunService.Arguments, "|"))
print("the project's folder is called " .. (string.match(RunService.ProjectFolder, "[^/]+$") or ""))
print("PATH is set: " .. tostring(RunService:GetEnv("PATH") ~= nil))
print("NO_SUCH_NAME: " .. tostring(RunService:GetEnv("NO_SUCH_NAME")))
RunService.Stopping:Connect(function()
print("stopping")
end)
RunService:Quit(2)
print("never printed")prints, and the run ends with exit code 2:
2 arguments: north|b c
the project's folder is called weather
PATH is set: true
NO_SUCH_NAME: nil
stoppingNot there yet
shot check knows only the nine services above. A require of any other name is an Unknown require, which lists what there is:
Demo.ssp/Main.shot:1:14 Unknown require "@shotsystem/NopeService": a System project's services are EncodingService, FileService, HttpService, JsonService, PageService, ProcessService, ProgramService, RunService and TimeService, and its built-in modules Mop, QuickSignal and QuickValue (use FileService)See also
| Overview | System projects |
| One-file projects | the header and project kinds |
| Externals | use external |
| Instances | instances, signals, Instance.new, Clone and Destroy |
| FileService | files and folders |
| ProcessService | programs on this computer |
