Architecture

How to design an event-driven app on IoTMan. The core model, the primitives, a decision framework, and reference architectures.

This guide is the map for designing an app on IoTMan. It explains the one idea everything is built on, catalogues the primitives by the job they do, gives you a decision framework for picking the right one, and walks through reference architectures that wire them together. If you are configuring a workspace through an agent or the MCP server, read this first: it is the difference between a working app and a correct one.


The core model

A workspace is one durable, append-only log of JSON events.

Producers append events. Everything else in the platform is a derivation of that log: a reshaped read, a folded state table, a reaction that fires on arrival. You do not design tables and write CRUD. You describe how raw events become the outputs your consumers need, and IoTMan maintains those derivations as data flows.

Every event carries the payload the producer sent plus metadata stamped at ingest: received_at, source_ip, auth_method, oauth2_client_id, and ingest_context. That metadata is addressable everywhere downstream, so attribution and authorization travel with the data permanently.

The platform organizes around the path an event takes:

 Connect  →  Process  →  Compute  →  Export
  ingest      tag &       state &     serve
            reshape      reactions    out
                  (all gated by Access)

A crucial detail for getting expressions right: the two middle stages speak different languages. Process (tag rules, outputs) uses JSONPath (RFC 9535) with PostgreSQL's object-constructor extension. Compute (views, atomic functions, triggers) uses JSONata. Do not mix the syntaxes.


The primitives at a glance

StagePrimitiveWhat it doesLanguage
ConnectHTTP endpointA URL that accepts POSTed JSON, optionally schema-validated(none)
ConnectMQTT connectorSubscribes to an external broker and ingests messages(none)
ProcessTag ruleAttaches key:value labels to each event for selection and groupingJSONPath / regex
ProcessOutputSelects events by tag and reshapes them into a new JSON object at read timeJSONPath
ComputeViewFolds events into a per-key state table, maintained on every writeJSONata
ComputeAtomic functionComposes admin-API calls inside one transactionJSONata
ComputeTriggerFires an atomic function when a matching event arrivesJSONata (condition)
ComputeCron jobFires an atomic function on a schedule(schedule)
ExportExport endpointMaps one output to a URL for GET, SSE, and webhooks(none)
ExportWebhookAt-least-once signed POST to your URL on each match(none)
ExportMQTT pushPublishes each match to an external broker(none)
AccessScopeA namespace:action permission string checked on protected reads(none)
AccessOAuth2 clientAn app or device principal (PKCE, Auth Code, or Client Credentials)(none)
AccessEnd-userA non-member person you grant scopes to, authenticated by one-time passcode (OTP)(none)

The central design choice: read-time vs write-time derivation

Two primitives turn raw events into consumable shapes. Choosing between them is the most consequential decision you make, and it is decided entirely by how the data will be read.

Output (read-time)View (write-time)
When work happensOn each read, re-reading the matching log sliceOn each write, folding the event into stored state
ShapeA filtered, reshaped stream of eventsOne row per key, holding folded state
ReadsHistory, ranges, the full seriesPoint lookup or prefix scan of current state
Answers"every reading from sensor X this week""the latest reading per sensor right now"
Changes to the ruleRe-read the log, no backfill neededApplies going forward; existing rows keep prior state
LanguageJSONPath constructorJSONata reducer

The rule of thumb: if the question is "the series", use an output; if the question is "the current state per entity", use a view. Latest reading per device, current session per user, running count per asset are all views. Histograms, sums across many rows, time ranges, and audit trails are all outputs over the raw stream. A view is the wrong tool for analytics, and an output polled to find "the latest" is the wrong tool for current state.

Views also enforce per-row ownership for end-user apps (see per-user isolation below), which outputs cannot.


Reacting to events

Derivations are passive. When an event must cause something (send an email, call an external API, emit a derived event, update a quota), you need a reaction. Reactions fire atomic functions.

An atomic function is a JSONata expression that composes admin-API calls inside a single database transaction. It is how a workspace exposes higher-level operations without custom server code. Inside the body, $iotman_call(method, path, body) dispatches a loopback call that joins the function's transaction; $require and $fail raise structured errors; $send_email and $ftp_upload reach external systems. The whole sequence commits together or rolls back together. A function runs under a worker identity (an OAuth2 client flagged is_worker), so its privilege surface is exactly the scopes you granted that worker, nothing more.

A function can be invoked three ways:

