Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM
Turning the first Quorentra pipeline widget into an interactive CRM experience with MCP tool calls, opportunity lists, filtering, drill-down navigation, and tenant-safe read-only interactions.

1. Introduction
In Part 16, Quorentra gained its first visual interface inside ChatGPT.
The user could ask:
Show my EUR pipeline.
ChatGPT could retrieve authoritative CRM data through:
get_pipeline_summary
and render:
┌────────────────────────────────────────────┐│ QUORENTRA ││ Sales Pipeline ││ ││ Open Opportunities 4 ││ ││ Total Pipeline €385,000 ││ Weighted Pipeline €161,000 ││ ││ Weighted Coverage 41.8% │└────────────────────────────────────────────┘
That was an important milestone.
But the component was passive.
The user could look at the pipeline, but could not interact with it.
In Part 17, we change that.
We will add:
[View Opportunities]
to the pipeline component.
Selecting it will cause the Quorentra component itself to invoke a governed MCP capability.
The widget will then transform into an opportunity list.
We are moving from:
CRM Visualization
to:
Interactive CRM Application
inside ChatGPT.
2. What Are We Building?
Our starting component is:
┌─────────────────────────────────────┐│ QUORENTRA ││ Sales Pipeline ││ ││ Open Opportunities 4 ││ Total Pipeline €385,000 ││ Weighted Pipeline €161,000 ││ ││ [View Opportunities] │└─────────────────────────────────────┘
When the user selects:
View Opportunities
the component will call:
list_opportunities
through MCP.
The UI will then become:
┌────────────────────────────────────────────┐│ QUORENTRA ││ Open Opportunities ││ ││ 4 opportunities · €385,000 │├────────────────────────────────────────────┤│ 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% │├────────────────────────────────────────────┤│ [Back to Pipeline] │└────────────────────────────────────────────┘
No page reload.
No conventional CRM navigation.
No mutation.
Just:
User Interaction ↓Widget ↓MCP ↓Quorentra ↓Structured Data ↓Updated Widget
3. Why This Step Matters
Part 16 proved:
Quorentra ↓ChatGPT UI
Part 17 proves:
ChatGPT UI ↓Quorentra
The communication is now bidirectional.
That changes the architectural role of the component.
It is no longer merely:
Presentation
It becomes:
Presentation+Interaction
while Quorentra remains authoritative for:
AuthenticationTenant isolationPermissionsBusiness rulesCRM data
4. The Architecture Before Part 17
Our Part 16 path was essentially:
User │ ▼ChatGPT │ ▼get_pipeline_summary │ ▼Quorentra │ ▼Structured Result │ ▼render_pipeline_summary │ ▼Pipeline Widget
The widget was the end of the path.
5. Architecture After Part 17
Now the component can initiate another capability:
User │ ▼ChatGPT │ ▼Pipeline Widget │ │ user clicks │ │ View Opportunities ▼MCP Apps Bridge │ ▼list_opportunities │ ▼Authentication │ ▼TenantContext │ ▼RBAC │ ▼OpportunityService │ ▼PostgreSQL │ ▼Structured Result │ ▼Pipeline Widget │ ▼Opportunity List
The widget becomes an MCP client within the governed ChatGPT experience.
6. The Most Important Security Rule
The component does not gain privileged CRM access.
A widget-initiated tool call must go through exactly the same security boundary as a model-initiated call.
Therefore:
Widget │ ▼MCP Tool │ ▼Authentication │ ▼TenantContext │ ▼Permission │ ▼Application Service
Never:
Widget │ ▼Database
and never:
Widget │ ▼Privileged internal endpoint
7. Widgets Are Clients
This gives us another Quorentra architectural principle:
A ChatGPT-hosted component is a client of Quorentra, not part of the trusted CRM core.
The component can request capabilities.
It cannot decide whether those capabilities are authorized.
That decision remains server-side.
8. Why Keep Part 17 Read-Only?
Once a component can call tools, it would be tempting to add:
[Move to Negotiation][Mark Won][Delete Opportunity]
We will not do that yet.
Part 17 remains entirely read-only.
The widget may call:
list_opportunitiesget_opportunitylist_opportunities_by_stage
but not:
create_opportunityupdate_opportunitydelete_opportunity
This lets us validate the interactive architecture without introducing mutation risk.
9. Starting Checkpoint
Before making changes, verify Part 16.
From:
quorentra/chatgpt-ui
run:
npm run build
Then:
cd ..\backendpython -m pytest
Verify the MCP server starts.
Then ask ChatGPT:
Show my EUR pipeline.
The Part 16 pipeline widget must render correctly.
Only then continue.
10. Our Existing Tool
Part 15 already introduced:
list_opportunities
Conceptually:
Name:list_opportunitiesDescription:List sales opportunities in the authenticateduser's active Quorentra organization.Input:limitoffsetOutput:Opportunity summariesPermission:opportunities.readBehavior:Read-only
We can reuse this capability.
That is exactly why we built MCP before the UI.
11. Do Not Create a Widget-Specific Opportunity API
Avoid adding:
GET /widget/opportunities
or:
GET /chatgpt/opportunities
just because the component needs opportunity data.
We already have the capability:
list_opportunities
Reuse it.
Our architecture remains:
ChatGPTWidgetsOther MCP Clients │ ▼ MCP Tools │ ▼Application Services
12. The First Widget-Initiated Call
The new button will conceptually execute:
callTool( "list_opportunities", { limit: 25, offset: 0 })
The exact bridge syntax should follow the current MCP Apps SDK and host implementation.
The important contract is:
Tool:list_opportunitiesArguments:limit = 25offset = 0
13. Why Start With 25?
Do not request:
all opportunities
The CRM may eventually contain:
1010010,0001,000,000
opportunities.
Every list capability should be bounded.
Our first page will contain:
25
records.
Later pages can be requested explicitly.
14. Opportunity Result Contract
Our component needs a predictable structure.
Conceptually:
export type OpportunitySummary = { id: string; company_id: string; company_name?: string; name: string; stage: string; amount: string; currency: string; probability: number; expected_close_date?: string | null;};
One important addition is:
company_name
because displaying only a Company UUID is not useful to the user.
15. Should list_opportunities Return Company Names?
This is a useful design question.
Our domain entity stores:
company_id
but the UI needs:
company_name
We have several choices.
The widget could call:
get_company
for every Opportunity.
But that produces an N+1 tool-call pattern.
For 25 Opportunities:
1 list call+25 company calls
That is inefficient.
16. Create an Opportunity Summary Projection
A better solution is for the application layer to expose an enriched read model.
Conceptually:
OpportunitySummary├── id├── company_id├── company_name├── name├── stage├── amount├── currency├── probability└── expected_close_date
This is not changing the Opportunity domain model.
It is creating a read projection.
17. Read Models Are Useful
Transactional domain models optimize for:
CorrectnessPersistenceRelationshipsBusiness rules
UI read models optimize for:
PresentationQuery efficiencyReduced round trips
They do not need to be identical.
This is a common enterprise application pattern.
18. Add OpportunityListItem
In the backend schemas, conceptually add:
class OpportunityListItem(BaseModel): id: UUID company_id: UUID company_name: str name: str stage: OpportunityStage amount: Decimal currency: str probability: int expected_close_date: date | None
This becomes the model-facing list representation.
19. Keep the Full Opportunity Separate
The existing:
get_opportunity
may return more detail.
For example:
descriptionownercreated_atupdated_at
if appropriate.
But the list tool should remain compact.
This keeps context usage efficient.
20. Pagination Metadata
Returning only an array makes navigation harder.
Instead of:
[ {...}, {...}]
return something conceptually like:
{ "items": [ {...}, {...} ], "limit": 25, "offset": 0, "has_more": false}
This gives the widget enough information to implement pagination.
21. Why has_more?
The UI does not need to know the total number of records immediately.
A simple:
has_more
allows:
[Load More]
without requiring an expensive count query.
Later we may add:
total
if the product requires it.
22. Updated Tool Output
Conceptually:
{ "items": [ { "id": "...", "company_id": "...", "company_name": "Contoso", "name": "Microsoft 365 Migration", "stage": "proposal", "amount": "75000.00", "currency": "EUR", "probability": 60, "expected_close_date": "2026-09-30" } ], "limit": 25, "offset": 0, "has_more": false}
This is much more useful to interactive clients.
23. Update the Frontend Contract
In:
chatgpt-ui/src/shared/types.ts
create:
export type OpportunityListItem = { id: string; company_id: string; company_name: string; name: string; stage: string; amount: string; currency: string; probability: number; expected_close_date?: string | null;};export type OpportunityListResult = { items: OpportunityListItem[]; limit: number; offset: number; has_more: boolean;};
This gives the UI a stable contract.
24. Introduce a Generic MCP Tool Hook
Part 16 gave us a hook for receiving the original render result.
Now we need to initiate tool calls.
Extend:
src/shared/mcp.ts
with a generic capability.
Conceptually:
export async function callMcpTool<T>( name: string, arguments_: Record<string, unknown>): Promise<T> { // Current MCP Apps bridge implementation}
The exact transport syntax should remain isolated here.
25. Why Isolate Host Calls?
We do not want:
PipelineWidget.tsxOpportunityList.tsxCompanyCard.tsxContactList.tsx
all containing their own host integration logic.
Instead:
shared/mcp.ts
owns:
tool invocationhost messagesJSON-RPCerror normalization
The components remain focused on CRM UX.
26. Feature Detection
Because host capabilities can evolve, the integration layer should feature-detect the available bridge.
Conceptually:
if standard MCP Apps bridge available use itelse if ChatGPT compatibility API available use window.openaielse show unsupported state
Do not scatter compatibility logic throughout the UI.
27. Add View State
The pipeline component now needs to know which view is active.
For example:
type View = | "pipeline" | "opportunities";
Then:
const [view, setView] = useState<View>("pipeline");
This is UI state.
It does not belong in PostgreSQL.
28. Add Opportunity State
We also need:
const [opportunities, setOpportunities] = useState<OpportunityListResult | null>(null);const [loading, setLoading] = useState(false);const [error, setError] = useState<string | null>(null);
These values represent the current UI session.
29. Add the Button
Our pipeline component becomes:
<button type="button" onClick={handleViewOpportunities}> View Opportunities</button>
Use a real:
<button>
rather than a clickable <div>.
Accessibility matters even inside embedded UI.
30. The Click Handler
Conceptually:
async function handleViewOpportunities() { setLoading(true); setError(null); try { const result = await callMcpTool<OpportunityListResult>( "list_opportunities", { limit: 25, offset: 0, } ); setOpportunities(result); setView("opportunities"); } catch { setError( "Unable to load opportunities." ); } finally { setLoading(false); }}
This is our first user-initiated CRM tool call from the component.
31. What Happens Server-Side?
The button does not directly query Opportunities.
The server still executes:
list_opportunities │ ▼Authentication │ ▼TenantContext │ ▼opportunities.read │ ▼OpportunityService │ ▼OpportunityRepository │ ▼PostgreSQL
The widget receives only the authorized result.
32. The Button Does Not Carry Tenant Identity
Do not send:
{ "organization_id": "...", "limit": 25}
from the widget.
Send:
{ "limit": 25, "offset": 0}
Tenant identity belongs to authenticated request context.
This rule remains fundamental.
33. Build OpportunityList
Create:
src/opportunities/OpportunityList.tsx
Conceptually:
type Props = { result: OpportunityListResult; onBack: () => void;};export function OpportunityList({ result, onBack,}: Props) { return ( <main> <header> <button onClick={onBack}> Back </button> <div> <span>Quorentra</span> <h1>Open Opportunities</h1> </div> </header> <section> {result.items.map((opportunity) => ( <OpportunityCard key={opportunity.id} opportunity={opportunity} /> ))} </section> </main> );}
34. Create OpportunityCard
Create:
src/opportunities/OpportunityCard.tsx
The component might display:
Microsoft 365 MigrationContosoProposal€75,00060% probabilityExpected close30 September 2026
The card should make the important sales information scannable.
35. Information Hierarchy
Prioritize:
1. Opportunity name2. Company3. Stage4. Amount5. Probability6. Expected close
Avoid filling the card with internal metadata.
A CRM UI should help users make decisions quickly.
36. Stage Labels
Backend values remain:
qualificationdiscoveryproposalnegotiationwonlost
The UI can display:
QualificationDiscoveryProposalNegotiationWonLost
Formatting is a presentation concern.
Do not change the domain vocabulary.
37. Stage Styling
We may visually distinguish stages.
However:
Do not rely on color alone.
Always display the textual stage.
For example:
[Proposal]
rather than only showing a colored dot.
This improves both clarity and accessibility.
38. Currency Formatting
Reuse the Part 16 formatter:
formatCurrency()
Do not duplicate formatting logic inside OpportunityCard.
This is the beginning of our shared Quorentra UI utility layer.
39. Date Formatting
Use browser internationalization:
new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric",});
For example:
Sep 30, 2026
or an appropriate localized representation.
Again, avoid hard-coded English date formatting.
40. Probability Formatting
The backend provides:
60
The UI displays:
60%
The component should not recalculate probability.
It merely formats it.
41. Weighted Opportunity Value
Should the card show:
€45,000 weighted
for:
€75,000 × 60%
?
Not yet.
The backend should eventually provide authoritative calculated values if we want them exposed.
Avoid gradually moving business calculations into React.
42. Empty Opportunity List
If:
items = []
show:
No opportunities found.
Do not show an empty container with no explanation.
43. Loading Transition
When the user clicks:
View Opportunities
show:
Loading opportunities…
or a small skeleton state.
Disable the button while the request is active.
This prevents accidental duplicate calls.
44. Error State
If the tool call fails:
Unable to load opportunities.[Try Again]
is better than collapsing the entire widget.
A transient tool error should be recoverable.
45. Preserve the Pipeline View on Error
If loading Opportunities fails, keep the pipeline summary visible.
Do not switch to a blank opportunity screen.
Conceptually:
Pipeline+Error Message+Retry
This provides a more resilient experience.
46. Back Navigation
Once Opportunities are displayed:
[Back to Pipeline]
should return to the existing pipeline data.
It should not need to call:
get_pipeline_summary
again unless the data needs refreshing.
The current result can remain in component state.
47. This Is Client-Side Navigation
We do not need:
React Router
for two small component states.
Simple state is enough:
pipelineopportunities
Do not add routing infrastructure prematurely.
48. Pagination
What if:
has_more = true
?
Show:
[Load More]
The next call becomes:
{ "limit": 25, "offset": 25}
The returned records are appended.
49. Load More Handler
Conceptually:
async function handleLoadMore() { if (!opportunities?.has_more) { return; } const result = await callMcpTool<OpportunityListResult>( "list_opportunities", { limit: opportunities.limit, offset: opportunities.offset + opportunities.limit, } ); setOpportunities({ ...result, items: [ ...opportunities.items, ...result.items, ], });}
This gives us bounded incremental retrieval.
50. Prevent Duplicate Records
If pagination is retried or requests overlap, avoid displaying the same Opportunity twice.
A simple deduplication strategy can use:
opportunity.id
as the stable key.
This is another reason machine identifiers remain useful.
51. Do Not Trust Pagination Inputs
The backend still validates:
limitoffset
even if they came from our own component.
For example:
limit <= 100offset >= 0
Client validation is convenience.
Server validation is authority.
52. Filter by Stage
Now that the interactive path works, we can add a small read-only filter.
For example:
AllQualificationDiscoveryProposalNegotiation
We should normally exclude:
WonLost
from the default open-pipeline view.
53. Use the Existing Stage Tool
We already have:
list_opportunities_by_stage
So selecting:
Proposal
can invoke:
list_opportunities_by_stage
with:
{ "stage": "proposal", "limit": 25, "offset": 0}
assuming we extend the tool’s pagination contract consistently.
54. Avoid Client-Side Filtering of Partial Data
Suppose we loaded only the first:
25
Opportunities.
If the user selects:
Proposal
and React filters those 25 locally, the result may be incomplete.
Therefore stage filtering should call the server.
This is an important rule:
Never present a partial client-side subset as if it were a complete CRM query result.
55. Server-Side Filtering
The correct flow is:
User selects Proposal ↓Widget ↓list_opportunities_by_stage ↓stage = proposal ↓Tenant-safe query ↓Complete bounded result
The backend owns query semantics.
56. Filter State
The UI can maintain:
type StageFilter = | "all" | "qualification" | "discovery" | "proposal" | "negotiation";
This is display/query state.
It is not CRM business state.
57. Filter Labels
Display:
AllQualificationDiscoveryProposalNegotiation
while passing:
qualificationdiscoveryproposalnegotiation
to MCP.
Again:
Domain value
and:
Presentation label
are related but separate.
58. Example Filtered View
The user selects:
Proposal
and sees:
┌────────────────────────────────────────┐│ Open Opportunities ││ ││ All Qualification Discovery ││ [Proposal] Negotiation │├────────────────────────────────────────┤│ Microsoft 365 Migration ││ Contoso ││ Proposal · €75,000 · 60% │└────────────────────────────────────────┘
No full application reload is required.
59. Tool Calls Remain Explicit
The widget should not send arbitrary query expressions such as:
{ "filter": "stage='proposal' OR 1=1"}
Instead, use typed arguments:
{ "stage": "proposal", "limit": 25, "offset": 0}
Narrow contracts are safer and easier to reason about.
60. Drill Down Into an Opportunity
The next read-only interaction is:
Opportunity Card ↓View Details
Selecting an Opportunity can invoke:
get_opportunity
with:
{ "opportunity_id": "..."}
This gives us a third view:
pipelineopportunitiesopportunity_detail
61. View State Evolution
Conceptually:
type View = | "pipeline" | "opportunities" | "opportunity_detail";
We also store:
const [ selectedOpportunity, setSelectedOpportunity] = useState<OpportunityDetail | null>( null);
Still no router required.
62. Opportunity Detail Contract
The detailed tool may return:
idcompany_idcompany_namenamedescriptionstageamountcurrencyprobabilityexpected_close_datecreated_atupdated_at
depending on the current Quorentra schema.
Only expose fields that are genuinely useful.
63. Detail View
Conceptually:
┌─────────────────────────────────────────┐│ ← Opportunities ││ ││ Microsoft 365 Migration ││ Contoso ││ ││ Proposal ││ ││ Value €75,000 ││ Probability 60% ││ Expected Close 30 Sep 2026 ││ ││ Description ││ Migration of Microsoft 365 workloads... │└─────────────────────────────────────────┘
This now feels much closer to a conventional CRM application.
But it is running inside ChatGPT.
64. Company Drill-Down Comes Later
The Company name could eventually become interactive:
Contoso
leading to:
get_company
and a Company card.
But Part 17 should remain centered on Opportunities.
Avoid turning one article into the entire CRM interface.
65. Model-Visible Context
Interactive UI introduces another important capability.
Suppose the user opens:
Microsoft 365 Migration
inside the component.
Then types into ChatGPT:
What do you think about this opportunity?
What does:
this opportunity
mean?
The UI may need to communicate the selected context to the host.
66. UI Context and Conversation Context
Conceptually:
Widget Stateselected_opportunity =Microsoft 365 Migration
can be reflected into:
Model-visible context
so ChatGPT understands subsequent references.
This creates continuity between:
clicking
and:
conversation
67. Keep Context Small
Do not send the entire CRM record into model context every time the user clicks something.
A useful context update might be:
Selected Quorentra opportunity:Microsoft 365 MigrationOpportunity ID: <uuid>Company: Contoso
The model can call:
get_opportunity
if it needs authoritative details.
This keeps context efficient.
68. Context Is Not Authority
Even if model context says:
Opportunity ID = X
a future tool call still needs:
AuthenticationTenantContextRBACTenant-scoped lookup
Model context helps resolve intent.
It does not grant authorization.
69. Never Put Secrets Into Model Context
Do not expose:
JWTssession tokensdatabase IDs not needed for tool useinternal secretscredentials
through model-visible context.
Only expose the minimum application references needed for useful interaction.
70. A Powerful New Interaction
Now consider this sequence:
User:
Show my EUR pipeline.
The widget renders.
The user selects:
View Opportunities
then opens:
Microsoft 365 Migration
and asks:
How strong is this deal?
ChatGPT now has enough context to understand which Opportunity the user means.
It can call:
get_opportunity
and reason over the result.
That is a much richer CRM interaction model.
71. We Still Do Not Have AI Opportunity Intelligence
At this stage, ChatGPT can reason about the structured Opportunity fields it receives.
But Quorentra does not yet have the advanced:
Opportunity Intelligence Engine
from our deeper architecture series.
That will come later.
For the MVP, ChatGPT-native access to ordinary CRM data already provides substantial value.
72. Tool Errors
Widget-initiated tool calls can fail because of:
AuthenticationPermissionInvalid argumentsResource not foundNetwork problemsMCP problemsServer errors
The UI should normalize these into safe user states.
73. Error Mapping
Our shared MCP layer can conceptually map:
AUTHENTICATION_REQUIRED→ "Please reconnect Quorentra."PERMISSION_DENIED→ "You do not have permission to view this data."RESOURCE_NOT_FOUND→ "This opportunity is no longer available."INVALID_ARGUMENT→ "The request could not be completed."INTERNAL_ERROR→ "Quorentra could not load the data."
Do not expose backend stack traces.
74. Authentication Expiry
Interactive components may remain open while credentials expire.
Therefore:
View Opportunities
could work initially and fail later.
The UI should handle authentication failure gracefully rather than assuming the session remains valid forever.
75. Stale Data
Suppose the pipeline widget shows:
4 opportunities
but another user creates a fifth Opportunity before:
View Opportunities
is clicked.
The list may show:
5 opportunities
That is acceptable.
The list tool returns current authoritative data.
Do not force consistency with stale display state.
76. Refresh Capability
We can optionally add:
[Refresh]
to the Opportunity list.
It simply repeats the current query.
For example:
all
calls:
list_opportunities
while:
proposal
calls:
list_opportunities_by_stage
again.
77. Do Not Auto-Poll Yet
Avoid automatically calling Quorentra every few seconds.
That introduces:
unnecessary loadtool-call volumecostcomplexity
Manual refresh is enough for the MVP.
Real-time updates can come later.
78. Tool Invocation Logging
Part 15 introduced basic MCP logging.
Now distinguish:
invocation_source
conceptually:
modelwidget
For example:
tool_name: list_opportunitiessource: widgetuser_id: ...organization_id: ...success: trueduration_ms: 42
This will help us understand how Quorentra is actually being used.
79. Why Invocation Source Matters
Later we may want to know:
Which tools does ChatGPT choose?Which buttons do users click?Which CRM views are most useful?Where do errors occur?How much traffic comes from widgets?
This becomes part of product telemetry.
But avoid logging sensitive payloads unnecessarily.
80. Opportunity UI Structure
Our frontend structure now becomes:
chatgpt-ui/└── src/ ├── pipeline/ │ ├── PipelineWidget.tsx │ └── pipeline.css │ ├── opportunities/ │ ├── OpportunityList.tsx │ ├── OpportunityCard.tsx │ ├── OpportunityDetail.tsx │ └── opportunities.css │ └── shared/ ├── mcp.ts ├── types.ts ├── formatting.ts └── errors.ts
The ChatGPT UI is becoming a small modular application.
81. Shared Formatting
Move:
formatCurrencyformatDate
into:
shared/formatting.ts
Do not leave them buried inside Pipeline components.
Future:
CompanyContactTaskActivity
components will reuse them.
82. Shared Errors
Move tool error normalization into:
shared/errors.ts
Again, one implementation should serve every component.
This prevents inconsistent error messages across Quorentra.
83. A Small UI Shell
We may now introduce:
QuorentraApp.tsx
as the view coordinator.
Conceptually:
QuorentraApp├── PipelineWidget├── OpportunityList└── OpportunityDetail
This avoids putting all state transitions into PipelineWidget.tsx.
84. QuorentraApp
Conceptually:
function QuorentraApp() { switch (view) { case "pipeline": return <PipelineWidget ... />; case "opportunities": return <OpportunityList ... />; case "opportunity_detail": return <OpportunityDetail ... />; }}
This is enough navigation architecture for the MVP.
85. Do Not Build a Generic CRM Framework Yet
Avoid immediately creating:
EntityRouterGenericRecordRendererUniversalToolDispatcherDynamicSchemaForm
We have only three views.
Premature abstraction makes simple systems harder to understand.
Build concrete patterns first.
Generalize when repetition becomes obvious.
86. Opportunity List Tool Tests
Update:
tests/mcp/test_opportunity_tools.py
to verify:
items existcompany_name existslimit is respectedoffset is respectedhas_more is correctonly active tenant data appearspermission is enforced
87. Stage Filter Tests
Verify:
qualificationdiscoveryproposalnegotiationwonlost
produce only matching records.
Invalid:
almost_closed
must fail validation.
88. Pagination Tests
Create more than:
25
test Opportunities.
Request:
limit = 25offset = 0
Expected:
25 itemshas_more = true
Then:
limit = 25offset = 25
and verify the next records are returned.
89. Tenant Pagination Test
This is important.
Suppose:
Tenant A = 30 opportunitiesTenant B = 100 opportunities
Tenant A’s:
has_more
must be calculated only from Tenant A’s records.
Never allow cross-tenant records to influence pagination metadata.
90. Company Name Isolation
When enriching Opportunity summaries with:
company_name
ensure the Company join is also tenant-scoped.
Do not merely join on:
company_id
without preserving organization boundaries.
Defense in depth matters.
91. Frontend Tests
Test:
pipeline button rendersbutton enters loading stateopportunities renderempty list renderstool error rendersretry worksback worksload more worksstage filter worksdetail view works
These are now meaningful interaction tests.
92. Mock the MCP Boundary
Frontend tests should not require a live PostgreSQL database.
Mock:
callMcpTool()
and provide known results.
Then backend integration tests separately prove the real MCP capability.
This preserves clean testing boundaries.
93. End-to-End Test
We should still have at least one full workflow test:
ChatGPT ↓Pipeline Widget ↓View Opportunities ↓MCP ↓Quorentra ↓PostgreSQL ↓Opportunity List
This proves all boundaries work together.
94. First End-to-End Scenario
Seed:
Contoso├── Microsoft 365 Migration└── Azure ModernizationFabrikam└── Security AssessmentNorthwind└── Data Platform Upgrade
Ask:
Show my EUR pipeline.
Then select:
View Opportunities
Expected:
4 Opportunities
with the correct Companies and values.
95. Second Scenario
Select:
Proposal
Expected:
Microsoft 365 MigrationContoso€75,00060%
and no Opportunities from other stages.
96. Third Scenario
Open:
Microsoft 365 Migration
Expected:
Opportunity Detail
for exactly that record.
Then ask ChatGPT:
Which company is this opportunity for?
Expected:
Contoso
This begins testing UI/conversation continuity.
97. Fourth Scenario: Tenant Isolation
Connect as Tenant B.
The same widget must show only Tenant B Opportunities.
The frontend code does not change.
The tenant context changes server-side.
That is exactly what we want.
98. Fifth Scenario: Viewer
Connect with a Viewer account.
Verify:
PipelineView OpportunitiesFilterView Details
all work because they require read permissions only.
No mutation capability exists.
99. What We Have Not Added
Part 17 deliberately does not include:
Create OpportunityEdit OpportunityMove StageMark WonMark LostDelete OpportunityAdd NoteCreate Task
Those actions change CRM state.
They require a stronger interaction model.
100. Why Mutation Is Different
Consider:
Move Microsoft 365 Migration to negotiation.
The system needs to know:
Which opportunity?Current stage?Requested stage?Does the user have permission?Is the transition allowed?Should the user confirm?What should be audited?What happens if the record changed meanwhile?
Read-only interaction did not require most of these concerns.
Mutation deserves its own architecture.
101. Confirmation Becomes Important
For future mutation tools, we may classify actions as:
Low RiskMedium RiskHigh Risk
For example:
Create Note→ Low/MediumUpdate Opportunity Stage→ MediumDelete Company→ High
Different actions may need different confirmation behavior.
102. Audit Becomes Important
When a model or widget changes CRM data, Quorentra should know:
Who initiated it?Which tenant?Which tool?What changed?Old value?New value?When?Was confirmation required?Was the action model-initiated or widget-initiated?
That will be part of the mutation architecture.
103. Part 17 Is the Safe Bridge
Part 17 gives us almost everything needed for a rich application:
ButtonsTool callsLoadingErrorsPaginationFiltersNavigationDetailsContext
without the most dangerous capability:
Changing data
This is a strong intermediate milestone.
104. The User Experience Has Changed Significantly
At the start of the series, CRM interaction looked like:
HTTP Request ↓JSON Response
Now:
User:"Show my EUR pipeline." ↓ChatGPT ↓Pipeline Widget ↓Click ↓Opportunity List ↓Filter ↓Opportunity Detail ↓Conversation
This is already a usable CRM interaction model.
105. The Screen Is No Longer the Starting Point
Traditional CRM:
Open CRM ↓Choose Sales ↓Choose Opportunities ↓Apply Filter ↓Open Opportunity
Quorentra:
"Show my pipeline."
Then interaction continues contextually.
That is the central product idea behind a ChatGPT-native CRM.
106. But Structured UI Still Matters
Natural language is excellent for:
IntentQuestionsExplanationReasoning
UI is excellent for:
ScanningComparingSelectingNavigatingReviewing
Quorentra should use both.
Not:
Chat only
and not:
GUI only
but:
Conversation+Contextual UI
107. The Emerging Quorentra Experience Layer
We now have:
Experience├── Conversation├── Pipeline View├── Opportunity List├── Stage Filter└── Opportunity DetailCapabilities├── get_pipeline_summary├── list_opportunities├── list_opportunities_by_stage└── get_opportunityCore├── Authentication├── TenantContext├── RBAC├── OpportunityService└── PostgreSQL
The layers remain clean.
108. Version Update
Part 17 adds interactive application behavior.
Update:
app/core/constants.py
from:
APP_VERSION = "0.3.0"
to:
APP_VERSION = "0.4.0"
Quorentra now supports interactive CRM navigation inside ChatGPT.
109. Quorentra 0.4.0
Our MVP 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 ✓ChatGPT UI├── Pipeline Widget ✓├── Widget-Initiated Tools ✓├── Opportunity List ✓├── Opportunity Cards ✓├── Stage Filtering ✓├── Pagination ✓├── Opportunity Detail ✓├── Loading States ✓├── Error States ✓└── Read-Only Navigation ✓Mutation├── Create from ChatGPT -├── Update from ChatGPT -├── Delete from ChatGPT -├── Confirmation -└── Mutation Audit -Activities -Tasks -AI Intelligence -
110. Acceptance Criteria
Part 17 is complete when:
✓ Part 16 regression suite remains green✓ list_opportunities returns UI-friendly summaries✓ company_name is available safely✓ pagination metadata exists✓ pagination is tenant-scoped✓ stage filtering is server-side✓ shared MCP call helper exists✓ host-specific integration is isolated✓ widget can invoke list_opportunities✓ widget never sends tenant_id✓ View Opportunities button exists✓ loading state exists✓ duplicate calls are prevented✓ opportunity list renders✓ opportunity cards render✓ empty list renders✓ tool errors render safely✓ retry works✓ Back to Pipeline works✓ Load More works✓ records are deduplicated✓ pagination remains bounded✓ stage filter works✓ invalid stages are rejected server-side✓ partial client data is not presented as complete filtering✓ Opportunity Detail works✓ get_opportunity is invoked securely✓ selected Opportunity remains tenant-scoped✓ model-visible selection context is minimal✓ model context does not grant authorization✓ no secrets enter model context✓ Viewer can use all Part 17 interactions✓ unauthenticated calls fail✓ cross-tenant Opportunity access fails✓ cross-tenant Company enrichment fails✓ no widget has direct database access✓ frontend interaction tests pass✓ backend MCP tests pass✓ tenant-isolation tests pass✓ end-to-end widget flow works✓ "Show my EUR pipeline" works✓ View Opportunities works✓ Proposal filter works✓ Load More works when required✓ Opportunity Detail works✓ conversational follow-up can identify selected Opportunity✓ Quorentra reports version 0.4.0
Most importantly:
A user can now navigate real Quorentra CRM data interactively inside ChatGPT without leaving the conversation and without giving the AI mutation authority.
111. What We Have Achieved
The interaction path is now:
Natural Language ↓ChatGPT ↓MCP ↓Quorentra ↓Pipeline UI ↓User Click ↓MCP Tool ↓Opportunity List ↓User Selection ↓Opportunity Detail ↓Conversation
This is no longer merely an AI assistant attached to a CRM.
The CRM itself is becoming conversational and contextual.
112. The Architecture Is Still Modular
Despite the richer UX, our core remains independent.
┌─────────────────┐
│ ChatGPT │
└────────┬────────┘
│
┌─────────────┴─────────────┐
│ │
▼ ▼
Conversation Quorentra UI
│
▼
MCP Tools
│
▼
Application Services
│
▼
PostgreSQL
REST still exists alongside MCP.
The database knows nothing about ChatGPT.
The domain services know nothing about React.
React knows nothing about SQLAlchemy.
That separation is exactly what we want.
113. We Are Approaching the Mutation Boundary
We now have enough interaction infrastructure to ask the next important question:
Can the user safely change CRM data from ChatGPT?
For example:
Create an opportunity for Contoso.
or:
Move Microsoft 365 Migration to negotiation.
or:
Change the probability to 80%.
These are extremely valuable CRM workflows.
But they change authoritative business state.
Therefore the next step needs stronger controls.
114. Mutation Must Be Intentional
The architecture should never allow:
Model guesses ↓CRM changed
Instead:
User Intent ↓Entity Resolution ↓Permission Check ↓Validate Change ↓Present Proposed Action ↓Confirmation when required ↓Execute Mutation ↓Audit ↓Return Authoritative Result
That is a much stronger workflow.
115. Example Future Interaction
User:
Move the Microsoft 365 Migration opportunity to negotiation.
ChatGPT identifies:
Opportunity:Microsoft 365 MigrationCurrent Stage:ProposalRequested Stage:Negotiation
Quorentra could present:
┌─────────────────────────────────────────┐│ Update Opportunity ││ ││ Microsoft 365 Migration ││ Contoso ││ ││ Stage ││ Proposal → Negotiation ││ ││ [Cancel] [Confirm Update] │└─────────────────────────────────────────┘
Only after confirmation does Quorentra execute the mutation.
116. The Next Layer of MCP Tools
We will need capabilities such as:
create_opportunityupdate_opportunity_stageupdate_opportunity_probability
But we should resist generic tools like:
update_anything
Specific capabilities remain safer.
117. Why Stage Update First?
The first mutation should be narrow.
A good candidate is:
update_opportunity_stage
because it has:
One entityOne fieldControlled valuesClear old stateClear new stateEasy validationEasy auditEasy confirmation
That makes it ideal for proving the mutation architecture.
118. Mutation Audit Trail
We will begin recording something conceptually like:
MutationEvent├── user_id├── organization_id├── tool_name├── entity_type├── entity_id├── field├── old_value├── new_value├── invocation_source├── confirmed└── created_at
This will establish the foundation for trustworthy AI-assisted CRM operations.
119. The Goal Is Not Autonomous CRM Yet
We are not trying to make ChatGPT independently run the sales organization.
The MVP principle is:
AI assists. Quorentra governs. The user remains in control.
Later we can explore more autonomous workflows where the risk model justifies them.
But we earn that capability gradually.
120. Next Article
Safe CRM Mutations from ChatGPT — Opportunity Stage Updates, Confirmation, Authorization, and Audit
We will introduce:
update_opportunity_stageMutation-specific permissionsCurrent-state validationAllowed stage validationOptimistic concurrencyProposed actionsConfirmation UIConfirm / Cancel flowWidget-initiated mutationModel-initiated mutationIdempotencyMutation audit eventsOld/new value recordingInvocation sourceError recoveryStale-record detectionAuthoritative post-update resultsUI refresh after mutationTenant isolationRBAC enforcementRead versus write tool annotationsMutation testing
The target workflow will be:
User:"Move the Microsoft 365 Migrationopportunity to negotiation."
ChatGPT resolves the Opportunity and requested stage.
Quorentra presents:
┌────────────────────────────────────────────┐│ QUORENTRA ││ Update Opportunity ││ ││ Microsoft 365 Migration ││ Contoso ││ ││ Stage ││ ││ Proposal ││ ↓ ││ Negotiation ││ ││ This will update the CRM record. ││ ││ [Cancel] [Confirm Update] │└────────────────────────────────────────────┘
After confirmation:
Confirm Update ↓update_opportunity_stage ↓Authentication ↓TenantContext ↓opportunities.update ↓Current-State Validation ↓Database Transaction ↓Audit Event ↓Updated Opportunity ↓UI Refresh
ChatGPT can then report:
Microsoft 365 Migration has been movedfrom Proposal to Negotiation.
That will be the point where Quorentra moves from:
Conversational CRM access
to:
Conversational CRM operation
while preserving the principle that has guided the entire build:
ChatGPT interprets intent. Quorentra controls authority.