# Others

# Quotation Request Form (QRF) - broken down

<div class="paragraph" id="bkmrk-key-inputs-that-must">Key inputs that must be supplied in a **Quotation Request Form (QRF)** for a Pre-Engineered Building (PEB) project are grouped below. If any of these items are missing the supplier cannot price or design the building accurately.</div>1. <div class="paragraph">General project data  
    • Project / building name  
    • Exact site address (affects wind &amp; seismic codes, freight, taxes)  
    • Intended use / occupancy category  
    • Required delivery / erection schedule</div>
2. <div class="paragraph">Geometry  
    • Clear span width (outside-to-outside of main frames)  
    • Building length (number of bays × bay spacing)  
    • Eave height (bottom of knee connection to finished floor)  
    • Roof slope (e.g., 1:10 or 2:12)  
    • Required roof live load (kN/m² or psf)  
    • Collateral loads (false ceiling, sprinklers, HVAC, lighting, etc.)  
    • Floor live load (if mezzanine is requested)</div>
3. <div class="paragraph">Cladding specification  
    • Roof sheeting: single-skin or insulated sandwich panel, thickness, color coating, finish  
    • Wall sheeting: same detail as roof, plus liner panel if required  
    • Skylight / daylight panels area (if any)</div>
4. <div class="paragraph">Environmental / code parameters  
    • Basic wind speed (3-sec gust) or wind pressure  
    • Seismic zone / design acceleration  
    • Snow load (if applicable)  
    • Temperature range for insulation calculation  
    • Local building code and edition (IBC, Eurocode, IS 875, ASCE 7, etc.)</div>
5. <div class="paragraph">Structural add-ons  
    • Overhead crane: capacity, hook height, class of usage, runway length  
    • Mezzanine: area, floor loading, column grid  
    • Canopies / lean-tos: width, projection, height  
    • Parapet height, fascias, gutters, downpipes</div>
6. <div class="paragraph">Openings &amp; accessories  
    • Ridge vent or turbo vent area  
    • Roll-up doors: clear opening width × height, quantity  
    • Personnel doors: size, location, fire rating  
    • Louvers / windows: size and quantity  
    • Insulation: roof and/or wall R-value or U-value target</div>
7. <div class="paragraph">Architectural finishes &amp; extras  
    • Color chart reference for roof &amp; wall  
    • Special coatings (e.g., food-grade, coastal environment)  
    • Fire-proofing requirements  
    • Internal partitions (if supplied by PEB vendor)</div>
8. <div class="paragraph">Site constraints  
    • Maximum truck length allowed to site  
    • Crane reach restrictions for unloading / erection  
    • Local welding restrictions or pre-approved vendors</div>
9. <div class="paragraph">Commercial terms  
    • Scope split (supply-only vs. supply-and-erect)  
    • Applicable taxes, duties, freight responsibility  
    • Incoterms (EXW, FOB, CIF, DDP, etc.)  
    • Payment milestones  
    • Performance bond / insurance requirements</div>

<div class="paragraph" id="bkmrk-supplying-all-of-the">Supplying all of the above in the QRF ensures the PEB supplier returns an accurate quotation, preliminary general-arrangement drawing, and a detailed BOQ without back-and-forth clarifications.</div>

# Step-by-step guide to using the OpenSTAAD API from Python

### 1. Install prerequisites

```bash
pip install comtypes pywin32 openstaad
```

<div id="bkmrk-"></div>### 2. Launch STAAD.Pro and connect

```python
import subprocess, time, comtypes.client
from pythoncom import CoInitialize, CoUninitialize

CoInitialize()                       # Initialise COM
staad_path = r"C:\Program Files\Bentley\Engineering\STAAD.Pro 2024\STAAD\Bentley.Staad.exe"
subprocess.Popen([staad_path])
time.sleep(8)                        # Wait for STAAD to open

openstaad = comtypes.client.GetActiveObject("StaadPro.OpenSTAAD")
```

<div id="bkmrk--1"></div>### 3. Create or open a model

```python
from pathlib import Path
std_file_path = Path.cwd() / "my_model.std"
length_unit = 4   # 4 = metres
force_unit   = 5  # 5 = kN
openstaad.NewSTAADFile(str(std_file_path), length_unit, force_unit)
time.sleep(3)
```

<div id="bkmrk--2"></div>### 4. Define material and section

```python
prop = openstaad.Property
prop.SetMaterialName("STEEL")

# European IPE200 section
prop_no = prop.CreateBeamPropertyFromTable(
    country_code=7,        # 7 = European database
    section_name="IPE200",
    type_spec=0,           # single section from table
    add_spec_1=0.0,
    add_spec_2=0.0
)
```

<div id="bkmrk--3"></div>### 5. Add nodes and beams

```python
geom = openstaad.Geometry
geom.CreateNode(1, 0, 0, 0)
geom.CreateNode(2, 5, 0, 0)
geom.CreateBeam(1, 1, 2)          # Beam 1: node 1 → node 2
prop.AssignBeamProperty(1, prop_no)
```

<div id="bkmrk--4"></div>### 6. Supports and loads

```python
sup = openstaad.Support
sup_no = sup.CreateSupportFixed()
sup.AssignSupportToNode(1, sup_no)
sup.AssignSupportToNode(2, sup_no)

ld = openstaad.Load
case = ld.CreateNewPrimaryLoad("Self-Weight")
ld.SetLoadActive(case)
ld.AddSelfWeightInXYZ(case, -1.0)   # factor −1 in global Y
```

<div id="bkmrk--5"></div>### 7. Run the analysis (silent mode)

```python
cmd = openstaad.Command
cmd.PerformAnalysis(6)      # 6 = static analysis
openstaad.SetSilentMode(1)
openstaad.Analyze()
while openstaad.isAnalyzing():
    time.sleep(2)
```

