Guides

Record & Label Scripts

Record and label scripts are Lua scripts that run in response to events on the AT Protocol 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_scripts table and are managed separately. Existing index hooks were automatically migrated.

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

TriggerFires 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

TriggerFires 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

TriggerFires when
labeler.apply:<nsid>A label arrives whose subject is at://<did>/<nsid>/<rkey>
labeler.apply:_actorA 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

TriggerFires 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

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

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 true
end

The function is called once per event.

Record script return values

Return valueEffect
nilThe record is not indexed (skipped entirely)
A tableThat table is stored as the record instead
trueThe 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).

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

Return valueEffect
nilThe label is not persisted (skipped entirely)
A tableThe returned fields are merged with the original label
trueThe 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

Record script globals

These globals are set before handle() is called for record events:

GlobalTypeDescription
actionstring"create", "update", or "delete"
uristringThe full AT URI (e.g. at://did:plc:abc/col/rkey)
didstringThe repo DID
collectionstringThe collection NSID
rkeystringThe record key
recordtable?The full record as a Lua table (nil on delete)
eventtableThe 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

These globals are set before handle() is called for label events:

GlobalTypeDescription
srcstringDID of the labeler that issued the label
uristringThe label subject (at:// URI or bare DID)
valstringThe label value (e.g. "!hide", "nudity")
negbooleantrue if this is a negation (label removal)
ctsstringCreation timestamp (ISO 8601)
expstring?Expiration timestamp (nil if the label does not expire)
eventtableThe 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

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 -- env table with key-value pairs configured in the dashboard

Error handling and retries

Record and label scripts are designed to be resilient:

  1. If a script fails, it retries up to 4 attempts total (1 initial + 3 retries) with exponential backoff (1s, 2s, 4s delays).
  2. If all attempts are exhausted, the failed event is inserted into the happyview_dead_letter_scripts table for later inspection.
  3. 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

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

The happyview_dead_letter_scripts table stores events that failed all retry attempts:

ColumnTypeDescription
idBIGSERIALPrimary key
script_reftextThe trigger id of the script that failed
host_kindtext'record' or 'label'
host_idtextIdentifies the specific event source
collectiontextThe collection NSID of the failed event
payloadjsonbThe full event payload
errortextThe error message from the last attempt
attemptsintTotal number of attempts made
created_attextWhen the failure was recorded (ISO 8601)
resolved_attextWhen the failure was resolved (null until resolved)

Examples

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 record  -- allow deletes to proceed
  end

  if record.title == nil or record.title == "" then
    return nil  -- skip: no title
  end

  return record
end

Transform a record before storage

Enrich a record with a computed field before it is stored:

function handle()
  if action == "delete" then
    return record
  end

  record.slug = string.lower(string.gsub(record.title or "", "%s+", "-"))
  return record
end

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
end

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
end

See the full Algolia sync reference for more detail.

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
end

See the full Meilisearch sync reference for more detail.

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 event
end

Rewrite a label field

Normalize the label value before it is stored:

function handle()
  return { val = string.lower(val) }
end

Fields not returned fall back to their original values, so only val is changed here.

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