Reactions are evaluated after the ingest transaction commits, each in its own transaction-session, so a slow or failing function never blocks ingest and the inbound event stays durable regardless of the function's outcome.

Host functions

Inside an atomic function's JSONata body, these host calls are available beyond standard JSONata:

CallSignatureReturnsNotes
$iotman_call$iotman_call(method, path, body)response body on 2xxLoopback call to this workspace's API as the function's worker identity. method is GET/POST/PUT/PATCH/DELETE; path starts with /; body is any value (null for body-less methods). A non-2xx response throws, preserving the upstream status.
$require$require(cond, status, message)condGuard. If cond is falsy, abort with status and message; otherwise returns cond so it composes in a pipeline.
$fail$fail(status, message)throwsUnconditional abort with status and message.
$send_email$send_email(to, subject, body)queue idEnqueues an email, delivered asynchronously via the workspace SMTP config (platform default as fallback).
$ftp_upload$ftp_upload(profile_id, remote_path, content)bytes writtenUploads content to remote_path over SFTP using a connection profile (its UUID), configured in Workspace settings.

Two things to keep straight:

The host-function catalogue is fixed and maintained server-side. If your workspace needs a capability that is not listed here, contact the IoTMan admin to request it be exposed in the JSONata API.


Getting data out

Export offers three delivery modes with different guarantees. Match the mode to what the consumer can tolerate.

ModeURL / mechanismGuaranteeUse when
PullGET on the endpoint, paginatedHistory, replayable, on demandDashboards, reports, backfills
Live stream/events (SSE), /stream (WebSocket, legacy)Live-only, no replay, drops on lagReal-time UI where missing a frame is fine
Push (webhook)Signed outbound POST per matchAt-least-once with retries, dedupe on event idReliable delivery to a system you control
Push (MQTT)Publish to an external broker per matchAt-least-once with retriesFeeding existing MQTT infrastructure

The trade-off to internalize: SSE is cheap and live but lossy; webhooks are durable but require you to dedupe and tolerate out-of-order delivery. If a consumer must not miss an event, it is a webhook, not a stream. If a consumer wants history, it is a GET, not a stream.


The access model

Three kinds of principal interact with a workspace. Keep them distinct when you design.

PrincipalWhoAuthenticates withManages the workspace?
MemberA person on your teamOne-time passcode (OTP) to the dashboardYes
End-userA person who uses your appOne-time passcode (OTP) via PKCENo
OAuth2 clientA device or service (M2M)Client Credentials, or PKCE/Auth Code on a user's behalfNo

Scopes (namespace:action) are the access boundary. Every protected read checks them. There are two namespaces:

Two patterns are worth committing to memory:

Per-user data isolation

