Reacting to records
Read first: getting-started/running-locally, getting-started/lexicons, getting-started/scripts
This page shows you how to run your own code each time a record in one of your collections changes, or a labeler applies a label to it.
You’ll add a record script to your project, watch it run, extend it to handle updates and deletes, and then add a label script. The examples use the xyz.statusphere.status collection. Use any record collection your project defines.
You need a project made with hpy happyview create, running under hpy happyview dev. hpy happyview dev picks up each file when you save it, so you don’t need to restart it.
Pick a collection
Section titled “Pick a collection”A record script sits next to its collection’s record lexicon in your project. So the collection needs a project lexicon: a file under src/lexicons/ whose path matches its NSID. For xyz.statusphere.status, that’s src/lexicons/xyz/statusphere/status.json. If your project doesn’t have it yet, add it first. See Lexicons.
Write a create script
Section titled “Write a create script”A record script’s file name is its lexicon’s name, then the action, then the language’s extension. Create status.create.lua (or .js, .ts) next to status.json:
src/ lexicons/ xyz/ statusphere/ status.json status.create.luaIts trigger is record.create:xyz.statusphere.status. Put this in the file:
local log = require("internal.logging")
---@param input lexicons.xyz.statusphere.status.Event---@param ctx happyview.RecordCtxfunction handle(input, ctx) log.info("new status", { uri = input.uri, did = input.did })
-- Return the record so it is indexed as it arrived. return input.recordendimport log from "internal.logging";
/** * @param {import("happyview:lexicons/xyz.statusphere.status").Event} input * @param {import("happyview:ctx").RecordCtx} ctx */export default function handle(input, ctx) { log.info("new status", { uri: input.uri, did: input.did });
// Return the record so it is indexed as it arrived. return input.record;}import log from "internal.logging";import type { Event } from "happyview:lexicons/xyz.statusphere.status";import type { RecordCtx } from "happyview:ctx";
export default function handle(input: Event, ctx: RecordCtx) { log.info("new status", { uri: input.uri, did: input.did });
// Return the record so it is indexed as it arrived. return input.record;}The types come from .happyview/types/, which hpy happyview dev writes from your lexicons.
input is the event: action, uri, did, collection, rkey and record. The script runs before the record is indexed, and what it returns decides what gets stored. Returning the record stores it unchanged.
A file that breaks the layout fails the build, and hpy happyview dev prints why. For example, status.create.lua with no status.json next to it is an error. hpy happyview check runs the same build without serving the project.
See it run
Section titled “See it run”A record script runs for records that arrive from the network. It doesn’t run for an XRPC request. By default, hpy happyview dev reads the real network, so a status posted from a Statusphere client reaches your script.
To watch it, open your local dashboard and go to System > Event Logs. Set Category to Script. Each log.info call appears as a script.log event whose subject is the script’s trigger, record.create:xyz.statusphere.status. info, warn and error lines are recorded here. debug lines aren’t recorded as events.
To see every script run, not just the lines your script logs, start hpy happyview dev with VERBOSE_EVENT_LOGGING=true in its environment. It logs a script.executed event for each run. It writes a lot, so turn it off again when you’re done. The Verbose Event Logging switch under System > General sets the same thing, and wins over the variable.
If the script fails, HappyView tries it up to 4 times in all, then writes the event to Data > Dead Letters. The record is still indexed, so a broken script never stops indexing.
To run the script on a record you choose, without waiting for the network, fire a record create at it from a test. See Testing.
Handle updates and deletes
Section titled “Handle updates and deletes”A record.create script only sees creates. To handle every action in one script, rename the file to status.index.lua. Its trigger is record.index:xyz.statusphere.status. HappyView uses status.create, status.update or status.delete when one exists for the action, and falls back to status.index otherwise.
A delete carries no record, so returning input.record on a delete returns nothing and skips the delete. Return true to let it through.
This version also filters: it skips any record whose status field is empty, so those records never reach the index.
local log = require("internal.logging")
---@param input lexicons.xyz.statusphere.status.Event---@param ctx happyview.RecordCtxfunction handle(input, ctx) if input.action == "delete" then log.info("status deleted", { uri = input.uri }) return true end
if not input.record.status or input.record.status == "" then return nil -- skip: not indexed end
log.info("status " .. input.action, { uri = input.uri }) return input.recordendimport log from "internal.logging";
/** * @param {import("happyview:lexicons/xyz.statusphere.status").Event} input * @param {import("happyview:ctx").RecordCtx} ctx */export default function handle(input, ctx) { if (input.action === "delete") { log.info("status deleted", { uri: input.uri }); return true; }
if (!input.record.status) { return undefined; // skip: not indexed }
log.info(`status ${input.action}`, { uri: input.uri }); return input.record;}import log from "internal.logging";import type { Event } from "happyview:lexicons/xyz.statusphere.status";import type { RecordCtx } from "happyview:ctx";
export default function handle(input: Event, ctx: RecordCtx) { if (input.action === "delete") { log.info("status deleted", { uri: input.uri }); return true; }
if (!input.record?.status) { return undefined; // skip: not indexed }
log.info(`status ${input.action}`, { uri: input.uri }); return input.record;}To change what is stored instead, return a different object. It replaces the record in the index. The full table of return values is in Script types and triggers.
React to labels
Section titled “React to labels”Labelers are services that apply labels such as spam or !hide to records and accounts. HappyView can subscribe to a labeler and run a script on each label it sends, before the label is stored.
Subscribe to a labeler
Section titled “Subscribe to a labeler”List the labeler’s DID under [labels] in happyview.toml:
[labels]subscribe = ["did:plc:ar7c4by46qjdydhdevvrndac"]HappyView subscribes to each labeler in the list when it starts, and again each time you save happyview.toml under hpy happyview dev. Taking a DID out of the list doesn’t unsubscribe from it: hpy happyview dev tells you the subscription stays until you delete it in the dashboard. Your subscriptions are listed under Integrations > Labelers.
Write a label script
Section titled “Write a label script”A label script lives in src/labels/, named by the label’s subject:
src/labels/xyz.statusphere.status.luaruns for labels on records in that collection. Its trigger islabeler.apply:xyz.statusphere.status.src/labels/_actor.luaruns for labels on accounts. Its trigger islabeler.apply:_actor.
src/labels/ is flat: a file’s name is the whole NSID, so a label script can’t sit in a subdirectory.
input carries the label: src (the labeler’s DID), uri (the subject), val (the label value), neg (true when the label is being removed), cts (when it was created) and, if it expires, exp.
This script keeps only the label values you care about. Returning nothing drops a label. Returning an object merges its fields over the label, so return input keeps it unchanged.
local log = require("internal.logging")
local ALLOWED = { ["!hide"] = true, ["spam"] = true }
---@param input happyview.LabelEvent---@param ctx happyview.LabelCtxfunction handle(input, ctx) if not ALLOWED[input.val] then return nil -- drop the label end
log.info("label kept", { val = input.val, uri = input.uri, neg = input.neg }) return inputendimport log from "internal.logging";
const ALLOWED = new Set(["!hide", "spam"]);
/** * @param {import("happyview:ctx").LabelEvent} input * @param {import("happyview:ctx").LabelCtx} ctx */export default function handle(input, ctx) { if (!ALLOWED.has(input.val)) { return undefined; // drop the label }
log.info("label kept", { val: input.val, uri: input.uri, neg: input.neg }); return input;}import log from "internal.logging";import type { LabelEvent, LabelCtx } from "happyview:ctx";
const ALLOWED = new Set(["!hide", "spam"]);
export default function handle(input: LabelEvent, ctx: LabelCtx) { if (!ALLOWED.has(input.val)) { return undefined; // drop the label }
log.info("label kept", { val: input.val, uri: input.uri, neg: input.neg }); return input;}A label script has no caller, so ctx.caller_did is absent and it can’t write to a PDS or create a job. A failing label script is retried and dead-lettered like a record script, and the label is stored as it arrived. See Script types and triggers.
Next steps
Section titled “Next steps”- Background jobs: move slow work out of record scripts
- Testing: fire record and label triggers at your scripts
- Script types and triggers: every trigger and return value
- The script contract: what
inputandctxcarry - Create scripts may run more than once
- Labelers
- Limits

