Scripting with Lua
Goop2 includes an embedded Lua runtime for server-side logic. Lua scripts power template backends, chat commands, data validation, game rules, and more.
Enabling Lua
Add the lua section to your goop.json:
{
"lua": {
"enabled": true,
"script_dir": "site/lua",
"timeout_seconds": 5,
"max_memory_mb": 10
}
}
Templates enable Lua automatically when they include scripts in lua/functions/.
Script types
graph LR
CM["!hello Alice"] --> E["Lua Engine"]
E --> VM1["Sandboxed VM"]
VM1 --> R["Hello, Alice!"]
JS["db.call('kanban', {action: 'get_board'})"] --> E
E --> VM2["Data VM + goop.orm"]
VM2 --> JSON["{columns: [...]}"]
Chat commands
Files in site/lua/ (not in functions/) are chat commands. A visitor sends a message starting with ! and the matching script runs.
File: site/lua/hello.lua
function handle(args)
return "Hello, " .. (args ~= "" and args or "world") .. "!"
end
A visitor typing !hello Alice receives Hello, Alice!.
Data functions
Files in site/lua/functions/ are data functions — the template backend. They are called from the browser via Goop.data.call() and return structured data.
File: site/lua/functions/myapp.lua
function call(request)
local params = request.params
return { message = "Hello", action = params.action }
end
Called from JavaScript:
var result = await Goop.data.call("myapp", { action: "greet" });
// result = { message: "Hello", action: "greet" }
How frontend and backend connect
When JS calls db.call("kanban", { action: "get_board" }), the SDK sends the request to the Lua engine. The engine creates a fresh sandboxed VM, loads lua/functions/kanban.lua, and calls its call() function with a request table:
function call(request)
-- request.params = the JS object you passed
-- request.params.action = "get_board"
-- request.params.card_id = 42 (if you passed it)
local action = request.params.action
local card_id = request.params.card_id
end
The Lua function returns a table, which becomes the JSON response in JS:
sequenceDiagram
participant JS as app.js
participant SDK as Goop.data
participant LUA as kanban.lua
participant DB as SQLite
JS->>SDK: db.call("kanban", {action: "get_board"})
SDK->>LUA: call({params: {action: "get_board"}})
LUA->>DB: goop.orm("columns"):find(...)
DB-->>LUA: rows
LUA-->>SDK: {columns: [...]}
SDK-->>JS: result.columns
JS->>JS: renderBoard(result.columns)
Who is calling?
Every Lua invocation knows who's calling:
goop.peer.id -- the viewer's peer ID (who triggered this call)
goop.peer.label -- the viewer's display name
goop.peer.email -- the viewer's email address (empty if not configured)
goop.self.id -- the site owner's peer ID (whose site this is)
goop.self.label -- the site owner's display name
Check if the caller is the site owner:
if goop.peer.id ~= goop.self.id then
return { error = "only the site owner can do this" }
end
Or use the goop.owner() wrapper (see below).
Patterns
Action dispatcher with goop.route
Most templates use a single Lua function with goop.route() to handle multiple actions. The router extracts action from request.params and calls the matching handler with params:
local function get_items(params)
return { items = items_tbl:find({ order = "_id DESC" }) or {} }
end
local function add_item(params)
if not params.title or params.title == "" then
return { error = "title required" }
end
local id = items_tbl:insert({ title = params.title })
return { id = id }
end
local dispatch = goop.route({
get_items = get_items,
add_item = add_item,
})
function call(req) return dispatch(req) end
JS calls different actions on the same function:
var result = await db.call("myapp", { action: "get_items" });
await db.call("myapp", { action: "add_item", title: "New task" });
Or using the api() shorthand (inserts action automatically):
var api = Goop.data.api("myapp");
var result = await api("get_items");
await api("add_item", { title: "New task" });
Owner-only actions with goop.owner
Wrap handlers with goop.owner() to restrict them to the site owner. If a non-owner calls it, an error is raised automatically:
local dispatch = goop.route({
get_board = get_board, -- anyone can read
add_card = add_card, -- anyone can add
add_column = goop.owner(add_column), -- owner only
delete_column = goop.owner(delete_column), -- owner only
})
Lazy init pattern
ORM handles persist across the function body but the VM is fresh each call. Use lazy init to avoid re-creating handles:
local items_tbl = nil
local function init()
if not items_tbl then items_tbl = goop.orm("items") end
end
local function i(fn) return function(p) init(); return fn(p) end end
local dispatch = goop.route({
get_items = i(get_items),
add_item = i(add_item),
})
Seed data
To populate initial data when the template is installed, create lua/functions/seed.lua:
function call(req)
local cols = goop.orm("columns")
local cfg = goop.orm("config")
local n1 = cols:seed({
{ name = "To Do", position = 0, color = "#6366f1" },
{ name = "In Progress", position = 1, color = "#f59e0b" },
{ name = "Done", position = 2, color = "#22c55e" },
})
local n2 = cfg:seed({
{ key = "title", value = "My Board" },
{ key = "subtitle", value = "Shared kanban board" },
})
return n1 + n2
end
seed() only inserts rows if the table is empty. It runs once after the template tables are created.
Available APIs
goop.orm (recommended)
The ORM provides typed, schema-aware database access. Tables are defined in schemas/*.json and created automatically when the template is installed.
local posts = goop.orm("posts")
Reading:
local rows = posts:find({ where = "published = 1", order = "_id DESC", limit = 10 })
local row = posts:find_one({ where = "slug = ?", args = { "hello" } })
local post = posts:get(42) -- by _id
local bySlug = posts:get_by("slug", "hello") -- by column
local all = posts:list(100) -- shorthand for find with limit
local titles = posts:pluck("title") -- single column as array
local n = posts:count({ where = "published = 1" }) -- row count
local yes = posts:exists({ where = "slug = ?", args = { "hello" } }) -- boolean
local uniq = posts:distinct("category") -- unique values
local agg = posts:aggregate("SUM(score) as total", { where = "active = 1" })
Writing:
local id = posts:insert({ title = "New", body = "Content" }) -- returns _id
posts:update(42, { title = "Updated" }) -- partial update by _id
posts:delete(42) -- delete by _id
posts:update_where( -- bulk update
{ published = 1 },
{ where = "draft = 0" }
)
posts:delete_where({ where = "archived = 1" }) -- bulk delete
posts:upsert("slug", { slug = "hello", title = "Hello" }) -- insert or update by key
posts:seed({ {title = "A"}, {title = "B"} }) -- insert only if table is empty
local ok, err = posts:validate({ title = "Test" }) -- check types/required
The handle also exposes posts.columns and posts.access.
goop.expr
Raw SQL expression inside update_where data:
cards:update_where(
{ position = goop.expr("position + 1") },
{ where = "column_id = ? AND position >= ?", args = { col_id, pos } }
)
Without goop.expr, the value would be bound as a literal string.
goop.config
Key-value configuration backed by a database table. The table must have key and value columns:
local cfg = goop.config("settings", { theme = "light", accent = "#6366f1" })
cfg.theme -- read: returns "light" (or DB value if set)
cfg:set("theme", "dark") -- write: persists to DB immediately
goop.route
Action dispatcher. Takes a table mapping action names to handler functions:
local dispatch = goop.route({
list = list_fn,
save = save_fn,
})
function call(req) return dispatch(req) end
Extracts request.params.action and calls the matching handler with params. Raises an error for unknown actions.
goop.owner
Owner-only wrapper. Returns a new function that raises an error if goop.peer.id ~= goop.self.id:
local dispatch = goop.route({
read = read_fn, -- anyone
delete = goop.owner(delete_fn), -- owner only
})
goop.group
Group membership, role queries, and group management:
local is_member = goop.group.is_member() -- boolean
local my_id = goop.group.member.id -- current peer's ID (string)
local my_role = goop.group.member.role() -- "owner", "coauthor", "viewer", etc.
local owner_id = goop.group.owner() -- group owner's peer ID
Group management (host-side operations):
local types = goop.group.grouptypes() -- {"template", "files", "listen", ...}
local gid = goop.group.create(
"Room A", -- name
"template", -- type (must be a registered group type)
goop.template.name, -- context (identifies the owner, e.g. template name)
10 -- max members (0 = unlimited)
)
goop.group.add(gid, peer_id) -- invite/add a peer
goop.group.remove(gid, peer_id) -- kick a peer
goop.group.set_role(gid, peer_id, "coauthor") -- change a member's role
goop.group.send(gid, { type = "chat", text = "hi" }) -- broadcast to group
local members = goop.group.members(gid) -- [{peer_id, role}, ...]
local groups = goop.group.list() -- [{id, name, group_type}, ...]
goop.group.close(gid) -- close and clean up
The context parameter links the group to its creator. For template groups, use goop.template.name so the group is cleaned up when the template is switched. For other types, use whatever identifies the owner.
The owner always gets role "owner". Other members get the group's default_role (set in the manifest or via the API).
Event handlers
Scripts can define well-known global functions that the engine calls when MQ bus events fire:
function on_group_close(group_id)
local rooms = goop.orm("rooms")
local rows = rooms:find({ where = "group_id = ? AND status = 'open'", args = { group_id } }) or {}
for _, r in ipairs(rows) do
rooms:update(r._id, { status = "closed" })
end
end
Currently supported events:
| Function | Trigger | Argument |
|---|---|---|
on_group_close(group_id) |
Group closed (via MQ bus) | The closed group's ID |
goop.template
The full manifest is available as a Lua table:
goop.template.name -- "Clubhouse"
goop.template.description -- "A cozy real-time chat room..."
goop.template.category -- "community"
goop.template.icon -- "🏠"
goop.template.schemas -- {"rooms"}
goop.template.require_email -- false
goop.template.default_role -- "coauthor" (or nil if not set)
goop.db (legacy)
Raw SQL database access. Still available but goop.orm() is preferred for new code:
local rows = goop.db.query("SELECT * FROM posts WHERE _owner = ?", goop.peer.id)
local count = goop.db.scalar("SELECT COUNT(*) FROM responses")
goop.db.exec("UPDATE games SET turn = ? WHERE _id = ?", "O", game_id)
Use goop.db when you need SQL that the ORM can't express (complex joins, CTEs, etc.).
goop.http
HTTP client (requires http_enabled: true in config):
local body, err = goop.http.get("https://api.example.com/data")
local body, err = goop.http.post("https://api.example.com/submit", { key = "val" })
Only http:// and https:// URLs allowed. Private/loopback addresses blocked (SSRF protection). Limited to 3 requests per invocation, 1 MB max response.
goop.json
local obj = goop.json.decode('{"name":"Alice"}')
local str = goop.json.encode({ name = "Bob" })
goop.kv
Persistent key-value store, scoped per script (requires kv_enabled: true):
goop.kv.set("api_key", "secret123")
local key = goop.kv.get("api_key")
goop.kv.del("api_key")
Limited to 1000 keys and 64 KB total per script. Useful for storing API keys or per-script state that shouldn't be in the database.
goop.log
goop.log.info("processing request")
goop.log.warn("API key missing")
goop.log.error("connection failed")
Logs appear in the Logs tab.
goop.site
Read files from the site content directory:
local content, err = goop.site.read("config.json")
local config = goop.json.decode(content)
goop.chat
Group chat room management. Messages are broadcast via the chat.room: MQ topic.
local room, err = goop.chat.create("Room Name", "description", 10, goop.template.name)
goop.chat.send(group_id, "Hello everyone!")
local room, err = goop.chat.state(group_id) -- room + members + messages
local rooms = goop.chat.rooms() -- all active rooms
goop.chat.close(group_id)
goop.listen
Audio listening session control:
local group, err = goop.listen.create("My Session")
goop.listen.load("/path/to/track.mp3")
goop.listen.play()
goop.listen.pause()
goop.listen.seek(30.5)
goop.listen.close()
goop.commands()
Returns a list of all loaded chat commands (name + description).
Script annotations
--- A weather lookup command
--- @rate_limit 10
function handle(args)
-- ...
end
- Description: First
---line becomes the script's description, shown in command listings and the Lua Scripts tab. @rate_limit N: Override per-peer rate limit.0= unlimited. Without this, the globalrate_limit_per_peerconfig applies.
Complete example: task list backend
A minimal but complete Lua backend for a task list template:
--- Todo list operations
--- @rate_limit 0
local todos = nil
local function init()
if not todos then todos = goop.orm("todos") end
end
local function list_todos(params)
return { todos = todos:find({ order = "position ASC" }) or {} }
end
local function add_todo(params)
if not params.text or params.text == "" then
return { error = "text required" }
end
local max = todos:aggregate("COALESCE(MAX(position), -1) as v")
local pos = (max and #max > 0) and max[1].v + 1 or 0
local id = todos:insert({
text = params.text,
done = 0,
position = pos,
created_by = params.peer_name or "",
})
return { id = id }
end
local function toggle_todo(params)
if not params.id then return { error = "id required" } end
local row = todos:find_one({ where = "_id = ?", args = { params.id }, fields = { "done" } })
if not row then return { error = "not found" } end
todos:update(params.id, { done = row.done == 0 and 1 or 0 })
return { status = "toggled" }
end
local function delete_todo(params)
if not params.id then return { error = "id required" } end
todos:delete(params.id)
return { status = "deleted" }
end
local function i(fn) return function(p) init(); return fn(p) end end
local dispatch = goop.route({
list = i(list_todos),
add = i(add_todo),
toggle = i(toggle_todo),
delete = goop.owner(i(delete_todo)),
})
function call(req) return dispatch(req) end
The JS side:
var todo = Goop.data.api("todo");
var result = await todo("list"); // {todos: [...]}
await todo("add", { text: "Buy milk" }); // {id: 1}
await todo("toggle", { id: 1 }); // {status: "toggled"}
await todo("delete", { id: 1 }); // {status: "deleted"} (owner only)
Security
graph LR
S[Script] --> RL{Rate limiter}
RL -->|allowed| VM[Fresh sandboxed VM]
RL -->|blocked| D[Rate limit error]
VM --> T{Timeout watchdog}
VM --> M{Memory monitor}
T -->|exceeded| K[Kill VM]
M -->|exceeded| K
Every Lua invocation runs in a fresh, sandboxed VM:
- No filesystem access --
io,loadfile,dofiledisabled. - No module loading --
require,packagedisabled. - No shell execution --
os.execute,os.removedisabled. - Hard timeout -- Default 5 seconds, configurable up to 60.
- Memory limit -- Default 10 MB per VM.
- Rate limiting -- Per-peer (30/min) and global (120/min) limits.
Hot reload
Scripts are automatically reloaded when files change. No restart needed. If a script has a syntax error, the previous working version stays active and the error is logged.