Skip to content
OpenAppPhysical access, fully managed
Login

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.

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:

Terminal window
openapp scripting --file script.openapp

With template expansion (see Template expansion):

Terminal window
openapp scripting --file script.openapp --from-env-file .env
openapp scripting --file script.openapp --from-secret my-secret-id

Queue the execution and return immediately, printing only its id:

Terminal window
openapp scripting --file script.openapp --no-wait

List your recent executions (newest first; the server default is 20, maximum 100) and cancel one:

Terminal window
openapp scripting list
openapp scripting list --limit 50
openapp scripting cancel 01J9ZC4Q5W3X0M7E2R8T6Y1V9K

openapp 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).

Open a REPL to run script snippets:

Terminal window
openapp shell

At 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:

Terminal window
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=0
while [ "$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 5
done
# 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.

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.

ScriptingExecution:

FieldTypeDescription
idstringExecution id. Use it to poll and to cancel.
statusstringpending, running, succeeded, failed, or canceled.
resultany JSON | nullValue of the script’s last expression; set when succeeded.
errorstring | nullFailure reason; set when failed.
created_atRFC3339When the execution was accepted.
started_atRFC3339 | nullWhen a worker picked it up.
completed_atRFC3339 | nullWhen 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.

  • Runtime cap: 15 minutes. Exceeding it marks the execution failed with an error naming the limit.
  • Result cap: the serialized result must stay under 8 MB; a larger result fails the execution.
  • Retention: executions are deleted 24 hours after completion — read the result before 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/executions consumes 1 scripting_executions unit, charged to the org that grants you execute (see Quotas).

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.

  • Run scripts: the caller needs the execute role in at least one organization (see Roles). execute only lets you submit a script; it grants nothing inside it.
  • Per operation: every create, get, update, delete, purge, and list checks the same permission the equivalent HTTP route checks, against the organization that owns the resource — a device’s, integration’s, or zone’s org_id, an entity’s device org, the org itself, or one of a user’s orgs. It is never checked against the org that granted execute. 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: create enforces 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 through secrets. When an update omits 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 gets 404.

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").

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:

// Correct
create("org", #{ name: #{ en: "My Org", he: "הארגון שלי" } })
// Rejected
create("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.

FunctionDescription
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.

Scripting has no request org, so list answers for every org subtree where you hold the kind’s *:list role:

KindReturns
orgOrgs you are a member of whose tree grants you orgs:list, admin, or platform_admin, plus their descendants. Membership alone is not enough.
userUsers with a membership in an org under a subtree where you hold users:list. Users with no org membership are never listed.
integration, device, entityRows in the subtrees where you hold integrations:list, devices:list, or entities:list.
zoneZones in the subtrees where you hold zones:list.
wifi_networkWi-Fi networks in the subtrees where you hold zones:list.

For get(kind, id, opts) and list(kind, opts), opts can include:

KeyTypeDescription
include_deletedboolInclude soft-deleted resources (default: false).
only_deletedboolReturn 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 })

FunctionDescription
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.

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. Requires users:create in every org named in roles, and each org’s org_users quota. Omitting roles is allowed only for root platform_admin.
  • Idempotent by email: if a user with that email exists, it is returned and the listed roles are added to it; a different id is an error.
  • update: name, email. Requires users:update in 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:list in one of the user’s orgs. You can always get yourself.
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). Requires admin on the parent org (or an ancestor) and the parent’s organizations quota; OpenApp does not grant admin on the new org automatically.
  • update: name, description, parent_id. Requires orgs:update on the org. Changing parent_id also requires admin on 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:read on the org, or any role in it. delete / purge: orgs:delete. list: see What list returns.
  • create: org_id (ULID), provider_type (string); optional: name (localized map), enabled (bool, default true), config (object), secrets (object), location (object with address and/or lat + lng). Requires integrations:create in org_id and the integrations quota.
  • update: name, enabled, config, secrets, location. Requires integrations:update. config replaces the stored config (credential fields you omit are kept); config: () clears it. secrets replaces the stored secrets; secrets: () deletes them.
  • get / delete / purge / list: integrations:read, integrations:delete, integrations:delete, integrations:list. Returned objects never include secrets or credential fields.
  • config vs secrets: fields in the provider’s secrets schema must go under secrets, and every other field under config; the wrong placement is rejected. Secrets are stored in AWS Secrets Manager.
  • provider_type values: go2rtc, homeassistant, knx, mqtt, palgate_cloud, shelly_cloud, shelly_websocket, tasmota, virtual_access, virtual_demo, waveshare. virtual_access sites cannot be created or updated with enabled: 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). Requires zones:create in org_id and its zones quota.
  • update: name, parent_zone_id. Requires zones: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:list on the zone’s org.

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: "…" }, plus username for enterprise), zone_ids (array of zone ULIDs in the same org). Requires zones:update in org_id. A secured network needs credentials; an open one takes none.
  • update: any of ssid, security, hidden, priority, credentials, notes (() clears it), and zone_ids (replaces the zone links). Keys you omit are kept. Requires zones:update.
  • get / delete / list: zones:read, zones:update, zones:list on the network’s org. get returns () when the network doesn’t exist.
  • 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). Requires devices:create in org_id and the devices quota.
  • update: name, external_id, zone_id, device_metadata. Requires devices:update. device_metadata replaces 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.
  • 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). Requires entities:create on the device’s org and its entities quota. channel_index is not supported — use metadata.
  • update: zone, name, external_id, metadata. Requires entities:update on the device’s org, or listing admin for a directory_listing entity.
  • get: entities:read on the device’s org, or membership of that directory listing. delete / purge / list: entities:delete, entities:delete, entities:list.
  • entity_type values: 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.

  • create: integration_id (ULID), name (localized map), device_id (ULID); optional: directory_id (ULID).
  • device_id must reference a virtual_access_portal device in the integration; directory_id must reference a virtual_access_directory device. Both are validated before the write.
  • Idempotent: returns the existing portal when one already exists for the same integration and device_id.
  • Requires integrations:update on the integration’s org.
  • 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 when is_enabled is false), invitee_message (object), creation_justification (string).
  • schedules is 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 as 2h or P1D); the two forms are mutually exclusive per entry. Omitting the start opens the window immediately. An entry may also carry invite_recurrence. See Create a time-bound guest invitation.
  • Every portal must belong to the integration.
  • Idempotent by name: when name is set and an invite with that name already exists on the integration, the existing invite is returned with invite_token: null instead 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; an invitation_require_named_audience policy therefore rejects them. When an authoring policy is set to require approval, the create fails with invitation requires policy approval — nothing is written and no pending approval is opened.
  • create: invite_link_id (ULID), user_id (ULID); optional: claimed_via (string, default dsl_seed).
  • Requires integrations:update on the invite’s org.
  • create: entity_id (ULID), user_id (ULID); optional: role (admin or member, default member), receives_calls (bool, default true).
  • entity_id must be a directory listing on a Virtual Access integration. The user also gets the matching site role.
  • Requires entities:update on the listing’s org, or listing admin.

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-file paths are merged.
  • --from-secret: Load key-value pairs from an AWS Secrets Manager secret (SecretString must be a JSON object). Multiple --from-secret IDs 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}}";

Scripts can inline other files using the include directive:

include "path" // path relative to current script's directory
include "path.openapp" // .openapp extension optional
  • Path: Relative to the directory of the script containing the include (for example include "users/main" loads users/main.openapp from 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, .openapp is 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_doc
include "../../tooling/dsl/site/orgs_seed"
include "shelly"
let orgs_out = provision_orgs_from_seed(orgs_seed_doc);
let shelly_out = run_shelly(orgs_out);

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 .json file. Path must end with .json.
  • load_toml "path" as var_name — Load a .toml file. 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_doc
load_toml "data/demo_users_seed.toml" as demo_users_seed_data
  • 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_ms is capped at 30 000 ms; sleep(seconds) has the same cap.
  • List size: each list returns at most 10 000 items.
  • Execution runtime: 15 minutes per execution, and the serialized result must 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.