<div id="bkmrk--6"></div>### 8. Retrieve results

```python
from openstaad import Output
out = Output()
fx, fy, fz, mx, my, mz = out.GetMemberEndForces(beam=1, start=True, lc=1)
print("Start-end forces:", fx, fy, fz, mx, my, mz)
```

<div id="bkmrk--7"></div>### 9. Clean up

```python
openstaad.SaveModel(1)
CoUninitialize()
```

<div id="bkmrk--8"></div>### 10. Helper wrappers

<div class="paragraph" id="bkmrk-if-you-prefer-a-high">If you prefer a higher-level interface, install **OpenStaadPython**:</div>```bash
pip install openstaad
```

<div class="paragraph" id="bkmrk-then-use-convenience">Then use convenience classes:</div>```python
from openstaad import Geometry, Root
print(Geometry().GetBeamList())
print(Root().GetSTAADFile())
```

<div class="paragraph" id="bkmrk-%28note%3A%C2%A0openstaad-cur">(Note: `openstaad` currently focuses on **querying** an **already-open** model.)</div>### Documentation &amp; Community

- <div class="paragraph">**Official docs**:  
    `C:\Program Files\Bentley\Engineering\STAAD.Pro 2024\OSAPP_Help`  
    (Examples are mainly VB/C++; Python help lives in the Bentley forums.)</div>
