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)- Connect: producers append events over HTTP or MQTT.
- Process: declarative tagging and read-time reshaping with JSONPath.
- Compute: stateful folds (views) and transactional logic (atomic functions, triggers, cron) with JSONata.
- Export: serve derivations out over pull, live stream, or push.
- Access: scopes and principals gate every protected surface.
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
| Stage | Primitive | What it does | Language |
|---|---|---|---|
| Connect | HTTP endpoint | A URL that accepts POSTed JSON, optionally schema-validated | (none) |
| Connect | MQTT connector | Subscribes to an external broker and ingests messages | (none) |
| Process | Tag rule | Attaches key:value labels to each event for selection and grouping | JSONPath / regex |
| Process | Output | Selects events by tag and reshapes them into a new JSON object at read time | JSONPath |
| Compute | View | Folds events into a per-key state table, maintained on every write | JSONata |
| Compute | Atomic function | Composes admin-API calls inside one transaction | JSONata |
| Compute | Trigger | Fires an atomic function when a matching event arrives | JSONata (condition) |
| Compute | Cron job | Fires an atomic function on a schedule | (schedule) |
| Export | Export endpoint | Maps one output to a URL for GET, SSE, and webhooks | (none) |
| Export | Webhook | At-least-once signed POST to your URL on each match | (none) |
| Export | MQTT push | Publishes each match to an external broker | (none) |
| Access | Scope | A namespace:action permission string checked on protected reads | (none) |
| Access | OAuth2 client | An app or device principal (PKCE, Auth Code, or Client Credentials) | (none) |
| Access | End-user | A 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 happens | On each read, re-reading the matching log slice | On each write, folding the event into stored state |
| Shape | A filtered, reshaped stream of events | One row per key, holding folded state |
| Reads | History, ranges, the full series | Point 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 rule | Re-read the log, no backfill needed | Applies going forward; existing rows keep prior state |
| Language | JSONPath constructor | JSONata 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:
- Directly:
POST /functions/{workspace}/{name}, for synchronous request/response operations. - By a trigger: bind the function to a source with a JSONata condition. Every matching event fires the function automatically, turning "POST and wait" into an event-driven workflow. An optional reply-to source captures each invocation's outcome as a new event, which you can then export, stream, or webhook like any other.
- By a cron job: run the function every N seconds or on a cron expression, for rollups, sweeps, and scheduled maintenance.
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:
| Call | Signature | Returns | Notes |
|---|---|---|---|
$iotman_call | $iotman_call(method, path, body) | response body on 2xx | Loopback 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) | cond | Guard. If cond is falsy, abort with status and message; otherwise returns cond so it composes in a pipeline. |
$fail | $fail(status, message) | throws | Unconditional abort with status and message. |
$send_email | $send_email(to, subject, body) | queue id | Enqueues an email, delivered asynchronously via the workspace SMTP config (platform default as fallback). |
$ftp_upload | $ftp_upload(profile_id, remote_path, content) | bytes written | Uploads content to remote_path over SFTP using a connection profile (its UUID), configured in Workspace settings. |
Two things to keep straight:
$requireand$failare user-intent errors. They ride the trigger's reply channel as the function's authored response; they do not mark the trigger binding as failing. Authoring and runtime errors do.- Transactional vs external effects.
$iotman_calljoins the function's transaction, so it rolls back with a later abort.$ftp_uploadperforms its upload immediately and is not rolled back: order it last, after every$requireguard has passed.$send_emailonly enqueues; delivery happens after commit.
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.
| Mode | URL / mechanism | Guarantee | Use when |
|---|---|---|---|
| Pull | GET on the endpoint, paginated | History, replayable, on demand | Dashboards, reports, backfills |
| Live stream | /events (SSE), /stream (WebSocket, legacy) | Live-only, no replay, drops on lag | Real-time UI where missing a frame is fine |
| Push (webhook) | Signed outbound POST per match | At-least-once with retries, dedupe on event id | Reliable delivery to a system you control |
| Push (MQTT) | Publish to an external broker per match | At-least-once with retries | Feeding 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.
| Principal | Who | Authenticates with | Manages the workspace? |
|---|---|---|---|
| Member | A person on your team | One-time passcode (OTP) to the dashboard | Yes |
| End-user | A person who uses your app | One-time passcode (OTP) via PKCE | No |
| OAuth2 client | A device or service (M2M) | Client Credentials, or PKCE/Auth Code on a user's behalf | No |
Scopes (namespace:action) are the access boundary. Every protected read checks them. There are two namespaces:
- Workspace scopes you define (
sensor:read,video:gold): gate your data endpoints and views. - Internal-API scopes (
iotman:endpoints:create, …): let a token call the workspace management API directly. This is how an agent or MCP server configures a workspace on a user's behalf. Grant them via the Read-only / Operator / Full-access presets and narrow once setup is done.
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 service | HTTP endpoint (Connect) |
| Pull from an existing MQTT broker | MQTT connector (Connect) |
| Reject or flag malformed payloads at ingest | Source JSON Schema in reject or warn mode |
| Label events so you can select them later | Tag rule (Process) |
| Expose a clean, reshaped read of the event history | Output + export endpoint |
| Answer "current state per entity" with fast lookups | View |
| Compute analytics (sums, ranges, percentiles) | Output over the raw stream, queried with from/to |
| Run a multi-step operation atomically | Atomic function, called directly |
| Do something automatically when an event arrives | Trigger → atomic function |
| Do something on a schedule | Cron job → atomic function |
| Capture a reaction's result as data | Trigger reply-to source |
| Route each event to a destination chosen per-key by looked-up state | Trigger + atomic function reading a routing view |
| Push every match to a system you control, reliably | Webhook |
| Drive a live UI | SSE stream (/events) |
| Let each end-user see only their own data | View with a scope-field body + PKCE end-users |
| Gate a read behind a permission | Scope + required-scope on the endpoint or view |
| Let an agent configure the workspace | iotman: 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 rowsIdentity 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 channelThe 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
- Polling an output to find the latest value. That is current-state-per-key; use a view.
- Building a view for analytics. Sums, ranges, and percentiles read the raw stream through an output, not a per-key fold.
- Putting stateless classification inside an atomic function. If a label depends only on the payload, it is a tag rule (declarative, applied at ingest). Reach for a function only when the decision needs looked-up state or a side effect, as in the view-driven routing pattern above.
- Using an SSE stream where delivery must be guaranteed. Streams are live-only and drop on lag; use a webhook and dedupe on the event id.
- Polling a view on a cron when the work is event-driven. If the trigger is "an event arrived", use a trigger, not a schedule.
- Granting an agent or worker
Full accessand leaving it there. Start broad to bootstrap, then narrow to the smallest preset that keeps the app running. - Mixing JSONPath and JSONata. Process expressions are JSONPath; Compute expressions are JSONata. A constructor that works in an output will not work in a view.
Building it
The recommended path, especially for an agent:
- Model the flow against the decision framework: name the sources, the derivations, the reactions, and the scopes before creating anything.
- 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.
- 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.
- 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.