Skip to content

Scripts

Manage trigger-keyed scripts. Scripts run automatically in response to events like record indexing, XRPC calls, or labeler actions. The trigger id (e.g. record.index:xyz.statusphere.status) determines when a script fires.

Permissions: scripts:read for GET endpoints, scripts:manage for mutating endpoints.

const TOKEN = "hv_..."; // your API key
const headers = { Authorization: `Bearer ${TOKEN}` };
GET /admin/scripts

Optionally filter by NSID suffix with the ?suffix= query parameter.

interface Script {
id: string;
script_type: string;
body: string;
description: string | null;
// "project" when a file in the instance's project defines this script,
// otherwise "manual".
layer: "project" | "manual";
// Project-relative path of the file, for a project script only.
path: string | null;
// A dashboard script sits under this project file. It is kept and runs
// again if the file is removed.
shadows_stored: boolean;
// null for a project script, which has no stored row.
created_at: string | null;
updated_at: string | null;
// v2 globals (`db`, `Record`, `now`, `params`, …) this script still
// references as free names; empty once migrated onto
// require("happyview.*") / require("internal.*"). Always empty for a
// non-Lua script. Can instead be ["unparseable"] when a Lua `body` does
// not parse at all.
needs_migration: string[];
// false when no installed interpreter claims `script_type`, so the script
// is stored but inert. See "A script whose language has no interpreter".
runnable: boolean;
}
// List all scripts
const response = await fetch("http://127.0.0.1:3000/admin/scripts", {
headers,
});
const data: Script[] = await response.json();
// Filter by NSID suffix
const filtered = await fetch(
"http://127.0.0.1:3000/admin/scripts?suffix=xyz.statusphere.status",
{ headers },
);
const filteredData: Script[] = await filtered.json();
ParameterTypeRequiredDescription
suffixstringnoFilter to scripts whose id ends with :<suffix> (query param)

Response: 200 OK

[
{
"id": "record.index:xyz.statusphere.status",
"script_type": "lua",
"body": "function handle(input, ctx)\n return input.record\nend",
"description": "Process indexed statuses",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"needs_migration": [],
"runnable": true
}
]
GET /admin/scripts/languages

The languages a script can be written in on this instance: the language_id of each installed interpreter plugin, sorted. A create or update with any other script_type is refused, so this is the list to offer.

const response = await fetch("http://127.0.0.1:3000/admin/scripts/languages", {
headers,
});
const languages: string[] = await response.json();

Response: 200 OK

["javascript", "lua", "typescript"]
GET /admin/scripts/{id}

The {id} path parameter is the trigger string, URL-encoded (e.g. record.index%3Axyz.statusphere.status).

const response = await fetch(
"http://127.0.0.1:3000/admin/scripts/record.index%3Axyz.statusphere.status",
{ headers },
);
const data: Script = await response.json();

Response: 200 OK

{
"id": "record.index:xyz.statusphere.status",
"script_type": "lua",
"body": "function handle(input, ctx)\n return input.record\nend",
"description": "Process indexed statuses",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"needs_migration": [],
"runnable": true
}
POST /admin/scripts

Creates a new script or replaces an existing one by id. The trigger grammar and Lua body are validated at write-time, a body that still references a removed v2 global is refused, and so is a script_type no installed interpreter claims.

const response = await fetch("http://127.0.0.1:3000/admin/scripts", {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: "record.index:xyz.statusphere.status",
script_type: "lua",
body: "function handle(input, ctx)\n return input.record\nend",
description: "Process indexed statuses",
}),
});
const data: Script = await response.json();
FieldTypeRequiredDescription
idstringyesTrigger string (e.g. record.index:xyz.statusphere.status)
script_typestringnoScript language; defaults to "lua". Must be one an installed interpreter claims
bodystringyesThe script source code
descriptionstringnoHuman-readable description (max 300 characters)

Response: 201 Created (new) or 200 OK (update)

{
"id": "record.index:xyz.statusphere.status",
"script_type": "lua",
"body": "function handle(input, ctx)\n return input.record\nend",
"description": "Process indexed statuses",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"needs_migration": [],
"runnable": true
}
PATCH /admin/scripts/{id}

