This workflow follows the Execute Workflow Trigger → Google Sheets recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "upkeep-digest",
"nodes": [
{
"parameters": {
"inputSource": "passthrough"
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
-420,
300
],
"id": "b1000000-0000-4000-8000-000000000001",
"name": "Called"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "YOUR_FAILEDITEMS_SHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "FailedItems",
"mode": "name"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
-180,
300
],
"id": "b1000000-0000-4000-8000-000000000002",
"name": "Get Failures",
"alwaysOutputData": true,
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "YOUR_BILLING_LEDGER_SPREADSHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Tasks",
"mode": "name"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
60,
300
],
"id": "b1000000-0000-4000-8000-000000000003",
"name": "Get Tasks",
"alwaysOutputData": true,
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// Briefing plugin \u2014 the UPKEEP section.\n//\n// Contract: 05_daily-briefing/docs/briefing-plugin-spec.md\n// Design and the reason behind every filter: 10_error-handler/docs/upkeep-tasks-spec.md\n//\n// PLAIN TEXT ONLY \u2014 never HTML. The briefing escapes centrally, and error text is\n// exactly the kind of string that carries angle brackets. One unescaped '<' fails\n// the whole sendMessage and the morning message never arrives.\n\n// ---- tuning, all read-side on purpose -------------------------------------\n// 010 only labels and counts; every policy decision lives here, so tuning never\n// means editing the error path.\nconst THRESHOLD = 3; // identical occurrences before a signature is a defect\n// EVERY row is tappable \u2014 each one is a thing you either did or handed to someone.\n// The ceiling is a blast guard, not a display limit: each action is its own Telegram\n// message, and the section's text also competes for the 4096-char cap on the main\n// one. Fifteen is well above the real list (8) and well below anything that breaks\n// the morning. Overflow is REPORTED, never silent.\nconst MAX_ITEMS = 15;\nconst SIG_CHARS = 46;\nconst CB_LIMIT = 60; // Telegram caps callback_data at 64 BYTES and rejects the\n // entire keyboard if one button is over\nconst COMMITMENT = 'self::code-health';\n\n// Tier gate \u2014 root CLAUDE.md \u00a7 User tiers. 'off' | 'fix' | 'report'\n// off text only, no buttons. The default for anyone who has not chosen.\n// fix \u2705 Done / \ud83e\udd16 Assign \u2014 a reader who edits these workflows (T3)\n// report NOT BUILT. See the spec \u00a7 Beyond.\n// This is not a permission check. Setting 'fix' on a box whose owner does not code\n// grants nothing \u2014 it just puts a useless button on a message.\nconst ACTION_MODE = 'fix';\n\nconst input = $('Called').first().json;\nconst parsed = Date.parse(String(input.date || '').slice(0, 10) + 'T00:00:00');\nconst nowTs = Number.isFinite(parsed) ? parsed : Date.now();\n\nconst lower = (v) => String(v ?? '').trim().toLowerCase();\nconst ts = (v) => { const n = Date.parse(String(v || '')); return Number.isFinite(n) ? n : null; };\nconst dayOf = (n) => new Date(n).toISOString().slice(0, 10);\n\nconst failures = $('Get Failures').all().map((i) => i.json).filter((r) => r && r.fingerprint);\nconst tasks = $('Get Tasks').all().map((i) => i.json).filter((r) => r && r.task_id);\nconst byId = new Map(tasks.map((t) => [String(t.task_id).trim(), t]));\n\n// ---- OBSERVED \u2014 grouped from the failure log, never counted into a column --\n// Counting at write time would race: failures arrive in bursts (four in eleven\n// minutes, in the case this was built for) and Sheets read-then-write is not\n// atomic, so two handlers both read 3 and both write 4. Worse, an upsert could\n// reset a status a human set. Events are counted at read; state is stored.\nconst groups = new Map();\nfor (const r of failures) {\n const fp = String(r.fingerprint).trim();\n if (!fp) continue;\n let g = groups.get(fp);\n if (!g) groups.set(fp, (g = { fp, n: 0, first: null, last: null, row: r }));\n g.n++;\n const t = ts(r.timestamp);\n if (t === null) continue;\n if (g.first === null || t < g.first) g.first = t;\n if (g.last === null || t > g.last) { g.last = t; g.row = r; }\n}\n\nconst observed = [];\nfor (const g of groups.values()) {\n if (g.n < THRESHOLD) continue;\n\n // RECURRENCE PROMOTES, IT NEVER DEMOTES.\n // Repeating identically is not evidence of a defect \u2014 the two largest recurring\n // signatures on this instance are rate limits and task-runner timeouts, which is\n // precisely what retry exists for. Recurrence can lift an already-non-retryable\n // error to \"defect\"; the classifier keeps the veto on everything else.\n // Empty / unknown counts as retryable, so an unclassifiable row is never elevated.\n if (lower(g.row.is_retryable) !== 'false') continue;\n\n const taskId = COMMITMENT + '@' + g.fp;\n const t = byId.get(taskId);\n const st = lower(t && t.status);\n\n if (st === 'wont_fix') continue;\n\n // Optimistic hide, self-auditing. 'done' drops the row \u2014 but a NEW occurrence\n // after closed_at is direct proof the fix did not work, so it comes back on its\n // own. No reopen branch, no second book to check against.\n let reopened = false;\n if (st === 'done') {\n const closed = ts(t.closed_at);\n if (closed === null || g.last === null || g.last <= closed) continue;\n reopened = true;\n }\n observed.push({ g, taskId, reopened, status: st });\n}\nobserved.sort((a, b) => (b.g.last || 0) - (a.g.last || 0));\n\n// 'assigned' stays on the list and says so. Nothing acts on an assignment yet, so a\n// row sits at \u23f3 until it is closed by hand \u2014 which is the honest display, because it\n// is genuinely not done. Presence is the default; absence is the achievement.\nconst stateSuffix = (st) => (st === 'assigned' ? ' \u00b7 \u23f3 assigned'\n : st === 'failed' ? ' \u00b7 \u26a0\ufe0f failed' : '');\n\n// Cutting the signature can bisect a normalisation placeholder and leave a bare\n// '<' (\"...item at index <n\" ). The briefing's esc() makes that safe rather than\n// dangerous, but it still reads as broken, so trim back to the last clean break.\nconst clipSig = (s) => {\n let out = String(s || '').slice(0, SIG_CHARS);\n const lt = out.lastIndexOf('<');\n if (lt > -1 && !out.slice(lt).includes('>')) out = out.slice(0, lt);\n return out.trim();\n};\n\nconst observedLine = ({ g, reopened, status }) => {\n const wf = String(g.row.workflow_name || 'unknown').replace(/\\.n8n$/, '');\n const node = String(g.row.failed_node || '?');\n const sig = clipSig(g.row.error_signature);\n const since = g.first === null ? 'unknown' : dayOf(g.first);\n return wf + ' \u00b7 ' + node + ' \u00b7 ' + sig + ' \u00b7 ' + g.n + '\u00d7 since ' + since\n + (reopened ? ' \u00b7 \u26a0\ufe0f recurred after being marked fixed' : '')\n + stateSuffix(status);\n};\n\n// ---- DECLARED \u2014 hand-typed obligations, the Open thread blocks -------------\n// No parser reads CLAUDE.md. That file is prose written for an LLM; turning it\n// into a data source would put format constraints on the one file loaded into\n// every session. The block is the explanation, the row is the occurrence.\nconst declared = tasks\n .filter((t) => lower(t.commitment_id) === COMMITMENT && lower(t.source) === 'declared')\n .filter((t) => !['done', 'wont_fix', 'cancelled'].includes(lower(t.status)))\n .sort((a, b) => String(a.task_id).localeCompare(String(b.task_id)));\n\nconst declaredLine = (t) => {\n const txt = String(t.action || t.notes || String(t.task_id).split('@')[1] || t.task_id).trim();\n return txt + stateSuffix(lower(t.status));\n};\n\n// ---- render ---------------------------------------------------------------\n// No silent caps: when the list is trimmed the section says so, because a\n// truncated list that looks complete is worse than a long one.\n// Text and buttons are cut by the SAME budget, shared across both headings, so a row\n// can never appear in the list without its buttons or the reverse.\nconst shown = [\n ...observed.map((o) => ({ id: o.taskId, text: observedLine(o) })),\n ...declared.map((t) => ({ id: String(t.task_id).trim(), text: declaredLine(t) })),\n].slice(0, MAX_ITEMS);\nconst dropped = observed.length + declared.length - shown.length;\n\nconst blocks = [];\nconst seen = new Set(shown.map((s) => s.id));\nconst obsItems = observed.filter((o) => seen.has(o.taskId)).map(observedLine);\nconst decItems = declared.filter((t) => seen.has(String(t.task_id).trim())).map(declaredLine);\nif (obsItems.length) blocks.push({ heading: 'Observed', items: obsItems });\nif (decItems.length) blocks.push({ heading: 'Declared', items: decItems });\n// No silent caps: a truncated list that looks complete is worse than a long one.\nif (dropped > 0) blocks.push({ items: ['\u2026 +' + dropped + ' more, not shown and not tappable'] });\n\n// ---- actions --------------------------------------------------------------\n// EVERY shown row gets its own two buttons \u2014 each is a separate thing you either\n// did or handed off, so a shared button would be a lie about which one you meant.\n//\n// Exactly two: the keyboard is a static fixedCollection, so the count cannot vary\n// at runtime, and Telegram rejects the ENTIRE keyboard if one button is invalid.\n//\n// The same two verbs as the commitments plugin, and for the same reason \u2014 they are\n// different claims. 'd' asserts the work is finished; 'a' hands it to an assistant\n// and leaves it OPEN. Nothing acts on an assignment yet, so an assigned row keeps\n// showing up at \u23f3 until it is genuinely closed. That is correct, not a gap.\nconst actions = [];\nif (ACTION_MODE === 'fix') {\n for (const row of shown) {\n const done = 'tk|d|' + row.id;\n const assign = 'tk|a|' + row.id;\n if (done.length > CB_LIMIT || assign.length > CB_LIMIT) continue;\n actions.push({\n text: row.text.slice(0, 120),\n buttons: [\n { label: '\u2705 Done', cb: done },\n { label: '\ud83e\udd16 Assign', cb: assign },\n ],\n });\n }\n}\n\n// Always exactly one item, even with nothing to report \u2014 returning zero items is\n// indistinguishable from a crash. Silence here answers a question you would\n// otherwise go and check, so the section prints rather than disappearing.\nreturn [{\n json: {\n key: 'upkeep',\n icon: '\ud83d\udd27',\n title: 'UPKEEP',\n blocks,\n actions,\n empty: 'Nothing outstanding',\n },\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
300,
300
],
"id": "b1000000-0000-4000-8000-000000000004",
"name": "Build Section"
},
{
"parameters": {
"content": "## upkeep-digest \u2014 the UPKEEP briefing section\n\nOne morning section, **two producers**:\n\n- **Observed** \u2014 signatures that recurred in `FailedItems` and the classifier\n already judged non-retryable. Grouped at READ time, never counted into a column.\n- **Declared** \u2014 `Tasks` rows typed by hand, `commitment_id = self::code-health`,\n `source = declared`. The Open thread blocks in the root CLAUDE.md.\n\nBoth close by tap through **16_commitments-ledger** \u2014 same `Tasks` table, same\n`task_id` key, same handler. Nothing here writes.\n\nFull design: `10_error-handler/docs/upkeep-tasks-spec.md`",
"height": 320,
"width": 460,
"color": 4
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-420,
-60
],
"id": "b1000000-0000-4000-8000-000000000005",
"name": "Sticky - Overview"
},
{
"parameters": {
"content": "## After importing \u2014 4 things\n\n1. **Get Failures** \u2192 pick the FailedItems document + `FailedItems` sheet.\n2. **Get Tasks** \u2192 pick the **Billing_Ledger** document + `Tasks` sheet.\n \u26a0\ufe0f Two DIFFERENT spreadsheets. Check which one before copying a node.\n3. **ACTIVATE this workflow.** A subworkflow whose only trigger is an Execute\n Workflow Trigger still has to be active, or the briefing renders\n `\u26a0\ufe0f UPKEEP unavailable` and the symptom names no cause.\n4. Add the registry row in `05_daily-briefing` \u2192 **Plugins**.\n\n**`FailedItems` needs the `fingerprint` and `error_signature` headers**, or\n`autoMapInputData` in 010 drops them silently and this section stays empty.\n\n**`ACTION_MODE`** in *Build Section* is `'fix'` \u2014 for a reader who edits these\nworkflows. Set it to `'off'` if that is not you: text, no buttons.",
"height": 400,
"width": 460,
"color": 3
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
80,
-60
],
"id": "b1000000-0000-4000-8000-000000000006",
"name": "Sticky - Setup"
}
],
"connections": {
"Called": {
"main": [
[
{
"node": "Get Failures",
"type": "main",
"index": 0
}
]
]
},
"Get Failures": {
"main": [
[
{
"node": "Get Tasks",
"type": "main",
"index": 0
}
]
]
},
"Get Tasks": {
"main": [
[
{
"node": "Build Section",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"errorWorkflow": "YOUR_ERROR_WORKFLOW_ID",
"timezone": "Europe/Zurich",
"saveDataSuccessExecution": "none"
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleSheetsOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
upkeep-digest. Uses executeWorkflowTrigger, googleSheets. Event-driven trigger; 6 nodes.
Source: https://github.com/runfish5/micro-services/blob/main/projects/n8n/10_error-handler/workflows/upkeep-digest.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
NDA Import. Uses httpRequest, googleSheets, jira, executeWorkflowTrigger. Event-driven trigger; 30 nodes.
12 - Market Intelligence Report Builder (Stakeholder v0.3). Uses googleSheets, httpRequest, executeWorkflowTrigger. Event-driven trigger; 27 nodes.
Google Maps Email Scraper Template. Uses removeDuplicates, splitInBatches, httpRequest, splitOut. Event-driven trigger; 26 nodes.
Splitout Comparedatasets. Uses manualTrigger, gong, stickyNote, executeWorkflow. Event-driven trigger; 26 nodes.
Streamline your sales call analysis with CallForge, an automated workflow that extracts, enriches, and refines Gong.io call data for AI-driven insights.