An unattended AI agent needs a narrow job and a morning check. This n8n recipe schedules one read-only agent to turn up to three approved work notes into an internal action digest. It records the schedule slot, source versions, execution and draft in one work-order row. A person decides what, if anything, to do with the draft.
The big change
- What changed: A published n8n schedule can hand one approved work order to a read-only AI agent and save an internal morning draft.
- Why it matters: A fixed source allowlist, durable run row and finite model settings give an owner evidence to inspect before acting on unattended output.
- What to watch: The schedule, model and error alert each need an actual production readback; cost limits do not guarantee one charge or a hard dollar ceiling.
This is a build guide based on n8n's current documentation, checked October 21, 2026. BIG CHANGE did not run an n8n instance, call a model, test an alert or observe a night slot. Labels, model availability, retention, plan limits and provider prices must be checked in the reader's own account.
Before you set the clock
You need an n8n role that can create Data Tables and publish workflows; a permitted model credential; a mailbox and SMTP credential for failure alerts; and a human reviewer. Check that your n8n plan allows the workflow timeout you intend to use, that saved production executions and the needed detail are available under your data policy, and that the model provider permits these notes. Keep sensitive material out of execution history and email. If production execution data is redacted or purged before morning, arrange an authorized way to inspect the source and tool trace; otherwise the acceptance check below cannot pass.
Prepare real, permitted inputs, not sample facts invented for a test. Create an ApprovedSources Data Table with sourceId, version, approved, noteText and lastReviewedAt. Each note is at most 500 words. Only the owner puts reviewed notes here. Create a NightWorkOrders table with slotKey, scheduledAt, deadlineAt, status, reviewer, inputVersion, sourceId1–sourceId3, their corresponding sourceVersion1–sourceVersion3, executionId, startedAt, finishedAt, result, failureReason and, if available, observed usage. Empty source pairs stay empty. Use one ready row for one intended local night slot. Set its slotKey and times from this approved work order, in a consistent date-time format with timezone offset. The trigger's eventual start time does not redefine the slot.
This design expects one owner to maintain unique slot keys and prevent simultaneous manual starts. n8n's Data Table operations document filtered Get and Update, but do not promise an atomic claim. Inspect existing running, done or ambiguous rows and their saved executions before any replay. Never use Upsert to create a replacement result on a retry.
1. Publish a source-only lookup workflow
Create a sub-workflow named Approved note lookup with a When Executed by Another Workflow trigger and declared inputs: slotKey and sourceId, both strings. Put Edit Fields immediately after the trigger, name it Lookup request, and map those two fields from {{ $json.slotKey }} and {{ $json.sourceId }}. Its only successful output will be one approved note; every rejected lookup returns { "status": "noMatch" } with no note text. Connect Data Table Row → Get named Get active work order: NightWorkOrders, Must Match → All Conditions, slotKey Equals {{ $('Lookup request').first().json.slotKey }}, status Equals running, Return All off, Limit 2, Settings → Always Output Data on. A Get replaces the current item, so later nodes use Lookup request by name rather than assuming $json.slotKey is still the request.
Connect Aggregate → All Item Data, name it Collect active rows, set Put Output in Field to rows and Include → All fields. In an If node named One active row?, choose Boolean → is true and paste this expression as the left value:
{{ Array.isArray($json.rows) && $json.rows.length === 1 && $json.rows[0].slotKey === $('Lookup request').first().json.slotKey && $json.rows[0].status === 'running' }}Connect false to Edit Fields returning only status=noMatch and no noteText. The rows.length === 1 check rejects two matches; the matching identity rejects the empty item made by Always Output Data. On true, add Edit Fields named Active row with one object field workOrder = {{ $json.rows[0] }}. Then add If, Allowed source ID?, Boolean → is true with:
{{ Boolean($('Lookup request').first().json.sourceId) && [1,2,3].some(i => $('Active row').first().json.workOrder['sourceId'+i] === $('Lookup request').first().json.sourceId && Boolean($('Active row').first().json.workOrder['sourceVersion'+i])) }}Its false branch returns noMatch without reading note text. On true, use Edit Fields named Expected source to set sourceId = {{ $('Lookup request').first().json.sourceId }} and version from the matching fixed pair. For the version field use:
{{ [1,2,3].map(i => ({ id: $('Active row').first().json.workOrder['sourceId'+i], version: $('Active row').first().json.workOrder['sourceVersion'+i] })).find(p => p.id === $('Lookup request').first().json.sourceId).version }}Now Get ApprovedSources in Get approved source with All Conditions sourceId Equals {{ $('Expected source').first().json.sourceId }}, version Equals {{ $('Expected source').first().json.version }} and approved Equals true; Return All off, Limit 2, Always Output Data on. Aggregate All Item Data into rows in Collect source rows. In If named One approved short source?, use Boolean → is true:
{{ Array.isArray($json.rows) && $json.rows.length === 1 && $json.rows[0].sourceId === $('Expected source').first().json.sourceId && $json.rows[0].version === $('Expected source').first().json.version && $json.rows[0].approved === true && typeof $json.rows[0].noteText === 'string' && $json.rows[0].noteText.trim().length > 0 && $json.rows[0].noteText.trim().split(/\s+/).length <= 500 }}Again, false returns only noMatch. On true, Edit Fields returns status=approved and sourceId, version, noteText from {{ $json.rows[0].sourceId }}, {{ $json.rows[0].version }} and {{ $json.rows[0].noteText }}. Check each expression's preview against a permitted real row in the reader's instance. The noMatch branches must reach an output node, never fall through to the approved return.
Publish this database-sourced lookup workflow. n8n's Call n8n Workflow Tool documentation says an unpublished database workflow fails in production, even if a manual test appeared to work.
2. Build one scheduled work-order run
Create Night digest with Schedule Trigger → Days, one day at the intended local hour and minute. Set Workflow settings → Timezone explicitly. Record that zone, the trigger's daily setting and the work order's scheduledAt/deadlineAt. In workflow settings, save failed and successful production executions, enable Save execution progress if allowed by your data policy, set Timeout Workflow → Timeout After to a finite duration supported by your plan, and select the separate error workflow from step 4. A saved draft workflow does not activate the schedule; publication does. A queue or outage can delay it, so the due-row gate checks the deadline.
After the trigger, add Data Table Row → Get, Get due work orders: NightWorkOrders, Must Match → All Conditions status Equals ready, scheduledAt Less Than Or Equal {{ $now.toISO() }}, deadlineAt Greater Than Or Equal {{ $now.toISO() }}; Return All off, Limit 2, Always Output Data on. Store both time fields as date-time values and check the expression preview in the intended workflow timezone. Add Aggregate → All Item Data named Collect due rows, Put Output in Field → rows, Include → All fields. In If named One due row?, choose Boolean → is true and use:
{{ Array.isArray($json.rows) && $json.rows.length === 1 && Boolean($json.rows[0].slotKey) && Boolean($json.rows[0].reviewer) && Boolean($json.rows[0].inputVersion) && $json.rows[0].status === 'ready' && [1,2,3].some(i => Boolean($json.rows[0]['sourceId'+i]) && Boolean($json.rows[0]['sourceVersion'+i])) && [1,2,3].every(i => Boolean($json.rows[0]['sourceId'+i]) === Boolean($json.rows[0]['sourceVersion'+i])) }}Connect false to Stop And Error before any model request. This catches zero results (including the empty {} sentinel), two results and incomplete source pairs. If several valid work orders are due, this one-slot design stops for the owner to resolve them. On true, Edit Fields named Trusted work order maps each field from the sole array item: slotKey={{ $json.rows[0].slotKey }}, reviewer={{ $json.rows[0].reviewer }}, inputVersion={{ $json.rows[0].inputVersion }}, deadlineAt={{ $json.rows[0].deadlineAt }}, plus sourceId1–sourceId3 and sourceVersion1–sourceVersion3 using the same $json.rows[0].fieldName pattern. Keep this as exactly one item; later nodes refer to it by name because another Get replaces current input.
Use Data Table Row → Update named Mark running, with All Conditions slotKey Equals {{ $('Trusted work order').first().json.slotKey }} and status Equals ready. Set status=running, executionId from the current saved execution, and startedAt from current time; preserve the approved input/version fields. Turn Settings → Always Output Data on for this Update: even when it changes zero rows, the following Get must run and reject that state. This is an update, not an insert. Immediately Get by exact slotKey, executionId and status=running, Limit 2, Always Output Data on. Aggregate All Item Data into rows in Collect running rows; then If → Boolean → is true in One running row? checks:
{{ Array.isArray($json.rows) && $json.rows.length === 1 && $json.rows[0].slotKey === $('Trusted work order').first().json.slotKey && String($json.rows[0].executionId) === String($execution.id) && $json.rows[0].status === 'running' && $json.rows[0].inputVersion === $('Trusted work order').first().json.inputVersion && [1,2,3].every(i => $json.rows[0]['sourceId'+i] === $('Trusted work order').first().json['sourceId'+i] && $json.rows[0]['sourceVersion'+i] === $('Trusted work order').first().json['sourceVersion'+i]) }}Its false branch goes to Stop And Error. If the update affected nothing, state changed, or readback is ambiguous, the agent must not run. Prevent overlapping manual runs; filtered Update and readback are not a database compare-and-swap guarantee.
3. Constrain the AI draft and verify it outside the model
Connect the single readback item to AI Agent and one account-approved OpenAI Chat Model. In the agent, set Prompt → Define below. Map a fixed instruction containing the named Trusted work order's slot key, reviewer and approved source IDs/versions; do not rely on an absent chatInput from Schedule Trigger. In System Message, tell the agent: “For this work order, request only the approved notes through the lookup tool. Return at most three proposed actions, each with its source ID and version, plus gaps or uncertainty. Do not present a claim without a retrieved source. This is a draft for the named human reviewer. Do not send, post, update tasks or make purchases.” These instructions shape output; the workflow gates enforce authority.
Attach only Call n8n Workflow Tool, pointing to the published Approved note lookup workflow. Map its slotKey input as fixed {{ $('Trusted work order').first().json.slotKey }}. Let the model supply only sourceId; the lookup tests that ID against the work order before returning text. Do not attach email, browser, file, code, write or payment tools. Set Max Iterations = 3 and Return Intermediate Steps = on. Turn Require Specific Output Format on and connect Structured Output Parser to its parser port. In the parser choose Schema Type → Define using JSON Schema and enter:
{
"type": "object",
"additionalProperties": false,
"required": ["slotKey", "actions", "gaps", "reviewRequired"],
"properties": {
"slotKey": { "type": "string", "minLength": 1 },
"actions": {
"type": "array",
"maxItems": 3,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["text", "sourceId", "version"],
"properties": {
"text": { "type": "string", "minLength": 1 },
"sourceId": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 }
}
}
},
"gaps": { "type": "array", "items": { "type": "string" } },
"reviewRequired": { "const": true }
}
}Use string versions consistently in the tables. The parser checks shape; it cannot prove that a citation supports a sentence.
On the OpenAI Chat Model, select a model actually available to your approved account. Under Options, set Maximum Number of Tokens for completion to 600, a reviewed per-request Timeout in milliseconds, and Max Retries = 0. Turn off optional built-in web, file and code tools. Also leave each relevant node's Retry On Fail off. Choose a workflow timeout longer than an expected single request yet finite for the overall run. Confirm the chosen model accepts these options and inspect real provider usage in the account. Three iterations and 600 completion tokens bound part of the job, not all input or tool tokens and not currency spending. If a strict nightly dollar ceiling is necessary, verify that the provider or gateway actually enforces it before publishing; a budget alert is not an enforcement control.
Before unattended use, run a permitted manual test and open the AI Agent output. n8n documents the structured parser and Return Intermediate Steps, but does not specify one stable JSON path or inner shape for every model and node version. Add Edit Fields named Evidence map after the agent. Use the expression editor's input panel to map the actual parsed object to an object field draft. Map every Approved note lookup call result into an array field lookupResults, each entry containing exactly status, sourceId and version; include noMatch so a rejected call remains visible. Set a Boolean traceComplete only when the inspected intermediate steps expose every lookup call and its returned status. If your instance cannot expose or map that evidence, route the run to Stop And Error and leave the workflow unpublished until the trace can be checked. Do not substitute model-written citations for tool results or assume $json.output/$json.intermediateSteps is the path without seeing it.
Connect Evidence map to If named Valid cited draft?, select Boolean → is true, and paste this expression. The three mapped fields are the contract to inspect in the test execution; all work-order values still come from the named trusted node:
{{ Boolean($json.traceComplete) && $json.draft?.slotKey === $('Trusted work order').first().json.slotKey && $json.draft?.reviewRequired === true && Array.isArray($json.draft?.actions) && $json.draft.actions.length <= 3 && Array.isArray($json.draft?.gaps) && Array.isArray($json.lookupResults) && $json.lookupResults.length >= 1 && $json.lookupResults.length <= 3 && $json.lookupResults.every(r => r.status === 'approved' && [1,2,3].some(i => r.sourceId === $('Trusted work order').first().json['sourceId'+i] && r.version === $('Trusted work order').first().json['sourceVersion'+i])) && $json.draft.actions.every(a => typeof a.text === 'string' && a.text.trim().length > 0 && $json.lookupResults.some(r => r.status === 'approved' && r.sourceId === a.sourceId && r.version === a.version)) }}Connect false to Stop And Error. On true, insert If named Before deadline? immediately before Mark done, select Boolean → is true, and use:
{{ Boolean($('Trusted work order').first().json.deadlineAt) && DateTime.fromISO($('Trusted work order').first().json.deadlineAt, { setZone: true }).isValid && DateTime.fromISO($('Trusted work order').first().json.deadlineAt, { setZone: true }).toMillis() >= $now.toMillis() }}Its false branch goes to Stop And Error with a deadline-expired reason; only true may finish. n8n's date guidance parses date strings with Luxon, and $now is the current workflow time. Compare instants with the offset-bearing deadlineAt from the trusted work order. A noMatch, missing lookup, extra call, unauthorized ID, changed version, empty or malformed output also stops. In the manual test inspect the lookup sub-workflow's actual returned row and source version, then configure a current-source reread if approval can change during a run. Parser or agent nodes that stop on error never reach Evidence map or a downstream row Update; the saved execution and error workflow must account for those failures. This gate checks the mapped trace and IDs, while the human still checks whether each proposed action follows from its note.
Only a passing, still-on-time draft reaches Mark done, a Data Table Row → Update with All Conditions slotKey, executionId and status=running. Set status=done, the checked result and finishedAt. Turn Settings → Always Output Data on for this Update. Get that exact row back with Limit 2 and Always Output Data on, aggregate into rows, and send any count other than one, identity mismatch or status other than done to Stop And Error. The readback still runs if Update changed zero rows. No email or task action is connected to this result. Save any provider usage exposed by the reader's execution or account; do not infer a bill from node settings.
4. Wire and test the failure notice
Make a separate workflow starting with Error Trigger. In the main workflow settings, select it as Error Workflow. First use If → Boolean → is true with {{ Boolean($json.execution?.id) }}; the false branch sends a generic owner alert with unknown row state and makes no row update. On true, Get NightWorkOrders by executionId Equals {{ $('Error Trigger').first().json.execution.id }}, Limit 2, Always Output Data on. Aggregate All Item Data to field rows in Collect error rows. An If node, Boolean → is true, uses {{ Array.isArray($json.rows) && $json.rows.length === 1 && String($json.rows[0].executionId) === String($('Error Trigger').first().json.execution.id) && $json.rows[0].status === 'running' && Boolean($json.rows[0].slotKey) }}. Its false branch sends the generic alert and leaves rows unchanged. Only true may Update by slotKey, executionId and status=running to failed, with a short reason and time. Turn Always Output Data on for this Update, then Get that exact row with Limit 2 and Always Output Data on. Send the specific failed-row notice only after one matching failed readback; a zero-row update or ambiguous readback goes to the generic unknown-state alert. Keep the exact saved execution ID in either notice. A trigger-stage error may have no execution ID. The error workflow itself can fail, and an unstarted schedule may produce no error notice.
Use a reader-owned Send Email SMTP node to alert the owner. Include the execution ID or trigger failure, workflow name, status and a link to a saved execution when present. Exclude note text and credentials. This email is a workflow action after an error, never an agent tool.
Manually test permitted inputs, including zero and two due rows, unauthorized and missing sources, a changed version, an invalid draft, state readback and the execution trace. Manual execution does not trigger Error Trigger. After publishing, arrange one controlled automatic production test in an approved slot with a deliberately absent approved source. Confirm the saved failed production execution, the exact work row marked failed, and the alert in the owner mailbox. If correlation is missing, confirm the row stayed unchanged and the generic alert arrived. Restore normal inputs by creating a fresh valid slot for the real night run; never silently replay the failed one. This is a test for the reader to perform, not a result BIG CHANGE observed.
5. Inspect the night slot in the morning
Find the intended slotKey in NightWorkOrders, its saved production execution, the lookup sub-workflow executions and the model/tool trace. Match start time, status, executionId, approved source IDs and versions, returned note rows, parsed draft and finishedAt. Read each proposed action against its real note. Accept, edit or reject each one explicitly before acting elsewhere. A done status means the gate passed, not that the prose is true.
If there is no execution, inspect schedule publication, the workflow timezone, instance health and queue before starting a new controlled slot. If the row remains running, is failed, or conflicts with an execution, inspect its exact saved run and provider usage before any retry. Preserve the ambiguous state until reconciled; do not launch two manual starts together. Record actual duration and spend only from the reader's n8n and provider evidence. Output and execution retention may be limited by the instance, while the Data Table row remains the durable state to review under the reader's retention policy.
Sources & further reading
- Schedule Trigger explains the daily schedule, timezone behavior and publication prerequisite; it does not promise exact-time delivery.
- Workflow settings documents execution saving, timeout, timezone and error-workflow controls, including plan-dependent timeout limits.
- Data Table row operations describes Get filters and limits and Update conditions; it does not document an atomic claim.
- Aggregate, If and previous-node expressions support the explicit one-row gates and trusted references after a Get.
- Tools Agent, Call n8n Workflow Tool, OpenAI Chat Model and Structured Output Parser document the prompt, tool, iteration, request and output-shape controls; none verifies the draft's factual claims.
- Error Trigger, error workflows and execution history support failure correlation and next-morning inspection. Error Trigger does not fire for a manual run.