To let each end-user read only their own data: ingest under PKCE so each event is attributed (the end-user's id lands in sub and ingest_context is stamped onto the payload), then build a view with a scope-field body that extracts the owning end-user id from each stored row. The view returns only rows whose owner matches the caller's token sub. This is the "users see only their own rows" guarantee, enforced server-side, and it is a capability of views that outputs do not have.

Agent-operated configuration

An agent configuring a workspace acts as a principal holding iotman: scopes. For end-to-end bootstrapping, the whole workspace is described by a single AsyncAPI document: fetch it with workspace_spec_get, edit it, apply it with workspace_spec_import. That round-trip is idempotent and lossless, and it is the preferred way to configure sources, scopes, tag rules, outputs, export endpoints, webhooks, MQTT configs, views, atomic functions, and triggers together. Members, end-user grants, OAuth2 client secrets, and workspace lifecycle are managed with the per-resource tools.


Decision framework

Start from the requirement, not the primitive.

You need to…Use
Accept JSON from a device or serviceHTTP endpoint (Connect)
Pull from an existing MQTT brokerMQTT connector (Connect)
Reject or flag malformed payloads at ingestSource JSON Schema in reject or warn mode
Label events so you can select them laterTag rule (Process)
Expose a clean, reshaped read of the event historyOutput + export endpoint
Answer "current state per entity" with fast lookupsView
Compute analytics (sums, ranges, percentiles)Output over the raw stream, queried with from/to
Run a multi-step operation atomicallyAtomic function, called directly
Do something automatically when an event arrivesTrigger → atomic function
Do something on a scheduleCron job → atomic function
Capture a reaction's result as dataTrigger reply-to source
Route each event to a destination chosen per-key by looked-up stateTrigger + atomic function reading a routing view
Push every match to a system you control, reliablyWebhook
Drive a live UISSE stream (/events)
Let each end-user see only their own dataView with a scope-field body + PKCE end-users
Gate a read behind a permissionScope + required-scope on the endpoint or view
Let an agent configure the workspaceiotman: scopes + the AsyncAPI spec round-trip

Reference architectures

These show how the primitives compose. Each is a starting skeleton, not the only shape.

1. Telemetry dashboard

Devices report readings; a dashboard shows both the current state and the history.

HTTP endpoint (Client Credentials)
  → tag rule: device_id = $.device_id   (JSONPath value capture)
  → View "current"  : key $.payload.device_id, reducer last  → fast "latest per device"
  → Output "history": tag device_id, constructor {ts:$received_at, v:$.value}
       → export endpoint (GET for history, /events SSE for live tiles)

The view answers "show me every device's latest value" with point lookups; the output answers "chart the last 24h for device X" over the raw stream. One ingest path feeds both.

2. Command and control

A backend issues commands; devices act; the backend needs the outcome.

HTTP endpoint "commands"
  → trigger: condition $.payload.kind = "command"
       → atomic function: $iotman_call to dispatch + record, $fail on bad input
       → reply-to source "command-results"
  → export endpoint on "command-results" (webhook to the backend)

The trigger turns each command event into a transactional function run; the reply-to source captures ok/error per invocation as durable events, delivered reliably to the backend by webhook.

3. Multi-tenant end-user app

Your app's users submit and read only their own data.

End-users invited with scope app:data   (PKCE)
HTTP endpoint (OAuth2 / PKCE)  → ingest stamps sub + ingest_context
  → View "my_data": key $.payload.id, reducer last,
       scope-field body $.end_user, required scope app:data
  → end-users read GET /views/{ws}/my_data/data, seeing only their rows

Identity is owned by IoTMan; the scope-field view enforces row ownership server-side. No per-user filtering logic ships in your app.

4. Scheduled rollup and alerting

Summarize on a schedule and notify on a threshold.

Cron job (0 * * * *) → atomic function:
   $iotman_call to read the hour's events, compute a summary,
   $require(threshold not breached) else $send_email(ops, "alert", …)

Time-driven work and alerting both ride the same atomic-function mechanism; no separate scheduler or mailer to operate.

5. View-driven routing (fan-out to per-persona channels)

Events arrive on one shared intake; each must reach the channel owned by a specific persona (an end-user or worker), and which persona owns a given event depends on state held in a view. Here the view is a routing table, not a read model.

Intake source "ingest" (shared)
  → View "routing": key $.payload.device_id, reducer last,
        value { owner, channel }   (folded from registration / assignment events)
  → Trigger on "ingest" (condition true)
        → atomic function "route":
             owner = $iotman_call GET /views/{ws}/routing/data?key=<device_id>
             $require(owner, 422, "unregistered device")
             $iotman_call POST /data/{ws}/<owner.channel>   (re-ingest, enriched with owner)
Destination sources, one per persona, each behind a per-persona scope
  → each persona reads only its own channel

The view answers "who owns this key right now" as a point lookup; the trigger turns each intake event into a routing decision; the function performs the redirect by re-ingesting the event onto the destination source inside its transaction. Re-assigning ownership is just another event folded into the routing view, so subsequent data follows the new owner with no rule change. Because each destination source is gated by its own scope, personas stay isolated by construction.

This composes three ideas the guide introduces separately: a view as current-state-per-key (used here as a lookup table), a trigger as the reactive edge, and an atomic function's $iotman_call writing a derived event onto another source.


Anti-patterns


Building it

The recommended path, especially for an agent:

  1. Model the flow against the decision framework: name the sources, the derivations, the reactions, and the scopes before creating anything.
  2. Describe it as a spec. Fetch the workspace's AsyncAPI document, add the sources, tag rules, outputs, views, functions, and triggers, and apply it in one idempotent import.
  3. Validate the expressions with the test tools before they go live: test a JSONPath, a constructor against a sample payload, or a view's key-and-reducer behavior, and run an atomic function in a transaction that always rolls back.
  4. Wire access last. Define scopes, attach required scopes to endpoints and views, and grant principals exactly what they need.

For the protocols behind the scopes and tokens, see the Authorization guide.