Updates individual fields of an existing script. At least one field must be provided. Setting description to null in JSON clears it. If script_type is changed, body must also be provided so validation can run against the new type. A body that still references a removed v2 global is refused, as is a script_type no installed interpreter claims.

const response = await fetch(
"http://127.0.0.1:3000/admin/scripts/record.index%3Axyz.statusphere.status",
{
method: "PATCH",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
description: "Updated description for status processing",
}),
},
);
const data: Script = await response.json();
FieldTypeRequiredDescription
script_typestringnoScript language; requires body alongside, and must be one an installed interpreter claims
bodystringnoNew script source; re-validated against script_type
descriptionstring|nullnoNew description, or null to clear

Response: 200 OK

{
"id": "record.index:xyz.statusphere.status",
"script_type": "lua",
"body": "function handle(input, ctx)\n return input.record\nend",
"description": "Updated description for status processing",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"needs_migration": [],
"runnable": true
}
DELETE /admin/scripts/{id}
const response = await fetch(
"http://127.0.0.1:3000/admin/scripts/record.index%3Axyz.statusphere.status",
{
method: "DELETE",
headers,
},
);

Response: 204 No Content

GET /admin/editor/declarations?language={language_id}

The declarations an interpreter renders for editors, the same files happyview types writes into .happyview/types/<language_id>/, but rendered from everything this instance holds: its installed libraries, and every lexicon it serves, whether from a project, the dashboard or the network. The dashboard’s script editor completes from them. Requires scripts:read.

Terminal window
curl "http://127.0.0.1:3000/admin/editor/declarations?language=lua" \
-H "$AUTH"
interface EditorDeclarations {
language_id: string;
files: { path: string; contents: string }[];
/** What the input could not describe, such as a library whose surface does not parse. */
notes: string[];
}

Response: 200 OK, with an ETag.

  • Revalidation: the rendering is kept until a library, a lexicon or the interpreter changes. Send the tag back as If-None-Match and get 304 Not Modified while nothing has.
  • An interpreter that renders no declarations answers an empty files and a note saying why.
  • 404 Not Found when no installed interpreter claims the language.
  • 502 Bad Gateway when the interpreter fails to render.

When the instance runs a project, a script defined by a file under src/ answers its trigger ahead of any dashboard script with the same id. Creating, patching, deleting or applying the codemod to it is refused with 409 Conflict, naming the file. A dashboard script the file shadows is kept as it is and runs again when the file is removed.

POST /admin/scripts/{id}/codemod

Rewrites a Lua script’s body onto the v3 handle(input, ctx) contract — the rewrite Migrating Scripts to v3 describes. Only script_type: "lua" is supported. Previewing needs scripts:read; apply: true needs scripts:manage as well, since it stores the rewritten body. Omit the request body entirely, or send {}, to preview.

interface CodemodResult {
source: string;
notes: { line: number; message: string }[];
changed: boolean;
}
const response = await fetch(
"http://127.0.0.1:3000/admin/scripts/record.index%3Axyz.statusphere.status/codemod",
{
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({}),
},
);
const preview: CodemodResult = await response.json();
// Apply once the notes are reviewed; allow_markers is required if any remain
const applied = await fetch(
"http://127.0.0.1:3000/admin/scripts/record.index%3Axyz.statusphere.status/codemod",
{
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ apply: true, allow_markers: true }),
},
);
const data: CodemodResult = await applied.json();
FieldTypeRequiredDescription
applybooleannoDefaults to false (preview only). true stores the rewritten body.
allow_markersbooleannoRequired alongside apply: true when the rewrite still leaves -- codemod: markers behind.
sourcestringnoLua text to rewrite in place of the stored body. Preview only.

Response: 200 OK

{
"source": "local log = require(\"internal.logging\")\n\nfunction handle(input, ctx)\n log.info(\"script fired\", { trigger = ctx.trigger })\n return input\nend",
"notes": [],
"changed": true
}
FieldTypeDescription
sourcestringThe rewritten script body.
notesarrayConstructs the codemod could not rewrite mechanically, each left under a -- codemod: comment in source.
notes[].linenumberLine number in the original body the construct sits on — not the rewritten source above.
notes[].messagestringWhat changed and what to finish by hand.
changedbooleanfalse when the rewrite equals the stored body, so there is nothing to apply; source then equals the stored body. Not a statement that the script is on the v3 contract: one applied with markers left in place reports false too.

