Worked example: extract once, check daily
A contract register built from two workflows — one that writes it, one that watches it.
This is the pattern most Data Tables end up being: extract once, check daily. Somebody uploads a contract, a run reads it, a person confirms it, and the result becomes a row. From then on a schedule watches the register and emails whoever needs to act.
Two workflows, one table. The table is what connects them — neither workflow knows the other exists.
The table
Create it in Studio → Data Tables, or with a Table · Create table node that has Return existing if it exists ticked so the workflow is safe to re-run.
| Column | Type | Holds |
|---|---|---|
Contract | string | The contract reference. The key. |
Counterparty | string | Who it is with. |
Deadline | date | The date something has to happen by. |
Owner | string | The email address that gets the reminder. |
Status | string | open while it still matters. |
Contract is what makes the whole pattern work: it is the value the upsert matches on, so the same
contract uploaded twice updates one row instead of making two.
Workflow 1 — write the register
Form Trigger — the upload
A Form trigger with a file field named contract and a text
field named owner. The run starts when somebody submits it, and the uploaded file arrives as
{{ trigger.body.contract }}.
AI Prompt — the extraction
An AI Prompt node with the uploaded file attached, asked for the three facts the register needs as JSON:
Read the attached contract and answer with JSON only:
{"contract": "<reference>", "counterparty": "<name>", "deadline": "<YYYY-MM-DD>"}Ask for the deadline as YYYY-MM-DD. A date column accepts ISO 8601 and nothing else, so
"next Tuesday" is a failed node rather than a wrong row.
Human Review — the confirmation
A Human Review node, assigned to whoever owns contracts, with three
response fields: contract (text), deadline (date) and counterparty (text). Put what the model
read into the node's message, so the reviewer is checking a claim rather than starting from a
blank form:
Extracted from the upload: {{ upstream.extract-1.output.text }}
Correct anything that is wrong below.This is the step that makes an extraction trustworthy enough to keep. What the reviewer submits is
what gets written:
{{ upstream.<review node id>.output.response.deadline }}.
Data Table — the upsert
A Row · Upsert row node on the register. Its conditions are the key, and its mapping is the row:
{
"resource": "row",
"operation": "upsert",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Contract register",
"conditions": [
{
"id": "cond-1",
"column": "Contract",
"operator": "eq",
"value": "{{ upstream.review-1.output.response.contract }}",
"valueMode": "expression"
}
],
"conditionMatch": "all",
"mapping": "manual",
"mappingRows": [
{
"id": "map-1",
"column": "Contract",
"value": "{{ upstream.review-1.output.response.contract }}",
"valueMode": "expression"
},
{
"id": "map-2",
"column": "Counterparty",
"value": "{{ upstream.review-1.output.response.counterparty }}",
"valueMode": "expression"
},
{
"id": "map-3",
"column": "Deadline",
"value": "{{ upstream.review-1.output.response.deadline }}",
"valueMode": "expression"
},
{
"id": "map-4",
"column": "Owner",
"value": "{{ trigger.body.owner }}",
"valueMode": "expression"
},
{ "id": "map-5", "column": "Status", "value": "open", "valueMode": "fixed" }
]
}Run it twice with the same contract reference and the register holds one row: the node's output
says "action": "inserted" the first time and "action": "updated" the second.
Tick Dry Run while you are still wiring this up. The node reports what it would write — and
for an upsert that would insert, the exact cells, as result.insertValues — without touching the
register.
Workflow 2 — watch the register
Recurrence — daily
A Recurrence trigger set to run once a day. A scheduled run carries no payload, so everything this workflow needs comes out of the table.
The cutoff date
{{ }} is dot-path substitution, not an expression language: there is no arithmetic in it, so
today + 30 days has to be produced by a node. An AI Prompt node asked for exactly that is the
simplest version:
Answer with today's date plus 30 days, as YYYY-MM-DD, and nothing else.Its output is then {{ upstream.horizon-1.output.text }}.
Data Table — what is coming due
A Row · Get row(s) node: everything still open whose Deadline is at or before the cutoff,
soonest first.
{
"resource": "row",
"operation": "get",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Contract register",
"conditions": [
{ "id": "cond-1", "column": "Status", "operator": "eq", "value": "open", "valueMode": "fixed" },
{
"id": "cond-2",
"column": "Deadline",
"operator": "lte",
"value": "{{ upstream.horizon-1.output.text }}",
"valueMode": "expression"
}
],
"conditionMatch": "all",
"orderBy": [{ "id": "sort-1", "field": "Deadline", "direction": "asc" }],
"returnAll": true
}conditionMatch is all: both have to hold. Switch it to any and you would email about every
open contract and every past deadline, which is not the same question.
Send Email — the reminder
A Send Email node. The rows are
{{ upstream.due-1.output.result.rows }}, and how many there were is
{{ upstream.due-1.output.affected }}.
To send nothing on a quiet day, put an If / Else node in
front of it and branch on {{ upstream.due-1.output.affected }} — or use a Row · Row exists
node, whose {{ upstream.<id>.output.result.exists }} is a plain boolean.
What this pattern gets you
- The register outlives the run. A contract read in October is still found by the schedule in November.
- Corrections stick. A deadline fixed by hand in Studio → Data Tables is what the next day's Get row(s) reads. Nothing re-extracts over the top of it.
- Re-uploading is safe. The upsert's key makes the second upload an update.
- The two workflows stay independent. Replace the extraction with a better one, or the email with a Slack message, and the other half does not change.