AutomationFlowsAI & RAG › Rewrite Legal Time Entry Narratives with Sharepoint, Microsoft Graph, and Gpt-4o

Rewrite Legal Time Entry Narratives with Sharepoint, Microsoft Graph, and Gpt-4o

ByAI Solutions @legalgpts on n8n.io

This scheduled workflow pulls attorney time entries and related client billing guidelines from SharePoint via Microsoft Graph, uses OpenAI (GPT-4o) to rewrite narratives and check compliance, writes the AI-reviewed narrative back to SharePoint, and emails a run digest through…

Cron / scheduled trigger★★★★☆ complexityAI-powered18 nodesHTTP RequestOpenAIMicrosoft Outlook
AI & RAG Trigger: Cron / scheduled Nodes: 18 Complexity: ★★★★☆ AI nodes: yes Added:

This workflow corresponds to n8n.io template #17708 — we link there as the canonical source.

This workflow follows the HTTP Request → Microsoft Outlook 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 →

Download .json
{
  "id": "RdBb8pCIxLqLmDNW",
  "name": "N8N-Time Entry AI Narrative",
  "tags": [],
  "nodes": [
    {
      "id": "bfa481d0-d591-4499-afce-d0dd27a7b76e",
      "name": "Sticky Note \u2014 Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -112,
        -112
      ],
      "parameters": {
        "width": 528,
        "height": 1196,
        "content": "### Time Entry AI Narrative & Billing Compliance\n\nTurns rough attorney time-entry drafts into polished, billing-guideline-compliant narratives \u2014 automatically, on a schedule, from SharePoint lists.\n\n### How it works\n1. Every 2 hours the workflow pulls the **Time Entries**, **Projects**, **Billing Guidelines**, and **Clients** SharePoint lists via Microsoft Graph.\n2. Entries with a Narrative and Status = *Complete* are selected; each entry's Project is joined to its Client, and that client's guidelines (plus firm-wide rules with no client, plus the firm rules in Config) are assembled.\n3. GPT-4o rewrites each narrative to uniform best-practice style and checks it against the guidelines, returning strict JSON { aiNarrative, compliant, issues[] }.\n4. The AI Narrative is written back; Status becomes *AI Reviewed* (or *Flagged* when issues are found, with issues appended to Notes).\n5. A run digest email (processed / flagged counts with an issue table) goes to the configured recipients.\n\nStatus is the sole state machine: setting an entry back to *Complete* regenerates and overwrites its AI Narrative.\n\n### Setup\n1. Create the four SharePoint lists \u2014 the included **SharePoint-Lists-Starter.xlsx** has one sheet per list ready for SharePoint's \"create list from Excel\" import.\n2. Fill in **Config \u2014 Client Settings**: site ID, the four list IDs, field internal names, status values, digest recipients, and your firm-wide billing rules.\n3. Attach a generic OAuth2 Microsoft Graph credential to the four Fetch nodes and the Update node (see red note); OpenAI credential to the AI node; Outlook credential to the email node.\n\n### Customization\n- Edit `firmBillingRules` in Config for minimum increments, block-billing bans, and forbidden phrases \u2014 no prompt surgery needed.\n- Add gold-standard example narratives to the AI system prompt for firm-specific voice.\n- Pairs with the LEDES 1998B invoice conversion template from AI Solutions for a complete pre-bill pipeline.\n\nSupport: support@ais.email\nhttps://automatedintelligentsolutions.com"
      },
      "typeVersion": 1
    },
    {
      "id": "eda12ec7-12fd-46db-9212-1043fb22d216",
      "name": "Sticky Note \u2014 Fetch & Filter",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        944,
        -112
      ],
      "parameters": {
        "color": 7,
        "width": 1300,
        "height": 608,
        "content": "## 1 \u2014 Fetch & Filter\nPull Time Entries, Projects, Billing Guidelines, and Clients via Graph, then select entries with Narrative filled and Status = Complete. Joins Project \u2192 Client \u2192 Guidelines."
      },
      "typeVersion": 1
    },
    {
      "id": "9a26f841-5519-483e-9c4a-ed4ecf439f3a",
      "name": "Sticky Note \u2014 AI Loop",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2416,
        208
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 300,
        "content": "## 2 \u2014 AI Processing Loop\nOne entry at a time: GPT-4o rewrites the narrative and checks it against firm + client billing guidelines, returning JSON { aiNarrative, compliant, issues[] }."
      },
      "typeVersion": 1
    },
    {
      "id": "542b9397-e0e2-47ac-825e-96e3b89d3810",
      "name": "Sticky Note \u2014 Digest",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2416,
        -112
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 260,
        "content": "## 3 \u2014 Update & Digest\nWrites AI Narrative + Status = \"AI Reviewed\" (and Notes when flagged) back to SharePoint. After the loop, an HTML run summary is emailed to the configured recipients."
      },
      "typeVersion": 1
    },
    {
      "id": "0c94d062-0c43-4f5f-adea-b29d49a0d4df",
      "name": "Sticky Note \u2014 Column Names Warning",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        464,
        -112
      ],
      "parameters": {
        "color": 3,
        "width": 420,
        "height": 340,
        "content": "### \u26a0\ufe0f Two setup traps\n1. **SharePoint internal column names** rarely match display names (e.g. a column shown as \"Narrative\" may be `field_3`). Check via Graph: `GET /sites/{siteId}/lists/{listId}/columns` and put the *internal* names in Config.\n2. Graph HTTP nodes need a *generic OAuth2* credential (Auth/Token URLs at login.microsoftonline.com, scope: `openid offline_access Sites.ReadWrite.All Mail.Send`) \u2014 not the built-in SharePoint credential."
      },
      "typeVersion": 1
    },
    {
      "id": "9a15478d-ce05-4846-b86d-d9dacdb7c77a",
      "name": "Schedule \u2014 Every 2 Hours",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        992,
        128
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 2
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "76bf8dff-d77a-40fd-a75d-b100f4efeb60",
      "name": "Config \u2014 Client Settings",
      "type": "n8n-nodes-base.set",
      "position": [
        1184,
        128
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "a1",
              "name": "graphBase",
              "type": "string",
              "value": "https://graph.microsoft.com/v1.0"
            },
            {
              "id": "a2",
              "name": "siteId",
              "type": "string",
              "value": "YOUR_TENANT.sharepoint.com,YOUR_SITE_GUID,YOUR_WEB_GUID"
            },
            {
              "id": "a3",
              "name": "timeEntryListId",
              "type": "string",
              "value": "YOUR_TIME_ENTRY_LIST_ID"
            },
            {
              "id": "a4",
              "name": "projectsListId",
              "type": "string",
              "value": "YOUR_PROJECTS_LIST_ID"
            },
            {
              "id": "a5",
              "name": "billingListId",
              "type": "string",
              "value": "YOUR_BILLING_GUIDELINES_LIST_ID"
            },
            {
              "id": "a21",
              "name": "clientsListId",
              "type": "string",
              "value": "YOUR_CLIENTS_LIST_ID"
            },
            {
              "id": "a22",
              "name": "clientTitleField",
              "type": "string",
              "value": "Title"
            },
            {
              "id": "a6",
              "name": "statusToProcess",
              "type": "string",
              "value": "Complete"
            },
            {
              "id": "a7",
              "name": "statusAfter",
              "type": "string",
              "value": "AI Reviewed"
            },
            {
              "id": "a19",
              "name": "statusFlagged",
              "type": "string",
              "value": "Flagged"
            },
            {
              "id": "a20",
              "name": "noteTimezone",
              "type": "string",
              "value": "America/Chicago"
            },
            {
              "id": "a8",
              "name": "fieldNarrative",
              "type": "string",
              "value": "Narrative"
            },
            {
              "id": "a9",
              "name": "fieldAiNarrative",
              "type": "string",
              "value": "AINarrative"
            },
            {
              "id": "a10",
              "name": "fieldStatus",
              "type": "string",
              "value": "Status"
            },
            {
              "id": "a11",
              "name": "fieldNotes",
              "type": "string",
              "value": "Notes"
            },
            {
              "id": "a12",
              "name": "fieldInstruction",
              "type": "string",
              "value": "Instruction"
            },
            {
              "id": "a13",
              "name": "digestTo",
              "type": "string",
              "value": "user@example.com"
            },
            {
              "id": "a14",
              "name": "digestCc",
              "type": "string",
              "value": ""
            },
            {
              "id": "a15",
              "name": "teProjectIdField",
              "type": "string",
              "value": "ProjectLookupId"
            },
            {
              "id": "a16",
              "name": "projClientIdField",
              "type": "string",
              "value": "ClientLookupId"
            },
            {
              "id": "a18",
              "name": "bgClientIdField",
              "type": "string",
              "value": "ClientLookupId"
            },
            {
              "id": "a23",
              "name": "firmBillingRules",
              "type": "string",
              "value": "Minimum time increment is 0.1 hours. No block billing - one task per entry. Never use the phrases 'attention to', 'work on', or 'various matters'. Identify all participants of calls and conferences. Travel time is billed at half rate and must be labeled as travel."
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "cc83640d-4c56-4022-836a-078f1417c72f",
      "name": "Graph \u2014 Fetch Time Entries",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1376,
        128
      ],
      "parameters": {
        "url": "={{ $('Config \u2014 Client Settings').first().json.graphBase }}/sites/{{ $('Config \u2014 Client Settings').first().json.siteId }}/lists/{{ $('Config \u2014 Client Settings').first().json.timeEntryListId }}/items?$expand=fields&$top=500",
        "options": {},
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "headerParameters": {
          "parameters": [
            {
              "name": "Prefer",
              "value": "HonorNonIndexedQueriesWarningMayFailRandomly"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "5e3089c5-25f5-40d5-bdc3-19de9df2f023",
      "name": "Graph \u2014 Fetch Projects",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1552,
        128
      ],
      "parameters": {
        "url": "={{ $('Config \u2014 Client Settings').first().json.graphBase }}/sites/{{ $('Config \u2014 Client Settings').first().json.siteId }}/lists/{{ $('Config \u2014 Client Settings').first().json.projectsListId }}/items?$expand=fields&$top=500",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "58cdc0f6-19d6-44c3-bdcd-c3a05107e7ca",
      "name": "Graph \u2014 Fetch Billing Guidelines",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1728,
        128
      ],
      "parameters": {
        "url": "={{ $('Config \u2014 Client Settings').first().json.graphBase }}/sites/{{ $('Config \u2014 Client Settings').first().json.siteId }}/lists/{{ $('Config \u2014 Client Settings').first().json.billingListId }}/items?$expand=fields&$top=500",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "20dd8990-f428-4a63-80ed-65132fb96ece",
      "name": "Graph \u2014 Fetch Clients",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1904,
        128
      ],
      "parameters": {
        "url": "={{ $('Config \u2014 Client Settings').first().json.graphBase }}/sites/{{ $('Config \u2014 Client Settings').first().json.siteId }}/lists/{{ $('Config \u2014 Client Settings').first().json.clientsListId }}/items?$expand=fields&$top=500",
        "options": {},
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "e318527b-a6a4-432a-984e-4413c6cc45a3",
      "name": "Filter \u2014 Select Entries To Process",
      "type": "n8n-nodes-base.code",
      "position": [
        2096,
        128
      ],
      "parameters": {
        "jsCode": "const cfg = $('Config \u2014 Client Settings').first().json;\nconst te = ($('Graph \u2014 Fetch Time Entries').first().json.value) || [];\nconst projects = ($('Graph \u2014 Fetch Projects').first().json.value) || [];\nconst guidelines = ($('Graph \u2014 Fetch Billing Guidelines').first().json.value) || [];\nconst clients = ($('Graph \u2014 Fetch Clients').first().json.value) || [];\n\nconst projById = {};\nfor (const p of projects) { projById[String(p.id)] = p.fields || {}; }\n\nconst clientNameById = {};\nfor (const c of clients) {\n  const cf = c.fields || {};\n  clientNameById[String(c.id)] = String(cf[cfg.clientTitleField] || '');\n}\n\nconst firmRules = String(cfg.firmBillingRules || '').trim();\n\nconst out = [];\nfor (const item of te) {\n  const f = item.fields || {};\n  const narrative = String(f[cfg.fieldNarrative] || '').trim();\n  const status = String(f[cfg.fieldStatus] || '').trim();\n  if (!narrative) continue;\n  if (status !== cfg.statusToProcess) continue;\n\n  const projLookupId = (f[cfg.teProjectIdField] !== undefined && f[cfg.teProjectIdField] !== null) ? String(f[cfg.teProjectIdField]) : null;\n  const proj = (projLookupId && projById[projLookupId]) ? projById[projLookupId] : null;\n  const clientLookupId = (proj && proj[cfg.projClientIdField] !== undefined && proj[cfg.projClientIdField] !== null) ? String(proj[cfg.projClientIdField]) : null;\n  const clientName = (clientLookupId && clientNameById[clientLookupId]) ? clientNameById[clientLookupId] : '';\n\n  const applicable = [];\n  for (const g of guidelines) {\n    const gf = g.fields || {};\n    const gClientId = (gf[cfg.bgClientIdField] !== undefined && gf[cfg.bgClientIdField] !== null) ? String(gf[cfg.bgClientIdField]) : null;\n    // Guidelines with no client are firm-wide; client-specific ones must match this entry's client\n    if (!gClientId || (clientLookupId && gClientId === clientLookupId)) {\n      const title = String(gf.Title || '').trim();\n      const instruction = String(gf[cfg.fieldInstruction] || '').trim();\n      if (instruction) { applicable.push((title ? title + ': ' : '') + instruction); }\n    }\n  }\n  let guidelinesText = applicable.length\n    ? applicable.map(function (a) { return '- ' + a; }).join('\\n')\n    : 'None on file. Apply general legal billing best practices only.';\n  // Firm-wide rules from Config (block-billing bans, minimum increments, forbidden phrases, etc.)\n  if (firmRules) {\n    guidelinesText = 'FIRM-WIDE BILLING RULES (always apply):\\n' + firmRules + '\\n\\nCLIENT GUIDELINES:\\n' + guidelinesText;\n  }\n\n  out.push({ json: {\n    itemId: String(item.id),\n    title: String(f.Title || ''),\n    narrative: narrative,\n    hours: (f.Hours !== undefined && f.Hours !== null) ? f.Hours : '',\n    projectName: proj ? String(proj.Title || '') : '',\n    clientName: clientName,\n    guidelinesText: guidelinesText,\n    existingNotes: String(f[cfg.fieldNotes] || '')\n  }});\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "44f1967e-71ff-4230-96b1-fcb13f26449b",
      "name": "Loop \u2014 Each Time Entry",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        2288,
        80
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "6894e36c-fcf4-43de-864a-80d71937c609",
      "name": "AI \u2014 Rewrite & Check Narrative",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        2464,
        336
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o",
          "cachedResultName": "GPT-4O"
        },
        "options": {
          "textFormat": {
            "textOptions": {
              "name": "time_entry_review",
              "type": "json_schema",
              "schema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"aiNarrative\": { \"type\": \"string\" },\n    \"compliant\": { \"type\": \"boolean\" },\n    \"issues\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }\n  },\n  \"additionalProperties\": false,\n  \"required\": [\"aiNarrative\", \"compliant\", \"issues\"]\n}",
              "strict": true
            }
          }
        },
        "responses": {
          "values": [
            {
              "role": "system",
              "content": "You are a senior legal billing specialist for a law firm. Your job is to rewrite draft time entry narratives into polished, professional final narratives (the 'AI Narrative') and verify compliance with the client's billing guidelines.\n\nREWRITING RULES (best practices, client acceptance, uniformity):\n- Past tense, active voice, third person; never first person.\n- Be specific about the legal task performed and its purpose, but concise (typically 1-3 sentences).\n- Eliminate vague phrases such as 'attention to', 'work on', 'review file', 'various matters'.\n- No block billing language; describe the single task represented by this entry.\n- No internal jargon, abbreviations, or shorthand; expand them into plain professional English.\n- Uniform style across all entries: sentence case, consistent verb choices (Drafted, Reviewed, Analyzed, Prepared, Conferred with, Corresponded with), no trailing periods inconsistencies.\n- Never invent facts, parties, or work not present in the original narrative. Never reference the hours or change the substance.\n- Where a billing guideline dictates phrasing or content (e.g., identify participants of calls), incorporate it into the rewrite when the original narrative contains the needed facts.\n- The FIRM-WIDE BILLING RULES supplied with each entry always apply, in addition to any client-specific guidelines.\n\nCOMPLIANCE CHECK:\nCompare the entry (narrative, hours) against the applicable billing guidelines. If a guideline conflict cannot be cured by rewording alone (e.g., time caps exceeded, non-billable task types, missing required approvals), list it as an issue. If everything either complies or was cured by the rewrite, issues must be an empty array."
            },
            {
              "content": "=Time entry to review:\nTitle: {{ $json.title }}\nClient: {{ $json.clientName || 'Unknown' }}\nProject: {{ $json.projectName || 'Unknown' }}\nHours: {{ $json.hours }}\n\nOriginal narrative:\n{{ $json.narrative }}\n\nApplicable billing guidelines:\n{{ $json.guidelinesText }}"
            }
          ]
        },
        "builtInTools": {}
      },
      "typeVersion": 2.3
    },
    {
      "id": "e8a59696-a9bd-4e92-849c-6bc50ba8e78c",
      "name": "Parse \u2014 AI Output",
      "type": "n8n-nodes-base.code",
      "position": [
        2768,
        336
      ],
      "parameters": {
        "jsCode": "const cfg = $('Config \u2014 Client Settings').first().json;\nconst entry = $('Loop \u2014 Each Time Entry').first().json;\nconst resp = $input.first().json;\n\n// Extract the model output across possible response shapes.\n// With a strict json_schema output format, output[].content[].text arrives as an ALREADY-PARSED OBJECT.\nlet raw;\nif (Array.isArray(resp.output)) {\n  const msg = resp.output.find(function (o) { return o.type === 'message' && Array.isArray(o.content); });\n  if (msg) {\n    const part = msg.content.find(function (c) { return c.type === 'output_text' || c.text !== undefined; });\n    if (part) { raw = part.text; }\n  }\n}\nif (raw === undefined) {\n  if (resp.message && resp.message.content !== undefined) { raw = resp.message.content; }\n  else if (resp.output_text !== undefined) { raw = resp.output_text; }\n  else if (resp.text !== undefined) { raw = resp.text; }\n  else { raw = resp; }\n}\n\nlet parsed;\nif (typeof raw === 'string') {\n  const clean = raw.replace(/```json|```/g, '').trim();\n  parsed = JSON.parse(clean);\n} else {\n  parsed = raw; // already an object \u2014 use as-is\n}\nif (Array.isArray(parsed)) { parsed = parsed[0]; }\nif ((!parsed || parsed.aiNarrative === undefined) && resp.aiNarrative !== undefined) { parsed = resp; }\n\nconst aiNarrative = String((parsed && parsed.aiNarrative) || '').trim();\nconst issues = (parsed && Array.isArray(parsed.issues)) ? parsed.issues.map(function (i) { return String(i); }) : [];\nconst compliant = (parsed && parsed.compliant !== false) && issues.length === 0;\n\nif (!aiNarrative) { throw new Error('AI returned an empty aiNarrative for item ' + entry.itemId + '. Raw response keys: ' + Object.keys(resp).join(', ')); }\n\nconst body = {};\nbody[cfg.fieldAiNarrative] = aiNarrative;\nbody[cfg.fieldStatus] = issues.length ? cfg.statusFlagged : cfg.statusAfter;\nif (issues.length) {\n  const stamp = new Date().toLocaleString('en-US', {\n    timeZone: cfg.noteTimezone || 'America/Chicago',\n    month: 'short', day: 'numeric', year: 'numeric',\n    hour: 'numeric', minute: '2-digit', hour12: true\n  });\n  const noteLine = stamp + ' \u2014 AI Billing Review: ' + issues.join(' | ');\n  body[cfg.fieldNotes] = entry.existingNotes ? (entry.existingNotes + '\\n' + noteLine) : noteLine;\n}\n\nreturn [{ json: {\n  itemId: entry.itemId,\n  title: entry.title,\n  clientName: entry.clientName,\n  projectName: entry.projectName,\n  aiNarrative: aiNarrative,\n  compliant: compliant,\n  issues: issues,\n  updateBody: body\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "8ed3a580-4bcd-4596-a1c9-739c3e81ad92",
      "name": "Graph \u2014 Update Time Entry",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2976,
        336
      ],
      "parameters": {
        "url": "={{ $('Config \u2014 Client Settings').first().json.graphBase }}/sites/{{ $('Config \u2014 Client Settings').first().json.siteId }}/lists/{{ $('Config \u2014 Client Settings').first().json.timeEntryListId }}/items/{{ $json.itemId }}/fields",
        "method": "PATCH",
        "options": {},
        "jsonBody": "={{ JSON.stringify($json.updateBody) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api"
      },
      "typeVersion": 4.2
    },
    {
      "id": "0243b1df-35d2-4ebe-806e-7e754ae97321",
      "name": "Digest \u2014 Build Summary",
      "type": "n8n-nodes-base.code",
      "position": [
        2528,
        16
      ],
      "parameters": {
        "jsCode": "const cfg = $('Config \u2014 Client Settings').first().json;\n\n// Accumulate results across all loop iterations of the Parse node\nlet results = [];\nlet run = 0;\nwhile (run < 1000) {\n  let items;\n  try { items = $('Parse \u2014 AI Output').all(0, run); } catch (e) { break; }\n  if (!items || !items.length) break;\n  results = results.concat(items.map(function (i) { return i.json; }));\n  run++;\n}\n\nif (!results.length) { return []; }\n\nconst flagged = results.filter(function (r) { return !r.compliant; });\nconst rows = results.map(function (r) {\n  const flag = r.compliant ? '' : ' style=\"background:#fdecea\"';\n  return '<tr' + flag + '>' +\n    '<td style=\"padding:6px 10px;border:1px solid #c9c7c8;\">' + (r.title || '(untitled)') + '</td>' +\n    '<td style=\"padding:6px 10px;border:1px solid #c9c7c8;\">' + (r.clientName || '-') + '</td>' +\n    '<td style=\"padding:6px 10px;border:1px solid #c9c7c8;\">' + (r.compliant ? 'Compliant' : 'Flagged') + '</td>' +\n    '<td style=\"padding:6px 10px;border:1px solid #c9c7c8;\">' + (r.issues.length ? r.issues.join('<br>') : '-') + '</td>' +\n    '</tr>';\n}).join('');\n\nconst html = '<div style=\"font-family:Aptos,Roboto,Arial,sans-serif;color:#002657;\">' +\n  '<h2 style=\"color:#002657;\">AI Narrative Review \u2014 Run Summary</h2>' +\n  '<p><strong>' + results.length + '</strong> time entries processed &nbsp;|&nbsp; <strong>' + flagged.length + '</strong> flagged for billing guideline issues.</p>' +\n  '<table style=\"border-collapse:collapse;font-size:14px;\">' +\n  '<tr style=\"background:#002657;color:#ffffff;\">' +\n  '<th style=\"padding:6px 10px;border:1px solid #c9c7c8;text-align:left;\">Entry</th>' +\n  '<th style=\"padding:6px 10px;border:1px solid #c9c7c8;text-align:left;\">Client</th>' +\n  '<th style=\"padding:6px 10px;border:1px solid #c9c7c8;text-align:left;\">Result</th>' +\n  '<th style=\"padding:6px 10px;border:1px solid #c9c7c8;text-align:left;\">Issues</th>' +\n  '</tr>' + rows + '</table>' +\n  '<p style=\"color:#9e9e9e;font-size:12px;\">Automated by the Time Entry AI Narrative workflow.</p></div>';\n\nreturn [{ json: {\n  subject: 'AI Narrative Review: ' + results.length + ' processed, ' + flagged.length + ' flagged',\n  html: html,\n  to: cfg.digestTo,\n  cc: cfg.digestCc\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "8356ddd0-b46c-4402-b80e-ffc1dfe96cf1",
      "name": "Email \u2014 Send Run Digest",
      "type": "n8n-nodes-base.microsoftOutlook",
      "position": [
        2736,
        16
      ],
      "parameters": {
        "subject": "={{ $json.subject }}",
        "bodyContent": "={{ $json.html }}",
        "toRecipients": "={{ $json.to }}",
        "additionalFields": {
          "ccRecipients": "={{ $json.cc }}",
          "bodyContentType": "html"
        }
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "f42bf757-261e-4655-8387-f359eb170386",
  "nodeGroups": [],
  "connections": {
    "Parse \u2014 AI Output": {
      "main": [
        [
          {
            "node": "Graph \u2014 Update Time Entry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Graph \u2014 Fetch Clients": {
      "main": [
        [
          {
            "node": "Filter \u2014 Select Entries To Process",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Digest \u2014 Build Summary": {
      "main": [
        [
          {
            "node": "Email \u2014 Send Run Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Graph \u2014 Fetch Projects": {
      "main": [
        [
          {
            "node": "Graph \u2014 Fetch Billing Guidelines",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop \u2014 Each Time Entry": {
      "main": [
        [
          {
            "node": "Digest \u2014 Build Summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "AI \u2014 Rewrite & Check Narrative",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config \u2014 Client Settings": {
      "main": [
        [
          {
            "node": "Graph \u2014 Fetch Time Entries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule \u2014 Every 2 Hours": {
      "main": [
        [
          {
            "node": "Config \u2014 Client Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Graph \u2014 Update Time Entry": {
      "main": [
        [
          {
            "node": "Loop \u2014 Each Time Entry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Graph \u2014 Fetch Time Entries": {
      "main": [
        [
          {
            "node": "Graph \u2014 Fetch Projects",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI \u2014 Rewrite & Check Narrative": {
      "main": [
        [
          {
            "node": "Parse \u2014 AI Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Graph \u2014 Fetch Billing Guidelines": {
      "main": [
        [
          {
            "node": "Graph \u2014 Fetch Clients",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter \u2014 Select Entries To Process": {
      "main": [
        [
          {
            "node": "Loop \u2014 Each Time Entry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This scheduled workflow pulls attorney time entries and related client billing guidelines from SharePoint via Microsoft Graph, uses OpenAI (GPT-4o) to rewrite narratives and check compliance, writes the AI-reviewed narrative back to SharePoint, and emails a run digest through…

Source: https://n8n.io/workflows/17708/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Gemini Mockup Generator v1.1. Uses httpRequest, googleGemini, microsoftOutlook. Scheduled trigger; 98 nodes.

HTTP Request, Google Gemini, Microsoft Outlook
AI & RAG

AI Institutional Stock Valuation Engine with Risk Scoring & Scenario Targets

Google Sheets, XML, HTTP Request +3
AI & RAG

Overview This is a production-grade, fully automated stock analysis system built entirely in n8n. It combines institutional-level financial analysis, dual AI model consensus, and a self-improving back

Google Sheets, XML, HTTP Request +3
AI & RAG

Workflow runs at 08:00 (UK), AI stories from TechCrunch, The Verge, and the OpenAI Blog via RSS, AI to select and editorially shape one signal into a post and image prompt, generates and QA-checks a p

RSS Feed Read, OpenAI, Airtable +2
AI & RAG

This automation is a complete end-to-end system designed to find, qualify, and contact B2B leads — fully automated and powered by AI. Searches for target companies on LinkedIn via Ghost Genius API, us

Google Sheets, HTTP Request, OpenAI +1