Quorentra

Interactive Quorentra CRM Opportunities in ChatGPT: Building from Zero — Part 17

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.

Interactive Quorentra Opportunities in ChatGPT: Building from Zero — Part 17
Interactive Quorentra Opportunities in ChatGPT: Building from Zero — Part 17

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:

Authentication
Tenant isolation
Permissions
Business rules
CRM 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_opportunities
get_opportunity
list_opportunities_by_stage

but not:

create_opportunity
update_opportunity
delete_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 ..\backend
python -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_opportunities
Description:
List sales opportunities in the authenticated
user's active Quorentra organization.
Input:
limit
offset
Output:
Opportunity summaries
Permission:
opportunities.read
Behavior:
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:

ChatGPT
Widgets
Other 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_opportunities
Arguments:
limit = 25
offset = 0

13. Why Start With 25?

Do not request:

all opportunities

The CRM may eventually contain:

10
100
10,000
1,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:

Correctness
Persistence
Relationships
Business rules

UI read models optimize for:

Presentation
Query efficiency
Reduced 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:

description
owner
created_at
updated_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.tsx
OpportunityList.tsx
CompanyCard.tsx
ContactList.tsx

all containing their own host integration logic.

Instead:

shared/mcp.ts

owns:

tool invocation
host messages
JSON-RPC
error 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 it
else if ChatGPT compatibility API available
use window.openai
else
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 Migration
Contoso
Proposal
€75,000
60% probability
Expected close
30 September 2026

The card should make the important sales information scannable.


35. Information Hierarchy

Prioritize:

1. Opportunity name
2. Company
3. Stage
4. Amount
5. Probability
6. Expected close

Avoid filling the card with internal metadata.

A CRM UI should help users make decisions quickly.


36. Stage Labels

Backend values remain:

qualification
discovery
proposal
negotiation
won
lost

The UI can display:

Qualification
Discovery
Proposal
Negotiation
Won
Lost

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:

pipeline
opportunities

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:

limit
offset

even if they came from our own component.

For example:

limit <= 100
offset >= 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:

All
Qualification
Discovery
Proposal
Negotiation

We should normally exclude:

Won
Lost

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:

All
Qualification
Discovery
Proposal
Negotiation

while passing:

qualification
discovery
proposal
negotiation

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:

pipeline
opportunities
opportunity_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:

id
company_id
company_name
name
description
stage
amount
currency
probability
expected_close_date
created_at
updated_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 State
selected_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 Migration
Opportunity 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:

Authentication
TenantContext
RBAC
Tenant-scoped lookup

Model context helps resolve intent.

It does not grant authorization.


69. Never Put Secrets Into Model Context

Do not expose:

JWTs
session tokens
database IDs not needed for tool use
internal secrets
credentials

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:

Authentication
Permission
Invalid arguments
Resource not found
Network problems
MCP problems
Server 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 load
tool-call volume
cost
complexity

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:

model
widget

For example:

tool_name: list_opportunities
source: widget
user_id: ...
organization_id: ...
success: true
duration_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:

formatCurrency
formatDate

into:

shared/formatting.ts

Do not leave them buried inside Pipeline components.

Future:

Company
Contact
Task
Activity

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:

EntityRouter
GenericRecordRenderer
UniversalToolDispatcher
DynamicSchemaForm

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 exist
company_name exists
limit is respected
offset is respected
has_more is correct
only active tenant data appears
permission is enforced

87. Stage Filter Tests

Verify:

qualification
discovery
proposal
negotiation
won
lost

produce only matching records.

Invalid:

almost_closed

must fail validation.


88. Pagination Tests

Create more than:

25

test Opportunities.

Request:

limit = 25
offset = 0

Expected:

25 items
has_more = true

Then:

limit = 25
offset = 25

and verify the next records are returned.


89. Tenant Pagination Test

This is important.

Suppose:

Tenant A = 30 opportunities
Tenant 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 renders
button enters loading state
opportunities render
empty list renders
tool error renders
retry works
back works
load more works
stage filter works
detail 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 Modernization
Fabrikam
└── Security Assessment
Northwind
└── 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 Migration
Contoso
€75,000
60%

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:

Pipeline
View Opportunities
Filter
View 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 Opportunity
Edit Opportunity
Move Stage
Mark Won
Mark Lost
Delete Opportunity
Add Note
Create 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 Risk
Medium Risk
High Risk

For example:

Create Note
→ Low/Medium
Update Opportunity Stage
→ Medium
Delete 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:

Buttons
Tool calls
Loading
Errors
Pagination
Filters
Navigation
Details
Context

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:

Intent
Questions
Explanation
Reasoning

UI is excellent for:

Scanning
Comparing
Selecting
Navigating
Reviewing

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 Detail
Capabilities
├── get_pipeline_summary
├── list_opportunities
├── list_opportunities_by_stage
└── get_opportunity
Core
├── 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 Migration
Current Stage:
Proposal
Requested 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_opportunity
update_opportunity_stage
update_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 entity
One field
Controlled values
Clear old state
Clear new state
Easy validation
Easy audit
Easy 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

In Part 18, we will build:

Safe CRM Mutations from ChatGPT — Opportunity Stage Updates, Confirmation, Authorization, and Audit

We will introduce:

update_opportunity_stage
Mutation-specific permissions
Current-state validation
Allowed stage validation
Optimistic concurrency
Proposed actions
Confirmation UI
Confirm / Cancel flow
Widget-initiated mutation
Model-initiated mutation
Idempotency
Mutation audit events
Old/new value recording
Invocation source
Error recovery
Stale-record detection
Authoritative post-update results
UI refresh after mutation
Tenant isolation
RBAC enforcement
Read versus write tool annotations
Mutation testing

The target workflow will be:

User:
"Move the Microsoft 365 Migration
opportunity 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 moved
from 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.

Quorentra

Contact

Menu

Designed with WordPress

Discover more from Quorentra

Subscribe now to keep reading and get access to the full archive.

Continue reading