FlowKit

AI-generated audit summary reports in n8n: from Supabase to email

Published 18 July 2026 · 7 min read

A well-run audit questionnaire produces dozens of timestamped answers in a database — but a database doesn't get read in a management meeting. Someone has to go through every answer, spot the non-conformities, prioritize them, and write a presentable summary. That's the last mile of an audit, and it's almost always the part that gets rushed for lack of time. This tutorial builds the n8n workflow that does this work on demand: reading answers from Supabase, summarizing them with a language model constrained to a precise format, and sending a readable HTML report.

This workflow completes two earlier articles: collecting timestamped answers, covered in our guide on GDPR audit trails with n8n and Supabase, and chasing incomplete files, detailed in automating follow-ups for incomplete case files. The summary report is the third building block: the one that turns rows into a decision.

Why not let an LLM write the report freestyle

It's tempting to wire up an AI Agent with a one-line instruction like "write an audit report" and let it query the database on its own. Two concrete problems follow: the model can summarize from memory instead of citing the actual answers (an invented non-conformity in an audit report is not a small detail), and the output format drifts from one run to the next — impossible to archive or compare across files.

The right architecture here isn't an autonomous agent but a Basic LLM Chain (chainLlm): a prompt goes in, a structured answer comes out, nothing more. The context — the file's actual answers — is explicitly injected into the prompt by the workflow, not fetched by the model itself. It's the same logic covered in our guide to n8n's AI nodes: when the task is deterministic and fits in one sentence, the simple chain beats the agent, on both reliability and cost.

Architecture in four building blocks

  1. Trigger — an Execute Sub-workflow Trigger (called from an internal form or a "Generate report" webhook) or, more simply to start, a Manual Trigger with a case_id input field.
  2. Data fetch — a Supabase node that reads every row from audit_responses for that file, ordered by timestamp.
  3. Summarization — a Basic LLM Chain with a Structured Output Parser, which receives the formatted answers and returns structured JSON (summary, strengths, non-conformities, action plan).
  4. Delivery — a Send Email node that turns the JSON into a readable HTML email sent to the file's owner.

Step 1: gather a file's answers

The Supabase node, Get Many Rows operation, table audit_responses, filter case_id = {{ $json.case_id }}, sorted by created_at ascending. This is exactly the table described in our audit trail guide: every row carries question, answer, and a server timestamp — the guarantee that the report rests on recorded facts, not a reconstruction after the fact.

Step 2: format the context before sending it to the model

A Code node (JavaScript) turns the array of rows into a numbered text block, easier for the model to reason over than raw JSON:

const lines = items.map((item, i) => {
  const { question, answer } = item.json;
  return `${i + 1}. Q: ${question}\n   A: ${answer}`;
});
return [{ json: { context: lines.join("\n\n"), case_id: items[0].json.case_id } }];

This step has an important side effect: it fixes the exact number of questions handled. If the file has 8 answers, the prompt sees 8 — no risk of the model skipping one or inventing a ninth while summarizing "from memory."

Step 3: the prompt and the structured output schema

The system message is where the report's reliability actually gets decided:

You are a quality auditor. Here are the answers of an audit file, in order:

{{ $json.context }}

Write a factual summary. Mandatory rules:
- Every non-conformity must cite the number of the relevant question.
- Never invent an answer: if a question has no answer, mark it as "not provided".
- The action plan contains between 1 and 5 actions, ranked by decreasing priority.
- Answer only in English, neutral and professional tone.

The Structured Output Parser then enforces the JSON schema, exactly as described in our invoice data extraction guide — the same mechanism, applied here to a report rather than an invoice field:

{
  "type": "object",
  "properties": {
    "summary": { "type": "string", "description": "3 to 5 sentence summary" },
    "strengths": { "type": "array", "items": { "type": "string" } },
    "non_conformities": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "question_num": { "type": "integer" },
          "description": { "type": "string" },
          "severity": { "type": "string", "enum": ["minor", "major", "critical"] }
        },
        "required": ["question_num", "description", "severity"]
      }
    },
    "action_plan": { "type": "array", "items": { "type": "string" } }
  },
  "required": ["summary", "strengths", "non_conformities", "action_plan"]
}

Constraining severity to an enum rather than free text changes everything downstream: the next node can sort or color-code non-conformities by severity without going back through a prompt.

Step 4: turn the JSON into a readable HTML email

A Send Email (SMTP) node builds the message body with expressions, iterating over the arrays with a small inline function rather than an external template:

<h2>Audit report — file {{ $json.case_id }}</h2>
<p>{{ $json.summary }}</p>
<h3>Strengths</h3>
<ul>{{ $json.strengths.map(p => `<li>${p}</li>`).join('') }}</ul>
<h3>Non-conformities</h3>
<ul>{{ $json.non_conformities.map(n => `<li><b>[${n.severity}]</b> Q${n.question_num} — ${n.description}</li>`).join('') }}</ul>
<h3>Action plan</h3>
<ol>{{ $json.action_plan.map(a => `<li>${a}</li>`).join('') }}</ol>

An HTML email reads on any client with no external dependency — the choice made by the report workflow in the Compliance & Audit Pack. If an archivable PDF is genuinely needed (signature, regulatory filing), n8n has no native conversion node: an HTTP Request node to a service like ConvertAPI or PDF4me adds that step at the end of the chain, without changing the rest of the workflow.

