OpenApp Scripting
OpenApp Scripting is OpenApp’s automation language. Technically it is a domain-specific language (DSL): a small Rhai-embedded language with OpenApp built-ins for creating, reading, updating, listing, and deleting platform resources (users, orgs, integrations, zones, devices, entities, and access resources). Scripts run with the permissions of the caller and are commonly used for provisioning, testing, and automation. User-facing surfaces use the name Scripting — for example openapp scripting, HTTP POST /scripting/executions on the /api/v1 API base, and client.scripting in SDKs. Over HTTP an execution is an asynchronous job: submitting returns a ScriptingExecution that you poll until it reaches a terminal status — see SDK — Scripting for CreateScriptingExecutionRequest / ScriptingExecution and multi-language examples.
Running scripts
Section titled “Running scripts”Execute a script from a file (.openapp is the conventional extension). The CLI submits an execution, polls it until it finishes, and prints the result:
openapp scripting --file script.openappWith template expansion (see Template expansion):
openapp scripting --file script.openapp --from-env-file .envopenapp scripting --file script.openapp --from-secret my-secret-idQueue the execution and return immediately, printing only its id:
openapp scripting --file script.openapp --no-waitList your recent executions (newest first; the server default is 20, maximum 100) and cancel one:
openapp scripting listopenapp scripting list --limit 50openapp scripting cancel 01J9ZC4Q5W3X0M7E2R8T6Y1V9Kopenapp scripting needs either --file or one of the list / cancel subcommands. Use the global --json flag to print machine-readable output: the script result, the execution object with --no-wait, the execution array for list, or { "id": …, "canceled": true } for cancel. A failed or canceled execution makes the CLI exit non-zero with the server’s error. cancel reports separately when the execution has already finished (409) or is unknown to you (404).
Interactive shell
Section titled “Interactive shell”Open a REPL to run script snippets:
openapp shellAt the openapp(scripting)> prompt you can type expressions and multi-line scripts. Each submission runs as its own execution, and its result is printed when it finishes.
Same wire contract as SDK — Scripting: authenticated POST /scripting/executions with CreateScriptingExecutionRequest — for example:
{ "script": "let x = create(\"org\", #{ name: #{ en: \"My Org\" } }); x" }202 Accepted returns a ScriptingExecution — the script has been queued, not finished. Read id, poll GET /scripting/executions/{id} until status is terminal, then read result:
export OPENAPP_API_BASE='https://openapp.house/api/v1'export OPENAPP_API_KEY='https://openapp.house_openapp_YOUR_SECRET'
# 1. Submit — 202 Accepted with the job object.EXECUTION_ID=$(curl -sS -X POST \ -H "X-API-Key: ${OPENAPP_API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "script": "let x = create(\"org\", #{ name: #{ en: \"My Org\" } }); x" }' \ "${OPENAPP_API_BASE}/scripting/executions" | jq -r '.id')
# 2. Poll until terminal. Bounded: 200 attempts x 5s covers the 15-minute runtime cap.attempt=0while [ "$attempt" -lt 200 ]; do JOB=$(curl -sS -H "X-API-Key: ${OPENAPP_API_KEY}" \ "${OPENAPP_API_BASE}/scripting/executions/${EXECUTION_ID}") case "$(printf '%s' "$JOB" | jq -r '.status')" in succeeded|failed|canceled) break ;; esac attempt=$((attempt + 1)) sleep 5done
# 3. Read the outcome.printf '%s' "$JOB" | jq '{ status, result, error }'The API key must be unscoped: a scoped API key, voice key, or agent credential gets 403 on every scripting route (see API keys).
There is no separate “execute file” route, and no SDK ships a filesystem helper — read UTF-8 source from disk and send it as script. include, load_json, load_toml, and {{KEY}} expansion are CLI features; send the expanded source when you call the API directly. Python’s execute submits the program and polls the execution for you, returning the result; use create_execution plus GET /scripting/executions/{id} when you want the job itself.
Why executions are asynchronous
Section titled “Why executions are asynchronous”The API gateway terminates any request that runs longer than 120 seconds. Provisioning programs routinely exceed that — many chained create calls, integration round-trips, and sleep waits — so a single synchronous request could not carry them to completion. Submitting an execution returns immediately and the program continues on a backend worker.
Execution object
Section titled “Execution object”ScriptingExecution:
| Field | Type | Description |
|---|---|---|
id | string | Execution id. Use it to poll and to cancel. |
status | string | pending, running, succeeded, failed, or canceled. |
result | any JSON | null | Value of the script’s last expression; set when succeeded. |
error | string | null | Failure reason; set when failed. |
created_at | RFC3339 | When the execution was accepted. |
started_at | RFC3339 | null | When a worker picked it up. |
completed_at | RFC3339 | null | When it reached a terminal status. |
GET /scripting/executions?limit=20 lists your recent executions newest first as ScriptingExecutionSummary — the same object without result. limit defaults to 20 and caps at 100.
Lifecycle and limits
Section titled “Lifecycle and limits”- Runtime cap: 15 minutes. Exceeding it marks the execution
failedwith an error naming the limit. - Result cap: the serialized
resultmust stay under 8 MB; a larger result fails the execution. - Retention: executions are deleted 24 hours after completion — read the
resultbefore then. - Backend restart: an execution that is in flight when the backend restarts is marked
failed(“interrupted by backend restart”) and is never retried. Resubmit it yourself. - Quota: each
POST /scripting/executionsconsumes 1scripting_executionsunit, charged to the org that grants youexecute(see Quotas).
Canceling an execution
Section titled “Canceling an execution”DELETE /scripting/executions/{id} asks the worker to stop: 204 when the cancel is accepted, 409 when the execution is already terminal, 404 when the id is unknown to the caller.
Authorization
Section titled “Authorization”- Run scripts: the caller needs the
executerole in at least one organization (see Roles).executeonly lets you submit a script; it grants nothing inside it. - Per operation: every
create,get,update,delete,purge, andlistchecks the same permission the equivalent HTTP route checks, against the organization that owns the resource — a device’s, integration’s, or zone’sorg_id, an entity’s device org, the org itself, or one of a user’s orgs. It is never checked against the org that grantedexecute. See Resource kinds and fields for the permission each operation needs. - Failures: a denied operation raises a runtime error containing
status_code=403 Forbidden; the script stops and earlier side effects are kept. - Plan limits:
createenforces the same capacity quotas as HTTP (org_users,organizations,integrations,zones,devices,entities), counted against the resource’s org, and a non-admin cannot create in a degraded org. A full quota fails the operation with the quota error message. - Credentials: credential fields (password-format fields in device metadata and integration config, for example Tasmota
mqtt_password) are never returned by any operation. Integration secrets are write-only throughsecrets. When anupdateomits a credential field, the stored value is kept. - Scoped credentials cannot run scripting at all (403 at the route).
- Own executions: only the user who created an execution can read (
GET) or cancel (DELETE) it. Another principal asking for the same id gets404.
Syntax (Rhai)
Section titled “Syntax (Rhai)”The scripting language is Rhai. Summary:
- Variables:
let x = 10; - Map literal:
#{ key: "value", num: 42 }(keys are strings; use#{}for an empty map) - Array literal:
[1, 2, 3] - Indexing:
obj["key"],arr[0] - Control flow:
if ... { } else { },for x in array { },while condition { } - Functions:
fn name(a, b) { a + b } - Unit / null:
()(Rhai’s unit type; often used like “no value”) - Comments:
// line comment
Built-in constants in scope:
OPENAPP— string"1"(version marker)TYPES— array of the eleven resource kind names:["access_invite", "access_invite_claim", "access_portal", "directory_listing_member", "device", "entity", "integration", "org", "user", "wifi_network", "zone"]
Resource kinds are case-insensitive and can be singular or plural ("users" → "user"; "organization" → "org").
Localized names
Section titled “Localized names”Most name fields are localized maps keyed by locale, not plain strings. A plain string is rejected with localized string field 'name' must be an object map:
// Correctcreate("org", #{ name: #{ en: "My Org", he: "הארגון שלי" } })
// Rejectedcreate("org", #{ name: "My Org" })Localized: name on user, org, integration, zone, device, and access_portal. Plain strings: name on entity and access_invite, and every description.
Built-in functions
Section titled “Built-in functions”Resource operations
Section titled “Resource operations”| Function | Description |
|---|---|
create(kind, fields) | Create a resource. Returns the created object. |
get(kind, id, opts) | Get one resource by ULID. opts is a map (see Output options). Returns the object, or () if it does not exist. A resource that exists but you may not read raises an error. |
update(kind, id, patch) | Update a resource by ULID. Returns the updated object. |
delete(kind, id) | Soft-delete a resource. Returns the deleted object. |
purge(kind, id) | Hard-delete a resource. Returns the purged object. |
list(kind, opts) | List resources you may list. opts is a map (see Output options). Returns an array of at most 10 000 items. |
get, update, delete, purge, and list support user, org, integration, zone, device, and entity. wifi_network supports create, get, update, delete, and list; it has no soft-delete state, so purge is refused. The access kinds are create-only.
All ids are ULID strings. Invalid ULIDs raise a runtime error.
What list returns
Section titled “What list returns”Scripting has no request org, so list answers for every org subtree where you hold the kind’s *:list role:
| Kind | Returns |
|---|---|
org | Orgs you are a member of whose tree grants you orgs:list, admin, or platform_admin, plus their descendants. Membership alone is not enough. |
user | Users with a membership in an org under a subtree where you hold users:list. Users with no org membership are never listed. |
integration, device, entity | Rows in the subtrees where you hold integrations:list, devices:list, or entities:list. |
zone | Zones in the subtrees where you hold zones:list. |
wifi_network | Wi-Fi networks in the subtrees where you hold zones:list. |
Output options
Section titled “Output options”For get(kind, id, opts) and list(kind, opts), opts can include:
| Key | Type | Description |
|---|---|---|
include_deleted | bool | Include soft-deleted resources (default: false). |
only_deleted | bool | Return only soft-deleted resources (default: false). |
Lifecycle timestamps (created_at, updated_at, deleted_at, purge_at, hard_delete_at) are always on the resource. Entity metadata is always in metadata.
Example: list("entity", #{ include_deleted: true })
Assertions and utilities
Section titled “Assertions and utilities”| Function | Description |
|---|---|
assert(condition) | Fails the script if condition is false. |
assert(condition, message) | Same, with a custom error message. |
sleep(seconds) | Pause execution (integer seconds, at most 30). |
sleep_ms(ms) | Pause execution in milliseconds (0 through 30 000). |
ulid() | Return a new ULID string. |
toggle_entity(entity_id) | Same as entity_action(entity_id, "switchable.toggle", #{}). |
entity_action(entity_id, action_id, payload) | Invoke an entity action such as switchable.open. payload is a map, often #{}. Requires the same role as the entity action API (entities:set_state, or entities:set_hold for switchable.set_relay_mode). |
has_image(kind, id) | Return true if the resource already has a stored image. kind is device, entity, integration, user, org, or access_invite. Requires the kind’s *:read role (integrations:read for access_invite); you may always check your own user. |
upload_image(kind, id, url) | Fetch an image from an http(s):// or s3:// URL and store it on the resource. Idempotent: skips when the resource already has a stored image. Same kinds as has_image; invites use the invitation photo slot. Requires the kind’s *:update role (integrations:update for access_invite); you may always update your own user. |
change_plan(org_id, tier_slug) | Put the org on a published plan through the same checkout as POST /orgs/{id}/billing/checkout, and return the subscription (tier_slug, status, provider, external_id). Requires billing:manage on that org. An unknown or unpublished tier fails the script. |
Scripted entity actions are entity-action API calls made as the script’s user: they pass the same
role check, life-safety classification, emergency lockdown and free egress, quiet_hours,
rate_limit, require_step_up, invitation curfew, allowed days, anti-tailgating cooldown and
maintenance window, and write the same entity.action.succeeded / entity.action.denied audit
record as POST /entities/{id}/actions/{action_id}. A policy that needs confirmation
(require_step_up) makes the scripted action fail, because a script cannot supply
X-OpenApp-Confirm.
Resource kinds and fields
Section titled “Resource kinds and fields”Each kind lists its fields and the role each operation needs (see Authorization).
- create:
name(localized map),email(string),roles(map: org id → array of role names); optional:id(ULID),password(string — also creates the sign-in identity),first_name,last_name. Requiresusers:createin every org named inroles, and each org’sorg_usersquota. Omittingrolesis allowed only for rootplatform_admin. - Idempotent by email: if a user with that email exists, it is returned and the listed roles are added to it; a different
idis an error. - update:
name,email. Requiresusers:updatein one of the target’s orgs. You cannot change your own email here — use account security email verification. - get / delete / purge / list:
users:read,users:delete,users:delete,users:listin one of the user’s orgs. You can alwaysgetyourself.
let user = create("user", #{ name: #{ en: "Dana Levi" }, email: "dana@example.com", roles: #{ "01J8Z3NDEKTSV4RRFFQ69G5FAV": ["users:read"] }});- create:
name(localized map); optional:description(string),id(ULID),parent_id(ULID, default the root org). Requiresadminon the parent org (or an ancestor) and the parent’sorganizationsquota; OpenApp does not grantadminon the new org automatically. - update:
name,description,parent_id. Requiresorgs:updateon the org. Changingparent_idalso requiresadminon the new parent, cannot move the root or create a cycle, and must respect the maximum org depth.parent_id: ()is an error — an org cannot be detached. - get:
orgs:readon the org, or any role in it. delete / purge:orgs:delete. list: see Whatlistreturns.
integration
Section titled “integration”- create:
org_id(ULID),provider_type(string); optional:name(localized map),enabled(bool, default true),config(object),secrets(object),location(object withaddressand/orlat+lng). Requiresintegrations:createinorg_idand theintegrationsquota. - update:
name,enabled,config,secrets,location. Requiresintegrations:update.configreplaces the stored config (credential fields you omit are kept);config: ()clears it.secretsreplaces the stored secrets;secrets: ()deletes them. - get / delete / purge / list:
integrations:read,integrations:delete,integrations:delete,integrations:list. Returned objects never includesecretsor credential fields. configvssecrets: fields in the provider’s secrets schema must go undersecrets, and every other field underconfig; the wrong placement is rejected. Secrets are stored in AWS Secrets Manager.provider_typevalues:go2rtc,homeassistant,knx,mqtt,palgate_cloud,shelly_cloud,shelly_websocket,tasmota,virtual_access,virtual_demo,waveshare.virtual_accesssites cannot be created or updated withenabled: false.
let ha = create("integration", #{ org_id: "01J8Z3NDEKTSV4RRFFQ69G5FAV", provider_type: "homeassistant", name: #{ en: "Home Assistant" }, config: #{ base_url: "{{HOMEASSISTANT_BASE_URL}}" }, secrets: #{ token: "{{HOMEASSISTANT_TOKEN}}" }});- create:
org_id(ULID),name(localized map); optional:parent_zone_id(ULID, a zone in the same org). Requireszones:createinorg_idand itszonesquota. - update:
name,parent_zone_id. Requireszones:update. A parent in another org, or one that would form a cycle, fails the operation. - get / delete / purge / list:
zones:read,zones:delete,zones:delete,zones:liston the zone’s org.
wifi_network
Section titled “wifi_network”Runs the same code as the Wi-Fi networks API, so validation and audit events match. Credentials are write-only: returned objects carry has_credentials, never the password.
- create:
org_id(ULID),ssid(string),security("open","wpa_psk", or"wpa_enterprise"); optional:hidden(bool),priority(integer; lower is listed first),notes(string),credentials(map:#{ password: "…" }, plususernamefor enterprise),zone_ids(array of zone ULIDs in the same org). Requireszones:updateinorg_id. A secured network needs credentials; an open one takes none. - update: any of
ssid,security,hidden,priority,credentials,notes(()clears it), andzone_ids(replaces the zone links). Keys you omit are kept. Requireszones:update. - get / delete / list:
zones:read,zones:update,zones:liston the network’s org.getreturns()when the network doesn’t exist.
device
Section titled “device”- create:
org_id(ULID),integration_id(ULID),name(localized map); optional:external_id(string),zone_id(ULID, a zone in the same org),device_metadata(object). Requiresdevices:createinorg_idand thedevicesquota. - update:
name,external_id,zone_id,device_metadata. Requiresdevices:update.device_metadatareplaces the stored metadata; credential fields you omit are kept. - get / delete / purge / list:
devices:read,devices:delete,devices:delete,devices:list. Returned objects never include credential fields.
entity
Section titled “entity”- create:
device_id(ULID),entity_type(string); optional:zone(map:#{ mode: "inherit" },#{ mode: "explicit", zone_id: "…" }, or#{ mode: "none" }; defaults to inherit),name(plain string; defaults to the device’s English name),external_id(string),metadata(object; values are stored as strings). Requiresentities:createon the device’s org and itsentitiesquota.channel_indexis not supported — usemetadata. - update:
zone,name,external_id,metadata. Requiresentities:updateon the device’s org, or listing admin for adirectory_listingentity. - get:
entities:readon the device’s org, or membership of that directory listing. delete / purge / list:entities:delete,entities:delete,entities:list. entity_typevalues:switch,light,sensor,door,directory_listing.
For some integrations, external_id is not allowed on entities; the engine raises an error if you set it.
access_portal
Section titled “access_portal”- create:
integration_id(ULID),name(localized map),device_id(ULID); optional:directory_id(ULID). device_idmust reference avirtual_access_portaldevice in the integration;directory_idmust reference avirtual_access_directorydevice. Both are validated before the write.- Idempotent: returns the existing portal when one already exists for the same integration and
device_id. - Requires
integrations:updateon the integration’s org.
access_invite
Section titled “access_invite”- create:
integration_id(ULID),portal_ids(array of ULID strings),schedules(array of entry objects); optional:name(string),max_uses(int),max_devices(int, greater than 0),is_enabled(bool, default true),disabled_justification(string, kept only whenis_enabledis false),invitee_message(object),creation_justification(string). schedulesis the only way to express validity. Each entry takes an absolute pair (valid_from+valid_to, RFC 3339 UTC) or a relative pair (starts_in/expires_in, duration string such as2horP1D); the two forms are mutually exclusive per entry. Omitting the start opens the window immediately. An entry may also carryinvite_recurrence. See Create a time-bound guest invitation.- Every portal must belong to the integration.
- Idempotent by name: when
nameis set and an invite with that name already exists on the integration, the existing invite is returned withinvite_token: nullinstead of creating a second one. - Returns
id,invite_token,invite_url,integration_id,valid_from,valid_to,max_uses,max_devices. - Requires
integrations:update. Invitation authoring policies are enforced as they are through the HTTP API. The DSL has no contact surface, so its invitations are open to anyone with the link; aninvitation_require_named_audiencepolicy therefore rejects them. When an authoring policy is set to require approval, the create fails withinvitation requires policy approval— nothing is written and no pending approval is opened.
access_invite_claim
Section titled “access_invite_claim”- create:
invite_link_id(ULID),user_id(ULID); optional:claimed_via(string, defaultdsl_seed). - Requires
integrations:updateon the invite’s org.
directory_listing_member
Section titled “directory_listing_member”- create:
entity_id(ULID),user_id(ULID); optional:role(adminormember, defaultmember),receives_calls(bool, default true). entity_idmust be a directory listing on a Virtual Access integration. The user also gets the matching site role.- Requires
entities:updateon the listing’s org, or listing admin.
Template expansion
Section titled “Template expansion”Scripts can contain placeholders {{KEY}} where KEY is an identifier ([A-Za-z_][A-Za-z0-9_]*). When using the CLI with --from-env-file or --from-secret, placeholders are replaced before the script is submitted.
--from-env-file: Load key-value pairs from a dotenv file. Multiple--from-env-filepaths are merged.--from-secret: Load key-value pairs from an AWS Secrets Manager secret (SecretString must be a JSON object). Multiple--from-secretIDs are merged.
If any placeholder in the script has no value in the expansion map, the CLI returns an error listing the missing keys. Replacement is raw text (no extra quoting).
Example script snippet:
let base_url = "{{HOMEASSISTANT_BASE_URL}}";let token = "{{HOMEASSISTANT_TOKEN}}";Include directive
Section titled “Include directive”Scripts can inline other files using the include directive:
include "path" // path relative to current script's directoryinclude "path.openapp" // .openapp extension optional- Path: Relative to the directory of the script containing the
include(for exampleinclude "users/main"loadsusers/main.openappfrom the same directory). - Processing: Handled at load time by the CLI, before template expansion. Included content is inlined and shares the same scope.
- Extension: If the path has no extension or a different extension,
.openappis appended. - Cycles: Circular includes are detected and rejected.
For cross-module dependencies, include shared helpers and call them in order (paths are often under tooling/dsl/site/):
load_json "data/orgs.json" as orgs_seed_docinclude "../../tooling/dsl/site/orgs_seed"include "shelly"
let orgs_out = provision_orgs_from_seed(orgs_seed_doc);let shelly_out = run_shelly(orgs_out);Load data directives
Section titled “Load data directives”Scripts can load JSON or TOML files and bind them to variables. Paths are resolved relative to the entry-point script’s directory.
load_json "path" as var_name— Load a.jsonfile. Path must end with.json.load_toml "path" as var_name— Load a.tomlfile. Path must end with.toml. Use TOML when you need comments (for example documenting derived values like full emails).
Both directives are expanded by the CLI at load time, before template expansion. The file content is inlined as a Rhai map literal.
load_json "data/orgs.json" as orgs_seed_docload_toml "data/demo_users_seed.toml" as demo_users_seed_dataLimits and safety
Section titled “Limits and safety”- Operations: at most 500 000 Rhai operations per script (prevents runaway loops).
- Expression/call depth: bounded (expression depth 128, call depth 64) to avoid stack overflow.
- Sleep:
sleep_msis capped at 30 000 ms;sleep(seconds)has the same cap. - List size: each
listreturns at most 10 000 items. - Execution runtime: 15 minutes per execution, and the serialized
resultmust stay under 8 MB — see Lifecycle and limits.
Scripts run with the identity and permissions of the caller; there is no separate scripting admin bypass.