{
  "id": "GM05l1dpZcmu4BNq",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Trade Instruction Capture and Logger \u2014 Gmail + ChatGPT AI + Google Sheets",
  "tags": [],
  "nodes": [
    {
      "id": "86f3f8bb-e669-4ff5-88c8-5ce3b0b8322a",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1984,
        -224
      ],
      "parameters": {
        "color": 4,
        "width": 636,
        "height": 1332,
        "content": "## Trade Instruction Capture and Logger \u2014 Gmail + ChatGPT AI + Google Sheets\n\nFor trading desks, brokers, and fund ops who receive trade instructions by email and want them captured into a ledger automatically \u2014 without manual data entry. This workflow watches Gmail every minute for emails with the subject Trade Instruction. When one arrives, a ChatGPT AI model reads the body and extracts the key fields (asset, ticker, action, quantity, price, client). Valid instructions are logged to a Google Sheets ledger, the sender gets an automatic confirmation of receipt, and the ops team is notified. Unparseable emails are logged separately and the ops team gets a manual-review alert so nothing is missed.\n\n## This captures, it does not execute\nThis workflow only reads, logs, and acknowledges trade instructions. It does not connect to any brokerage and does not place, execute, or settle any trade. The client confirmation says the instruction was captured, not executed.\n\n## How it works\n- **1. Gmail Trigger \u2014 Watch for Trade Instructions** polls every minute for emails with subject Trade Instruction\n- **2. Code \u2014 Extract Email Context** pulls the full body, sender, subject, and message ID\n- **3. HTTP \u2014 AI Extract Trade Fields** sends the body to a ChatGPT AI model and returns structured JSON\n- **4. Code \u2014 Parse and Validate Trade Data** parses the JSON and validates asset and quantity\n- **5. IF \u2014 Trade Valid?** routes valid versus invalid\n- **6a. Sheets \u2014 Log Valid Trade** and **6b. Sheets \u2014 Log Failed Trade** record either outcome\n- **7. Gmail \u2014 Reply Confirmation to Client** replies to the sender that the instruction was captured\n- **8. Gmail \u2014 Ops Alert (Success)** notifies the ops team of a captured trade\n- **9. Gmail \u2014 Ops Alert (Extraction Failed)** alerts the ops team for manual review\n\n## Sheet setup (two tabs in one sheet)\n- Tab **Valid Trades**: Trade ID, Timestamp, Sender, Asset, Ticker, Action, Quantity, Price, Client, Notes, Subject, Status\n- Tab **Failed Trades**: Timestamp, Sender, Subject, Email Snippet, Failure Reason\n\n## Set up steps\n1. **Gmail** \u2014 connect Gmail OAuth2 and use it in the trigger (node 1), the client reply (node 7), and the ops alerts (nodes 8 and 9). Connect the mailbox that receives trade-instruction emails\n2. **OpenAI** \u2014 in **3. HTTP \u2014 AI Extract Trade Fields**, connect your OpenAI API credential\n3. **Google Sheets** \u2014 connect Google Sheets OAuth2 and replace `YOUR_TRADE_LEDGER_SHEET_ID` in nodes 6a and 6b. Create the two tabs above\n4. **Ops email** \u2014 replace `YOUR_OPS_TEAM_EMAIL` in nodes 8 and 9. The client reply in node 7 goes to the original sender automatically, so it needs no address"
      },
      "typeVersion": 1
    },
    {
      "id": "92f6c893-1191-421c-a991-794051862437",
      "name": "Section \u2014 Gmail Trigger and Body Extract",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1248,
        160
      ],
      "parameters": {
        "color": 5,
        "width": 564,
        "height": 404,
        "content": "## Gmail Trigger and Body Extract\nPolls Gmail every minute for new emails with the subject Trade Instruction. Code extracts the full body, subject, sender, and message ID, and parses the sender's reply address."
      },
      "typeVersion": 1
    },
    {
      "id": "b01ed6ce-1174-4424-ad9b-947a94205bda",
      "name": "Section \u2014 AI Extract, Parse, and Validate",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -640,
        80
      ],
      "parameters": {
        "color": 6,
        "width": 628,
        "height": 644,
        "content": "## AI Extract, Parse, and Validate\nThe ChatGPT AI model extracts the trade fields as JSON. Code parses safely and validates the result: asset must be present and quantity must be greater than zero."
      },
      "typeVersion": 1
    },
    {
      "id": "9b06f6e4-fe7a-4bea-b982-a431f60913bc",
      "name": "Section \u2014 Route, Log, Reply, and Alert",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        64,
        -80
      ],
      "parameters": {
        "color": 4,
        "width": 948,
        "height": 932,
        "content": "## Route, Log, Reply, and Alert\nIF routes valid versus invalid. Valid trades are logged, the client is sent a receipt confirmation, and ops is notified. Invalid emails are logged to the failed tab and ops gets a manual-review alert."
      },
      "typeVersion": 1
    },
    {
      "id": "7b3187bb-70d1-4c92-8ae1-b0d172ad28cc",
      "name": "1. Gmail Trigger \u2014 Watch for Trade Instructions",
      "type": "n8n-nodes-base.gmailTrigger",
      "position": [
        -1104,
        336
      ],
      "parameters": {
        "filters": {
          "q": "subject:\"Trade Instruction\""
        },
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "7f110329-8eca-48f7-9c61-de6ef037307f",
      "name": "2. Code \u2014 Extract Email Context",
      "type": "n8n-nodes-base.code",
      "position": [
        -864,
        336
      ],
      "parameters": {
        "jsCode": "const item = $input.first().json;\n\nconst fullBody  = item.text || item.snippet || '';\nconst sender    = item.From  || item.from   || '';\nconst subject   = item.Subject || item.subject || 'Trade Instruction';\nconst messageId = item.id || '';\nconst threadId  = item.threadId || '';\n\nconst senderEmailMatch = sender.match(/<([^>]+)>/);\nconst senderEmail = senderEmailMatch ? senderEmailMatch[1] : sender.trim();\n\nconst tradeRef = 'TRADE-' + Date.now();\nconst capturedAt = new Date().toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' });\nconst capturedAtISO = new Date().toISOString();\n\nif (!fullBody || fullBody.trim().length < 10) {\n  throw new Error('Email body is empty or too short to extract trade data.');\n}\n\nreturn [{\n  json: {\n    tradeRef,\n    messageId,\n    threadId,\n    sender,\n    senderEmail,\n    subject,\n    fullBody: fullBody.trim(),\n    capturedAt,\n    capturedAtISO\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "3b905a75-b1f4-43d0-9d3f-11c1f16be465",
      "name": "3. HTTP \u2014 AI Extract Trade Fields",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -544,
        336
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/chat/completions",
        "method": "POST",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={\n  \"model\": \"gpt-4o-mini\",\n  \"temperature\": 0.1,\n  \"messages\": [\n    {\n      \"role\": \"system\",\n      \"content\": \"You are a Middle Office Trade Capture Assistant. Extract trade details from email text and return ONLY valid raw JSON. No markdown, no code fences, no explanation. Output must start with { and end with }.\"\n    },\n    {\n      \"role\": \"user\",\n      \"content\": {{ JSON.stringify(\"Extract trade fields from this email.\\n\\nReturn ONLY a JSON object with exactly these fields:\\n{\\n  \\\"asset\\\": \\\"Full asset name e.g. Apple Inc.\\\",\\n  \\\"ticker\\\": \\\"Ticker symbol e.g. AAPL or null if not mentioned\\\",\\n  \\\"action\\\": \\\"buy or sell or null\\\",\\n  \\\"quantity\\\": number,\\n  \\\"price\\\": number or null \u2014 if market order use the target or reference price mentioned, if truly no price return null,\\n  \\\"client\\\": \\\"Client name or null\\\",\\n  \\\"notes\\\": \\\"Any additional instruction from the email e.g. limit order, immediate execution etc.\\\"\\n}\\n\\nRules:\\n- quantity must be a plain number not a string\\n- price must be a plain number not a string\\n- If the email says market order find any targeting price mentioned and use that\\n- If no price at all return null for price\\n- Return ONLY the JSON object\\n\\nEmail:\\n\") + $json.fullBody }}\"\n    }\n  ]\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "nodeCredentialType": "openAiApi"
      },
      "typeVersion": 4.2
    },
    {
      "id": "8905b38a-0d1d-4137-ba98-137ef0e2874f",
      "name": "4. Code \u2014 Parse and Validate Trade Data",
      "type": "n8n-nodes-base.code",
      "position": [
        -256,
        336
      ],
      "parameters": {
        "jsCode": "const resp    = $input.first().json;\nconst context = $('2. Code \u2014 Extract Email Context').first().json;\n\nconst raw = resp?.choices?.[0]?.message?.content || '';\n\nif (!raw) {\n  return [{ json: {\n    ...context,\n    valid: false,\n    failReason: 'AI returned empty response',\n    asset: null, ticker: null, action: null,\n    quantity: null, price: null, client: null\n  }}];\n}\n\nlet trade = {};\ntry {\n  const cleaned = raw.trim()\n    .replace(/^```json\\s*/i, '')\n    .replace(/^```\\s*/i, '')\n    .replace(/```$/i, '')\n    .trim();\n  const start = cleaned.indexOf('{');\n  const end   = cleaned.lastIndexOf('}');\n  if (start !== -1 && end !== -1) {\n    trade = JSON.parse(cleaned.substring(start, end + 1));\n  } else {\n    throw new Error('No JSON object found');\n  }\n} catch (e) {\n  return [{ json: {\n    ...context,\n    valid: false,\n    failReason: 'JSON parse failed: ' + e.message,\n    asset: null, ticker: null, action: null,\n    quantity: null, price: null, client: null\n  }}];\n}\n\nconst asset    = (trade.asset    || '').trim();\nconst ticker   = (trade.ticker   || '').trim();\nconst action   = (trade.action   || '').toLowerCase().trim();\nconst quantity = parseFloat(trade.quantity) || 0;\nconst price    = trade.price !== null && trade.price !== undefined ? parseFloat(trade.price) : null;\nconst client   = (trade.client   || '').trim();\nconst notes    = (trade.notes    || '').trim();\n\nlet failReason = null;\nif (!asset)    failReason = 'Asset name could not be extracted';\nelse if (quantity <= 0) failReason = 'Quantity is zero or invalid (extracted: ' + trade.quantity + ')';\n\nreturn [{ json: {\n  ...context,\n  valid:      !failReason,\n  failReason: failReason || null,\n  asset,\n  ticker:   ticker   || null,\n  action:   action   || null,\n  quantity,\n  price:    price    !== null ? price : null,\n  client:   client   || null,\n  notes:    notes    || null\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "32e08eb3-093f-45ad-8811-5a88914196c8",
      "name": "5. IF \u2014 Trade Valid?",
      "type": "n8n-nodes-base.if",
      "position": [
        160,
        336
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "valid-check",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.valid }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "12edaef3-5f0e-4a98-af14-dc256261930a",
      "name": "6a. Sheets \u2014 Log Valid Trade",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        416,
        192
      ],
      "parameters": {
        "columns": {
          "value": {
            "Asset": "={{ $json.asset }}",
            "Notes": "={{ $json.notes || '' }}",
            "Price": "={{ $json.price || 'Market' }}",
            "Action": "={{ $json.action || '' }}",
            "Client": "={{ $json.client || '' }}",
            "Sender": "={{ $json.sender }}",
            "Status": "Captured",
            "Ticker": "={{ $json.ticker || '' }}",
            "Subject": "={{ $json.subject }}",
            "Quantity": "={{ $json.quantity }}",
            "Trade ID": "={{ $json.tradeRef }}",
            "Timestamp": "={{ $json.capturedAt }}"
          },
          "schema": [
            {
              "id": "Trade ID",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Trade ID",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Timestamp",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Timestamp",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Sender",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Sender",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Asset",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Asset",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Ticker",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Ticker",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Action",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Action",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Quantity",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Quantity",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Price",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Price",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Client",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Client",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Notes",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Notes",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Subject",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Subject",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Valid Trades"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_TRADE_LEDGER_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "51b2db68-5501-415b-abb9-e92819cb8a28",
      "name": "6b. Sheets \u2014 Log Failed Trade",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        416,
        496
      ],
      "parameters": {
        "columns": {
          "value": {
            "Sender": "={{ $json.sender }}",
            "Subject": "={{ $json.subject }}",
            "Timestamp": "={{ $json.capturedAt }}",
            "Email Snippet": "={{ $json.fullBody.substring(0, 300) }}",
            "Failure Reason": "={{ $json.failReason }}"
          },
          "schema": [
            {
              "id": "Timestamp",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Timestamp",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Sender",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Sender",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Subject",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Subject",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Email Snippet",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Email Snippet",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Failure Reason",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Failure Reason",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Failed Trades"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_TRADE_LEDGER_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "318abf7a-b7bf-4ef5-b395-8e8d486f94af",
      "name": "7. Gmail \u2014 Reply Confirmation to Client",
      "type": "n8n-nodes-base.gmail",
      "position": [
        656,
        96
      ],
      "parameters": {
        "message": "=Hello,\n\nYour trade instruction has been received and captured for our records. This is a confirmation of receipt only \u2014 it does not mean the trade has been executed.\n\nTrade Reference: {{ $json.tradeRef }}\nAsset: {{ $json.asset }}{{ $json.ticker ? ' (' + $json.ticker + ')' : '' }}\nAction: {{ $json.action || 'Not specified' }}\nQuantity: {{ $json.quantity }} shares\nPrice: {{ $json.price ? $json.price : 'Market Order' }}\nClient: {{ $json.client || 'Not specified' }}\nTimestamp: {{ $json.capturedAt }}\n\n{{ $json.notes ? 'Notes: ' + $json.notes + '\\n\\n' : '' }}This instruction has been added to the master ledger and your ops team has been notified for processing.\n\nBest regards,\nTrade Capture System",
        "options": {},
        "emailType": "text",
        "messageId": "={{ $json.messageId }}",
        "operation": "reply"
      },
      "typeVersion": 2.2
    },
    {
      "id": "3f9150ea-3660-46cb-8ec5-3494e34f84ff",
      "name": "8. Gmail \u2014 Ops Alert (Success)",
      "type": "n8n-nodes-base.gmail",
      "position": [
        656,
        320
      ],
      "parameters": {
        "sendTo": "YOUR_OPS_TEAM_EMAIL",
        "message": "=A new trade instruction has been captured and logged. This is a capture record, not an execution.\n\n-----------------------------\nTRADE DETAILS\n-----------------------------\nTrade ID:    {{ $json.tradeRef }}\nTimestamp:   {{ $json.capturedAt }}\nFrom:        {{ $json.sender }}\n\nAsset:       {{ $json.asset }}{{ $json.ticker ? ' (' + $json.ticker + ')' : '' }}\nAction:      {{ $json.action || 'Not specified' }}\nQuantity:    {{ $json.quantity }} shares\nPrice:       {{ $json.price ? $json.price : 'Market Order' }}\nClient:      {{ $json.client || 'Not specified' }}\n\n{{ $json.notes ? 'Notes: ' + $json.notes + '\\n\\n' : '' }}-----------------------------\nThis trade has been logged to the Trade Ledger, Valid Trades tab.\nSubject: {{ $json.subject }}\n",
        "options": {
          "senderName": "Trade Capture System"
        },
        "subject": "=New Trade Captured \u2014 {{ $json.tradeRef }} | {{ $json.asset }} | {{ $json.quantity }} shares"
      },
      "typeVersion": 2.1
    },
    {
      "id": "10314cf5-0850-4eaa-be1b-a0546aad5fba",
      "name": "9. Gmail \u2014 Ops Alert (Extraction Failed)",
      "type": "n8n-nodes-base.gmail",
      "position": [
        656,
        576
      ],
      "parameters": {
        "sendTo": "YOUR_OPS_TEAM_EMAIL",
        "message": "=A trade instruction email could not be processed automatically. Manual review is required.\n\n-----------------------------\nFAILURE DETAILS\n-----------------------------\nTimestamp:      {{ $json.capturedAt }}\nFrom:           {{ $json.sender }}\nSubject:        {{ $json.subject }}\nFailure Reason: {{ $json.failReason }}\n\n-----------------------------\nORIGINAL EMAIL CONTENT\n-----------------------------\n{{ $json.fullBody }}\n\n-----------------------------\nThis failed trade has been logged to the Trade Ledger, Failed Trades tab for audit.\nPlease process this trade manually and confirm with the client.\n",
        "options": {
          "senderName": "Trade Capture System"
        },
        "subject": "=Action Needed \u2014 Trade Extraction Failed | Manual Review Required | {{ $json.capturedAt }}"
      },
      "typeVersion": 2.1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "349418ee-3e6e-478b-8304-5f52dc91ad21",
  "nodeGroups": [],
  "connections": {
    "5. IF \u2014 Trade Valid?": {
      "main": [
        [
          {
            "node": "6a. Sheets \u2014 Log Valid Trade",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "6b. Sheets \u2014 Log Failed Trade",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6a. Sheets \u2014 Log Valid Trade": {
      "main": [
        [
          {
            "node": "7. Gmail \u2014 Reply Confirmation to Client",
            "type": "main",
            "index": 0
          },
          {
            "node": "8. Gmail \u2014 Ops Alert (Success)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6b. Sheets \u2014 Log Failed Trade": {
      "main": [
        [
          {
            "node": "9. Gmail \u2014 Ops Alert (Extraction Failed)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Code \u2014 Extract Email Context": {
      "main": [
        [
          {
            "node": "3. HTTP \u2014 AI Extract Trade Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3. HTTP \u2014 AI Extract Trade Fields": {
      "main": [
        [
          {
            "node": "4. Code \u2014 Parse and Validate Trade Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4. Code \u2014 Parse and Validate Trade Data": {
      "main": [
        [
          {
            "node": "5. IF \u2014 Trade Valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. Gmail Trigger \u2014 Watch for Trade Instructions": {
      "main": [
        [
          {
            "node": "2. Code \u2014 Extract Email Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}