- <div class="paragraph">**GitHub samples**:  
    [viktor-platform/sample-staad-integration](https://github.com/viktor-platform/sample-staad-integration)</div>

<div class="paragraph" id="bkmrk-you-can-now-create%2C-">You can now create, modify, analyse and extract results from STAAD.Pro entirely from Python scripts.</div>

# Monetary / financial calculations inside your QRF → BOQ automation

<div class="paragraph" id="bkmrk-for-monetary-%2F-finan">For **monetary / financial calculations inside your QRF → BOQ automation**, you need two things:</div>1. <div class="paragraph">**Exact decimal precision** (no binary-float rounding surprises).</div>
2. <div class="paragraph">**Convenience helpers** for currency formatting, FX, amortisation, etc.</div>

<div class="paragraph" id="bkmrk-%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80">──────────────────────────────────────────────</div>1. <div class="paragraph">Core precision → `decimal`</div>```python
    from decimal import Decimal, ROUND_HALF_UP
    
    qty   = Decimal('12.50')     # kg
    rate  = Decimal('78.35')     # $/kg
    total = (qty * rate).quantize(Decimal('0.01'), ROUND_HALF_UP)
    ```
2. <div class="paragraph">Money wrapper → `money` or `py-moneyed`</div>```python
    from money import Money
    
    unit_price = Money('78.35', 'USD')
    line_total = Money('12.50', 'USD') * unit_price
    ```
3. <div class="paragraph">Excel / reporting → `openpyxl`, `xlsxwriter` (they both **preserve `Decimal` precision** when you write values).</div>
4. <div class="paragraph">Optional extras  
    • `forex-python` – real-time FX rates for multi-currency bids.  
    • `numpy-financial` – NPV, IRR, loan amortisation if you need financing tables.  
    • `babel` – locale-aware currency formatting.</div>

<div class="paragraph" id="bkmrk-%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80%E2%94%80-1">──────────────────────────────────────────────  
Quick recipe (fits your Python stack):</div>```python
from decimal import Decimal
from openpyxl import Workbook
from money import Money

wb = Workbook()
ws = wb.active
ws.append(['Item', 'Qty (kg)', 'Rate ($)', 'Amount ($)'])

for item, qty, rate in [
        ('Column UC203', Decimal('253.4'), Decimal('1.08')),
        ('Beam UB305', Decimal('417.9'), Decimal('1.08'))]:
    amount = Money(qty * rate, 'USD')
    ws.append([item, float(qty), float(rate), str(amount)])

wb.save('BOQ_financial.xlsx')
```

<div id="bkmrk-">  
</div><div class="paragraph" id="bkmrk-all-values-stay-exac">All values stay exact (no rounding errors), and the worksheet shows standard $-formatting.</div>

# BIM/IFC models for QTO

<div class="paragraph" id="bkmrk-below-is-a-concise%2C-">Below is a concise, **Python-first** recipe that shows exactly how to plug an **IFC model** (provided with the QRF) into your **Quantity-Take-Off (QTO)** pipeline. It follows the **external-mode** pattern described in the BIM literature: the IFC file is **only a data source**; all logic runs in Python.</div>---

### 1. Install the core IFC library

```bash
pip install ifcopenshell pandas openpyxl
```

<div id="bkmrk--1"></div>### 2. Load the IFC file and list every structural element

```python
import ifcopenshell, ifcopenshell.util.element as util
from pathlib import Path

model = ifcopenshell.open(Path("rfx_structural.ifc"))

# Example: grab every IfcBeam, IfcColumn, IfcMember, etc.
elements = (model.by_type("IfcBeam") +
            model.by_type("IfcColumn") +
            model.by_type("IfcMember"))
```

<div id="bkmrk--2"></div>### 3. Extract the quantities you need

```python
rows = []
for e in elements:
    # 1. Identity
    name   = e.Name or e.GlobalId
    ifc_ent= e.is_a()

    # 2. Geometry quantities (IfcElementQuantity)
    qs = util.get_psets(e).get("Pset_ElementQuantity", {})
    length_m = float(qs.get("Length", 0))
    weight_kg= float(qs.get("Weight", 0))     # if the modeller exported it
    area_m2  = float(qs.get("SurfaceArea", 0))

    # 3. Material grade (IfcMaterial)
    mat = util.get_material(e)
    grade = mat.Name if mat else "Unknown"

    rows.append({
        "Item"      : name,
        "Type"      : ifc_ent,
        "Material"  : grade,
        "Length_m"  : length_m,
        "Weight_kg" : weight_kg,
        "Area_m2"   : area_m2
    })
```

<div id="bkmrk--3"></div>### 4. Build a Pandas DataFrame → instant QTO table

```python
import pandas as pd

df = pd.DataFrame(rows)
# Aggregate identical sections
qto = (df
       .groupby(["Type", "Material"], as_index=False)
       .agg({"Length_m":"sum",
             "Weight_kg":"sum",
             "Area_m2":"sum"}))
```

<div id="bkmrk--4"></div>### 5. Export to Excel (ready for BOQ merge)

```python
qto.to_excel("IFC_QTO.xlsx", index=False)
```

<div id="bkmrk--5"></div>### 6. Optional: validate IFC quality first

<div class="paragraph" id="bkmrk-use-bimvision-or-sol">Use **BIMvision** or **Solibri Anywhere** (free viewers) to visually inspect the model and confirm that all **Pset\_ElementQuantity** properties are populated.</div>### 7. Handling federated models (multiple IFC files)

<div class="paragraph" id="bkmrk-if-the-qrf-supplies-">If the QRF supplies **several partial IFC files**, merge them once:</div>- <div class="paragraph">**BIMvision → IFC Merge plugin** (permanent merge), or</div>
- <div class="paragraph">**IfcOpenShell** (memory merge) if you want to stay in Python.</div>

### 8. When IFC lacks quantities

<div class="paragraph" id="bkmrk-if-the-ifc-only-has-">If the IFC only has geometry, compute **volume/length/area** yourself:</div>```python
import ifcopenshell.geom as geom
settings = geom.settings()
shape = geom.create_shape(settings, e)
volume = shape.geometry.volume
```

<div id="bkmrk--6"></div>### 9. Keep the workflow **MVD-compliant**

<div class="paragraph" id="bkmrk-the-nbims-qto-guide-">The NBIMS QTO guide recommends exporting with the **CDB-2010 MVD** view (or later). Ask the designer to tick that option in Revit/ArchiCAD so that **Pset\_ElementQuantity** and **IfcMaterial** are automatically embedded.</div>### Summary of the integration pattern

<div class="table markdown-table" id="bkmrk-step-tool-%2F-library-"><div class="table-container"><table><thead><tr><th>Step</th><th>Tool / Library</th><th>Purpose</th></tr></thead><tbody><tr><td>IFC ingestion</td><td>`IfcOpenShell`</td><td>Parse geometry &amp; properties</td></tr><tr><td>Quantity extraction</td><td>`IfcElementQuantity` or auto-calc</td><td>Length, area, volume, weight</td></tr><tr><td>Data shaping</td><td>`Pandas`</td><td>Group, sum, clean</td></tr><tr><td>Output</td><td>`openpyxl`</td><td>Excel BOQ ready for pricing</td></tr><tr><td>Visual QC</td><td>BIMvision / Solibri</td><td>Confirm model quality</td></tr></tbody></table>

</div></div><div class="paragraph" id="bkmrk-this-external-mode-a">This external-mode approach keeps your Python stack lightweight, avoids STAAD-Pro geometry duplication, and produces **code-compliant QTO tables** in minutes</div>

# GHL

# Integration vCX with GHL

### ✅ Recommended Architecture

#### 1. **Push vCX Conversations into GHL**

<div class="paragraph" id="bkmrk-use-the-https%3A%2F%2Fhigh">Use the [https://highlevel.stoplight.io/docs/integrations/0443d7d1a4bd0-overview](https://highlevel.stoplight.io/docs/integrations/0443d7d1a4bd0-overview) to:</div>- <div class="paragraph">Create or update a **Contact** using `clientId` as the unique identifier</div>
- <div class="paragraph">Create a **Conversation** under that contact</div>
- <div class="paragraph">Add each message as a **Message** object inside the conversation</div>

> <div class="paragraph">All of this is supported via `POST /conversations/{id}/messages` and related endpoints .</div>

#### 2. **Pull GHL Replies into vCX**

<div class="paragraph" id="bkmrk-set-up-a-webhook-sub">Set up a **webhook subscription** in GHL to listen for:</div>- <div class="paragraph">`message.incoming`</div>
- <div class="paragraph">`conversation.updated`</div>

<div class="paragraph" id="bkmrk-these-webhooks-will-">These webhooks will fire whenever a GHL user (or bot) replies. You can map the GHL `conversationId` to your `conversationId` using metadata or a lookup table.</div>> <div class="paragraph">OAuth 2.0 is now required for all new integrations, so you’ll need to register your app in GHL’s [Developer Portal](https://developers.gohighlevel.com/) .</div>

---

### 🔑 Key GHL API Docs You’ll Need

<div class="table markdown-table" data-v-0909cf3c="" data-v-3a4aba44="" id="bkmrk-table-copy-task-endp"><header class="table-actions" data-v-0909cf3c=""><span class="table-title" data-v-0909cf3c="">Table</span><div class="simple-button size-medium" data-v-0909cf3c="" data-v-182d5fe2="" data-v-92afdd37=""><svg aria-hidden="true" class="simple-button-icon iconify" data-v-182d5fe2="" height="16" name="Copy" role="img" viewbox="0 0 1024 1024" width="16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M427.04896 379.12576a60.2112 60.2112 0 0 0-60.2112 60.2112v315.51488a60.2112 60.2112 0 0 0 60.2112 60.25216h315.51488a60.2112 60.2112 0 0 0 60.25216-60.2112v-315.55584a60.2112 60.2112 0 0 0-60.2112-60.2112H427.008z m-94.74048-34.48832a133.9392 133.9392 0 0 1 94.74048-39.23968h315.51488a133.98016 133.98016 0 0 1 133.98016 133.9392v315.51488a133.9392 133.9392 0 0 1-133.9392 133.98016H427.008a133.9392 133.9392 0 0 1-133.9392-133.9392v-315.55584c0-35.51232 14.09024-69.632 39.23968-94.69952z" fill="currentColor"></path><path d="M257.14688 233.472a36.16768 36.16768 0 0 0-35.96288 35.96288v364.05248a35.96288 35.96288 0 0 0 18.18624 31.21152 36.864 36.864 0 1 1-36.41344 64.1024A109.64992 109.64992 0 0 1 147.456 633.56928v-364.1344A109.89568 109.89568 0 0 1 257.14688 159.744h364.09344c20.56192 0 38.87104 5.48864 54.51776 16.83456 14.86848 10.77248 24.82176 25.10848 32.31744 38.5024a36.864 36.864 0 0 1-64.47104 35.84c-4.95616-8.97024-8.6016-12.86144-11.14112-14.66368-1.72032-1.2288-4.5056-2.78528-11.22304-2.78528h-364.1344z" fill="currentColor"></path></svg><span data-v-182d5fe2="">Copy</span></div></header><div class="table-container" data-v-0909cf3c=""><table data-v-0909cf3c=""><thead data-v-0909cf3c=""><tr data-v-0909cf3c=""><th align="left" data-v-0909cf3c="">Task</th><th align="left" data-v-0909cf3c="">Endpoint</th><th align="left" class="" data-v-0909cf3c="">Notes</th></tr></thead><tbody data-v-0909cf3c=""><tr data-v-0909cf3c=""><td align="left" class="" data-v-0909cf3c="">Create/Update Contact</td><td align="left" class="" data-v-0909cf3c="">`POST /contacts`</td><td align="left" class="" data-v-0909cf3c="">Use `clientId` as external ID</td></tr><tr data-v-0909cf3c=""><td align="left" class="" data-v-0909cf3c="">Create Conversation</td><td align="left" class="" data-v-0909cf3c="">`POST /conversations`</td><td align="left" class="" data-v-0909cf3c="">Link to contact</td></tr><tr data-v-0909cf3c=""><td align="left" class="" data-v-0909cf3c="">Send Message</td><td align="left" class="" data-v-0909cf3c="">`POST /conversations/{id}/messages`</td><td align="left" class="" data-v-0909cf3c="">Includes text, type, timestamp</td></tr><tr data-v-0909cf3c=""><td align="left" class="" data-v-0909cf3c="">Listen for Replies</td><td align="left" class="" data-v-0909cf3c="">Webhook: `message.incoming`</td><td align="left" class="" data-v-0909cf3c="">Use to sync back to vCX</td></tr></tbody></table>

</div></div><div class="paragraph" id="bkmrk-all-endpoints-are-do">All endpoints are documented at:  
🔗 [https://highlevel.stoplight.io/docs/integrations](https://highlevel.stoplight.io/docs/integrations)</div>#  

# vCX ↔ GoHighLevel – Two-Way Chat Sync (Node.js)

<details id="bkmrk-0.-prerequisites-nod"><summary>0. Prerequisites</summary>

- Node ≥ 18
- A GHL developer account ([https://developers.gohighlevel.com](https://developers.gohighlevel.com))
- Your app registered in the portal with scopes: `contacts.write conversations.write conversations.read locations.read`
- Redirect URI set to `https://yourdomain.com/auth/callback`
- Environment variables: ```
    GHL_CLIENT_ID=xxxxxxxx
    GHL_CLIENT_SECRET=xxxxxxxx
    GHL_REDIRECT_URI=https://yourdomain.com/auth/callback
    ```

</details><details id="bkmrk-1.-install-dependenc"><summary>1. Install dependencies</summary>

```
package.json (excerpt)
```

```
{
  "type": "module",
  "dependencies": {
    "axios": "^1.6.0",
    "dotenv": "^16.3.1",
    "express": "^4.18.2"
  }
}
```

```
npm install
```

</details><details id="bkmrk-2.-minimal-express-s"><summary>2. Minimal Express server skeleton</summary>

```
server.js (top)
```

```
import 'dotenv/config';
import express from 'express';
import axios from 'axios';
import crypto from 'crypto';
const app = express();
app.use(express.json());

const PORT = process.env.PORT || 3000;

/* --- In-memory maps for demo purposes --- */
const tokenStore = new Map();          // locationId -> {access_token, refresh_token, expires_at}
const conversationMap = new Map();     // vcxConversationId -> ghlConversationId

app.listen(PORT, () => console.log(`Listening on :${PORT}`));
```

</details><details id="bkmrk-3.-oauth-2.0-%E2%80%93-autho"><summary>3. OAuth 2.0 – Authorization URL</summary>

```
GET /install
```

```
app.get('/install', (req, res) => {
  const state = crypto.randomUUID();
  const url = `https://marketplace.gohighlevel.com/oauth/chooselocation?response_type=code&client_id=${process.env.GHL_CLIENT_ID}&redirect_uri=${encodeURIComponent(process.env.GHL_REDIRECT_URI)}&scope=contacts.write%20conversations.write%20conversations.read%20locations.read&state=${state}`;
  res.redirect(url);
});
```

</details><details id="bkmrk-4.-oauth-2.0-%E2%80%93-excha"><summary>4. OAuth 2.0 – Exchange code for tokens</summary>

```
GET /auth/callback
```

```
app.get('/auth/callback', async (req, res) => {
  const { code, locationId } = req.query;
  const { data } = await axios.post('https://services.leadconnectorhq.com/oauth/token', {
    client_id: process.env.GHL_CLIENT_ID,
    client_secret: process.env.GHL_CLIENT_SECRET,
    grant_type: 'authorization_code',
    code,
    redirect_uri: process.env.GHL_REDIRECT_URI
  });
  tokenStore.set(locationId, {
    access_token: data.access_token,
    refresh_token: data.refresh_token,
    expires_at: Date.now() + data.expires_in * 1000
  });
  res.send(`Sub-account ${locationId} connected.`);
});
```

</details><details id="bkmrk-5.-helper-%E2%80%93-get-vali"><summary>5. Helper – get valid access token (auto-refresh)</summary>

```
async function getToken(locationId) {
  let t = tokenStore.get(locationId);
  if (!t) throw new Error('Location not authorized');

  if (Date.now() > t.expires_at - 60_000) {
    const { data } = await axios.post('https://services.leadconnectorhq.com/oauth/token', {
      client_id: process.env.GHL_CLIENT_ID,
      client_secret: process.env.GHL_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: t.refresh_token
    });
    t = {
      access_token: data.access_token,
      refresh_token: data.refresh_token,
      expires_at: Date.now() + data.expires_in * 1000
    };
    tokenStore.set(locationId, t);
  }
  return t.access_token;
}
```

</details><details id="bkmrk-6.-upsert-contact-%28b"><summary>6. Upsert Contact (by vCX clientId)</summary>

```
POST /contact
```

```
app.post('/contact', async (req, res) => {
  const { locationId, clientId, email, phone, name } = req.body;
  const token = await getToken(locationId);

  // Search by externalId first
  const search = await axios.get(`https://rest.gohighlevel.com/v1/contacts/?query=${clientId}&locationId=${locationId}`, {
    headers: { Authorization: `Bearer ${token}` }
  });
  let contactId = search.data.contacts?.[0]?.id;

  if (!contactId) {
    const { data } = await axios.post('https://rest.gohighlevel.com/v1/contacts/', {
      locationId,
      name,
      email,
      phone,
      source: 'vCX',
      tags: ['vCX'],
      customFields: [{ id: 'clientId', value: clientId }]
    }, { headers: { Authorization: `Bearer ${token}` } });
    contactId = data.contact.id;
  }
  res.json({ contactId });
});
```

</details><details id="bkmrk-7.-create-conversati"><summary>7. Create Conversation &amp; Push Messages</summary>

```
POST /push-message
```

```
app.post('/push-message', async (req, res) => {
  const { locationId, clientId, vcxConversationId, fromUser, body, timestamp } = req.body;
  const token = await getToken(locationId);

  // 1. Ensure contact
  const { contactId } = (await axios.post(`http://localhost:${PORT}/contact`, {
    locationId, clientId, email: `${clientId}@example.com`, name: clientId
  })).data;

  // 2. Ensure conversation
  let ghlConvId = conversationMap.get(vcxConversationId);
  if (!ghlConvId) {
    const { data } = await axios.post('https://rest.gohighlevel.com/v1/conversations/', {
      locationId,
      contactId,
      type: 'chat'
    }, { headers: { Authorization: `Bearer ${token}` } });
    ghlConvId = data.conversation.id;
    conversationMap.set(vcxConversationId, ghlConvId);
  }

  // 3. Push message
  await axios.post(`https://rest.gohighlevel.com/v1/conversations/${ghlConvId}/messages`, {
    type: fromUser ? 'Inbound' : 'Outbound',
    message: body,
    dateAdded: new Date(timestamp).toISOString()
  }, { headers: { Authorization: `Bearer ${token}` } });

  res.sendStatus(200);
});
```

</details><details id="bkmrk-8.-receive-ghl-repli"><summary>8. Receive GHL Replies via Webhook</summary>

```
POST /webhook
```

```
app.post('/webhook', (req, res) => {
  const { type, locationId, conversationId, message } = req.body;

  if (type !== 'message.incoming') return res.sendStatus(200);

  // Reverse lookup
  let vcxConvId;
  for (const [vId, gId] of conversationMap.entries()) {
    if (gId === conversationId) vcxConvId = vId;
  }
  if (!vcxConvId) return res.sendStatus(200);

  // TODO: forward to vCX backend
  console.log('Forward to vCX:', { vcxConversationId: vcxConvId, fromUser: false, body: message, timestamp: Date.now() });

  res.sendStatus(200);
});
```

Register this URL in GHL → Settings → API → Webhooks.

</details><details id="bkmrk-9.-quick-test-with-c"><summary>9. Quick test with cURL</summary>

```
# 1. Start your server
node server.js

# 2. Install the app (open in browser)
open http://localhost:3000/install

# 3. Push a message
curl -X POST http://localhost:3000/push-message \
  -H "Content-Type: application/json" \
  -d '{"locationId":"LOC_ID","clientId":"c_abc123","vcxConversationId":"conv_456","fromUser":true,"body":"Hello from vCX","timestamp":1710000000000}'
```

</details><details id="bkmrk-10.-production-check"><summary>10. Production checklist</summary>

- Use persistent storage (Redis/Postgres) instead of in-memory maps.
- Verify webhook signatures (GHL sends headers `X-Signature`).
- Rate-limit token refresh.
- Handle pagination when searching contacts.
- Wrap axios calls in retries with exponential backoff.

</details>Last updated 2024-07-20

# n8n ↔ Zendesk Web Widget (Classic) – JWT Integration

<div class="paragraph" id="bkmrk-internal-documentati">*Internal Documentation v1.0 – 2025-08-25*</div>> <div class="paragraph">**Goal**  
> Allow visitors authenticated through your n8n chat front-end to start a Zendesk Web Widget session, while still requiring a human agent to approve any ticket creation.</div>

## 1. Prerequisites

<table id="bkmrk-item-where-to-find-z"><thead><tr><th>Item</th><th>Where to find</th></tr></thead><tbody><tr><td>Zendesk account with **Web Widget (Classic)** enabled</td><td>Admin Center → Channels → Widget</td></tr><tr><td>**Shared Secret** for JWT</td><td>Admin Center → Channels → Chat → Widget → *Authentication*</td></tr><tr><td>n8n instance reachable from the public internet</td><td>`https://your-n8n.com`</td></tr><tr><td>Existing n8n workflow that pauses for human review</td><td>(Human-in-the-Loop)</td></tr></tbody></table>

## 2. High-Level Flow

1. <div class="paragraph">Visitor loads your web-chat.</div>
2. <div class="paragraph">Front-end requests a **JWT** from n8n.</div>
3. <div class="paragraph">n8n signs and returns the JWT.</div>
4. <div class="paragraph">Zendesk Web Widget starts an **authenticated chat** session.</div>
5. <div class="paragraph">**Human-in-the-Loop** still controls *ticket creation* (Wait node).</div>

---

## 3. n8n Endpoints

### 3.1 JWT Issuer (`POST /webhook/zendesk-jwt`)

<div class="paragraph" id="bkmrk-purpose%3A-zendesk-wil">**Purpose:** Zendesk will call this endpoint to verify the visitor.</div>#### Workflow Steps

<div class="table markdown-table" id="bkmrk-node-settings-webhoo"><div class="table-container"><table><thead><tr><th>Node</th><th>Settings</th></tr></thead><tbody><tr><td>**Webhook**</td><td>Path = `/webhook/zendesk-jwt` (POST)  
  
</td></tr><tr><td>**Lookup User**</td><td>Any node that confirms the `user_token` sent by Zendesk is valid (Database, Google Sheets, etc.).</td></tr><tr><td>**JWT Sign**</td><td>Algorithm = `HS256`  
  
  
  
  
  
</td></tr><tr><td>**Respond to Webhook**</td><td>Status = `200`  
  
</td></tr></tbody></table>

</div></div>#### Example cURL

```bash
curl -X POST \
  'https://your-n8n.com/webhook/zendesk-jwt?user_token=abc123' \
  -H 'Content-Type: application/json'
```

<div id="bkmrk--1">  
</div><div class="paragraph" id="bkmrk-expect%3A">Expect:</div>```json
{"jwt":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
```

### 3.2 Optional Token Generator for Front-End (`GET /webhook/chat-token`)

<div class="paragraph" id="bkmrk-if-your-front-end-ne">If your front-end needs to fetch the token itself (instead of letting Zendesk call the endpoint directly), create a second simple workflow:</div><div class="table markdown-table" id="bkmrk-node-settings-webhoo-1"><div class="table-container"><table><thead><tr><th>Node</th><th>Settings</th></tr></thead><tbody><tr><td>Webhook</td><td>Method = `GET`</td></tr><tr><td>JWT Sign</td><td>Same payload &amp; secret as above</td></tr><tr><td>Respond to Webhook</td><td>Body = `{"jwt":"{{ $('JWT Sign').item.jwt }}"}`</td></tr></tbody></table>

</div></div>---

## 4. Zendesk Configuration

<div class="paragraph" id="bkmrk-admin-center-%E2%86%92-chann">Admin Center → Channels → **Chat** → **Widget** → **Authentication**</div><div class="table markdown-table" id="bkmrk-field-value-authenti"><div class="table-container"><table><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody><tr><td>**Authentication Method**</td><td>JWT</td></tr><tr><td>**JWT URL**</td><td>`https://your-n8n.com/webhook/zendesk-jwt`</td></tr><tr><td>**JWT Secret**</td><td>*Paste the same Shared Secret used in n8n*</td></tr></tbody></table>

</div></div>## 5. Front-End Snippet

<div class="paragraph" id="bkmrk-add-this-after-the-z">Add this after the Zendesk Web Widget script is loaded:</div>```html
<script>
  // Replace with your n8n endpoint if you created /webhook/chat-token
  fetch('/api/n8n/get-chat-token', { credentials: 'include' })
    .then(r => r.json())
    .then(({ jwt }) => {
      zE('webWidget', 'chat:setJwtFn', callback => callback(jwt));
    });
</script>
```

> <div class="paragraph">If you let Zendesk call your endpoint directly, omit the fetch and simply use the JWT URL configured above.</div>

## 6. Human-in-the-Loop Remains Intact

<div class="paragraph" id="bkmrk-your-existing-workfl">Your existing workflow already has a **Wait** node that pauses until an agent approves.  
Nothing in the JWT flow changes that—ticket creation only proceeds **after** the webhook resume call.</div>## 7. Security Checklist

- <div class="paragraph">[ ] HTTPS only (n8n &amp; Zendesk endpoints).</div>
- <div class="paragraph">[ ] Rotate Shared Secret periodically → update both Zendesk and n8n JWT node.</div>
- <div class="paragraph">[ ] Log every JWT issuance (`iat`, IP, user_token).</div>
- <div class="paragraph">[ ] Validate `user_token` strictly; deny unknown tokens immediately.</div>

## 8. Troubleshooting Quick-Table

<div class="table markdown-table" id="bkmrk-symptom-likely-cause"><div class="table-container"><table><thead><tr><th>Symptom</th><th>Likely Cause</th><th>Fix</th></tr></thead><tbody><tr><td>Widget shows “Unable to authenticate”</td><td>Wrong Shared Secret or expired `iat`</td><td>Check secret &amp; timestamp</td></tr><tr><td>404 from Zendesk</td><td>Wrong JWT URL</td><td>Ensure `https://your-n8n.com/webhook/zendesk-jwt` is publicly reachable</td></tr><tr><td>“Invalid JWT format”</td><td>Payload missing required claims</td><td>Ensure `name`, `email`, `jti`, `iat` are present</td></tr></tbody></table>

</div></div>## 9. Change Log

<div class="table markdown-table" id="bkmrk-date-author-notes-20"><div class="table-container"><table><thead><tr><th>Date</th><th>Author</th><th>Notes</th></tr></thead><tbody><tr><td>2025-08-25</td><td>DevOps</td><td>Initial draft based on Zendesk article [4408838925082](https://support.zendesk.com/hc/en-us/articles/4408838925082)</td></tr></tbody></table>

</div></div><div class="paragraph" id="bkmrk--3"></div>

# MCP -> wiring diagram

<div class="paragraph" id="bkmrk-below-is-a-minimal-b">Below is a minimal but complete “wiring diagram” + code snippets that let:</div>- <div class="paragraph">a React/JS chat UI</div>
- <div class="paragraph">talk to your existing backend over the WebSocket `wss://backend.chatbuilder.com/events/listen`</div>
- <div class="paragraph">which forwards every user sentence to **your** Node orchestrator (the “bot”)</div>
- <div class="paragraph">that hosts an LLM (Anthropic or OpenAI) **and** an MCP client</div>
- <div class="paragraph">which calls an MCP **server** (also Node) that owns the Airtable CRUD helpers</div>
- <div class="paragraph">and finally ships the answer back the same chain.</div>

<div class="paragraph" id="bkmrk-no-claude-desktop%2C-n">No Claude Desktop, no stdio, everything is plain HTTP/SSE inside your own VPC.</div>---

1. <div class="paragraph">Component map</div>

---

<div class="paragraph" id="bkmrk-chat-ui-%E2%87%84-wss-%E2%87%84-back">Chat UI ⇄ WSS ⇄ Backend.chatbuilder.com ⇄ HTTP ⇄ Bot/orchestrator ⇄ SSE ⇄ Airtable-MCP-server  
(React) (existing) (your Node service) (your Node MCP server)</div>- <div class="paragraph">The **bot** keeps the LLM API key and the MCP client.</div>
- <div class="paragraph">The **MCP server** only knows Airtable PAT + base ID and exports tools like  
    `airtable:select_records`, `airtable:create_record`, …</div>
- <div class="paragraph">Both services are Dockerised and scale horizontally.</div>

---

2. <div class="paragraph">Airtable MCP server (Node, SSE transport)</div>

---

<div class="paragraph" id="bkmrk-installmkdir-airtabl">Install  
mkdir airtable-mcp &amp;&amp; cd airtable-mcp  
npm init -y  
npm install @modelcontextprotocol/sdk airtable dotenv</div><div class="paragraph" id="bkmrk-server.js">server.js</div><div class="segment-code markdown-code" id="bkmrk-javascript-copy"><header class="segment-code-header"><div class="segment-code-header-content"><span class="segment-code-lang">JavaScript</span><div class="simple-button size-medium"><span>Copy</span></div></div></header><div class="syntax-highlighter dark segment-code-content">  
</div></div>```js
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
import Airtable from "airtable";
import "dotenv/config";

const app = express();
app.use(express.json());

const port = process.env.PORT || 8001;
const base = new Airtable({apiKey: process.env.AIRTABLE_PAT})
               .base(process.env.AIRTABLE_BASE_ID);

// 1. describe tools
const tools = [
  {
    name: "airtable:select_records",
    description: "List records from a table",
    inputSchema: {
      type: "object",
      properties: {
        table:   { type: "string" },
        filter:  { type: "string" },
        maxRecords: { type: "number", default: 10 }
      },
      required: ["table"]
    }
  },
  {
    name: "airtable:create_record",
    description: "Insert one record",
    inputSchema: {
      type: "object",
      properties: {
        table: { type: "string" },
        fields: { type: "object" }
      },
      required: ["table", "fields"]
    }
  }
];

// 2. instantiate MCP server
const server = new Server(
  { name: "airtable-mcp", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler("tools/list", async () => ({ tools }));
server.setRequestHandler("tools/call", async (req) => {
  const { name, arguments: args } = req.params;
  if (name === "airtable:select_records") {
    const recs = await base(args.table)
      .select({ maxRecords: args.maxRecords || 10, filterByFormula: args.filter || "" })
      .all();
    return { 
      records: recs.map(r => ({ id: r.id, fields: r.fields })) 
    };
  }
  if (name === "airtable:create_record") {
    const created = await base(args.table).create([{ fields: args.fields }]);
    return { id: created[0].id };
  }
  throw new Error("Unknown tool");
});

// 3. expose SSE endpoints
app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/message", res);
  await server.connect(transport);
});

app.post("/message", (req, res) => {
  const transport = SSEServerTransport.get(req.query.sessionId);
  if (transport) transport.handlePostMessage(req, res);
});

app.listen(port, () => console.log(`Airtable MCP listening on :${port}`));
```

<div id="bkmrk--4">  
</div><div class="paragraph" id="bkmrk-.env">.env</div><div class="segment-code markdown-code" id="bkmrk-copy"><header class="segment-code-header"><div class="segment-code-header-content"><div class="simple-button size-medium"><span>Copy</span></div></div></header><div class="syntax-highlighter dark segment-code-content">  
</div></div>```
AIRTABLE_PAT=patXXXXXXXXXXX  
AIRTABLE_BASE_ID=appXXXXXXXXXXX  
```

<div id="bkmrk--5">  
</div><div class="paragraph" id="bkmrk-runnode-server.js-%E2%86%92-">Run  
node server.js → [http://localhost:8001/sse](http://localhost:8001/sse) (SSE endpoint)</div>---

3. <div class="paragraph">Bot/orchestrator (Node, hosts LLM + MCP client)</div>

---

<div class="paragraph" id="bkmrk-mkdir-bot-%26%26-cd-botn">mkdir bot &amp;&amp; cd bot  
npm init -y  
npm install @modelcontextprotocol/sdk axios dotenv express</div><div class="paragraph" id="bkmrk-bot.js">bot.js</div><div class="segment-code markdown-code" id="bkmrk-javascript-copy-1"><header class="segment-code-header"><div class="segment-code-header-content"><span class="segment-code-lang">JavaScript</span><div class="simple-button size-medium"><span>Copy</span></div></div></header><div class="syntax-highlighter dark segment-code-content">  
</div></div>```js
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import axios from "axios";
import express from "express";
import "dotenv/config";

const app = express();
app.use(express.json());

// 1. connect MCP client to airtable server
const mcp = new Client({ name: "chat-bot", version: "1.0.0" });
const transport = new SSEClientTransport("http://localhost:8001/sse");
await mcp.connect(transport);
const tools = await mcp.listTools();

// 2. small helper: talk to LLM
async function callLLM(messages) {
  const body = {
    model: process.env.LLM_MODEL,        // "claude-3-5-sonnet-20241022" or "gpt-4-turbo"
    messages,
    tools: tools.map(t => t.inputSchema ? { ...t, function: t.inputSchema } : t),
    tool_choice: "auto",
    max_tokens: 2000
  };

  const url = process.env.LLM_PROVIDER === "anthropic"
    ? "https://api.anthropic.com/v1/messages"
    : "https://api.openai.com/v1/chat/completions";

  const headers = process.env.LLM_PROVIDER === "anthropic"
    ? { "x-api-key": process.env.ANTHROPIC_KEY, "content-type": "application/json" }
    : { "authorization": `Bearer ${process.env.OPENAI_KEY}`, "content-type": "application/json" };

  const { data } = await axios.post(url, body, { headers });
  return data;     // returns Claude or OpenAI shape
}

// 3. single HTTP endpoint that backend.chatbuilder.com will call
app.post("/handle_turn", async (req, res) => {
  const userSentence = req.body.text;          // comes from backend via HTTP
  const conversation = [{ role: "user", content: userSentence }];

  // first LLM call
  let llmResp = await callLLM(conversation);
  let assistantMsg = llmResp.content || llmResp.choices[0].message;

  // handle tool calls
  if (assistantMsg.tool_calls || assistantMsg.function_call) {
    const toolCalls = assistantMsg.tool_calls || [assistantMsg.function_call];
    for (const tc of toolCalls) {
      const name = tc.function?.name || tc.name;
      const args = JSON.parse(tc.function?.arguments || tc.arguments);
      const result = await mcp.callTool(name, args);
      conversation.push(assistantMsg);
      conversation.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(result) });
    }
    // second call with tool results
    llmResp = await callLLM(conversation);
    assistantMsg = llmResp.content || llmResp.choices[0].message;
  }

  const replyText = assistantMsg.content || assistantMsg.text || assistantMsg;
  res.json({ reply: replyText });   // goes back to backend.chatbuilder.com
});

app.listen(3000, () => console.log("Bot/orchestrator on :3000"));
```

<div id="bkmrk--8">  
</div><div class="paragraph" id="bkmrk-.env-1">.env</div><div class="segment-code markdown-code" id="bkmrk-copy-1"><header class="segment-code-header"><div class="segment-code-header-content"><div class="simple-button size-medium"><span>Copy</span></div></div></header><div class="syntax-highlighter dark segment-code-content">  
</div></div>```
LLM_PROVIDER=anthropic        # or openai  
ANTHROPIC_KEY=sk-ant-xxx  
OPENAI_KEY=sk-xxx  
LLM_MODEL=claude-3-5-sonnet-20241022   # or gpt-4-turbo  
```

<div id="bkmrk--9">  
</div>---

4. <div class="paragraph">Glue inside backend.chatbuilder.com</div>

---

<div class="paragraph" id="bkmrk-you-already-have-a-w">You already have a WebSocket handler.  
Add (pseudo):</div><div class="segment-code markdown-code" id="bkmrk-javascript-copy-2"><header class="segment-code-header"><div class="segment-code-header-content"><span class="segment-code-lang">JavaScript</span><div class="simple-button size-medium"><span>Copy</span></div></div></header><div class="syntax-highlighter dark segment-code-content">  
</div></div>```javascript
// when a message arrives from UI
ws.on('message', async (data) => {
  const { text, userId } = JSON.parse(data);
  // forward to bot/orchestrator
  const { data: { reply } } = await axios.post(
    "http://bot-service:3000/handle_turn",
    { text, userId }
  );
  // send answer back to same websocket
  ws.send(JSON.stringify({ type: "bot_reply", text: reply }));
});
```

<div id="bkmrk--12">  
</div>---

5. <div class="paragraph">One-shot docker-compose for local dev</div>

---

<div class="segment-code markdown-code" id="bkmrk-yaml-copy"><header class="segment-code-header"><div class="segment-code-header-content"><span class="segment-code-lang">yaml</span><div class="simple-button size-medium"><span>Copy</span></div></div></header><div class="syntax-highlighter dark segment-code-content">  
</div></div>```yaml
version: "3.8"
services:
  airtable-mcp:
    build: ./airtable-mcp
    ports: ["8001:8001"]
    env_file: ./airtable-mcp/.env
  bot:
    build: ./bot
    ports: ["3000:3000"]
    env_file: ./bot/.env
    depends_on: [airtable-mcp]
```

<div id="bkmrk--15">  
</div><div class="paragraph" id="bkmrk-docker-compose-up-%E2%86%92-">`docker compose up` → everything spins up, UI talks to your existing backend, backend forwards to bot, bot calls Airtable via MCP, answer flows back.</div>---

6. <div class="paragraph">What you gained</div>

---

- <div class="paragraph">Chat UI ⇄ WSS stays untouched.</div>
- <div class="paragraph">Backend.chatbuilder.com only needs to **forward** text to the bot service; no Airtable keys, no LLM keys, no MCP logic.</div>
- <div class="paragraph">Airtable CRUD lives in its own container; expose extra tools (update, delete, linked tables, …) by editing only the MCP server.</div>
- <div class="paragraph">Swap Anthropic ↔ OpenAI by changing one env var.</div>
- <div class="paragraph">Add Google-Calendar MCP server on port 8002, register it in the bot startup loop—zero other changes.</div>