Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM
Building the first interactive Quorentra CRM interface inside ChatGPT using MCP tools, structured results, React, and the MCP Apps UI architecture.

1. Introduction
In Part 15, Quorentra crossed an important boundary.
We connected:
ChatGPT │ ▼MCP │ ▼Quorentra
and exposed our first read-only CRM capabilities:
list_companiesget_companylist_contactsget_contactlist_company_contactslist_opportunitiesget_opportunitylist_company_opportunitieslist_opportunities_by_stageget_pipeline_summary
For the first time, a user could ask:
What is my current EUR pipeline?
ChatGPT could invoke:
get_pipeline_summary
and Quorentra could return:
{ "currency": "EUR", "open_opportunities": 4, "total_pipeline": "385000.00", "weighted_pipeline": "161000.00"}
ChatGPT could then explain the result conversationally.
That already gives us a useful ChatGPT-native CRM.
But CRM information is not always best represented as prose.
Sometimes the better interface is visual.
That is what we will build in Part 16.
2. What Are We Building?
We will create Quorentra’s first UI component designed to render inside ChatGPT.
The target experience is:
User:"Show my EUR pipeline."
Quorentra retrieves the pipeline data and presents a compact visual interface:
┌────────────────────────────────────────────┐│ QUORENTRA ││ Sales Pipeline ││ ││ Open Opportunities 4 ││ ││ Total Pipeline €385,000 ││ Weighted Pipeline €161,000 ││ ││ Weighted Coverage 41.8% │└────────────────────────────────────────────┘
ChatGPT can still provide conversational context around it.
The result becomes:
Natural Language +CRM Data +Interactive UI
This is our first true ChatGPT application surface.
3. Why Start With the Pipeline?
We could begin with:
Company ListContact ListOpportunity TableCompany DetailOpportunity Detail
But the pipeline summary is a better first component.
Why?
Because it is:
SmallRead-onlyHigh-valueEasy to verifyEasy to visualizeAlready implementedAlready testedAlready tenant-safeAlready available through MCP
Most importantly, the underlying business capability already works.
We are changing presentation, not business logic.
4. The Modular Principle Continues
The development sequence remains:
Business Capability ↓REST ↓MCP ↓ChatGPT ↓UI
Not:
Build fancy UI ↓Invent data later
The pipeline calculation already exists inside Quorentra.
The MCP interface already exposes it.
Now we add another adapter:
Pipeline Data ↓UI Resource ↓React Component
5. The Architecture Before Part 16
At the end of Part 15:
┌────────────────────┐│ User │└─────────┬──────────┘ │ ▼┌────────────────────┐│ ChatGPT │└─────────┬──────────┘ │ ▼┌────────────────────┐│ MCP │└─────────┬──────────┘ │ ▼┌────────────────────┐│ Quorentra Tools │└─────────┬──────────┘ │ ▼┌────────────────────┐│ Application Core │└─────────┬──────────┘ │ ▼┌────────────────────┐│ PostgreSQL │└────────────────────┘
Everything above the MCP tool result was primarily conversational.
6. Architecture After Part 16
Now we introduce a UI path:
┌───────────────────────────────────────────┐│ User │└──────────────────────┬────────────────────┘ │ ▼┌───────────────────────────────────────────┐│ ChatGPT │└──────────────────────┬────────────────────┘ │ ┌───────┴────────┐ │ │ ▼ ▼ Conversation Quorentra UI │ ▼ MCP Apps Bridge │ ▼ MCP Tools │ ▼ Quorentra Core │ ▼ PostgreSQL
This is an important evolution.
ChatGPT becomes both:
Conversational interface
and:
Application host
for Quorentra.
7. Current MCP Apps UI Model
The current architecture for MCP-backed ChatGPT interfaces is based around UI components running inside an isolated iframe.
Conceptually:
ChatGPT │ ▼Sandboxed iframe │ ▼Quorentra React Component
Communication between the component and host occurs through the MCP Apps bridge.
The important concepts are:
MCP ToolMCP ResourceUI TemplateStructured ContentMCP Apps BridgeiframeReact Component
ChatGPT-specific extensions can additionally be accessed through:
window.openai
where appropriate.
8. MCP Apps First, ChatGPT Extensions Second
For new Quorentra UI components, our principle will be:
Use the standard MCP Apps UI mechanism for the baseline application and use ChatGPT-specific extensions only when they materially improve the experience.
This improves portability.
Conceptually:
MCP Apps │ ├── Tool I/O ├── Tool calls ├── Messages └── Model context
with optional:
window.openai
features for ChatGPT-specific capabilities.
9. What Is a UI Resource?
An MCP tool provides a capability.
For example:
get_pipeline_summary
A UI resource provides a presentation.
For example:
ui://quorentra/pipeline.html
Conceptually:
Toolget_pipeline_summary
returns:
Structured CRM data
while:
Resourceui://quorentra/pipeline.html
defines how that information can be visually rendered.
These are separate concerns.
10. Data Is Not UI
This distinction is important.
Our business data is:
{ "currency": "EUR", "open_opportunities": 4, "total_pipeline": "385000.00", "weighted_pipeline": "161000.00"}
Our UI is:
┌────────────────────────────────────┐│ Sales Pipeline ││ ││ 4 Open Opportunities ││ €385,000 Total ││ €161,000 Weighted │└────────────────────────────────────┘
The data should remain useful even if no UI is available.
11. Do Not Make the Widget the Business Layer
Avoid:
React Component │ ▼Calculate Pipeline
Instead:
OpportunityService │ ▼Calculate Pipeline │ ▼MCP Structured Result │ ▼React Component
The UI renders facts.
It does not determine CRM truth.
12. A Better Data/UI Pattern
Current MCP Apps guidance supports a particularly useful pattern:
Data Tool ↓Structured Result ↓Render Tool ↓UI Resource
This decouples:
Data acquisition
from:
Presentation
For Quorentra, this is an excellent long-term architecture.
13. Why Decouple Data and Rendering?
Imagine later asking:
Show only the pipeline opportunities worth more than €100,000.
A tightly coupled tool might always render the same pipeline widget immediately.
A decoupled design allows:
ChatGPT │ ▼Fetch CRM Data │ ▼Reason / Filter │ ▼Render Appropriate UI
That gives the model more flexibility.
14. Part 16 Scope
We will therefore introduce two concepts:
get_pipeline_summary
as our data capability,
and:
render_pipeline_summary
as our presentation capability.
The first returns data.
The second renders the pipeline UI.
This separation will become useful as Quorentra grows.
15. Starting Checkpoint
Before modifying anything, verify Part 15.
From:
quorentra/backend
run:
python -m pytest
Then verify the MCP server.
Confirm these tools remain available:
list_companiesget_companylist_contactsget_contactlist_company_contactslist_opportunitiesget_opportunitylist_company_opportunitieslist_opportunities_by_stageget_pipeline_summary
Finally verify:
What is my current EUR pipeline?
still works through ChatGPT.
Do not add UI until the data path is green.
16. Our New Project Structure
We now need frontend code specifically for ChatGPT-hosted components.
A clean structure is:
quorentra/├── backend/│ └── app/│ └── mcp/│ ├── server.py│ ├── tools/│ └── resources/│└── chatgpt-ui/ ├── package.json ├── tsconfig.json ├── src/ │ ├── pipeline/ │ │ ├── PipelineWidget.tsx │ │ ├── pipeline.css │ │ └── index.tsx │ └── shared/ │ └── mcp.ts └── dist/
This keeps:
Python backend
separate from:
React UI
while keeping both inside the Quorentra repository.
17. Why a Separate chatgpt-ui Directory?
The component has different concerns:
TypeScriptReactBundlingCSSBrowser runtimeHost bridge
The backend has:
PythonFastAPIMCPSQLAlchemyPostgreSQL
Mixing them unnecessarily would make the project harder to maintain.
Our modular philosophy should apply to source structure too.
18. Create the UI Project
From the Quorentra root:
mkdir chatgpt-uicd chatgpt-uinpm init -y
Install React:
npm install react react-dom
Then development tooling:
npm install -D typescript esbuild
We deliberately keep the dependency set small.
19. Why esbuild?
We need to transform:
TypeScript+React JSX
into browser-executable JavaScript.
For this small component, esbuild provides a simple build pipeline.
We do not need:
Large application frameworkComplex routerSSRFull web application platform
for our first widget.
The component runs inside a host environment.
20. Create the Source Structure
From:
chatgpt-ui
create:
mkdir srcmkdir src\pipelinemkdir src\shared
Then create:
src/├── pipeline/│ ├── PipelineWidget.tsx│ ├── pipeline.css│ └── index.tsx│└── shared/ └── mcp.ts
21. Define the Pipeline Data Contract
The UI should consume the same conceptual structure returned by our tool.
Create a TypeScript type:
export type PipelineSummary = { currency: string; open_opportunities: number; total_pipeline: string; weighted_pipeline: string;};
This gives the component an explicit contract.
22. Why Use Strings for Money?
Our Python API uses decimal-safe values.
For example:
"385000.00"
rather than relying on binary floating-point arithmetic.
The UI can parse these values for formatting.
It should not perform authoritative financial calculations.
The backend remains responsible for CRM financial truth.
23. Weighted Coverage
The UI can derive a presentation metric:
weighted_pipeline----------------- total_pipeline
For example:
161,000-------385,000=41.8%
This is acceptable as a display-only derived metric.
It does not modify business state.
24. Build a Currency Formatter
We want:
385000.00
displayed as:
€385,000
or according to the user’s locale.
Use:
Intl.NumberFormat
rather than manually concatenating currency symbols.
Conceptually:
function formatCurrency( value: string, currency: string, locale: string) { return new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0, }).format(Number(value));}
25. Respect Host Locale
The host can provide locale context to the component.
A good browser-native fallback is:
document.documentElement.lang
For example:
const locale = document.documentElement.lang || "en-US";
This lets formatting adapt naturally.
A Dutch user might see:
€ 385.000
while a U.S. locale may render equivalent formatting differently.
26. Build the Pipeline Component
Open:
src/pipeline/PipelineWidget.tsx
Conceptually:
type Props = { data: PipelineSummary;};export function PipelineWidget({ data }: Props) { const locale = document.documentElement.lang || "en-US"; return ( <main> <header> <span>Quorentra</span> <h1>Sales Pipeline</h1> </header> <section> <Metric label="Open Opportunities" value={String(data.open_opportunities)} /> <Metric label="Total Pipeline" value={formatCurrency( data.total_pipeline, data.currency, locale )} /> <Metric label="Weighted Pipeline" value={formatCurrency( data.weighted_pipeline, data.currency, locale )} /> </section> </main> );}
The exact visual design can evolve.
The data contract is more important.
27. Create a Reusable Metric Component
Rather than repeating markup:
type MetricProps = { label: string; value: string;};function Metric({ label, value,}: MetricProps) { return ( <div className="metric"> <span className="metric-label"> {label} </span> <strong className="metric-value"> {value} </strong> </div> );}
Small reusable primitives will help when we build future Quorentra components.
28. Keep the First Design Simple
Avoid immediately building:
ChartsAnimationsGradientsLarge iconsComplex navigationTabsDrill-downModalsFilters
The first component needs to prove:
Tool Result ↓MCP Apps Bridge ↓React ↓ChatGPT
A clean metrics card is enough.
29. Suggested First Layout
Conceptually:
QUORENTRASales PipelineEUR┌────────────────────────────┐│ Open Opportunities 4 │├────────────────────────────┤│ Total Pipeline €385,000│├────────────────────────────┤│ Weighted Pipeline €161,000│├────────────────────────────┤│ Weighted Coverage 41.8%│└────────────────────────────┘
It is immediately understandable.
30. Empty State
What if:
open_opportunities = 0
?
Do not show a broken-looking card.
Instead:
QUORENTRASales PipelineNo open EUR opportunities.Your current open pipeline is empty.
Empty states are part of product design.
31. Loading State
The UI should tolerate data not being immediately available.
For example:
Loading pipeline…
This matters because tool input or tool results may arrive after the component initially mounts.
The component must not assume everything exists synchronously.
32. Error State
If the UI receives malformed data, show:
Unable to display pipeline data.
Do not show:
TypeError: Cannot read properties of undefined
to the user.
Client-side errors should remain safe and comprehensible.
33. Treat Tool Results as Untrusted Input
Even though the result comes from Quorentra, the browser component should still validate assumptions.
Check:
currency existsopen_opportunities is numericpipeline values exist
Do not blindly render arbitrary HTML returned by a tool.
The UI boundary deserves its own defensive programming.
34. Receiving MCP Tool Results
The current MCP Apps architecture sends tool results to the component through host messages.
Conceptually:
ChatGPT │ ▼ui/notifications/tool-result │ ▼iframe │ ▼React
The useful business data is available through:
structuredContent
The component should render from that structured data.
35. Create a Tool Result Hook
In:
src/shared/mcp.ts
we can create a small React helper.
Conceptually:
export function useToolResult() { const [result, setResult] = useState<unknown>(null); useEffect(() => { const handleMessage = ( event: MessageEvent ) => { if (event.source !== window.parent) { return; } const message = event.data; if ( !message || message.jsonrpc !== "2.0" ) { return; } if ( message.method !== "ui/notifications/tool-result" ) { return; } setResult( message.params?.structuredContent ?? null ); }; window.addEventListener( "message", handleMessage ); return () => { window.removeEventListener( "message", handleMessage ); }; }, []); return result;}
This isolates host communication from presentation logic.
36. Why Wrap the Bridge?
We do not want every Quorentra component to contain:
postMessageJSON-RPCevent listenershost message parsing
Instead:
shared/mcp.ts
becomes our UI integration layer.
Future components can simply use hooks.
This is the same modular pattern we used on the backend.
37. Render From Structured Content
Our pipeline entry component can conceptually do:
const result = useToolResult();if (!result) { return <LoadingState />;}return ( <PipelineWidget data={result as PipelineSummary} />);
A production implementation should add proper runtime validation.
But the flow remains:
Tool Result ↓Hook ↓Validated Data ↓Component
38. ChatGPT Compatibility Layer
ChatGPT also provides:
window.openai
for Apps SDK compatibility and additional ChatGPT-specific functionality.
For example, depending on the environment, this can provide access to:
toolInputtoolOutputcallToolrequestDisplayModerequestModalrequestClose
We should feature-detect such capabilities rather than assuming they always exist.
39. Do Not Depend on window.openai for Everything
Because the baseline UI architecture is now aligned around MCP Apps compatibility, our first component should primarily rely on the standard bridge.
Use:
window.openai
only when we specifically need ChatGPT extensions.
This gives Quorentra a cleaner architecture.
40. Build the Entry Point
Create:
src/pipeline/index.tsx
Conceptually:
import React from "react";import { createRoot } from "react-dom/client";import { PipelineWidget } from "./PipelineWidget";import { useToolResult } from "../shared/mcp";import "./pipeline.css";function App() { const result = useToolResult(); if (!result) { return <div>Loading pipeline…</div>; } return ( <PipelineWidget data={result as PipelineSummary} /> );}const root = document.getElementById("root");if (root) { createRoot(root).render(<App />);}
41. Add the Build Script
In:
package.json
add:
{ "scripts": { "build": "esbuild src/pipeline/index.tsx --bundle --format=esm --outfile=dist/pipeline.js" }}
Then create:
dist/
and run:
npm run build
Expected:
dist/pipeline.js
42. Keep the Bundle Small
The component will run inside ChatGPT.
Avoid adding large dependencies unless necessary.
For the pipeline card we do not need:
Chart.jsD3Material UILarge icon librariesState management frameworks
React plus browser APIs are enough.
Later components may justify additional libraries.
43. Add the Backend Resource Package
Back in:
backend/app/mcp
create:
resources/
with:
resources/├── __init__.py└── pipeline.py
The resource module will register the UI template.
44. Define the Resource URI
Use a stable URI such as:
ui://quorentra/pipeline.html
Conceptually:
PIPELINE_UI_URI = ( "ui://quorentra/pipeline.html")
This identifier connects the MCP server to the UI resource.
45. UI Resources Are Not Public URLs
Notice:
ui://
rather than:
https://
The resource URI identifies an MCP resource.
The MCP server resolves it and returns the component content.
It is not simply a conventional webpage URL.
46. Build the HTML Template
The server needs to provide an HTML document containing:
root elementstylescomponent bundle
Conceptually:
<html><head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /></head><body> <div id="root"></div> <script type="module"> /* bundled pipeline.js */ </script></body></html>
The build output can be embedded into this resource.
47. Why Bundle the Component?
The UI executes in a sandboxed host environment.
Bundling gives us:
One predictable artifactNo runtime npm dependency resolutionSimpler resource deliverySmaller deployment surface
For our first component, this is ideal.
48. Register the UI Resource
The MCP server conceptually registers:
Name:quorentra-pipelineURI:ui://quorentra/pipeline.html
and returns:
HTML
with the MCP Apps-compatible HTML MIME profile expected by the current SDK.
The exact registration syntax should follow the current MCP SDK version installed in the project.
49. Why We Keep SDK Syntax Isolated
The underlying architecture is stable:
ToolResourceStructured ResultComponent
But SDK registration APIs can evolve.
Therefore:
app/mcp/resources/pipeline.py
should isolate the SDK-specific resource registration code.
That makes future upgrades easier.
50. Register a Render Tool
Now add:
render_pipeline_summary
This is not a business-data tool.
Its purpose is presentation.
Conceptually:
Name:render_pipeline_summaryDescription:Render a Quorentra sales pipeline summaryusing pipeline data previously retrievedfor the active organization.Behavior:Read-only presentation
51. Render Tool Input
The render tool can accept:
currencyopen_opportunitiestotal_pipelineweighted_pipeline
For example:
{ "currency": "EUR", "open_opportunities": 4, "total_pipeline": "385000.00", "weighted_pipeline": "161000.00"}
It does not need to query PostgreSQL again.
Its job is rendering.
52. The Decoupled Flow
The complete request can now become:
User │ │ "Show my EUR pipeline." ▼ChatGPT │ ▼get_pipeline_summary │ ▼Quorentra Core │ ▼Structured Pipeline Data │ ▼ChatGPT │ ▼render_pipeline_summary │ ▼Pipeline UI Resource │ ▼React Widget
This cleanly separates:
Business retrieval
from:
Presentation
53. Why This Pattern Scales
Later:
search_opportunities
can retrieve data.
Then:
render_opportunity_table
can display it.
Likewise:
get_company
can retrieve data.
Then:
render_company_card
can display it.
Our future architecture becomes:
Data Tools +Render Tools +UI Resources
This is highly modular.
54. Tool Metadata
The render tool needs metadata linking it to the UI resource.
Conceptually, the current ecosystem supports associating a tool with a resource URI.
For ChatGPT compatibility this has historically included metadata such as an output template.
For broader MCP Apps compatibility, the UI resource URI can also be represented through the standardized UI metadata.
The implementation should use the current SDK conventions.
55. Do Not Attach UI to Every Data Tool
It is tempting to make:
get_pipeline_summary
always render a widget.
But then a request such as:
Compare my weighted pipeline with the total pipeline and explain the difference.
may cause unnecessary UI rendering when ChatGPT only needed the numbers.
The decoupled architecture lets the model choose when presentation adds value.
56. Data Tools Remain Reusable
Our original:
get_pipeline_summary
should remain useful for:
ReasoningComparisonConversationFuture agentsOther MCP clientsREST parityAutomation
without requiring a UI.
That is good interface design.
57. Rendering Is a Separate Capability
The render tool is specifically for:
User-visible presentation
This also makes testing easier.
We can test:
pipeline calculation
separately from:
pipeline rendering
Again:
one concern per layer.
58. Structured Content
The render tool should return its business payload as:
structuredContent
Conceptually:
{ "currency": "EUR", "open_opportunities": 4, "total_pipeline": "385000.00", "weighted_pipeline": "161000.00"}
The widget receives this structured data.
It should not parse prose to reconstruct the pipeline.
59. Never Make the UI Parse Natural Language
Avoid:
"Your pipeline contains four opportunitiesworth €385,000 with weighted value €161,000."
then trying to extract values from that string.
That is fragile.
Prefer:
structuredContent
for machines,
and:
content
or ChatGPT narration for humans.
60. Structured Content Is the Contract
This becomes another Quorentra principle:
Components render structured application data, never inferred prose.
The model can narrate the data separately.
This prevents presentation logic from depending on language-model wording.
61. UI-Only Metadata
Some information may be useful to the component but unnecessary for model reasoning.
For example:
display preferencesUI hintssession identifiers
can belong in metadata rather than the primary structured business result.
Keep the model-facing result focused.
62. Do Not Put Secrets in Widget Metadata
The UI runs client-side.
Never send:
JWT signing secretDatabase credentialsAPI secretsInternal service credentials
to the component.
Likewise, avoid unnecessary sensitive information.
The browser should receive only what it needs to render the user’s authorized data.
63. Tenant Security Still Happens Server-Side
The UI does not enforce tenant isolation.
This is critical.
Never rely on:
if ( company.organization_id === currentOrganization)
inside React as a security mechanism.
The backend must already have filtered the data.
The UI receives authorized results only.
64. The Widget Is Not Trusted
Treat the widget as a client.
The security architecture remains:
User │ ▼ChatGPT │ ▼MCP Request │ ▼Authentication │ ▼TenantContext │ ▼RBAC │ ▼Application Service │ ▼Authorized Result │ ▼Widget
Never reverse that order.
65. First Visual Test
Use known demo data:
Open Opportunities 4Total Pipeline €385,000Weighted Pipeline €161,000
Ask:
Show my EUR pipeline.
Verify the component displays exactly those values.
66. Verify Against REST
Request:
GET /api/v1/opportunities/pipeline/summary?currency=EUR
Then compare with the widget.
Expected:
REST=MCP=Widget
The presentation can differ.
The business values cannot.
67. Verify Against the MCP Data Tool
Call:
get_pipeline_summary
directly.
Compare:
currencyopen_opportunitiestotal_pipelineweighted_pipeline
with the UI.
Again:
Business truth
must be identical across interfaces.
68. Add UI Tests
Create a small frontend test strategy.
At minimum verify:
valid pipeline renderszero pipeline renderscurrency formatting worksmissing data shows safe stateweighted coverage handles zero total
For the first widget, we do not need an enormous frontend testing framework.
The goal is confidence around the data contract.
69. Division by Zero
Weighted coverage must handle:
total_pipeline = 0
Avoid:
NaN%
or:
Infinity%
Use:
0%
or omit the metric.
This is a small example of why UI edge cases matter.
70. Currency Edge Cases
Test:
EURUSDGBP
and potentially currencies with different fraction conventions later.
Use:
Intl.NumberFormat
instead of maintaining a manual symbol table.
71. Large Values
Test:
€385,000€5,000,000€125,000,000
The layout should remain usable.
Do not assume pipeline values are always short.
72. Responsive Layout
The ChatGPT component may render at different widths.
Therefore avoid fixed assumptions such as:
width: 900px;
Prefer flexible layouts.
For example:
CSS GridFlexboxmin/max widthsresponsive spacing
The component should work inline without requiring fullscreen.
73. Inline First
The pipeline summary is compact.
Therefore its default presentation should be:
inline
rather than:
fullscreen
Fullscreen should be reserved for interfaces that genuinely need additional space.
Later examples might include:
Opportunity tableSales analytics dashboardComplex editor
74. Theme Awareness
ChatGPT can be used with different appearance settings.
Avoid assumptions such as:
background always whitetext always black
Prefer host-compatible styling and semantic design tokens where supported.
At minimum, ensure the component remains readable in both light and dark contexts.
75. Accessibility
Our first widget should establish good habits.
Use:
Semantic HTMLReadable labelsSufficient contrastKeyboard-safe controlsMeaningful headings
Do not encode information only through color.
For example, the weighted pipeline value should have a textual label.
76. The Widget Should Not Fight ChatGPT
A ChatGPT component is not a conventional full-page website.
Avoid:
Large navigation headerHuge logoCookie bannerWebsite footerMarketing sectionsSide navigation
The conversation already provides application context.
The component should focus on the task.
77. Quorentra Branding
A subtle identifier is enough:
QUORENTRASales Pipeline
The UI should feel like part of Quorentra without consuming valuable space with branding.
The CRM data is the priority.
78. ChatGPT Narration Plus UI
The ideal result might be:
Your EUR pipeline currently contains four openopportunities worth €385,000, with a weightedpipeline of €161,000.
followed by the visual component.
The prose provides interpretation.
The widget provides scanning and visual structure.
79. Avoid Duplicating Everything
If the widget already displays:
Open Opportunities 4Total €385,000Weighted €161,000
ChatGPT does not need to repeat every number multiple times.
The model response should complement the UI.
For example:
Your weighted pipeline is currently about 42% of the total open pipeline.
Then the card provides the detailed values.
80. Future Interaction
The first pipeline widget is read-only.
Later we could add:
View OpportunitiesFilter by StageChange CurrencyOpen Pipeline Details
These actions could call additional MCP tools.
But we deliberately avoid that in the first version.
81. Why Read-Only UI First?
Interactive buttons introduce another execution path:
Widget │ ▼Tool Call │ ▼Quorentra
That path deserves separate testing.
For Part 16 we only need:
Quorentra │ ▼Widget
This keeps the milestone focused.
82. UI-Initiated Tool Calls
Later, a widget can request tools through the MCP Apps bridge.
Conceptually:
User clicks"View Opportunities" ↓Widget ↓tools/call ↓Quorentra MCP ↓list_opportunities ↓Structured Result ↓Widget updates
This turns the component from visualization into an interactive application.
That will be a future step.
83. Do Not Duplicate REST Calls From the Widget
Avoid:
Widget JavaScript ↓fetch("/api/v1/opportunities")
if the same capability is already available through MCP.
Prefer:
Widget ↓MCP Tool ↓Quorentra
This preserves:
AuthenticationTenantContextRBACTool auditingInterface consistency
84. One Governed Capability Layer
The long-term architecture should be:
REST
│
▼
Application Services
▲
│
MCP
▲
│
┌──────┴──────┐
│ │
ChatGPT Widgets
Not several independent paths into the database.
85. Content Security Policy
Hosted components operate under security constraints.
If a future widget needs external resources, such as:
API domainsImage domainsMap servicesExternal assets
those origins may need to be declared appropriately in UI resource metadata.
Our pipeline widget needs none of these.
That is another reason it makes a good first component.
86. No External Dependencies at Runtime
For Part 16, aim for:
No external fontsNo external APIsNo remote imagesNo third-party iframeNo analytics scripts
The widget needs only the structured pipeline result.
This gives us a very small security surface.
87. Build the Component
From:
quorentra/chatgpt-ui
run:
npm run build
Verify:
dist/pipeline.js
exists.
The backend resource loader can now incorporate this artifact.
88. Register the Resource
Start the Quorentra MCP server and verify the UI resource is discoverable.
Conceptually:
ui://quorentra/pipeline.html
should resolve successfully.
If the resource cannot be loaded, do not test ChatGPT yet.
89. Test the Render Tool
Call:
render_pipeline_summary
with:
{ "currency": "EUR", "open_opportunities": 4, "total_pipeline": "385000.00", "weighted_pipeline": "161000.00"}
Verify the tool result includes:
structuredContent
and references the correct UI resource.
90. Test the Component Independently
Before ChatGPT, test:
Resource loadsJavaScript executesReact mountsTool result is receivedValues renderNo console errors occur
Again, isolate boundaries.
91. Then Test Through ChatGPT
Now ask:
Show my EUR pipeline.
Expected sequence:
ChatGPT │ ▼get_pipeline_summary │ ▼{ EUR, 4, 385000, 161000} │ ▼render_pipeline_summary │ ▼Quorentra Pipeline Widget
If successful, we have our first complete ChatGPT-hosted Quorentra interface.
92. Test Another Currency
Ask:
Show my USD pipeline.
Expected:
get_pipeline_summarycurrency = USD
followed by the same reusable render component.
The UI must not be hard-coded for EUR.
93. Test an Empty Pipeline
For a tenant with no GBP Opportunities:
Show my GBP pipeline.
Expected:
No open GBP opportunities.
with a clean empty-state component.
This proves the UI handles legitimate zero-data scenarios.
94. Test Tenant Isolation Again
Create:
Tenant AEUR pipeline €385,000Tenant BEUR pipeline €5,000,000
Connect as Tenant A.
Ask:
Show my EUR pipeline.
The widget must display:
€385,000
Never:
€5,385,000
The UI does not change our security requirements.
95. Test Viewer Role
Connect as:
Viewer
Because:
get_pipeline_summary
requires only:
opportunities.read
the widget should render.
This proves ChatGPT UI respects Quorentra RBAC.
96. Test Unauthenticated Access
Without authentication, the pipeline tool should fail before rendering authorized CRM data.
The widget should never receive another user’s cached pipeline.
Authentication remains a server-side prerequisite.
97. Do Not Cache Tenant Data Globally
Avoid server-side code such as:
last_pipeline_result = result
shared across requests.
That could create catastrophic cross-tenant leakage.
All request data must remain properly scoped.
98. Widget State Is Not Business State
Another important principle:
Expanded sectionSelected tabDisplay mode
may be widget state.
But:
Opportunity stagePipeline amountCompany ownershipContact data
is business state.
Business state belongs in Quorentra.
Do not make the iframe authoritative for CRM data.
99. If the Widget Reloads
The component should be able to reconstruct itself from authoritative tool results.
It should not depend on an undocumented browser session for critical CRM information.
This makes the application more robust across host environments.
100. Model Context and UI State
Later, if a user selects something inside a component that ChatGPT should know about, the MCP Apps bridge provides mechanisms for updating model-visible context.
For example:
User selects:Contoso
The component could inform the host that:
Selected company = Contoso
This enables conversational follow-ups.
But the pipeline summary does not need this yet.
101. Example Future Interaction
Imagine:
Pipeline Widget[Proposal: €75,000]
The user clicks Proposal.
The widget could update model context:
User selected the Proposal stage.
Then the user says:
Show me those deals.
ChatGPT could understand what “those” refers to.
This is where UI and conversation begin sharing context.
102. Why This Matters for CRM
CRM applications contain many referential interactions:
this companythese opportunitiesthat contactthose dealsthis quarterthat pipeline stage
A strong ChatGPT-native UI needs to preserve enough state for natural follow-up conversation.
We will introduce this gradually.
103. First Component Boundary
For Part 16, however, our component remains:
InputPipeline SummaryOutputVisual Pipeline SummaryInteractionsNoneMutationsNone
This is intentionally small.
104. Add Backend Tests
Create:
tests/mcp/test_pipeline_ui.py
Test:
pipeline UI resource existsrender tool existsrender tool is read-onlyrender tool accepts valid datastructuredContent matches inputresource URI is correct
This ensures the MCP/UI contract remains stable.
105. Add Security Tests
Verify:
render tool does not accept tenant_idrender tool does not access databaserender tool does not bypass authenticationdata tool remains tenant-scoped
Remember:
render_pipeline_summary
is presentation.
The authoritative data still comes from the secure data tool.
106. Add Contract Tests
Given:
{ "currency": "EUR", "open_opportunities": 4, "total_pipeline": "385000.00", "weighted_pipeline": "161000.00"}
the structured render result should preserve those values exactly.
No rounding should occur before presentation.
Formatting belongs to the UI.
107. Add Frontend Build to Development Workflow
Our development sequence now becomes:
cd chatgpt-uinpm run buildcd ..\backendpython -m pytestpython -m uvicorn app.main:app --reload
plus the MCP server startup process established in Part 15.
Later we can automate this.
For now, explicit commands make each build stage understandable.
108. Do We Need Vite Yet?
No.
For one small embedded component:
React+TypeScript+esbuild
is enough.
We can introduce Vite or a more sophisticated frontend development environment when the number of components justifies it.
Avoid tooling complexity before product complexity requires it.
109. The Quorentra UI Library Begins Here
Even this tiny component establishes patterns for:
TypographySpacingMetric cardsLoading statesEmpty statesError statesCurrency formattingHost integrationTool-result hooks
Future widgets should reuse these patterns.
Eventually:
chatgpt-ui/src/shared/
may contain a small Quorentra component system.
110. Future UI Components
Likely future components include:
Company CardContact CardContact ListOpportunity CardOpportunity TablePipeline SummaryPipeline by StageTask ListActivity TimelineMeeting SummaryAI Insight Card
We should build them incrementally as CRM capabilities appear.
111. Do Not Build a Full Dashboard Yet
It would be tempting to jump immediately to:
Executive DashboardSales DashboardPipeline FunnelForecast ChartsActivity ChartsAI Recommendations
That would violate our modular strategy.
We first prove:
one tool+one resource+one component
Then expand.
112. ChatGPT Changes the Dashboard Concept
A conventional CRM requires users to open a dashboard and inspect whatever widgets were predefined.
A ChatGPT-native CRM can generate the relevant interface in response to intent.
User:
Show my pipeline.
gets:
Pipeline Widget
User:
Show Contoso.
gets:
Company Card
User:
Show proposal-stage opportunities.
gets:
Opportunity Table
The interface becomes contextual.
113. UI on Demand
This suggests a different product model:
Traditional CRMUser ↓Navigate ↓Screen ↓Data
versus:
ChatGPT-Native CRMUser Intent ↓Tool Selection ↓Data ↓Appropriate UI
The application surface is assembled around the task.
That is one of the most interesting aspects of the Quorentra architecture.
114. Conversation Remains the Primary Orchestrator
We are not replacing ChatGPT with React.
React handles:
Structured visual presentation
ChatGPT handles:
IntentReasoningTool orchestrationConversationExplanation
Quorentra handles:
Business rulesSecurityCRM statePersistence
Each layer has a distinct responsibility.
115. The Three-Layer Model
We can now describe Quorentra as:
┌─────────────────────────────────────────┐│ EXPERIENCE ││ ││ ChatGPT Conversation ││ ChatGPT-hosted UI │└────────────────────┬────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ CAPABILITIES ││ ││ MCP Tools ││ REST API │└────────────────────┬────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ CORE ││ ││ Authentication ││ TenantContext ││ RBAC ││ CRM Services ││ PostgreSQL │└─────────────────────────────────────────┘
This is becoming a mature platform architecture.
116. Version Update
Part 16 adds our first ChatGPT-hosted visual interface.
Update:
app/core/constants.py
from:
APP_VERSION = "0.2.0"
to:
APP_VERSION = "0.3.0"
This is another minor milestone because Quorentra now has a new user-experience surface.
117. Quorentra 0.3.0
Our status becomes:
Platform├── FastAPI ✓├── PostgreSQL ✓├── SQLAlchemy ✓└── Alembic ✓Identity├── Organizations ✓├── Users ✓├── Memberships ✓├── Authentication ✓└── JWT ✓Security├── TenantContext ✓├── Tenant Isolation ✓├── RBAC ✓└── Permissions ✓CRM├── Companies ✓├── Contacts ✓└── Opportunities ✓Sales├── Pipeline ✓├── Probability ✓├── Pipeline Value ✓└── Weighted Pipeline ✓Interfaces├── REST ✓├── MCP ✓└── ChatGPT ✓MCP├── Read-Only CRM Tools ✓├── Structured Results ✓├── Tenant-Aware Tool Calls ✓└── Render Tool Foundation ✓ChatGPT UI├── React Component Foundation ✓├── MCP Apps Bridge ✓├── Pipeline UI Resource ✓├── Pipeline Summary Widget ✓├── Loading State ✓├── Empty State ✓└── Error State ✓Mutation Tools -Interactive Tool Calls -Company UI -Contact UI -Opportunity UI -Activities -Tasks -AI Intelligence -
Quorentra is no longer only conversational.
It now has a native visual surface inside ChatGPT.
118. Acceptance Criteria
Part 16 is complete when:
✓ Part 15 regression suite remains green✓ chatgpt-ui project exists✓ React is configured✓ TypeScript is configured✓ component build works✓ pipeline bundle is generated✓ PipelineSummary TypeScript contract exists✓ currency formatting works✓ locale-aware formatting works✓ weighted coverage works✓ zero-total handling works✓ pipeline component exists✓ metric component exists✓ loading state exists✓ empty state exists✓ error state exists✓ component is responsive✓ MCP Apps tool-result handling exists✓ structuredContent drives rendering✓ widget does not parse model prose✓ host bridge code is isolated✓ pipeline UI resource exists✓ stable ui:// resource URI exists✓ resource returns component HTML✓ component bundle loads✓ render_pipeline_summary exists✓ data and rendering remain separated✓ get_pipeline_summary remains reusable✓ render tool performs no CRM calculations✓ render tool does not query PostgreSQL✓ tool result values match REST values✓ widget values match MCP values✓ currency is not hard-coded✓ Tenant A sees only Tenant A pipeline✓ Viewer can render authorized pipeline✓ unauthenticated access does not expose CRM data✓ no tenant ID is trusted from widget input✓ no secrets reach the browser✓ no global tenant-data cache exists✓ pipeline resource tests pass✓ render-tool tests pass✓ frontend contract tests pass✓ existing CRM regression tests pass✓ "Show my EUR pipeline" renders the widget✓ "Show my USD pipeline" uses the same widget✓ empty pipeline renders correctly✓ Quorentra reports version 0.3.0
Most importantly:
A Quorentra business capability can now move securely from PostgreSQL through the CRM core, through MCP, into an interactive visual surface inside ChatGPT.
119. What We Have Achieved
Part 15 gave ChatGPT access to Quorentra’s capabilities.
Part 16 gives those capabilities a visual application surface.
We now have:
User │ ▼Natural Language │ ▼ChatGPT │ ▼MCP Tool │ ▼Quorentra Core │ ▼Structured Data │ ▼MCP UI Resource │ ▼React Component
This is significantly different from simply adding a chatbot to a CRM.
120. The CRM Is Moving Into the Conversation
Traditional architecture:
CRM Application │ └── AI Assistant
Quorentra is exploring:
ChatGPT │ ├── Conversation ├── CRM Tools └── CRM UI │ ▼ Quorentra Core
The conversational environment itself becomes a major CRM workspace.
121. But the Backend Remains Independent
This remains non-negotiable.
If ChatGPT is unavailable tomorrow:
Quorentra Core
still exists.
REST still exists.
PostgreSQL still contains the authoritative CRM state.
Business rules still work.
Tenant isolation still works.
RBAC still works.
The ChatGPT experience is an interface, not the database or domain model.
122. Why This Is a Strong Modular Architecture
We can now replace or extend any interface independently.
For example:
React Web AppMobile AppMicrosoft TeamsSlackAnother AI ClientAutomation Agent
could eventually use the same Quorentra capabilities.
The domain core does not care whether the request began as:
HTTP
or:
natural language
That is exactly what modularity should achieve.
123. What Should Come Next?
We now have:
Pipeline Data Tool +Pipeline Render Tool +Pipeline Widget
The next useful step is not another static card.
We should prove that a ChatGPT-hosted Quorentra component can become interactive.
A natural next workflow is:
Pipeline Summary ↓View Opportunities ↓Opportunity List
This introduces:
Widget-initiated tool callsDynamic UI updatesOpportunity list renderingModel/UI context coordination
without yet introducing mutations.
That keeps the next step safe and modular.
124. Next Article
Interactive Opportunities in ChatGPT — Tool Calls, Opportunity Lists, and Drill-Down Navigation
The goal will be to evolve:
┌─────────────────────────────┐│ Sales Pipeline ││ ││ Open Opportunities 4 ││ Total €385,000 ││ Weighted €161,000 │└─────────────────────────────┘
into:
┌─────────────────────────────┐│ Sales Pipeline ││ ││ Open Opportunities 4 ││ Total €385,000 ││ Weighted €161,000 ││ ││ [View Opportunities] │└─────────────────────────────┘
When the user selects:
View Opportunities
the Quorentra UI will invoke a governed MCP capability:
list_opportunities
and update to something conceptually like:
┌──────────────────────────────────────────────┐│ Open Opportunities │├──────────────────────────────────────────────┤│ Microsoft 365 Migration ││ Contoso ││ Proposal · €75,000 · 60% │├──────────────────────────────────────────────┤│ Azure Modernization ││ Contoso ││ Discovery · €150,000 · 40% │├──────────────────────────────────────────────┤│ Security Assessment ││ Fabrikam ││ Negotiation · €40,000 · 80% │├──────────────────────────────────────────────┤│ Data Platform Upgrade ││ Northwind ││ Qualification · €120,000 · 20% │└──────────────────────────────────────────────┘
We will introduce:
Widget → MCP tool callsOpportunity list UIOpportunity cardsLoading transitionsTool-call errorsPaginationCompany contextStage presentationCurrency formattingNavigationModel-visible UI stateHost-backed routingResponsive list layoutsTool-call authorizationTenant isolationRead-only interaction
This will move Quorentra from:
ChatGPT+Static CRM Visualization
to:
ChatGPT+Interactive CRM Application
without yet giving the AI permission to change CRM data.
That is the next safe modular step in building Quorentra as a ChatGPT-native CRM.