Compute

Server-side compute that extends your workspace's API — SQL-style views over your data and JSONata atomic functions that compose admin calls in one transaction.

The Compute tab is where you define server-side logic against your workspace. Two primitives:

Views

A view materializes a fold of events from one or more sources into a per-key state table. Unlike outputs (filter + reshape at read time), views maintain state eagerly: each ingested event runs through the view's reducer and updates a stored row, so reads are point lookups against an indexed table.

Views are good for "current state per entity" questions: latest reading per device, current session per user, running count per asset, most recent status per beacon. They are not for analytics (sum across many rows, percentiles, histograms); for that, query the raw event stream through an output.

How a view is defined

A view has:

  1. Sources: one or more sources the view materializes from. Only events arriving on these sources are considered.
  2. Key body: a JSONata expression evaluated against {payload, metadata, received_at} for each incoming event. The result is coerced to a scalar string and used as the row key. If the expression returns null, missing, or a non-scalar value, the event is skipped — the key extractor doubles as a per-event filter, so a single view can apply to several event shapes by switching on payload fields.
  3. Reducer: a JSONata expression evaluated against {prev, event} that returns the new value to store under the key. prev is the row's existing value (or the init expression's result if no row exists). event is {payload, metadata, received_at, key}.
  4. Init (optional): a JSONata expression evaluated when the first event arrives for a key. Falls back to null if absent.

The dashboard provides canned reducers as shorthand:

NameEffect
lastLatest event wins. The most common choice.
firstKeep the first event seen for this key; ignore subsequent events.
countMaintain a counter per key. Returns { value: N }.
mergeDeep-merge each event's payload into the stored value.
customWrite your own JSONata expression.

Creating a view

  1. Click + New view under the Views section.
  2. Enter a name.
  3. Pick one or more sources.
  4. Write the key body. The default $.payload.id keys events by their id field; adjust to whatever uniquely identifies an entity in your payload.
  5. Pick a reducer, or write a custom JSONata expression.
  6. Optionally set an init expression and a required scope.
  7. Click Run test with a sample payload to confirm the key extraction and reducer behave as expected before saving.
  8. Click Create.

Requires Editor or Admin role.

Reading view data

Materialized rows are exposed at two URLs:

GET /views/{workspace_slug}/{view_name}/data?key=K
GET /views/{workspace_slug}/{view_name}/data?prefix=P&limit=N&cursor=C
GET /views/{workspace_slug}/{view_name}/events

If the view has a required scope set, callers must present a Bearer token with that scope in their scope claim.

If the view has a scope-field body, only rows whose value matches the caller's token subject (sub) are returned. This is the "end-user reads only their own rows" pattern: the scope-field expression is evaluated against each stored value, and rows are included only if its scalar result equals the token's sub. End-user tokens issued via PKCE carry the end-user's id as sub; the scope-field expression typically extracts the owning end-user id from the stored value (e.g., $.end_user).

Deleting a view

Click the delete button on a view row. Materialized rows for that view are removed.

Requires Admin role.

Errors

If a reducer or key body throws, that event is skipped for the affected view and the error is stamped on the view's row (last_error_at, last_error_message, and an incrementing error_count). The ingest itself is not blocked — other views and the underlying event remain durable. The dashboard surfaces the error count and most recent message in the view list.

Atomic functions

An atomic function is a JSONata expression that composes admin-API calls inside a single database transaction. Functions are how a workspace exposes higher-level operations (issue a credit, recompute a quota, close a session) without writing custom server code: every step the function performs hits the same admin routes the dashboard uses, and the whole sequence either commits together or rolls back.

Each function is exposed at POST /functions/{workspace}/{name} with the auth methods you choose (oauth2, hmac, none). Inside the body, $iotman_call(method, path, body) dispatches loopback HTTP — every call participates in the function's transaction — and $require / $fail produce structured errors with the HTTP status you specify.

See the Compute tab in the dashboard for the editor; the body is JSONata.

The editor includes syntax highlighting (JSON mode via CodeMirror), line numbers, and bracket matching. Use the Test button to run your function inside a transaction-session that always rolls back — you can experiment with $iotman_call and $send_email without side effects. The result or error message appears inline below the editor.

The dashboard also exposes a standalone /api/atomic-functions/validate endpoint that checks JSONata parse validity, and a /api/workspaces/{workspace}/atomic-functions/test endpoint for programmatic testing with rollback.

Worker identity

Every function is assigned a worker — an OAuth2 client in the workspace marked with is_worker = true. When the function runs, $iotman_call requests authenticate as that worker (Bearer auth), and scopes resolve from the client's iotman: scope grants. The function's privilege surface is exactly what the operator granted the worker; nothing more.

Workers are normal OAuth2 clients with one extra flag. Create them in the Access tab by checking Use as worker identity, then grant scopes on the client like any other. The same client can serve double duty — internal worker plus external machine caller — but the typical pattern is one worker per function (or per cluster of related functions) so privilege is bounded.

A function cannot be created or saved without a worker assignment. If no workers exist in the workspace, create one in the Access tab first.

Views also accept a worker assignment, but optionally. Today it is metadata-only — view materialization is a pure JSONata fold and makes no admin calls. The column exists so views can grow side effects (derived events, chained triggers) later without retrofitting identity.

Triggers

A trigger binds a function to a source: when an event arrives on that source and the trigger's condition matches, the function fires automatically. Triggers turn synchronous "POST and wait for the result" calls into event-driven workflows.

