Skip to content
You are reading the HappyView v2 docs, which are no longer updated. Read the v3 docs.

Lua Scripting

Without Lua scripts, HappyView’s query endpoints return raw records and procedure endpoints proxy simple creates and updates. To attach a script to an XRPC endpoint, create a script with trigger xrpc.query:<nsid> or xrpc.procedure:<nsid> — see trigger grammar. Lua scripts let you go much further:

  • Add filtering logic
  • Transform responses
  • Validate input
  • Compose multi-record operations
  • Build entirely custom behavior

Scripts run in a sandboxed Lua VM with access to the Record API, a database API, an HTTP client API, a JSON API, and a set of context globals.

For scripts that react to record changes or label events (rather than XRPC requests), see Record & Label Scripts.

Every script must define a handle() function. HappyView calls it when the XRPC endpoint is hit and returns its result as JSON to the client.

function handle()
-- your logic here
return { key = "value" }
end

You can define helper functions and variables outside handle(). They’re evaluated once when the script loads, then handle() is called per request.

Scripts run in a restricted environment. The following standard Lua modules are removed and unavailable:

io, debug, package, require, dofile, loadfile, load, collectgarbage

The os module is replaced with a safe subset exposing only os.time, os.date, os.difftime, and os.clock. Dangerous functions like os.execute, os.remove, os.rename, and os.exit are not available.

An instruction limit of 1,000,000 prevents infinite loops. Exceeding it terminates the script with an error.

See the Standard Libraries reference for the full list of available Lua modules and builtins.

These globals are set automatically before handle() is called.

GlobalTypeDescription
methodstringThe XRPC method name (e.g. xyz.statusphere.setStatus)
inputtableParsed JSON request body
paramstableQuery string parameters
caller_didstringDID of the authenticated user
collectionstringTarget collection NSID
delegate_didstring?DID of the delegated account, if using write delegation
envtableScript variables configured in the dashboard
GlobalTypeDescription
methodstringThe XRPC method name
paramstableQuery string parameters (all values are strings)
collectionstringTarget collection NSID
caller_didstring?DID of the authenticated user (nil if unauthenticated)
envtableScript variables configured in the dashboard

When a script handles a space-scoped request, the space global is set to a table with the space’s metadata. For non-space requests, space is nil.

FieldTypeDescription
spacestringThe full at:// space URI
space_idstringInternal space identifier
didstringThe space’s DID
authority_didstringThe space authority’s DID
type_nsidstringSpace type NSID
skeystringSpace key
function handle()
if space then
log("handling request for space: " .. space.space)
log("space type: " .. space.type_nsid)
end
end

Available in both queries and procedures:

FunctionReturnsDescription
now()stringCurrent UTC timestamp in ISO 8601 format
log(message)—Log a message (appears in server logs at debug level)
TID()stringGenerate a fresh atproto TID (13-character sortable identifier). Also provides conversion methods — see Utility Globals reference.
toarray(table)tableMark a table as a JSON array for serialization (see below)

Lua tables don’t distinguish between arrays and objects. When a table is serialized to JSON, an empty table {} becomes a JSON object {} instead of an array []. The toarray() function marks a table so it always serializes as a JSON array — even when empty.

return { items = toarray(results) }
-- With results: [{"name": "a"}, {"name": "b"}]
-- Without results: {"items": []} (not {"items": {}})

You don’t need toarray() on results from db.query, db.search, db.backlinks, or db.raw — those already return properly marked arrays. Use it when you build a table yourself with table.insert().

The Record API is available in procedure, query, and record/label scripts. In procedure scripts the full API is available — writes are proxied to the caller’s PDS and indexed locally. In query and record/label scripts it runs in no-auth mode: Record.load, r:save_local(), r:delete_local(), and Record.delete_local() work, but PDS-touching methods (r:save(), r:delete()) raise an error.

See the full Record API reference for constructor, static methods, instance methods, fields, schema validation, and save behavior.

Quick example:

function handle()
local r = Record(collection, input)
r:save()
return { uri = r._uri, cid = r._cid }
end

The db table provides access to the database. Available in both queries and procedures.

See the full Database API reference for db.query, db.get, db.search, db.backlinks, db.count, and db.raw.

Quick example:

function handle()
local result = db.query({ collection = collection, limit = 20 })
return { records = result.records, cursor = result.cursor }
end

The http table provides async HTTP client functions. Available in both queries and procedures.

See the full HTTP API reference for all methods, options, and response format.

Quick example:

local resp = http.get("https://api.example.com/data")
local data = json.decode(resp.body)

The xrpc table lets scripts call other XRPC endpoints — both local and proxied. Available in both queries and procedures.

See the full XRPC Lua API reference for xrpc.query and xrpc.procedure.

Quick example:

local resp = xrpc.query("xyz.statusphere.listStatuses", { limit = 5 })
local data = json.decode(resp.body)

The atproto table provides atproto utility functions like DID resolution, label queries, and record signing.

See the full atproto API reference for atproto.resolve_service_endpoint, atproto.get_labels, atproto.get_labels_batch, atproto.sign, and atproto.verify_signature.

The json global provides JSON serialization and deserialization.

See the full JSON API reference for json.encode and json.decode.

The jobs table lets any script queue background jobs for long-running work. Available in all script contexts.

See the full Jobs API reference for jobs.create() and the job global available inside job scripts.

Quick example:

local job_id = jobs.create("export", { collection = collection })
return { job_id = job_id }

For the full guide on background jobs, see Background Jobs.

Use log() to trace script execution. Output appears in the server logs at debug level with the field lua_log, and is also recorded as a script.log event in the event logs (accessible via GET /admin/events):

function handle()
log("handle called with params: " .. tostring(params.limit))
local result = db.query({ collection = collection, limit = params.limit })
log("query returned " .. #result.records .. " records")
return result
end

To see log output in stdout, make sure your RUST_LOG environment variable includes debug level for HappyView (the default happyview=debug works). See Configuration.

When a script fails, the client receives a generic 500 response:

  • {"error": "script execution failed"}: covers syntax errors, runtime errors, missing handle() function, and errors raised with error()
  • {"error": "script exceeded execution time limit"}: the script hit the 1,000,000 instruction limit

The full error message is logged server-side at error level. Check the server logs to see the actual Lua error, including line numbers and stack traces.

  • Missing handle() function: Every script must define a global handle() function. If it’s missing or misspelled, the script fails silently with “script execution failed”.
  • Calling error() for expected conditions: Lua’s error() triggers a 500 response. For expected conditions like “record not found”, return a structured error response instead: return { error = "not found" }.
  • Infinite loops: The sandbox enforces a 1,000,000 instruction limit. If your script processes large data sets, paginate with db.query() limits instead of loading everything at once.
  • Forgetting params values are strings: All query string parameters arrive as strings. Use tonumber(params.limit) if you need a number.

See the example script references for complete, ready-to-use scripts:

Queries:

Procedures:

Record & Label Scripts:

  • Algolia sync — push records to an Algolia search index on create/update/delete