Record & Label Scripts
Record and label scripts are Lua scripts that run in response to events on the atproto network. Record scripts fire when a record in a collection is created, updated, or deleted. Label scripts fire when a label is applied to a record or actor. Both run before the event is indexed, giving you the ability to filter, transform, or trigger side effects.
These scripts are event-driven — they react to incoming Jetstream events (which include events caused by HappyView’s own PDS writes), not to XRPC requests. For scripts that run in response to XRPC queries and procedures, see Lua Scripting.
Migration note: Prior to v2.9, record scripts were called “index hooks” and were attached directly to lexicons. They now live in their own
happyview_scriptstable and are managed separately. Existing index hooks were automatically migrated.
Trigger grammar
Section titled “Trigger grammar”Every script is identified by a trigger string — the script’s id in the happyview_scripts table IS its trigger binding. There is no separate name or host column; the trigger string determines which events the script receives.
Record event triggers
Section titled “Record event triggers”| Trigger | Fires when |
|---|---|
record.index:<nsid> | Any record event (create, update, or delete) |
record.create:<nsid> | A record is created |
record.update:<nsid> | A record is updated |
record.delete:<nsid> | A record is deleted |
Cascade rule: When a record event occurs, the dispatcher tries the action-specific trigger first (e.g. record.create:<nsid>), then falls back to record.index:<nsid> if no action-specific script exists. This means you can use record.index as a catch-all and override individual actions when needed.
XRPC triggers
Section titled “XRPC triggers”| Trigger | Fires when |
|---|---|
xrpc.query:<nsid> | An XRPC query endpoint is called |
xrpc.procedure:<nsid> | An XRPC procedure endpoint is called |
XRPC scripts handle the request and return the response. Without a script, HappyView uses default query/procedure behavior. See Lua Scripting for the full query/procedure scripting reference.
Label event triggers
Section titled “Label event triggers”| Trigger | Fires when |
|---|---|
labeler.apply:<nsid> | A label arrives whose subject is at://<did>/<nsid>/<rkey> |
labeler.apply:_actor | A label arrives whose subject is a bare DID (actor-level label) |
There is no cascade for label or XRPC triggers — each trigger string must match exactly.
Job triggers
Section titled “Job triggers”| Trigger | Fires when |
|---|---|
job.run:<type> | A background job with the matching type is picked up by the worker |
There is no cascade for job triggers — the type must match exactly. See Background Jobs for the full job scripting guide.
Creating scripts
Section titled “Creating scripts”You can create scripts through the dashboard (Settings > Scripts > New) or via the admin API (POST /admin/scripts).
When creating a script, you provide the trigger string as the script’s id. For example, a script with id record.index:xyz.statusphere.status will fire on every record event for the xyz.statusphere.status collection.
Script structure
Section titled “Script structure”Like query and procedure scripts, record and label scripts must define a handle() function:
function handle() if action == "delete" then log("deleted " .. uri) else log(action .. " " .. uri) end return trueendThe function is called once per event.
Record script return values
Section titled “Record script return values”| Return value | Effect |
|---|---|
nil | The record is not indexed (skipped entirely) |
| A table | That table is stored as the record instead |
true | The original record is stored as-is |
| (no script) | The original record is stored as-is |
On delete events, returning nil skips the delete (the record stays in the database). Because a delete event carries no record, return record returns nil there — use return true to let a delete proceed.
Important: If your script has side effects (e.g. syncing to a search index) but you want normal indexing to proceed, return record or true — not nothing. A missing return statement returns nil, which skips indexing.
Label script return values
Section titled “Label script return values”| Return value | Effect |
|---|---|
nil | The label is not persisted (skipped entirely) |
| A table | The returned fields are merged with the original label |
true | The original label is stored as-is |
| (no script) | The original label is stored as-is |
When a label script returns a table, any field the script omits falls back to the original value. This means return event passes the label through unchanged, while return { val = "new-value" } rewrites only the val field.
Context globals
Section titled “Context globals”Record script globals
Section titled “Record script globals”These globals are set before handle() is called for record events:
| Global | Type | Description |
|---|---|---|
action | string | "create", "update", or "delete" |
uri | string | The full AT URI (e.g. at://did:plc:abc/col/rkey) |
did | string | The repo DID |
collection | string | The collection NSID |
rkey | string | The record key |
record | table? | The full record as a Lua table (nil on delete) |
event | table | The full event payload (see below) |
The event table contains the same fields as the individual globals (action, uri, did, collection, rkey, record). New scripts can use either style — event.action or the bare action global — both work. The event table corresponds to the RecordEventPayload struct in the Rust dispatcher.
Label script globals
Section titled “Label script globals”These globals are set before handle() is called for label events:
| Global | Type | Description |
|---|---|---|
src | string | DID of the labeler that issued the label |
uri | string | The label subject (at:// URI or bare DID) |
val | string | The label value (e.g. "!hide", "nudity") |
neg | boolean | true if this is a negation (label removal) |
cts | string | Creation timestamp (ISO 8601) |
exp | string? | Expiration timestamp (nil if the label does not expire) |
event | table | The full label event as a table (same fields) |
Record and label scripts do not have access to caller_did, input, params, or method. They run from the event stream, not from a user request.
Available APIs
Section titled “Available APIs”Record and label scripts have access to:
- Record API (no-auth mode) —
Record.load,r:save_local(),r:delete_local(),Record.delete_local(). PDS-touching methods (r:save(),r:delete()) raise an error. - Database API —
db.query,db.get,db.search,db.backlinks,db.count,db.raw - HTTP API —
http.get,http.post,http.put,http.patch,http.delete,http.head - XRPC Lua API —
xrpc.query,xrpc.procedure - atproto API —
atproto.resolve_service_endpoint,atproto.get_labels,atproto.get_labels_batch - JSON API —
json.encode,json.decode - Utility globals —
log(),now(),TID(),toarray() - Script variables —
envtable with key-value pairs configured in the dashboard
Error handling and retries
Section titled “Error handling and retries”Record and label scripts are designed to be resilient:
- If a script fails, it retries up to 4 attempts total (1 initial + 3 retries) with exponential backoff (1s, 2s, 4s delays).
- If all attempts are exhausted, the failed event is inserted into the
happyview_dead_letter_scriptstable for later inspection. - On failure the system fails open — the original record or label is stored as-is so indexing is not permanently blocked. The firehose has no caller to surface errors to.
Failed scripts are logged as errors. Check the event logs or query the happyview_dead_letter_scripts table directly to find and replay failures.
Performance considerations
Section titled “Performance considerations”Because scripts run synchronously before indexing, they block the Jetstream consumer while executing. With retry logic (1s + 2s + 4s backoff), a persistently failing script could block for ~7 seconds per event. Keep scripts fast and ensure external services they depend on are reliable.
Dead letter table
Section titled “Dead letter table”The happyview_dead_letter_scripts table stores events that failed all retry attempts:
| Column | Type | Description |
|---|---|---|
id | BIGSERIAL | Primary key |
script_ref | text | The trigger id of the script that failed |
host_kind | text | 'record' or 'label' |
host_id | text | Identifies the specific event source |
collection | text | The collection NSID of the failed event |
payload | text | The full event payload (JSON) |
error | text | The error message from the last attempt |
attempts | int | Total number of attempts made |
created_at | text | When the failure was recorded (ISO 8601) |
resolved_at | text | When the failure was resolved (null until resolved) |
Examples
Section titled “Examples”Filter out records missing a required field
Section titled “Filter out records missing a required field”Create a script with trigger record.index:your.collection.nsid to skip indexing any record that doesn’t have a title field:
function handle() if action == "delete" then return true -- allow deletes to proceed end
if record.title == nil or record.title == "" then return nil -- skip: no title end
return recordendTransform a record before storage
Section titled “Transform a record before storage”Enrich a record with a computed field before it is stored:
function handle() if action == "delete" then return true end
record.slug = string.lower(string.gsub(record.title or "", "%s+", "-")) return recordendPost to a webhook
Section titled “Post to a webhook”function handle() http.post("https://hooks.example.com/records", { headers = { ["Content-Type"] = "application/json" }, body = json.encode({ action = action, uri = uri, did = did, record = record }) }) return record or true -- `record` is nil on delete; `true` lets it proceedendSync to Algolia
Section titled “Sync to Algolia”Push records to an Algolia search index on create/update, and remove them on delete:
function handle() local headers = { ["X-Algolia-API-Key"] = "your-api-key", ["X-Algolia-Application-Id"] = "your-app-id", ["Content-Type"] = "application/json" }
if action == "delete" then http.delete("https://YOUR-APP.algolia.net/1/indexes/records/" .. uri, { headers = headers }) else http.put("https://YOUR-APP.algolia.net/1/indexes/records/" .. uri, { headers = headers, body = json.encode({ objectID = uri, collection = collection, did = did, record = record }) }) end
return record or true -- `record` is nil on delete; `true` lets it proceedendSee the full Algolia sync reference for more detail.
Sync to Meilisearch
Section titled “Sync to Meilisearch”Push records to a self-hosted Meilisearch index on create/update, and remove them on delete:
function handle() local headers = { ["Authorization"] = "Bearer " .. env.MEILISEARCH_API_KEY, ["Content-Type"] = "application/json" }
if action == "delete" then http.delete(env.MEILISEARCH_URL .. "/indexes/records/documents/" .. uri, { headers = headers }) else http.post(env.MEILISEARCH_URL .. "/indexes/records/documents", { headers = headers, body = json.encode(toarray({ { id = uri, collection = collection, did = did, record = record } })) }) end
return record or true -- `record` is nil on delete; `true` lets it proceedendSee the full Meilisearch sync reference for more detail.
Filter labels by value
Section titled “Filter labels by value”Create a script with trigger labeler.apply:your.collection.nsid to only persist specific label values:
function handle() local allowed = { ["!hide"] = true, ["nudity"] = true, ["spam"] = true }
if not allowed[val] then return nil -- skip: label value not in allowlist end
return eventendRewrite a label field
Section titled “Rewrite a label field”Normalize the label value before it is stored:
function handle() return { val = string.lower(val) }endFields not returned fall back to their original values, so only val is changed here.
Next steps
Section titled “Next steps”- Lua Scripting: Full reference for the sandbox, APIs, and debugging (covers query and procedure scripts)
- Admin API — Scripts: Create and manage scripts via the API
- Lexicons: Understand how record, query, and procedure lexicons work together

