Lua Scripting
July 28, 2026 · View on GitHub
Rela includes an embedded Lua scripting runtime for programmable access to the entity graph. Scripts can query, mutate, analyze, and export data—enabling automation beyond what's possible with the CLI or MCP tools alone.
Quick Start
Via MCP (AI Assistants)
-- lua_eval: Execute inline Lua code
local tickets = rela.list_entities("ticket", "status=open")
rela.output({count = #tickets})
Via CLI
# Run a script file
rela lua scripts/report.lua
# Pass arguments
rela lua scripts/migrate.lua --from=v1 --to=v2
# Run an interactive flow (with user prompts)
rela flow scripts/create-ticket.lua
Shebang Support
Scripts can include a shebang line for direct execution from the command line:
#!/usr/bin/env -S rela script
local entities = rela.list_entities("ticket", "status=open")
rela.output({count = #entities})
chmod +x scripts/report.lua
./scripts/report.lua
The shebang line is automatically stripped before execution. Line numbers in error messages remain accurate.
Script Location
Scripts executed via lua_run must be in the scripts/ directory:
project/
├── scripts/
│ ├── report.lua
│ └── utils/
│ └── helpers.lua
├── entities/
└── metamodel.yaml
Interactive Flows
Interactive flows allow Lua scripts to present forms to users and receive input. The script suspends at each form, waits for user input, then continues execution. This enables multi-step wizards, guided entity creation, and interactive data collection.
Running a Flow
rela flow scripts/create-ticket.lua
Executable Scripts
Flow scripts can be made directly executable with a shebang:
#!/usr/bin/env -S rela flow
-- Your flow script here
local event = rela.flow.emit({
type = "form",
-- ...
})
Then run directly:
chmod +x scripts/create-ticket.lua
./scripts/create-ticket.lua
The emit() Function
Use rela.flow.emit() to present a form and wait for user response:
local event = rela.flow.emit({
type = "form",
title = "Create Ticket",
fields = {
{name = "title", type = "text", required = true},
{name = "priority", type = "select",
options = {{"high", "High"}, {"medium", "Medium"}, {"low", "Low"}}},
},
actions = {
{"submit", "Create"},
{"cancel", "Cancel"},
},
})
if event.action == "cancel" then
return
end
rela.create_entity("ticket", {
title = event.data.title,
priority = event.data.priority,
})
Form Specification
| Property | Type | Required | Description |
|---|---|---|---|
type | string | yes | Must be "form" |
title | string | no | Form title |
description | string | no | Help text shown below title |
fields | array | yes | Field definitions (see below) |
actions | array | yes | Action buttons (see below) |
Field Types
| Type | Options | Returns |
|---|---|---|
text | required, default, placeholder, lines | string |
select | options, required, default | selected value |
multi-select | options, required, default | array of values |
boolean | default | true/false |
number | required, default, min, max, step | number |
date | required, default, min, max | "YYYY-MM-DD" |
markdown | content, label | (display only, no data) |
Field Properties
| Property | Type | Description |
|---|---|---|
name | string | Field identifier (required, except for markdown) |
type | string | Field type (required) |
label | string | Display label (defaults to title-cased name) |
content | string | Markdown content (required for markdown fields) |
required | boolean | Whether field is required |
default | varies | Default value |
placeholder | string | Placeholder text (text fields only) |
lines | number | Number of lines for textarea (text fields only) |
options | array | Options for select/multi-select: {{"value", "Label"}, ...} |
min, max | number/string | Bounds for number or date fields |
step | number | Step increment for number fields |
Markdown Fields
Markdown fields display formatted text within a form. They don't collect user input—use them to provide instructions, context, or visual separators between input fields:
fields = {
{type = "markdown", content = "## Instructions\nPlease fill out the form below."},
{name = "title", type = "text", required = true},
{type = "markdown", content = "---\n*Additional options:*"},
{name = "priority", type = "select", options = {{"high", "High"}, {"low", "Low"}}},
{type = "markdown", label = "Note", content = "Fields marked with * are required."},
}
Markdown fields support:
content(required): The markdown text to displaylabel(optional): A title shown above the content
Actions
Actions are defined as tuples: {id, label} or {id, label, style}:
actions = {
{"submit", "Create"}, -- Default style
{"cancel", "Cancel", "warning"}, -- Warning style
}
Styles: primary, warning, danger
Event Response
When a form is submitted, emit() returns an event table:
{
action = "submit", -- The action ID the user chose
data = { -- Field values (for submit-like actions)
title = "My Ticket",
priority = "high",
},
}
Multi-Step Flows
Scripts can present multiple forms in sequence:
local data = {}
-- Step 1: Basic info
local e1 = rela.flow.emit({
type = "form",
title = "Step 1: Basic Info",
fields = {
{name = "title", type = "text", required = true},
{name = "kind", type = "select",
options = {{"bug", "Bug"}, {"feature", "Feature"}}},
},
actions = {{"next", "Next"}, {"cancel", "Cancel"}},
})
if e1.action == "cancel" then return end
data.title = e1.data.title
data.kind = e1.data.kind
-- Step 2: Details
local e2 = rela.flow.emit({
type = "form",
title = "Step 2: Details",
fields = {
{name = "description", type = "text", lines = 5},
{name = "priority", type = "select",
options = {{"high", "High"}, {"medium", "Medium"}, {"low", "Low"}}},
},
actions = {{"back", "Back"}, {"submit", "Create"}, {"cancel", "Cancel"}},
})
if e2.action == "cancel" then return end
if e2.action == "back" then
-- Handle back navigation (use goto or restructure as loop)
end
data.description = e2.data.description
data.priority = e2.data.priority
rela.create_entity("ticket", data)
Error Handling
Validation errors (invalid form spec) raise Lua errors. User cancellation is handled via actions, not errors:
-- Handle cancel action explicitly
if event.action == "cancel" then
print("User cancelled")
return
end
Transport errors (e.g., terminal not interactive) also raise Lua errors.
API Reference
Query Functions
| Function | Description | Returns |
|---|---|---|
rela.get_entity(id) | Get entity by ID | table or nil |
rela.list_entities(type, filter?) | List entities of a type | table (array) |
rela.search(query, limit?) | Full-text search | table (array) |
rela.get_relations(opts?) | Get relations with filters | table (array) |
rela.trace_from(id, depth?) | Trace outgoing dependencies | table (tree) |
rela.trace_to(id, depth?) | Trace incoming dependencies | table (tree) |
rela.find_path(from, to) | Find shortest path | table (array) or nil |
rela.get_relations takes an options table — {from = ..., type = ..., to = ...} — where each key is optional and must be a string. Omitting a
key means "no constraint on that field"; omitting the table entirely returns
every relation.
local rels = rela.get_relations({ from = e.id, type = "implements" })
A non-string option raises rather than being ignored, because silently dropping it would widen the query to the whole graph while the script reads the answer as filtered:
rela.get_relations({ from = 12345 }) -- error: option "from" must be a string
rela.get_relations(e.id) -- NOT a filter: a bare id is not a
-- table, so this returns EVERY relation
Mutation Functions
| Function | Description | Returns |
|---|---|---|
rela.create_entity(type, props, content?, id?) | Create entity | table, warnings? |
rela.update_entity(id, props, content?) | Update entity | table, warnings? |
rela.delete_entity(id, cascade?) | Delete entity | boolean |
rela.create_relation(from, type, to) | Create relation | table |
rela.delete_relation(from, type, to) | Delete relation | boolean |
rela.refresh() | Reload graph from disk | boolean |
Validation warnings (multi-return)
rela.create_entity and rela.update_entity return TWO values per
DEC-HWZHA: the
entity table (always present on success) and an optional warnings
table (nil when there are no warnings).
The contract follows string.gsub semantics — both returns can
be non-nil simultaneously, and the second is additional success
information, NOT an error indicator. This is the opposite of
io.open's (file, err) shape, where the two are mutually
exclusive.
-- Existing scripts (single return) keep working:
local e = rela.update_entity("TKT-001", {title = "x"})
print(e.id)
-- Read warnings when you care:
local e, warnings = rela.update_entity("TKT-001", {title = ""})
-- e.id is set; the entity was persisted with title cleared.
-- warnings is a table when validation surfaced soft conditions:
for _, w in ipairs(warnings or {}) do
print(w.code, w.path, w.detail)
-- e.g. required_property_unset /properties/title This field is required
end
Each warning is {code = string, path = string, detail = string}.
Warning codes match those in
docs/data-entry/api-reference.md.
Hard errors still raise. An entity with an unknown type or a
malformed ID prefix raises a Lua error — pcall catches it. Soft
validation conditions (missing required field, invalid enum value,
bad date) no longer raise; they show up in the warnings return so
the script can decide what to do.
-- Hard errors raise:
local ok, err = pcall(rela.update_entity, "BAD-PREFIX", {})
-- ok=false, err="entity not found: BAD-PREFIX"
-- Soft conditions don't raise:
local ok, e, warnings = pcall(rela.update_entity, "TKT-001", {title = ""})
-- ok=true, e is the entity, warnings is the validation findings.
Elevated access — rela.bypass_acl
By default an automation script's reads and writes are ACL-checked
against the triggering principal (the user whose action fired the
automation). That is correct for most automations. But some scripts enforce a
system invariant on behalf of a user who isn't authorized to act directly —
e.g. stamping submitter --created-by--> ticket on ticket create so the
submitter (and only they) can later read their own ticket, without the
submitter being able to write person relations themselves.
For those, rela.bypass_acl(fn) runs fn with a single argument admin: a
handle whose reads and writes skip the ACL. Elevation is scoped to the
closure and carried by the admin object — the ordinary rela.* bindings are
never elevated.
-- on ticket create: stamp the submitter as author, server-side, unforgeable
local me = rela.principal.user
rela.bypass_acl(function(admin)
admin.create_relation(me, "created-by", entity.id)
end)
-- back to normal (gated) authority here
admin exposes:
| Method | Purpose |
|---|---|
admin.create_relation(from, type, to) | Link, skipping the ACL deny |
admin.delete_relation(from, type, to) | Unlink, skipping the ACL deny |
admin.delete_entity(id, cascade?) | Remove, skipping the ACL deny |
admin.get_entity(id) | Read raw — full properties, no redaction |
admin.list_entities(type) | Every entity of type, ungated |
admin.get_relations(opts?) | Every matching edge, not peer-gated |
Elevated reads return raw data. admin.get_entity returns the entity with
all properties, including ones the triggering user cannot see; list_entities
returns rows the gated rela.list_entities would drop; get_relations returns
edges even when neither endpoint is visible to the caller. A half-elevated read
would be a confusing contract — the closure is the boundary.
Use them when an automation genuinely needs a whole-graph view, e.g. checking a uniqueness invariant across entities the submitter cannot see:
rela.bypass_acl(function(admin)
for _, t in ipairs(admin.list_entities("ticket")) do
if t.properties.external_ref == entity.properties.external_ref
and t.id ~= entity.id then
error("duplicate external_ref: " .. t.id)
end
end
end)
Note the two reads differ deliberately in what a nil means:
rela.get_entity returns nil for "missing or hidden" (it stays
oracle-free), while admin.get_entity returns nil only for a genuine
miss. A store failure (backend down, driver error) raises rather than
returning nil — otherwise the uniqueness check above would read a
transient outage as "no duplicate" and admit one, silently violating the
invariant it exists to enforce. All three admin read methods raise on
store errors.
Mistyped filter options raise too: admin.get_relations({from = 12345}) is
an error, not an unfiltered whole-graph dump. The gated rela.get_relations
applies the same rule, from the same code — the two cannot drift on what a
filter means.
Operator opt-in is required. rela.bypass_acl only exists when the
automation action sets allow_acl_bypass: true in metamodel.yaml (an
operator-only file). Without it the function is absent and a script cannot
elevate:
automations:
- name: stamp-ticket-author
on: { entity: [ticket], created: true }
do:
- lua_file: stamp-author.lua
allow_acl_bypass: true
Safety properties:
- Audited, not anonymous. Every elevated write records an
acl-bypassaudit row carrying the real triggering principal (not a system user) plusacl_bypass=true, so "who caused this elevated write" is always answerable. - Elevated reads are audited too. A closure that used any of the
adminread methods emits oneacl-bypass-readrow naming the principal, the automation, and which bindings were used. It is one row per closure, not per read — a singleadmin.list_entitiescan span the graph — and it deliberately records no subject and no entity data, since logging the read set would copy the very data the ACL protects into the audit log. The row is written even when the closure then raises, so failing is not a way to erase the evidence. Isolate them withop == "acl-bypass-read". - No leak into nested cascades. A cascade that an elevated write triggers runs with normal (gated) ACL authority — elevation does not propagate to descendant writes.
- Closure-scoped. After
fnreturns,adminis invalidated; stashing it in a global and calling it later raises — for reads as well as writes, so elevation cannot become a durable ungated handle. - Cannot forge identity.
rela.principalis read-only and the write attribution derives from the request context, never from the script. - Fails loudly, never silently degraded. If a deployment grants elevated
writes but no elevated read capability, the
adminread methods raise rather than falling back to the caller's gated view. A closure that looked elevated but silently returned a partial graph would be worse than one that refuses.
Schema Functions
| Function | Description | Returns |
|---|---|---|
rela.get_entity_types() | Get all entity type definitions | table |
rela.get_relation_types() | Get all relation type definitions | table |
Output Functions
| Function | Description |
|---|---|
rela.output(data) | Output data as JSON to stdout |
rela.write_file(path, content) | Write file to output/ directory |
Flow Functions
| Function | Description | Returns |
|---|---|---|
rela.flow.emit(screen) | Present form, wait for user input | event table |
See Interactive Flows for detailed documentation.
Utility Functions
| Function | Description | Returns |
|---|---|---|
rela.days_since(date) | Days between date and today | number (-1 if invalid) |
rela.sort_entities(list, prop, dir?) | Sort entities by property | sorted table |
Date Functions
| Function | Description | Returns |
|---|---|---|
rela.date_add(date, offset) | Add offset to date | date string |
rela.date_weekday(date) | Get weekday name | lowercase string |
rela.date_next_weekday(date, day) | Next occurrence of weekday after date | date string |
rela.rrule_next(rrule, after?) | Next RRULE occurrence (default: after today) | date string or nil |
date_add
Add a duration offset to a date. Offsets use Nd, Nw, Nm, Ny format. Negative values
are supported.
rela.date_add("2025-01-15", "7d") -- "2025-01-22"
rela.date_add("2025-01-15", "2w") -- "2025-01-29"
rela.date_add("2025-01-15", "1m") -- "2025-02-15"
rela.date_add("2025-01-15", "1y") -- "2026-01-15"
rela.date_add("2025-01-15", "-3d") -- "2025-01-12"
date_weekday
Returns the lowercase weekday name for a date.
rela.date_weekday("2025-01-06") -- "monday"
date_next_weekday
Returns the next occurrence of the given weekday strictly after the date. If the date is already that weekday, it advances to the following week.
rela.date_next_weekday("2025-01-06", "friday") -- "2025-01-10"
rela.date_next_weekday("2025-01-06", "monday") -- "2025-01-13" (not same day)
rrule_next
Computes the next occurrence of an iCal RRULE (RFC 5545) after a given date. If no after
date is provided, uses today. Returns nil if the rule has no more occurrences.
Accepts both RRULE:FREQ=... and bare FREQ=... formats.
Important: Rules with INTERVAL > 1 must include DTSTART to anchor the interval
cadence. Without it, the function raises an error.
-- Next Saturday
rela.rrule_next("FREQ=WEEKLY;BYDAY=SA;DTSTART=20250101T000000Z", "2025-01-06")
-- "2025-01-11"
-- 1st of each month
rela.rrule_next("FREQ=MONTHLY;BYMONTHDAY=1;DTSTART=20250101T000000Z", "2025-01-15")
-- "2025-02-01"
-- Last day of each month
rela.rrule_next("FREQ=MONTHLY;BYMONTHDAY=-1;DTSTART=20250101T000000Z", "2025-01-15")
-- "2025-01-31"
-- Every 2 weeks (INTERVAL > 1 requires DTSTART)
rela.rrule_next("FREQ=WEEKLY;INTERVAL=2;DTSTART=20250106T000000Z", "2025-01-06")
-- "2025-01-20"
-- 1st Saturday every 3 months (INTERVAL > 1 requires DTSTART)
rela.rrule_next("FREQ=MONTHLY;INTERVAL=3;BYDAY=1SA;DTSTART=20250101T000000Z", "2025-01-06")
-- "2025-04-05"
-- Uses today if no after date
rela.rrule_next("FREQ=DAILY") -- tomorrow's date
AI Functions
The ai module provides access to LLM providers (OpenAI, ollama, etc.) configured
via .rela/ai.yaml. These functions are available when AI is configured; otherwise
they return a not_configured error.
| Function | Description | Returns |
|---|---|---|
ai.chat(opts) | Chat completion | (result_table, nil) or (nil, err_table) |
ai.complete(prompt) | Single-message chat (convenience) | (string, nil) or (nil, err_table) |
ai.embed(input, opts?) | Compute vector embeddings | (array_of_arrays, nil) or (nil, err_table) |
Configuration
Create .rela/ai.yaml (gitignored, per-user):
base_url: http://127.0.0.1:11434/v1 # required
model: gemma3:12b # required, default chat model
embedding_model: nomic-embed-text # optional, falls back to model
api_key_env: OPENAI_API_KEY # optional, absent = no auth
timeout_seconds: 60 # optional, default 30
ai.chat
Send a chat completion request with full control over messages, model, and parameters.
local result, err = ai.chat({
messages = {
{role = "system", content = "You are concise."},
{role = "user", content = "What is 2+2?"},
},
model = "gemma3:12b", -- optional, falls back to config
temperature = 0, -- optional; 0 is distinct from unset
max_tokens = 50, -- optional
})
if err then
print("Error: " .. err.kind .. ": " .. err.message)
else
print(result.content)
-- result also has: model, finish_reason, usage (sub-table)
end
ai.complete
Convenience wrapper: sends a single user message and returns just the content string.
local text, err = ai.complete("Summarize: " .. entity.content)
ai.embed
Compute vector embeddings for one or more texts. Always returns an array of arrays (one vector per input), even for a single string input.
-- Single text
local vecs, err = ai.embed("hello world")
-- vecs[1] is the embedding vector (array of numbers)
-- Batch (one HTTP call for many texts, more efficient)
local vecs, err = ai.embed({"first", "second", "third"})
-- vecs[1], vecs[2], vecs[3] are the embedding vectors
-- Model override
local vecs, err = ai.embed("text", {model = "nomic-embed-text"})
Limits: Batch input is capped at 2048 texts. Empty strings and empty tables raise a programming error.
Error Handling
AI functions return (nil, err_table) for expected runtime failures (network errors,
rate limits, missing config) instead of raising. This is a deliberate deviation from
other rela bindings — AI calls are network-bound and scripts should handle failure inline.
Programming errors (wrong argument types, empty messages) still raise via error().
The error table has stable fields for branching:
| Field | Type | Description |
|---|---|---|
kind | string | Error category (see below) |
status | number | HTTP status code (0 for non-HTTP errors) |
message | string | Human-readable description |
retry_after | number | Seconds to wait (for rate limits) |
details | string | Underlying transport error (if any) |
err.kind | When |
|---|---|
not_configured | No .rela/ai.yaml or it failed to load |
auth | API key missing/invalid; HTTP 401/403 |
bad_request | HTTP 400/4xx; unknown model |
rate_limited | HTTP 429; check err.retry_after |
server_error | HTTP 5xx |
timeout | Request exceeded deadline |
network | DNS, connection refused, TLS |
bad_response | Non-JSON, malformed response |
local result, err = ai.chat({messages = {{role="user", content="hi"}}})
if err then
if err.kind == "rate_limited" then
print("Rate limited, retry after " .. err.retry_after .. "s")
elseif err.kind == "not_configured" then
print("AI not configured — create .rela/ai.yaml")
else
print("AI error: " .. err.message)
end
end
HTTP Functions
The http module provides an HTTP client for calling external APIs. No
configuration is needed — unlike ai.*, the module works out of the box.
| Function | Description | Returns |
|---|---|---|
http.request(opts) | Full request: url/method/headers/body/timeout | (response, nil) or (nil, err_table) |
http.get(url, opts?) | GET convenience | (response, nil) or (nil, err_table) |
http.post(url, body, opts?) | POST convenience | (response, nil) or (nil, err_table) |
http.put(url, body, opts?) | PUT convenience | (response, nil) or (nil, err_table) |
http.patch(url, body, opts?) | PATCH convenience | (response, nil) or (nil, err_table) |
http.delete(url, opts?) | DELETE convenience | (response, nil) or (nil, err_table) |
For request/response bodies, use rela.json.encode / rela.json.decode —
see JSON Functions.
http.request
local resp, err = http.request({
url = "https://api.example.com/data",
method = "POST", -- optional, default GET
headers = {["Content-Type"] = "application/json"},
body = rela.json.encode({key = "value"}),
timeout = 10, -- optional, seconds
})
Response Shape
resp is a table:
| Field | Type | Description |
|---|---|---|
status_code | number | HTTP status (200, 404, etc.) |
status | string | Status line ("200 OK") |
headers | table | Lowercase keys, first-value-wins for multi-value headers |
body | string | Response body (capped at 10 MiB) |
Convenience Methods
local resp, err = http.get("https://api.example.com/users")
local resp, err = http.post(
"https://api.example.com/items",
rela.json.encode({name = "new item"}),
{headers = {["Content-Type"] = "application/json"}}
)
Error Handling
HTTP functions follow the same (nil, err_table) convention as ai.chat.
The error table mirrors ai.chat's shape (minus status, which always
absent on http errors — non-2xx responses come back as a normal response
table with status_code populated):
| Field | Type | Notes |
|---|---|---|
kind | string | One of timeout, canceled, network, bad_response |
message | string | Human-readable summary |
retry_after | number | Always 0 for HTTP errors (populated by ai on rate limits) |
details | string | Unwrapped transport error (TLS cert, DNS record, etc.) when present |
err.kind | When |
|---|---|
timeout | Request or body read exceeded the deadline |
canceled | Request was canceled (runtime shutting down) |
network | DNS failure, connection refused, TLS error, generic body read error |
bad_response | Response body exceeded the 10 MiB cap |
Programming errors (missing URL, URL with userinfo, wrong argument types, invalid HTTP method, non-positive timeout) raise Lua errors.
Redirects
HTTP redirects are not followed automatically. The 3xx response is
returned directly so scripts can inspect the Location header and
follow redirects explicitly if needed.
Security
http.* is a new threat surface: a Lua script can make arbitrary
outbound requests using the user's machine. There is no SSRF protection
(the localhost / private-IP range is reachable). URLs containing
embedded credentials (http://user:pass@host/) are rejected — set the
Authorization header explicitly. Treat Lua scripts as trusted code.
JSON Functions
The rela.json module provides JSON encode/decode helpers, usable in any
script (HTTP request bodies, flow scripts, document renderers, ad-hoc
transforms).
| Function | Description | Returns |
|---|---|---|
rela.json.encode(value) | Lua value → JSON string | string (raises on bad input) |
rela.json.decode(string) | JSON string → Lua value | (value, nil) or (nil, err_table) |
local json_str = rela.json.encode({name = "test", items = {1, 2, 3}})
local data, err = rela.json.decode('{"name":"test","count":42}')
Array vs object encoding: Tables with only consecutive integer keys starting at 1 encode as JSON arrays. Tables with any string keys encode as JSON objects.
rela.json.decode returns (nil, err_table) with kind="bad_response"
for invalid JSON — the same shape http.* produces — so existing
error-handling branches work unchanged.
JSON Conversion Limits
- Empty tables encode as objects (
{}), not arrays ([]). Lua has no way to mark an empty table as array-shaped. If you need a JSON empty array, build the body server-side or include a placeholder element. - Cyclic tables are encoded with the offending branch replaced by the
string
"<cyclic reference>"rather than crashing the runtime. - Decoded JSON deeper than 64 levels is truncated with the sentinel
string
"<max-depth>"to prevent stack overflow from a hostile response.
Context
| Variable | Description |
|---|---|
rela.project_root | Absolute path to project root |
rela.args | Script arguments (table) |
rela.today | Current date as "YYYY-MM-DD" |
rela.params | Action script parameters (table, static string-valued map from data-entry config) |
rela.secrets | Per-script secrets (table, from .rela/secrets.yaml) |
rela.principal | Read-only {user, tool} — the identity this runtime runs as (the request principal, e.g. from X-Rela-User). Use it in write-path automations to attribute relations to the acting user, e.g. stamping a created-by edge from the submitter. Unstamped/CLI contexts read {user="unknown", tool="unknown"}. The table is frozen: assigning to it raises. It only reads identity — audit attribution is always derived from the request context inside the write bindings, never from this table, so it is not a spoofing vector. |
rela.cache | Process-wide memoization cache namespaced per script (see Cache) |
rela.mode | Set to "document" when rendering a data-entry document; nil otherwise (see Document Mode) |
rela.document | Present only in document mode; holds {id, entry_id} (see Document Mode) |
Document Mode
When a data-entry document is configured with script: (see
Documents in the data-entry guide), the
script runs in a specialized mode with extra context exposed:
| Variable | Meaning |
|---|---|
rela.mode | "document" in this context; absent elsewhere |
rela.document.id | The key under documents: in data-entry.yaml |
rela.document.entry_id | The ID of the entity being rendered |
List renders (a lists.<id>.export_render view export, see
Transforms) run in the
same document mode — rela.mode is "document" there too — with the row set
added and entry_id absent, since a list has no entry entity:
| Variable | Meaning |
|---|---|
rela.document.list_id | The key under lists: being exported |
rela.document.entity_type | The list's entity type |
rela.document.rows() | Iterator over the resolved rows |
rela.document.row(i) | One row by 1-based index, or nil |
rela.document.count | Rows this render can see |
rela.document.total | Rows before the export cap |
rela.document.truncated | Whether the cap applied |
rela.document.query | Read-only {q, filters, sort} |
Branch on rela.document.list_id (or rows) to tell the two apart. The rows
are handed in already resolved and gated; don't re-derive them with
rela.list_entities, or the export stops matching the list the user was
looking at.
The script's stdout is captured and used as the rendered document's
markdown (which is then converted to HTML). Use print() to produce
output.
-- scripts/docs/release_notes.lua
local entry = rela.get_entity(rela.document.entry_id)
print("# " .. (entry.properties.title or entry.id))
rela.output in document mode emits a warning line into the
captured stdout (and thus into the rendered document) instead of
writing JSON — captured stdout is the document body, so a raw JSON
payload in the middle of markdown is almost always a mistake.
Cache policy: rela.cache.memoize is namespaced by the script path,
not by the document config. Two documents: entries that share one
.lua file share a cache namespace — usually what you want (a helper
script caches work across all its callers). If you need doc-scoped
keys, include rela.document.id in your cache key explicitly.
Secrets
Scripts can access secrets (API keys, tokens, passwords) via the rela.secrets table.
Secrets are loaded from .rela/secrets.yaml, which lives inside the gitignored .rela/
directory.
Configuration
Create .rela/secrets.yaml in your project:
# Global secrets — available to all scripts
jira_api_key: sk-abc123
deploy_endpoint: https://deploy.example.com
# Per-script overrides (merged on top of global)
overrides:
reports/sync.lua:
jira_api_key: sk-different-key
extra_token: tok-xyz
- Top-level keys are global and available to every script
- Keys under
overrides.<script-path>apply only to that script and override globals - All values are plain strings
Usage in Lua
-- Access a secret
local key = rela.secrets.api_key
-- Check if a secret is configured
if rela.secrets.api_key then
-- use it
else
error("api_key not configured in .rela/secrets.yaml")
end
Resolution order
When a script runs, its rela.secrets table is built by:
- Starting with all global (top-level) values
- Merging any per-script overrides on top
If .rela/secrets.yaml does not exist, rela.secrets is an empty table (no error).
Security notes
.rela/is gitignored by convention — secrets are not committed to version control- Treat Lua scripts as trusted code: any script can read all secrets available to it
- For shared projects, each contributor maintains their own
.rela/secrets.yaml
Cache
Scripts can memoize expensive computations (AI calls, graph traversals, property
lookups) via the rela.cache module. The cache is in-memory, process-wide, and
namespaced per script path so two scripts that happen to use the same key
do not collide.
API
-- Fetch a cached value, or nil on miss/expiry.
local v = rela.cache.get("my-key")
-- Store a value. nil deletes the entry. ttl defaults to 1 hour.
rela.cache.set("my-key", value, {ttl = 3600})
-- Get-or-compute: on miss, call fn(), store and return all its
-- return values; on hit, return the cached values without calling fn.
local result = rela.cache.memoize("expensive-" .. entity.id, function()
return do_expensive_work(entity)
end, {ttl = 86400})
Options
| Option | Applies to | Default | Meaning |
|---|---|---|---|
ttl | set, memoize | 3600 (1h) | Seconds until expiry. 0 or negative = no expiry |
bypass | memoize only | false | Skip the read, always call fn and write |
get takes no options. Unknown option keys raise a Lua error, so typos like
{refersh = true} surface loudly instead of being silently dropped.
Multi-return values
memoize's fn may return multiple values; rela.cache stores and re-emits
all of them. This matches the (result, err) convention used by ai.chat:
local resp, err = rela.cache.memoize("summary:" .. id, function()
return ai.complete("Summarize: " .. content)
end)
if err then error(err.message) end
Caveat: nested set on the same key
If fn inside memoize("k", fn) itself writes to the same key via
rela.cache.set("k", ...), that write is silently overwritten when
memoize stores fn's return values. Use a different key for
side-channel writes:
rela.cache.memoize("result:" .. id, function()
local aux = compute_aux()
rela.cache.set("result:" .. id .. ":aux", aux) -- different key; survives
return compute_main(aux)
end)
Limits
- Keys are Lua strings, ≤ 512 bytes
- Values must be plain data (strings, numbers, booleans, nested tables);
functions, userdata, and coroutines are rejected at
settime with an error naming the offending type - The cache is capped globally at 10,000 entries across all namespaces; when the cap is reached, the least-recently-accessed entry is evicted on the next write
Scope
- The cache is in-memory and shared across all Lua runtimes within a single
relaprocess. Scheduled tasks running in the samerela schedulerprocess reuse cache state across their repeat invocations; HTTP action scripts on the samerela-servershare state across requests - Each new
relainvocation starts with an empty cache — nothing is persisted to disk. A one-shotrela scriptrun gets no cross-invocation reuse rela.cache.*is not available in inline/eval contexts (rela mcp'slua_eval) — calling it raisescache: not available in inline/eval contexts. This prevents accidental cross-session bleed where two sessions would share a nameless namespace
Numeric precision
Lua numbers are 64-bit floats. Integer IDs larger than cannot round-trip through any Lua value — this is a Lua limitation, not a cache one, but worth keeping in mind if you cache things keyed by raw integer IDs.
Markdown AST
The rela.md module exposes a markdown AST API for parsing and rendering
content. The AST is structure-preserving: links, code spans, raw HTML,
images, autolinks, line breaks, multi-paragraph blockquotes, and
multi-block list items all keep their structure on the Lua side.
Flattening to plain strings happens at render time and in helper APIs
(headers, first_paragraph, the public flatten), never at parse
time.
Use rela.md.parse(content) to turn a markdown string into an AST table
and rela.md.render(ast) to serialize it back. The pair is a fixed point
on a representative corpus of GFM input.
Block node shapes
-- Inline-bearing leaves carry an `inlines` array.
{ type = "paragraph", inlines = { ... } }
{ type = "heading", level = 2, inlines = { ... } }
-- Block containers carry `children` (an array of block nodes).
{ type = "blockquote", children = { ... } }
-- Lists hold items; each item is one of:
-- * a string (a plain item containing only text)
-- * a table with `inlines` (single-paragraph item with formatting)
-- * a table with `children` (multi-block item; e.g. an item
-- containing a fenced code block or nested list)
-- * a task table with `task=true`, `checked=<bool>`, and either
-- `inlines` or `children` per the same rules above
{ type = "list", ordered = false, items = { ... } }
-- Tables: each cell is an `inlines` array (link/code/raw HTML inside
-- a cell round-trips correctly).
{ type = "table",
header = { cell, cell, ... }, -- each cell is an inlines array
rows = { { cell, cell }, ... },
alignments = { "left", "center", "right" } }
-- Leaves that don't carry inline content keep a `content: string`.
{ type = "code_block", language = "go", content = "fmt.Println()" }
{ type = "thematic_break" }
{ type = "raw", content = "<unknown block>" }
Inline node shapes
-- Leaves with a `text` field:
{ type = "text", text = "hello" }
{ type = "code_span", text = "x = 1" }
{ type = "raw_html", text = "<br>" }
{ type = "soft_break" } -- a soft line break in the source
{ type = "hard_break" } -- two-trailing-space line break
-- Autolinks have a `url` (no inner text):
{ type = "autolink", url = "https://example.com" }
-- Containers wrap further inlines:
{ type = "emphasis", inlines = { ... } } -- *text*
{ type = "strong", inlines = { ... } } -- **text**
{ type = "strikethrough",inlines = { ... } } -- ~~text~~
{ type = "link", url = "/x", title = nil, inlines = { ... } }
-- Images store alt content as a separate inlines array (preserves
-- formatting inside ``):
{ type = "image", url, title = nil, alt_inlines = { ... } }
Constructors
Block constructors accept either a string (auto-wrapped to a single text leaf) or an inlines/children table:
rela.md.paragraph("hello")
rela.md.paragraph({rela.md.text("see "), rela.md.link_inline("docs", "/x")})
rela.md.heading(2, "Section")
rela.md.heading(2, {rela.md.text("Section "), rela.md.code_span("X")})
rela.md.blockquote("a single paragraph")
rela.md.blockquote({rela.md.paragraph("p1"), rela.md.paragraph("p2")})
Inline constructors:
rela.md.text(s) -- {type="text", text=s}
rela.md.code_span(s) -- {type="code_span", text=s}
rela.md.raw_html(s) -- {type="raw_html", text=s}
rela.md.link_inline(text_or_inlines, url, title?)
-- {type="link", url, [title], inlines={...}}
rela.md.link_inline is distinct from rela.md.link(text, url) → string,
which predates the inline AST and is still available for scripts that
build markdown by string concatenation.
Flattening to text
When you want a plain string instead of an inline tree, call
rela.md.flatten(inlines). It applies a conservative policy:
- Strikethrough (
~~...~~) — preserved as~~text~~. The checklist-validation layer treats strikethrough as the "skip" marker. - Code spans (
`...`) — preserved with their backticks. - Raw HTML — preserved verbatim.
- Autolinks — render as the URL.
- Soft and hard breaks — render as a single space (matching the pre-refactor flat-text behaviour).
- Emphasis, strong, link — wrapper dropped, only the inner content
is kept (so
[See docs](url)flattens toSee docs). - Images — render as the flattened alt content (no URL).
rela.md.headers(ast) and rela.md.first_paragraph(ast) use this
policy automatically, so existing scripts that read header.title or
the first-paragraph string see no behaviour change for typical content.
Worked example: reading and mutating a checklist
local ticket = rela.get_entity("TKT-001")
local ast = rela.md.parse(ticket.content)
for _, node in ipairs(ast) do
if node.type == "list" then
for _, item in ipairs(node.items) do
if type(item) == "table" and item.task then
local label = rela.md.flatten(item.inlines)
if label == "implement" then
item.checked = true
end
end
end
end
end
rela.update_entity("TKT-001", {}, rela.md.render(ast))
Limitations
- Mixed lists. A list mixing task and plain items may not be
symmetrically re-parseable: only items that carry their own
checkbox in the source are classified as task items on parse, but
the renderer always emits checkbox syntax for items with
task = true. - Sparse item tables. Scripts that delete items via
items[i] = nilwill get a compact rendering (the renderer skipsnilholes), but they should compact the table explicitly if they rely on#itemsafterwards — Lua's length operator returns a "border", not a count. - Performance. Parsing now allocates a Lua table per inline node. On a kitchen-sink fixture this is ~2.5–3× the allocations of the pre-refactor flat-string representation. For real-world content (mostly plain prose) the gap is smaller. If a hot-path script feels slow, consider parsing once and reusing the AST.
Resolving Entity-ID References
When a Lua script composes a document from one or more entities, the body text often mentions other entities by ID. Two helpers turn those mentions into proper markdown links:
rela.md.resolve_refs(ast, replacements) → ast— walks the AST and rewrites entity-ID code spans to caller-supplied markdown.rela.md.entity_refs(opts?) → table— builds thereplacementsmap for the common case of "link every entity (or every entity of these types)".
The matching rule: code spans only
The matcher targets code spans whose entire content is an entity
ID. A code span is the GFM inline form `TKT-1` (backticks). Bare
prose mentions like see TKT-1 here are left untouched — the
ambiguous-boundary problem in prose is hard, and authors already use
backticks to flag identifiers in entity bodies. Opt in to a link by
writing the ID as a code span; leave the backticks off and it stays
text.
This rule means:
see `TKT-1` here→see [Title](#anchor) heresee TKT-1 here→ unchanged (no opt-in)```fenced\nTKT-1\n```→ unchanged (code blocks are skipped)see [`TKT-1`](url)→ the code span inside the link is rewritten; the surrounding link wrapper stayssee [TKT-1](url)→ unchanged (only code spans are matched)`TKT-99`(not in map) → unchanged
rela.md.entity_refs(opts?)
Returns a {[id] = "<markdown>"} table covering some or all entities
in the project. Pass the result to resolve_refs.
Options (all optional):
types: list of entity-type names to include. Unknown names raise an error. Default: every entity type in the metamodel.style:"title-slug"(default) or"id". Controls the anchor in the default replacement value."title-slug"produces[Title](#title-slug)where the slug is the title lowercased (Unicode-aware), with non-alphanumeric runs collapsed to-and trailing-trimmed. Matches the auto-heading-id rules used by Pandoc, goldmark's auto-heading-id extension, and markdown-it-anchor."id"produces[Title](#tkt-123)(lowercased ID). For docs that emit their own ID-based anchors.
format:function(entity) → string. Overridesstyleentirely. The callback runs once per entity and must return a string with no newlines. The entity table has the usual fields plus a top-leveltitleconvenience.
Default-form replacement values escape [, ], and \ in entity
titles so a malicious or unusual title cannot break out of the
link-text slot. Custom format callbacks remain responsible for their
own escaping.
-- Common case: link every entity-ID code span to its title-slug anchor.
local refs = rela.md.entity_refs()
local ast = rela.md.parse(entity.content)
ast = rela.md.resolve_refs(ast, refs)
print(rela.md.render(ast))
-- Restrict to tickets and bugs, ID-based anchors:
local refs = rela.md.entity_refs({
types = {"ticket", "bug"},
style = "id",
})
-- Fully custom format:
local refs = rela.md.entity_refs({
format = function(e)
return "[" .. e.title .. " (" .. e.id .. ")](/x/" .. e.id .. ")"
end,
})
rela.md.resolve_refs(ast, replacements)
Walks a deep-copied AST and rewrites code-span entity-ID tokens in block text using the provided map. Returns the new AST; the input is not modified.
The replacements argument is a {[id] = "<markdown>"} table:
- Keys are exact code-span content. Values are inline markdown
fragments to splice in. The fragments are parsed as inline content,
so
[Title](url)becomes a structuredlinkinline (not opaque text) — link URLs survive a subsequentrenderround-trip cleanly. - Values must not contain
\nor\r(would break paragraph structure). - Hand-built tables can carry any string keys — only the ones whose content matches a code span will rewrite.
What the walker visits and what it skips:
| inline kind | behavior |
|---|---|
code_span | matched against keys; replaced if a hit |
link, emphasis, strong, strikethrough | recurse into inlines |
image | recurse into alt_inlines |
text, raw_html, autolink, breaks | left untouched |
inside code_block or raw block | left untouched (parser doesn't emit code-span inlines there) |
Entity Structure
Entities returned by query functions have this structure:
{
id = "TKT-001",
type = "ticket",
content = "Markdown body...",
mod_time = "2024-01-15T10:30:00Z", -- RFC3339 timestamp
properties = {
title = "Fix login bug",
status = "open",
priority = "high"
}
}
Entities also have helper methods:
entity:prop(name, default)- Get property with fallback defaultentity:strip_prefix()- Get ID without type prefix (e.g., "001" from "TKT-001")
Filter Expressions
The list_entities function accepts filter expressions:
-- Single value
rela.list_entities("ticket", "status=open")
-- Multiple values (OR)
rela.list_entities("ticket", "status=open,in-progress")
-- Not equal
rela.list_entities("ticket", "priority!=low")
Examples
Generate a Report
-- Export open tickets as markdown
local tickets = rela.list_entities("ticket", "status=open")
local lines = {"# Open Tickets", ""}
for _, t in ipairs(tickets) do
table.insert(lines, string.format("- **%s**: %s", t.id, t.properties.title))
end
rela.write_file("open-tickets.md", table.concat(lines, "\n"))
rela.output({exported = #tickets})
Bulk Update
-- Close all tickets in a completed sprint
local sprint = rela.args[1] or "2024-Q1"
local tickets = rela.list_entities("ticket", "sprint=" .. sprint)
local updated = 0
for _, t in ipairs(tickets) do
if t.properties.status ~= "done" then
rela.update_entity(t.id, {status = "cancelled"})
updated = updated + 1
end
end
rela.output({sprint = sprint, cancelled = updated})
Find Orphans
-- Find entities with no relations
local orphans = {}
local types = rela.get_entity_types()
for name, _ in pairs(types) do
local entities = rela.list_entities(name)
for _, e in ipairs(entities) do
local from = rela.trace_from(e.id, 1)
local to = rela.trace_to(e.id, 1)
if #from.children == 0 and #to.children == 0 then
table.insert(orphans, {
id = e.id,
type = e.type,
title = e.properties.title
})
end
end
end
rela.output(orphans)
Create Related Entities
-- Create test cases from feature acceptance criteria
local feature_id = rela.args[1]
local feature = rela.get_entity(feature_id)
if not feature then
error("Feature not found: " .. feature_id)
end
local created = {}
for line in feature.content:gmatch("[^\n]+") do
if line:match("^%s*[-*]") then
local criterion = line:gsub("^%s*[-*]%s*", "")
local test = rela.create_entity("test-case", {
title = "Verify: " .. criterion,
status = "pending"
})
rela.create_relation(test.id, "verifies", feature_id)
table.insert(created, test.id)
end
end
rela.output({feature = feature_id, tests_created = created})
Traceability Matrix
-- Generate requirement-to-test coverage matrix
local reqs = rela.list_entities("requirement")
local matrix = {}
for _, req in ipairs(reqs) do
local entry = {
id = req.id,
title = req.properties.title,
tests = {},
covered = false
}
local trace = rela.trace_from(req.id, 2)
for _, child in ipairs(trace.children) do
if child.type == "test-case" then
table.insert(entry.tests, child.id)
end
end
entry.covered = #entry.tests > 0
table.insert(matrix, entry)
end
-- Summary
local total = #matrix
local covered = 0
for _, entry in ipairs(matrix) do
if entry.covered then covered = covered + 1 end
end
rela.output({
matrix = matrix,
summary = {
total = total,
covered = covered,
uncovered = total - covered,
coverage_pct = math.floor(covered / total * 100)
}
})
Security
The Lua runtime is sandboxed for security:
What a script can read (access control)
When a project has an acl.yaml, read bindings return the acting
identity's view, not the whole graph. rela.get_entity,
rela.list_entities, rela.search, rela.get_relations,
rela.trace_from/trace_to/find_path and rela.md.entity_refs all
resolve against whoever the script is running as:
| Where the script runs | Reads as |
|---|---|
Data-entry action, export_render, MCP-invoked | the requesting user |
| Automation / cascade | the user whose write triggered it |
| Scheduled task | the task's identity (see run_as in Scheduled Tasks) |
| CLI, docs build | unrestricted (operator trust boundary) |
Consequences worth knowing when writing scripts:
- An entity the identity cannot read is simply absent —
get_entityreturnsnil, list/search omit it. That is indistinguishable from "does not exist", by design. get_relationsis peer-gated: a relation appears only when both endpoints are visible. An empty result means "none you may see", not "no such edges".- Traversals prune hidden nodes together with everything beneath them, and withhold a path that would run through one.
rela.update_entityreads the unredacted entity before writing, so updating one property never erases others the caller cannot see.- Without an
acl.yaml, everything behaves exactly as it always has.
A script that genuinely needs to read beyond its caller's view has no
implicit escape hatch — that is deliberate. The sanctioned mechanism is the
write-side rela.bypass_acl closure, whose read counterpart is tracked
separately.
Available Libraries
string- String manipulationtable- Table operationsmath- Mathematical functionscoroutine- Coroutine support- Base functions:
print,pairs,ipairs,type,tostring,tonumber,error,pcall,assert,select,next,unpack
Unavailable (Security)
io- No direct file system accessos- No system commandsdebug- No runtime introspectionload,loadfile,dofile,loadstring- No dynamic code loadingrawget,rawset,getmetatable,setmetatable- No metatable manipulation
AI / Network Access
When .rela/ai.yaml is configured, scripts can make HTTP requests to the configured
AI provider via ai.chat, ai.complete, and ai.embed. This means scripts can send
entity content to an external service.
The http.* module additionally lets scripts make arbitrary HTTP(S)
requests to any reachable URL. There is no SSRF filter — localhost and
private-network IPs are reachable. Treat Lua scripts as trusted code —
don't run scripts from untrusted sources.
API keys are never logged or included in error messages.
File Writing
Files can only be written to the output/ directory:
-- OK: writes to output/report.txt
rela.write_file("report.txt", "content")
-- OK: writes to output/reports/2024/summary.md
rela.write_file("reports/2024/summary.md", "content")
-- ERROR: path traversal blocked
rela.write_file("../secret.txt", "content")
-- ERROR: absolute paths blocked
rela.write_file("/etc/passwd", "content")
MCP Tools
Lua scripting is available via MCP tools:
| Tool | Description |
|---|---|
lua_eval | Execute inline Lua code |
lua_run | Execute a script from scripts/ |
lua_list | List available scripts |
lua_eval
Execute inline Lua code:
lua_eval(code: "rela.output(rela.list_entities('ticket'))")
lua_run
Execute a script file with arguments:
lua_run(path: "report.lua", args: ["2024-Q1"])
lua_list
List available scripts in scripts/ directory.
Validation Rules
Lua can be used in metamodel validation rules for complex business logic. See the Lua Validation section in the Metamodel Reference for details.
validations:
- name: check-coverage
description: "Components need 80% test coverage"
entity_type: component
lua: |
local cov = entity.properties.coverage
if cov == nil then return nil end
local value = tonumber(cov)
if value and value < 80 then
return { message = "Coverage is " .. value .. "%, need 80%" }
end
return nil
severity: error
Key differences from script execution:
| Feature | Scripts (lua_eval/lua_run) | Validation Rules |
|---|---|---|
| Workspace access | Full read/write | Read-only |
| Timeout | None | 5 seconds |
| Output | rela.output() | Return nil (pass) or {message=...} (violation) |
| File I/O | rela.write_file() | Not available |