A trigger has:

  1. Function: which atomic function fires.
  2. Source: the source whose events evaluate the trigger condition.
  3. Trigger condition: a JSONata expression evaluated against {payload, metadata, received_at}. Truthy result fires the function; null / false / empty skips the event. Same convention as a view's key body, so true fires on every event and a path expression like $.payload.kind = "command" fires on a subset.
  4. Reply-to source (optional): when set, every started invocation writes one result event to this source. The reply envelope carries status: "ok" with the function's return value, or status: "error" with the status code and message. Consumers subscribe to the reply source like any other source — SSE, webhooks, exports — to get the function's outcome.

Triggers are evaluated after the ingest transaction commits. Each fired function opens its own transaction-session, so a slow or failing function never blocks ingest, and the inbound event remains durable even if the function rolls back.

Error treatment

Triggers and views diverge on errors:

The split between the reply channel and the trigger row:

FailureReply event?Stamps trigger row?
Trigger condition parse / eval errorNo — function never startedYes
$require / $fail throwYes (status from $fail)No (user-intent)
$iotman_call non-2xxYes (upstream status)No
Function body parse errorYes (500)Yes
Runtime JSONata throwYes (500)Yes

User-intent errors ($require, $fail, deliberate non-2xx loopback calls) ride the reply channel only — they are the function's authored response. Authoring / runtime / infrastructure failures stamp the trigger row as well, so operators see the "this binding has been failing" indicator without subscribing.

If you create a trigger without a reply-to source, successes drop silently and only runtime errors stamp the row. That's the strict fire-and-forget mode — useful when the side effects are self-evident, otherwise prefer a reply target.

Creating a trigger

  1. Open the Compute tab.
  2. On the function row, click Triggers.
  3. Click + Add trigger.
  4. Pick the source whose events should evaluate the condition.
  5. Write the trigger condition. Default true fires on every event.
  6. Pick an optional reply-to source.
  7. Click Create.

Requires Editor or Admin role.

Scheduled execution (cron jobs)

A cron job runs an atomic function on a recurring schedule. Two schedule types:

The scheduler ticks every 10 seconds, claims due jobs with row-level locking, and invokes the associated atomic function. Each invocation opens its own transaction-session — a slow or failing job never blocks other jobs or the ingest pipeline.

Creating a cron job

  1. Open the Compute tab.
  2. Under the Cron Jobs section, click + Add cron job.
  3. Enter a name, schedule type, and select the atomic function to run. (Register the function first in the Registered functions section above.)
  4. If using interval, set the number of seconds between runs. If using cron, provide a standard cron expression.
  5. Click Create.

The job runs on the scheduler's next tick. The next scheduled run time is shown on the job row.

Seeing why a cron job failed

A cron job has no synchronous caller to receive an error, so failures are recorded on the job itself. GET /api/workspaces/{workspace}/cron-jobs returns last_error_at, last_error_message, and error_count alongside each job. last_error_at and last_error_message describe the most recent run and are cleared once a run succeeds; error_count is a lifetime tally.

Both failure kinds are recorded: authored errors from $require and $fail, and runtime errors from the evaluator or a failed $iotman_call.

next_run_at advances whether a run succeeds or fails, so a healthy-looking next_run_at is not evidence that anything is working. Check last_error_at and error_count. For a full per-run history, set a reply-to source on the job: the executor then writes a {status, result | error, started_at, finished_at, duration_ms} envelope to that source after every run, which you can view, export, or stream like any other event.

The invocation context

However a function is invoked, $ is bound to {args, ctx}. args varies by invocation path; ctx is the same contract everywhere:

FieldAlways presentNotes
ctx.workspaceyesWorkspace slug. Use it to build loopback paths: '/data/' & $.ctx.workspace & '/' & $slug.
ctx.calleryesObject with a kind discriminant, or null for an unauthenticated call to a public function.
ctx.caller.kindyesend_user, service, signed, trigger, or cron.

Path-specific additions: a trigger sets ctx.caller.atomic_function_trigger_id; a cron job sets ctx.caller.cron_job_id plus ctx.cron_job_id and ctx.name at the top level.

Because JSONata treats a missing field as undefined rather than raising, a body that reads $.ctx.workspace on a path that does not supply it silently builds a malformed string instead of failing loudly. Branch on ctx.caller.kind if a function needs to behave differently per path, and use $require to assert on anything you depend on.

Host functions

Atomic functions include built-in host functions beyond $iotman_call, $require, and $fail:

$send_email(to, subject, body [, attachments])

Enqueues an email for delivery via the workspace's SMTP config. The email is sent asynchronously by a background delivery task, so the function does not wait for SMTP negotiation. The workspace must have SMTP configured; if not, the platform's default SMTP is used as a fallback.

attachments is optional. When present it must be an array of objects, each with filename, content (base64-encoded bytes), and mime_type. All three are required per entry. At most 10 attachments, each up to 10 MB of base64 content.

$send_email(
  'ops@example.com',
  'Daily report',
  'The daily CSV is attached.',
  [{
    'filename':  'report.csv',
    'content':   $base64encode($to_csv({ 'columns': $cols, 'rows': $rows })),
    'mime_type': 'text/csv'
  }]
)

$ftp_upload(profile_id, remote_path, content)

Uploads a file to an FTP server using a pre-configured connection profile. profile_id is the UUID of the connection profile (created in Workspace settings). remote_path is the target path on the server. content is the string content to write.

Connection profiles are managed in the FTP Connection Profiles section of Workspace settings.

$to_csv(spec)

Assembles an RFC 4180 CSV string. spec is an object with columns (array of column names), rows (array of objects keyed by those column names), and an optional bom boolean that prefixes a UTF-8 byte-order mark for spreadsheet software that expects one. Returns the CSV as raw text, not base64, so pass it through $base64encode before using it as an attachment.

$base64encode(text) / $base64decode(text)

Standard base64 encode and decode over UTF-8 text. $base64decode errors if the input is not valid base64 or the decoded bytes are not valid UTF-8.