The complete workflow, ready to import

{
  "name": "Audit summary report",
  "nodes": [
    { "id": "1", "name": "Manual trigger", "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1, "position": [0, 0], "parameters": {} },
    { "id": "2", "name": "File answers", "type": "n8n-nodes-base.supabase",
      "typeVersion": 1, "position": [220, 0],
      "parameters": { "operation": "getAll", "tableId": "audit_responses",
        "filters": { "conditions": [{ "keyName": "case_id", "condition": "eq", "keyValue": "={{ $json.case_id }}" }] },
        "sort": { "rules": [{ "field": "created_at", "direction": "ASC" }] } } },
    { "id": "3", "name": "Format context", "type": "n8n-nodes-base.code",
      "typeVersion": 2, "position": [440, 0],
      "parameters": { "jsCode": "const lines = items.map((item, i) => { const { question, answer } = item.json; return `${i + 1}. Q: ${question}\\n   A: ${answer}`; }); return [{ json: { context: lines.join('\\n\\n'), case_id: items[0].json.case_id } }];" } },
    { "id": "4", "name": "AI summary", "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.5, "position": [660, 0],
      "parameters": { "promptType": "define",
        "text": "=Here are the answers of an audit file, in order:\n\n{{ $json.context }}\n\nWrite a factual summary. Every non-conformity must cite the relevant question number. Never invent an answer. The action plan contains between 1 and 5 prioritized actions. Answer in English." },
        "hasOutputParser": true } },
    { "id": "5", "name": "Chat model", "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1, "position": [620, 220], "parameters": { "model": "gpt-4o-mini" } },
    { "id": "6", "name": "Output format", "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.2, "position": [740, 220],
      "parameters": { "schemaType": "manual", "inputSchema": "// detailed JSON schema in step 3: summary, strengths[], non_conformities[], action_plan[]" } },
    { "id": "7", "name": "Send report", "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1, "position": [880, 0],
      "parameters": { "subject": "=Audit report — file {{ $json.case_id }}",
        "html": "=<!-- detailed HTML template in step 4 --><h2>Audit report — file {{ $json.case_id }}</h2><p>{{ $json.summary }}</p>" } }
  ],
  "connections": {
    "Manual trigger": { "main": [[{ "node": "File answers", "type": "main", "index": 0 }]] },
    "File answers": { "main": [[{ "node": "Format context", "type": "main", "index": 0 }]] },
    "Format context": { "main": [[{ "node": "AI summary", "type": "main", "index": 0 }]] },
    "AI summary": { "main": [[{ "node": "Send report", "type": "main", "index": 0 }]] },
    "Chat model": { "ai_languageModel": [[{ "node": "AI summary", "type": "ai_languageModel", "index": 0 }]] },
    "Output format": { "ai_outputParser": [[{ "node": "AI summary", "type": "ai_outputParser", "index": 0 }]] }
  }
}

Import this skeleton (Workflows → Import from File), wire up the Supabase credential and the chat model, and test with a real case_id: the report arrives in seconds, structured, sourced, ready to be reviewed.

Three pitfalls to know about

  • A file that's too large. Past about fifty answers, the context injected into the prompt gets long and expensive in tokens. Split the report into two passes — a chain that summarizes each section of the questionnaire, then a second one that summarizes the summaries — rather than sending everything at once.
  • Silence isn't compliance. A question with no answer (empty or null answer) must show up explicitly as "not provided" in the prompt, otherwise the model tends to skip it rather than flag it as a gap — exactly the kind of incomplete file covered in our article on automatic follow-ups.
  • The report is not the audit trail. The summary report is a human-readable view, regenerable at any time; the audit trail (the timestamped audit_responses rows) is the proof. Never replace the latter with the former — if an inspector asks for proof that an answer was given, it's the database row that counts, not the summary an LLM made of it.

Going further

Building this pipeline from scratch — Supabase filter, context formatting, structured output schema, HTML template — takes a solid morning the first time, especially to tune a prompt that doesn't skim over non-conformities. The Compliance & Audit Pack (€149) ships this report workflow ready to import, alongside the guided questionnaire bot, the Supabase audit-trail logger, and the automatic reminders for incomplete files — the four building blocks that cover audit collection and follow-up end to end. For a full view of everything FlowKit covers, including email triage and the RAG documentation assistant, the Complete FlowKit Bundle (€269) bundles all three packs.

FAQ

Frequently asked questions

Can the model invent non-conformities that don't exist?

Only if you let it reason without context. The prompt described in this article explicitly constrains the model to cite only non-conformities backed by an actual answer from the file, with a reference to the relevant question. No source answer, no non-conformity — that's a rule you write in black and white in the system message, not a default behavior.

Should the report be sent as a PDF?

n8n has no native HTML-to-PDF node: the simplest and most robust option remains an HTML email, readable everywhere with no external dependency. If a PDF is genuinely required (archiving, signature), an HTTP Request node to a service like ConvertAPI or PDF4me handles the conversion as one extra step.

Does this work for a single case file or several at once?

The workflow described here takes a case file ID as input and produces one report per file — the most common use case (GDPR audit, onboarding, supplier questionnaire). For a consolidated report across several files, aggregate the answers from all relevant files before the summarization step; the prompt then needs to ask for a global view rather than a file-by-file one.

Bundle FlowKit Complet

€269