Most business processes are a short chain of reads, writes and messages with one awkward step in the middle: someone has to say yes. Rent reminders should go out every Monday, but only after the manager has looked at the list. A repair request sent by email should become a work order, but only once someone accepts the cost.
Teams usually build this from a scheduler, a queue, a mail library and a table of pending approvals, then spend their effort on the edges: the same email fetched twice, the approval that never comes, the restart halfway through a run. InventDB SOAR, the database with the business engine, runs a workflow engine inside the server, next to the data it acts on, and handles those edges there.
This article covers what a workflow is, how each trigger starts a run, how a run stops for a person and resumes, and how you rehearse it before it can send anything. Workflows call a language model from inside the server, so they are part of InventDB SOAR only; InventDB Serverless, the managed database, has no AI inside.
A workflow is a plan of typed steps
You describe a workflow in plain English on the Operate page, for example "every Monday at 8, list the tenants with overdue rent, ask me, then email each one". The AI turns the request into a numbered plan, and you read every step before anything runs. Each step has a kind, its arguments, a one-line narration, an optional name for its result (save_as) and an optional condition (when).
The plan is stored as data and executed by a step runner in the server. The model writes the plan; it does not improvise during a run. Steps pass values with ${...} bindings: a query saved as overdue exposes its rows as ${overdue}, and a later step reads ${overdue.email}. The rent reminder, abridged:
{
"name": "Overdue rent reminders",
"trigger_kind": "cron",
"trigger_spec": { "expr": "0 8 * * 1", "tz": "Europe/London" },
"plan": [
{ "idx": 1, "kind": "sql_query", "save_as": "overdue",
"sql": "SELECT tenant, email, balance FROM pms.leases WHERE balance > 0 AND due_date < '${today}'" },
{ "idx": 2, "kind": "notify_user", "save_as": "decision",
"title": "Send this week's rent reminders?", "also_email": true,
"actions": [ { "id": "approve", "label": "Send", "kind": "approve" },
{ "id": "decline", "label": "Skip", "kind": "decline" } ] },
{ "idx": 3, "kind": "send_email", "when": "${decision.approved}",
"for_each": "overdue", "to": "${overdue.email}",
"subject": "Your rent is overdue", "body": "Balance due: ${overdue.balance}" }
]
}
The server validates a plan before saving it and refuses a bad one with a 400 and the reason, which the authoring AI reads and corrects. For example, a plan that renders a report and never sends or stores it is refused, because the report would be lost.
Every run executes as the workflow's owner. Its queries carry the owner's row rules and its writes pass the same checks as any other write, so a workflow cannot touch rows its owner could not touch by hand. The step kinds are:
| Step | What it does |
|---|---|
sql_query | Reads rows with a SELECT and binds them to a name. |
insert_record, update_record, delete_record | Writes one record as typed JSON through the engine's write path, with foreign-key checks. |
send_email | Sends one email, or one per row with for_each. |
create_calendar_event | Creates an event on the owner's Google Calendar. |
render_report | Renders a saved report template to HTML, once or once per row. |
notify_user, wait_for_event | Ask a person, or wait for a reply or another event, and park the run. |
llm_extract | Turns free text, such as an email body, into an object with a declared schema. |
call_workflow, finish | Run another workflow as a step (up to three levels deep), or end with a summary. |
What starts a run
A trigger never runs a step directly. It appends an event to a system table, and a router in the server reads pending events once a second, matches each to a workflow and creates the run. This one durable queue is what lets the server ignore duplicates, wake parked runs and keep events across a restart.
- A schedule. Five-field cron evaluated in an IANA timezone. The dispatcher checks due schedules every 30 seconds. The next fire time is stored in UTC, so the due check is a string comparison, and after a restart the missed ticks are skipped rather than replayed.
- An incoming email. The dispatcher polls the owner's connected Gmail every 30 seconds. A mailbox holds every kind of mail, so a model compares each new email with the workflow's plain-English trigger description and a run starts only on a match. An email that has already started a run is recognised by its message id and ignored when a later poll fetches it again.
- A record change. The engine writes every committed change to a change log at the one point all writes pass through, and a rolled-back transaction writes nothing. A trigger reader scans the log every 2 seconds and coalesces changes into at most one event per workflow and operation per pass, carrying up to 200 changed ids and a count. A concurrency cap, one by default, counts parked runs as in flight, so a burst of writes produces at most one follow-up run.
- A webhook call. Each webhook workflow has a URL of the form
/wf-hook/{workflow_id}/{secret}. The secret is compared in constant time. For senders that sign their requests, the workflow can also require an HMAC-SHA256 signature over the raw body in a header you name. A call to an inactive workflow gets a 410.
You can also start a run by hand from the console or with POST /api/workflows/{id}/run.
Reaching outside systems
Email goes out from the owner's connected Gmail account. If Gmail is not connected, the server uses the SMTP server you configure, over STARTTLS on port 587 by default, with a plain-text part derived from the HTML. There is no shared platform relay: mail is sent as you, from your account, or the step fails with a message pointing to the Connections screen.
Google Calendar events are created on the owner's calendar through the same connection the console uses. If a Google token has expired mid-run, the run parks for re-authorisation and the owner is notified; once they reconnect, the dispatcher resumes it on its next 30-second tick.
Webhooks work in both directions. Inbound calls start runs, as above. Outbound, a workflow has no generic HTTP step; instead, every record a step writes enters the change log like any other write, so the instance's webhook subscriptions on that type fire for it. The article on webhooks and the change feed covers delivery.
Stopping for a person
A notify_user step with actions posts a notification to the person's inbox in the console. An action is an approve, a decline, a choice, a small form or a dismiss. With also_email, the same notification is emailed with a link per action.
Each link carries a token the instance signs with HMAC. The token is the authorisation, so the person can decide from a phone without signing in. It names the notification, can be bound to one action, and expires after 14 days. A notification resolves once; a second click on any link gets a 409.
While the run waits, nothing in the server waits with it. Its variables and its position in the plan are saved to a table of waiting runs. When the person decides, the server appends an event; the router matches it to the parked run by notification id and resumes the plan at the next step, with ${decision.approved}, ${decision.chosen} and any form fields bound.
Steps whose when depends on the decision are approval-gated. Their writes join one transaction that opens after the resume and commits when that part of the run ends. If a gated step fails, the transaction rolls back, and because uncommitted writes never enter the change log, a rolled-back approval cannot fire a webhook or another workflow. Writes that do not depend on the decision are made as their steps run, before the park.
An approval waits at most 14 days. A sweep on the dispatcher's 30-second tick wakes expired runs with a timeout in place of a decision, so ${decision.approved} is empty and the gated steps are skipped. The wait_for_event step parks the same way with a timeout the plan sets, usually to wait for an email reply: thread ids, reply headers and record ids are matched first, and when a reply has lost its threading headers, a model compares it with the waiting runs' hints and the server accepts only a run id from the list it presented.
Rehearsing before anything is sent
A new workflow is created in sandbox mode and stays inactive until you activate it, so the only runs it can have are test runs. In sandbox mode, email, SMS, calendar and record-write steps return a mocked result marked "sandbox": true; for an email it holds the recipient, the subject and the first 160 characters of the body. Queries still read your live data, so a rehearsal shows exactly who would have been emailed and why. Extraction steps still call the model, so rehearsals count against the AI allowance.
You can rehearse with a sample event and force sandbox mode for one run, even on an active workflow:
POST /api/workflows/{id}/run
Authorization: Bearer <token>
Content-Type: application/json
{
"sample_payload": { "from": "tenant@example.com", "subject": "Leak in unit 4B" },
"sandbox_override": true
}
The run's timeline records every step with its inputs and its result. Activation is a separate call, POST /api/workflows/{id}/activate, which can switch sandbox mode off in the same request.
Runs that leave a record
Each step is written to the run's timeline before and after it executes, so a run that stops for any reason shows exactly which steps completed. A sweep every 60 seconds marks a run with no activity for an hour as failed and records why, so no run stays "running" forever. Every saved change to a trigger or plan creates a version you can roll back to; a rename alone does not.
Where the model is involved
The model writes the plan, decides whether an incoming email matches a trigger description, and matches replies that have lost their headers. Inside a run it is used in two bounded places. An llm_extract step runs at most eight turns, with read-only SQL probes capped at 50 rows and a final call whose parameters are the step's declared schema, so later steps receive the fields they expect. When a step returns nothing useful, a read-only recovery agent gets the same limit. Every other step is deterministic.
Limits
- Workflows are part of InventDB SOAR only.
- The change log records a write or a delete, so a record trigger treats an insert and an update the same way.
- Google Calendar is an action. Calendar changes do not start runs.
- Schedules and email polling run on a 30-second tick, so a run can start up to about half a minute after its scheduled time, and an email-triggered run also waits for the model call that matches it.
- An approval can wait 14 days at most.
- A run acts with its owner's permissions. Changing the owner's role changes what the workflow can read and write.
Using it
In InventDB SOAR, open Operate, describe the workflow, read the plan, rehearse it against your data and activate it. Each workflow keeps its plan, its runs and its pending approvals in one place. Everything the console does is also available under /api/workflows, documented in your instance's live reference at /api/docs, and to AI agents through the workflow tools of the built-in MCP server.