apply: true on a rewrite that changed the script (changed: true) and still has one or more notes returns 409 Conflict unless allow_markers: true is also set. Re-applying when the rewrite changes nothing (changed: false) is a no-op and returns 200 OK regardless of allow_markers.

source previews the rewrite of text the server doesn’t hold, such as an editor’s unsaved changes. No stored script is needed, so it works before a script is first saved: the {id} in the path then only tells the codemod which kind of script it is rewriting. Nothing is stored, changed compares against source rather than the stored body, and sending it with apply: true returns 400 Bad Request.

A non-Lua script_type returns 400 Bad Request. A body that doesn’t parse as Lua also returns 400 Bad Request rather than a result — this is different from needs_migration’s "unparseable", which is a value on the script list/get response, not this endpoint.

A script whose language has no interpreter

Section titled “A script whose language has no interpreter”

A script’s language is run by an interpreter plugin, and which languages an instance can run is therefore whatever is installed on it rather than a fixed list. POST /admin/scripts and PATCH /admin/scripts/{id} refuse a script_type no installed interpreter claims:

{
"error": "no interpreter installed for 'typescript' scripts; install one from the plugins page"
}

Response: 400 Bad Request. Nothing is stored. Nothing on the instance could check the body or run it, so storing it would mean keeping a script nothing has validated — the refusal happens at the edit rather than on the script’s first trigger. Installing the interpreter is the whole fix; the same request then succeeds unchanged.

The check runs after the unmigrated-body refusal, so a Lua body with removed globals still gets removed_globals and the offer of the codemod.

Stored rows are unaffected. A script whose interpreter was never installed, or was uninstalled afterwards, still lists and still reads with its body intact, and reports runnable: false. It can still be edited and deleted. What it cannot do is run, and each trigger answers that in the way its own caller needs:

TriggerWhat happens
xrpc.query:*, xrpc.procedure:*503 Service Unavailable with error: "ServerMisconfigured" and a message naming the language. A caller is refused rather than given an answer the script never shaped — in a procedure’s case, rather than having its raw input written
record.index:*, record.create:*, record.update:*, record.delete:*Fail-open: the event is indexed as if no script had run, and a dead-letter row records the reason. Ingest is not held up by a missing plugin
labeler.apply:*Fail-open: the label is persisted unchanged, with a dead-letter row recording the reason
job.run:*The job fails, with the same sentence in its error column. A job that silently never ran would tell an operator nothing

The dead-letter rows record one attempt rather than the usual four: a missing plugin is not a transient failure, so the retry budget is not spent on it. Retrying such a row through POST /admin/dead-letters/{id}/retry answers 500 Internal Server Error with a correlation id and writes the sentence to the row’s error column, until the interpreter is installed. (Label dead letters are never retryable, whatever the reason they were written.)

POST /admin/scripts and PATCH /admin/scripts/{id} refuse a Lua body that still references a removed v2 global, the same names needs_migration reports for the script’s kind and in the same order:

{
"error": "script references removed globals: db, params -- run the codemod first",
"removed_globals": ["db", "params"]
}
interface UnmigratedScriptError {
error: string;
removed_globals: string[];
}

Response: 400 Bad Request. Nothing is stored. removed_globals is present on this error and no other, so a client can key on it rather than on the wording of error. The check runs before the body is compiled, so a script that reads a removed global at file scope gets this answer rather than a compilation failure; a body that doesn’t parse at all still gets the compilation failure. Such a script would fail on its first run, so it is turned away at the edit instead. A PATCH that sends no body is not checked, so the description of a stored script that is still waiting for the codemod stays editable.

apply: true on this endpoint is the one write that can store a body with removed globals left in it: only the constructs it marked -- codemod: remain, and allow_markers: true is the explicit consent to store them. Saving that same body back through POST or PATCH is refused until the marked lines are rewritten by hand.