# Phase 5.5 — First-party agents and Sales Insights predictive scoring

> Run this **after** the demo-data reskin (phase 6) but **before** you demo Sales. Predictive
> scoring needs a training corpus, and the corpus has to look like your industry — so seeding it
> is a reskin activity, not a separate one.

---

## Part A — First-party agents

### What ships

A fully installed CE portfolio lands **45 first-party agents** in `bots`. Count them directly:

```js
const b = await Xrm.WebApi.retrieveMultipleRecords('bots','?$select=name,statecode,statuscode&$top=100');
console.log(b.entities.length);
console.table(b.entities.map(x => ({ name: x.name, state: x.statecode, status: x.statuscode })));
```

Verified Aug 2026: **45 bots, every one `statecode=0 / statuscode=1`.**

Of those, **21 are Sales agents**:

`Sales Opportunity Agent` · `Sales Opportunity Agent - Account Research` · `- Compete Research` ·
`- Custom Research` · `- Stakeholder` · `Sales Close Agent` · `Sales Qualification Agent Config
Assistant` · `Copilot in Dynamics 365 Sales` · `D365 Sales - Configuration Agent` ·
`D365 Sales - Data Enrichment` · `D365 Sales Agent - Outreach` · `- Research` · `- Competitor` ·
`- Stakeholder Research` · `- Custom Research` · `- Company Resolver` · `- Email Validation` ·
`- Engage Autonomous` · `- Readiness` · `- Summary Synthesizer` · `- TCP Prefill Agent`

The rest are Customer Service, Field Service and platform agents.

### ⚠️ `statuscode=1` on a bot does not mean "published"

This is the same trap as `pac copilot list` (see `SKILL.md`). The `bots` table reports a uniform
state that does **not** reflect Copilot Studio's publish state. **Verify in the Copilot Studio
Agents list**, never from `bots`.

The unresolved `PvaPublish` 409 blocker is documented in
`reference/02-connections-and-agents.md`. It remains the skill's one unsolved item.

### What actually works for a demo

Agents enabled **through their own admin surface** function correctly even when Copilot Studio
shows Draft — Case Management, Customer Intent and Knowledge were all configured successfully that
way.

**For the Sales agents, the surface is `App Settings → Dynamics 365 AI hub → Agent manager`** —
documented in Part B below. It is not Copilot Studio and not Premium-gated.

**Prerequisite:** connections and connection references must be bound first (phase 3). An unbound
connection reference makes an agent admin page spin on "Loading" forever with no error.

---

## Part B — Predictive lead and opportunity scoring

### ⚠️ No scoring model exists out of the box

`msdyn_aiconfiguration` will look busy — 50 rows on a fresh install — but inspect the names and
they are **Copilot prompts**, not scoring models:

```
Account summary prompt · Lead summary prompt · Deal Health prompt 2 ·
Sales DV query rewrite prompt · Custom prompt 07/03/2025... · ...Training_3/6/2026...
```

Do not read that row count as "scoring is configured". **It is not.** Predictive lead scoring and
predictive opportunity scoring each require a model that you create and train from
**Sales Hub → App Settings → Predictive scoring**.

### Entity notes

| Entity | Note |
|---|---|
| `msdyn_salesinsightssetting` | Exists, 1 row. **Entity set is singular** — `msdyn_salesinsightssettingses` returns 404. |
| `msdyn_aiconfiguration` | Copilot prompts, not scoring models |
| `msdyn_aimodel` | Model registry; empty of scoring models on a fresh org |
| `msdyn_predictivescoreconfig` | **Does not exist** — do not go looking for it |

### ⚠️ Predictive scoring is a **Premium** feature behind a terms-and-conditions gate

Verified Aug 2026. In **Sales Hub → change area → `Sales Insights settings`** the Overview page
splits cleanly in two:

**Standard (available immediately):**
- Sales accelerator setup
- Assistant (standard) — insight cards
- Auto capture (standard)
- Email engagement (standard)

**Premium (gated):**
- Assistant (premium) / assistant studio
- Notes analysis
- Relationship analytics
- Who knows whom
- **Predictive lead scoring**
- **Predictive opportunity scoring**
- Premium forecasting
- Conversation intelligence

The Premium block sits behind an **`I agree to the terms and conditions`** checkbox
(<https://aka.ms/salesPremiumTermsConditions>) and a **`Try Premium`** button.

### ⏱️ Premium activation takes up to an hour

Verified Aug 2026: after accepting the terms and clicking Activate, the portal warns that
provisioning **may take up to 1 hour**. Nothing Premium is configurable until it finishes — the
Predictive scoring nodes do not appear in the left nav before then.

Plan around it. This is the third multi-hour asynchronous wait in the build (app installs,
CI – Data, F&O provisioning, and now Premium), and like the others it gives **no progress
indicator**. Seed your lead and opportunity corpus *while the trial provisions* — the two are
independent, and the corpus is the long pole anyway.

Two further consequences:

1. **Until Premium is activated there is no "Predictive scoring" node in the left nav at all.**
   If you go looking for it and cannot find it, that is why — the nav shows only Sales accelerator,
   Assistant studio and Productivity.
2. **This is a licensing decision, not a configuration step.** It activates a trial and requires
   accepting terms on the tenant's behalf. If you are running this skill for someone else,
   **stop and ask them** — do not tick that box unilaterally, even on a demo tenant.

The area switcher also offers a separate **`App Settings`** area; Sales Insights has its own
dedicated area, which is the one you want.

### 🚨 The default BPF filter will train your model on ZERO records

Verified Aug 2026, and it is the single most damaging trap in this phase. Both the lead and
opportunity scoring forms pre-select a **Business process flow** — `Lead To Opportunity Sales
Process` and `Opportunity Sales Process` respectively. That is a *filter*: only records with an
instance of that BPF are used for training.

**Records created through the Web API get no BPF instance.** On a seeded corpus that means the
filter matches nothing:

```js
// 0 of 421 leads and 0 of 133 opportunities had a processid
const r = await fetch(base + 'leads?$select=leadid,statecode,processid&$top=1000');
```

Nothing warns you. The form is valid, **Get started** is enabled, and training begins against an
empty set.

> **Set Business process flow to `None` on both models** unless you have deliberately created BPF
> instances. Check first — one `$select=processid` query settles it in seconds.

Two related notes:

- **Per stage modeling** (opportunity scoring only) requires a BPF and 40+ closed opportunities in
  its last stage. With BPF = None it correctly greys itself out — a rare case of the UI being
  honest, and a useful confirmation you picked the right option.
- Check the corpus actually falls inside **"Train with … from the past N years"**. Backdate seeded
  records; a corpus all created today still works for the 2-year default, but a 1-year setting
  against 18 months of history silently drops the oldest rows.

### ⚠️ Training is not publishing

Both models end training with *"You can leave this page and come back later to publish the new
version of your model."* **A trained model is not live until you publish it.** Budget a return
visit; do not mark scoring done when training finishes.

Also note `msdyn_aimodel` is **not** the verification surface — no scoring model appears there
after training. Check the Lead scoring / Opportunity scoring tabs in the UI.

### ✅ The Sales agent surface: **App Settings → Dynamics 365 AI hub → Agent manager**

**This is the route the skill was missing.** It is *not* Copilot Studio, and it is **not**
Premium-gated — it works before the Sales Premium trial finishes provisioning.

Navigate: **Sales Hub → change area → `App Settings` → `Dynamics 365 AI hub`**.

That page offers two entry points:

| Card | Purpose |
|---|---|
| **Agent manager** — *"Create and manage agents"* | Discover and **publish** AI agents |
| **AI optimization hub** — *"See insights"* | Monitor agent value and impact |

The four documented Sales agents, described in-product:

- **Sales Qualification Agent** — identifies, engages and qualifies leads against your ICP;
  delivers outreach and follow-ups
- **Sales Opportunity Agent** — surfaces deal risks early: stalled deals, competitive threats,
  decision-maker changes, from CRM + M365 signals
- **Sales Close Agent** — autonomously engages customers to close high-velocity sales
- **Data Enrichment** — fills missing CRM detail and refreshes stale records

### Agent manager prerequisites

Agent manager gates on three prerequisites, each with its own `Set up` / `Accept terms` and
`Refresh` control. Verified all three reading **Done** on a Caldova build:

| Prerequisite | What it is |
|---|---|
| **Microsoft Copilot Studio capacity** | Agents consume tokens — needs credits or a pay-as-you-go billing plan |
| **Move data across regions** | Cross-region data movement terms, required for some agent features |
| **AI prompts** | The natural-language instructions that drive agent behaviour |

**Check these first.** If any reads other than `Done`, agent creation will not work, and the
failure will not necessarily be obvious. Note the **Copilot Studio capacity** item in particular —
on a tenant without credits or a PAYG plan this is a hard commercial blocker, not a toggle.

Once all three are `Done`, the page exposes a **Create** button for deploying agents.

### ⚠️ Each agent then has its OWN prerequisites — and two of them are real infrastructure

Verified Aug 2026. Clearing the three hub-level prerequisites is **not** enough. Opening
`Create` → *Sales Qualification Agent* reveals a second prerequisite block scoped to that agent:

| Agent prerequisite | Note |
|---|---|
| **Bing search** | Was already `Done` |
| **Create app in Azure** | An **Entra app registration**, so the agent can authenticate as a Dataverse application user |
| **Create app user in Dataverse** | The application user itself — lets the agent send mail and own records |

**`Save` and `Start agent` stay disabled until both are done**, so you cannot even park a
half-configured agent. Treat the Entra app registration as a decision point if you are running
this for someone else — it is tenant infrastructure, not a toggle.

The Create dialog now offers **five** agents, not four — Sales Qualification, Sales Opportunity,
Sales Close, Data Enrichment, and **Recommended Actions**. You may create **only one agent of each
type**.

### 🚨 Automation level: `Engage` sends real email

The Sales Qualification Agent has two automation levels, expressed as checkboxes:

| Level | Behaviour |
|---|---|
| **Research** | Checked and **disabled** — the mandatory baseline. Gathers insights, **drafts outreach emails for seller review**, identifies lead fit. Sends nothing. |
| **Engage** | Optional. Adds *"Send personalized emails to leads"*, follow-ups, and autonomous replies. |

**Leave `Engage` off unless live sending is explicitly authorised.** On a demo tenant whose
contacts are plus-addressed to a real person, ticking it points an autonomous mailer at that
inbox. Research alone still demos well because the drafted emails are visible on the lead.

### Relationship to the `PvaPublish` 409 blocker

`reference/02-connections-and-agents.md` documents 38 of 44 agents stuck in Draft, unpublishable
via `PvaPublish`, `pac copilot publish`, or the Copilot Service admin center.

**Agent manager is a different surface**, and it is the supported path for the *Sales* agents.
Try it before concluding the agents are unusable — the earlier finding that agents enabled through
their own admin surface work correctly holds here too.

## Part E — Customer Service and Field Service demo data

### Customer Service


A reskinned org typically has knowledge articles but very few cases. Verified starting state:
**17 cases, 118 KB articles, 333 queues, 0 entitlements, 0 SLAs.**

> ⚠️ **Cases cannot be resolved with `updateRecord`** — exactly the same masked
> `"An error has occurred. {1}{0}"` as opportunities. Use the **`CloseIncident`** action:
>
> ```js
> await fetch(base + '/api/data/v9.2/CloseIncident', {
>   method: 'POST', headers: { /* odata headers */ },
>   body: JSON.stringify({
>     Status: 5,
>     IncidentResolution: {
>       "incidentid@odata.bind": `/incidents(${id})`,
>       subject: "Resolved - " + title,
>       timespent: 90,
>       "@odata.type": "Microsoft.Dynamics.CRM.incidentresolution"
>     }
>   })
> });
> ```
>
> As with opportunities, **the failed close still leaves the case created**. Check state
> distribution before re-running.

Mix B2B (trade: short shipments, promo pricing not applied at POS, trade spend deductions, EDI
failures, planogram compliance) with B2C (consumer: damaged cartons, rewards points missing,
subscription problems, allergen questions). Bind B2B cases to `customerid_account` and B2C to
`customerid_contact`.

### Entitlements and SLAs — three schema traps

Both create cleanly via the Web API once you know the quirks. Verified Aug 2026: 8 entitlements
across 3 tiers plus 3 case SLAs, all Active and cross-linked.

**1. `Active` is `statecode 1 / statuscode 1`.** The obvious guess (`statuscode: 2`) is *Cancelled*
and is rejected outright:

```
0x80048408  2 is not a valid status code for state code EntitlementState.Active
```

Read the real option set rather than guessing:

```
GET EntityDefinitions(LogicalName='entitlement')/Attributes(LogicalName='statuscode')
    /Microsoft.Dynamics.CRM.StatusAttributeMetadata?$expand=OptionSet
→ 0 Draft(0) · 1 Active(1) · 2 Cancelled(2) · 3 Expired(3) · 1200 Waiting(4)
```

**2. An entitlement can only be edited while Draft.** Activate it and every later PATCH fails with
*"You can only edit a draft entitlement."* So **set the SLA and all lookups BEFORE activating** —
or revert to Draft (`statecode 0 / statuscode 0`), patch, then re-activate. That round trip works.

**3. `sla.objecttypecode` is an integer, not the logical name.** Passing `'incident'` fails with
*"Cannot convert the literal 'incident' to the expected type 'Edm.Int32'"*. **Incident is `112`.**

A working entitlement create:

```js
{
  name: 'Summit Mart Stores - Premier Trade Support',
  'customerid_account@odata.bind': `/accounts(${accountId})`,
  startdate: '2026-01-01', enddate: '2026-12-31',
  allocationtypecode: 1,          // number of cases
  totalterms: 500, remainingterms: 286,
  restrictcasecreation: false
}
```

Set `remainingterms` below `totalterms` so the demo shows realistic burn-down rather than a row of
untouched contracts.

### Field Service — build order matters

A fresh org is **completely empty** for Field Service: 0 work orders, 0 incident types, 0 work
order types, 0 customer assets, 1 bookable resource. There is a strict dependency chain:

```
Incident types + Work order types + Price list
        ↓
Customer assets (bound to accounts)
        ↓
Work orders (bound to account + asset + both types + price list)
        ↓
Bookable resources → Bookings
```

**Two blockers worth knowing before you start:**

1. **`msdyn_incidenttype` rejects `msdyn_defaultduration`.** Including it fails the whole create
   with a masked error; a name-only create succeeds. Add duration afterwards if you need it.
2. **`msdyn_workorder` requires a Price List.** The error is refreshingly clear —
   *"Price List is a required field."* — but it is not obvious from the schema. Required set:
   `msdyn_serviceaccount`, `msdyn_workordertype`, `msdyn_primaryincidenttype`, **`msdyn_pricelist`**.
   Reuse an existing `pricelevel` (a reskinned org already has one).

Bind each work order to a **customer asset that belongs to the same account** — read
`_msdyn_account_value` off the asset and use it for `msdyn_serviceaccount`, rather than picking an
account independently. Otherwise you get work orders at accounts that do not own the equipment,
which is visibly wrong on screen.

### ⚠️ `bookableresource` requires an undocumented `timezone` field

Verified Aug 2026. Creating a bookable resource with just `name` + `resourcetype` fails, and if you
also pass anything else wrong you get the useless masked form:

```
0x80048d19  Error identified in Payload provided by the user for Entity :'bookableresources'
```

Strip the payload back to `name` + `resourcetype` and the real error appears:

```
0x80040200  Required field timezone is missing.
```

`timezone` is an integer timezone code (35 = Eastern Time US & Canada). This works:

```js
{ name: 'Marcus Webb - Refrigeration', resourcetype: 1, timezone: 35 }
```

Also: **`CalendarId` is not a bindable navigation property** — `'CalendarId@odata.bind'` fails with
*"undeclared property 'CalendarId'"*. Do not try to share an existing calendar; Dynamics creates one
per resource automatically.

`resourcetype: 1` (Generic) needs no linked user or contact, which matters on a demo tenant where
you have no spare licensed users to represent technicians.

### ✅ Bookings cascade work order status for you

Creating a `bookableresourcebooking` **automatically updates the parent work order's
`msdyn_systemstatus`** to match the booking status. After creating 45 bookings the work orders moved
from 45 Unscheduled to 27 Scheduled / 16 Completed / 2 In Progress with **no separate update call**.
Do not write the work order status by hand — create the booking and verify the cascade.

Booking payload that works:

```js
{
  'Resource@odata.bind':      '/bookableresources(<id>)',
  'BookingStatus@odata.bind': '/bookingstatuses(<id>)',
  'msdyn_workorder@odata.bind':'/msdyn_workorders(<id>)',
  starttime: '2026-08-27T13:00:00Z',
  endtime:   '2026-08-27T15:00:00Z',
  bookingtype: 1
}
```

Use the **Field Service** booking statuses — the ones whose `msdyn_fieldservicestatus` is populated.
The org also ships generic scheduling statuses with the same display names (two rows called
`Completed`, two called `In Progress`), and picking the wrong one breaks the cascade.

For a schedule board that looks alive, spread bookings across weekdays either side of today and mix
the statuses — past dates Completed, today In Progress / Traveling, future Scheduled.

Verified build for a CPG/retail narrative: 13 incident types (cooler faults, dispenser calibration,
planogram resets, endcap installs), 5 work order types (Service Call, Preventive Maintenance,
Installation, Merchandising Visit, Inspection), 45 customer assets (branded coolers, dispensers,
vending units, shelf displays), 45 work orders.

---

## Part D — Activities (customer timeline)

**Data → Activities → Configure activities.** A four-step wizard: Activity tables → Activity
fields → Relationships → Review.

### Only date-typed columns appear in the Timestamp dropdown

The Timestamp picker offers **nothing but columns typed as date**. If you skipped the
`Table.TransformColumnTypes` step in Power Query (see the ingestion section above), every table
will show an empty Timestamp list and activities cannot be configured at all. This is the
downstream consequence of the "Detect data type is a no-op" trap.

### ⚠️ Semantic activity types demand far more configuration

The Activity type dropdown splits into two groups:

| Group | Examples |
|---|---|
| **Semantic types** | `SalesOrder`, `Loyalty`, `Subscription`, `Feedback`, `SalesOrderLine` |
| **Non-semantic types** | `Event`, `Incident`, `Review`, `EmailCorrespondence`, `LoyaltyActivity`, `PhoneCall`, `Purchase`, `Return`, `CampaignActivity`, `Appointment`, … |

Choosing a **semantic** type looks appealing — richer timeline rendering — but it adds a second
mandatory section, **"Map field types for your activity's attributes?"**, defaulted to **Yes**,
with a long list of required semantic fields:

- `SalesOrder` → Sales order ID, Order date, Sales amount, Store ID, Is return?
- `Feedback` → Feedback ID, Feedback text, Feedback date, NetPromoterScore, Category,
  SubCategory, Channel, Language, Feedback prompt, Feedback type

Every one of those must be mapped before **Next** enables.

> **For a demo build, prefer non-semantic types.** `Event`, `Incident`, `Review`,
> `EmailCorrespondence` and `LoyaltyActivity` need only **activity name + timestamp + icon** and
> render perfectly well on the timeline. Reserve semantic types for tables where you genuinely
> want the semantic behaviour and are willing to map ten fields.

### Two toggles, not one

Semantic-typed tables show **two** switches, and a naive "first switch on the page" selector hits
the wrong one:

1. **Show this activity in the timeline** — you want this **on**
2. **Map field types for your activity's attributes** — appears only for semantic types

Read both before toggling, and index them explicitly.

### Turning the timeline on makes Icon mandatory

Setting "Show this activity in the timeline" to Yes adds a required **Icon** picker. Leave it unset
and **Next** stays disabled with no visible error on the page — the explanation is hidden in the
tab's `aria-label`:

```
Datasource "CaldovaD2C" Table "CaldovaWebActivity"
Required fields are not satisfied or activity name conflicts with other activities.
```

**Read the tab panel's aria-label when Next is disabled.** It names the offending table and the
reason, and it is the only place that information appears.

### Automation notes

- Activity names must be **unique across all activities** and start with a letter, letters and
  numbers only — no spaces.
- Switching between tables in the left-hand list via scripted `.click()` is unreliable; the panel
  often does not re-render, so subsequent reads return the *previous* table's state. Verify the
  table name in the panel body before trusting anything you read, or drive this step by hand.
- Icon pickers open inconsistently under automation. This step is genuinely faster manually.

**If you do automate it, these selectors work** (verified Aug 2026, all 9 tables in ~6 minutes):

- Field inputs carry **no `aria-label`** — resolve them via `aria-labelledby` and match the
  referenced element's text (`Activity name`, `Timestamp`, `Event activity`, …).
- Text inputs are React-controlled: set them with the native value setter, then dispatch `input`
  **and** `change`, or the value silently reverts.
- The **Icon** control is not an input at all — it is a `div[aria-label="Icon"][role="combobox"]`
  (a Fluent `ms-Dropdown`). Its options are unlabelled glyphs; identify them by
  **`data-icon-name`** (`Mail`, `Telemarketer`, `Group`, `Globe`, `Shop`, plus `None`).
- Table tabs are keyed by **`title`** (`"CaldovaWebActivity : CaldovaD2C"`), not `aria-label`.
- Waiting ~700–800 ms after a tab click and then asserting the table name appears in the panel body
  makes the stale-panel problem go away.

### 🚨 Every activity table needs a relationship to a customer table — joined on the PRIMARY KEY

This is the step that will stop you, and it is a **data-model** constraint, not a UI quirk.

Step 3 (Relationships) requires each activity table to declare a path to a customer table. The join
is always `activity.<foreignKey>` → `targetTable.<primary key>`. **You cannot choose the target
column** — the dialog shows the target's primary key as read-only text.

So an activity table can only be related if it carries a column holding *that table's primary key
values*. An email column cannot join to a table keyed by `CustomerId`, no matter how well the two
datasets correspond.

> **Design your CDP sources with this in mind.** Give every activity table a stable customer key,
> not just an email address. Email is fine for *unification* — which matches fuzzily across
> sources — but activities need a deterministic key join, and the two mechanisms are unrelated.
> A dataset can unify beautifully and still be unable to configure a single activity.

Worse, the dialog **lets you build the wrong join**: pick an email column, pick a customer table
keyed by `CustomerId`, and **Apply** enables happily. It produces zero matches. Verify with a real
key overlap check before trusting it.

Finally, **`Save and close` is disabled until every step is valid.** There is no partial save. If
you cannot satisfy the relationship step for every selected table, either go back to step 1 and
deselect the ones that cannot join, or you will lose the entire configuration on exit.


---

## Part C — Seeding a trainable corpus

### The real blocker: training data

**Scoring models will not train on a stock demo org.** The out-of-box data is nowhere near enough:

| | Stock install | Needed |
|---|---|---|
| Leads | 16, **all Open** | ≥40 Qualified **and** ≥40 Disqualified |
| Opportunities | 22 (8 Won, 1 Lost) | a real Won/Lost history |

Zero terminal-state leads means zero training signal. The model has nothing to learn *from*.

### Status codes you need

Get these right or records land in the wrong state silently.

**Lead** — `statuscode` (and the `statecode` it implies):

| Value | Label | State |
|---|---|---|
| 1 | New | 0 Open |
| 2 | Contacted | 0 Open |
| 823270000 | Marketing Qualified | 0 Open |
| **3** | **Qualified** | **1 Qualified** |
| **4** | **Lost** | **2 Disqualified** |
| **5** | **Cannot Contact** | **2 Disqualified** |
| **6** | **No Longer Interested** | **2 Disqualified** |
| **7** | **Canceled** | **2 Disqualified** |

**Opportunity:**

| Value | Label | State |
|---|---|---|
| 1 | In Progress | 0 Open |
| 2 | On Hold | 0 Open |
| **3** | **Won** | **1 Won** |
| **4** | **Canceled** | **2 Lost** |
| **5** | **Out-Sold** | **2 Lost** |

Read them from your own org rather than trusting this table — custom status reasons are common:

```js
const base = Xrm.Utility.getGlobalContext().getClientUrl();
const r = await (await fetch(base +
  "/api/data/v9.2/EntityDefinitions(LogicalName='lead')/Attributes(LogicalName='statuscode')" +
  "/Microsoft.Dynamics.CRM.StatusAttributeMetadata?$select=LogicalName&$expand=OptionSet",
  { headers: { Accept: 'application/json' } })).json();
console.log(((r.value?.[0] ?? r).OptionSet.Options)
  .map(o => `${o.Value} = ${o.Label.UserLocalizedLabel.Label} (state ${o.State})`).join('\n'));
```

### Seeding technique

**1. Create first, then set the terminal state — two calls.** Setting `statecode` on create is
rejected or ignored. Create Open, then `updateRecord` with `statecode` + `statuscode`.

**2. Backdate with `overriddencreatedon`.** A training corpus where every record was created today
is worthless, and it looks obviously fake on screen.

```js
const daysAgo = n => new Date(Date.now() - n*86400000).toISOString();
// ...
overriddencreatedon: daysAgo(int(20, 540))
```

**3. Vary the features the model can actually learn from.** Scoring is uninteresting if every
record is identical. Populate `leadsourcecode`, `industrycode`, `revenue`, `numberofemployees`,
`leadqualitycode`, `budgetamount`, `purchasetimeframe`, `jobtitle`.

**4. Make disqualified leads *look* disqualified.** Give them weaker profiles — smaller revenue,
fewer employees, `leadqualitycode: 3` (Low), longer `purchasetimeframe`, and subjects like
"Requested pricing only", "Went with incumbent supplier", "Volume too low". If your qualified and
disqualified populations are statistically identical, the model has nothing to separate and the
demo shows meaningless scores.

**5. Use a seeded PRNG**, not `Math.random()`, so a re-run is reproducible:

```js
let seed = 424242;
const rnd = () => { seed = (seed*1664525 + 1013904223) % 4294967296; return seed/4294967296; };
```

### ⚠️ Batch size — this will bite you

`Xrm.WebApi` writes from a browser-automation context are **one HTTP round trip each**, and a
qualified/disqualified lead costs **two** (create + update). A 300-record loop runs for many
minutes and **will hit the automation tool's execution timeout mid-loop**.

Observed: a 300-lead batch timed out having written 180, with the disqualified tranche —
scheduled last — entirely missing. The org was left with 83 qualified and **0 disqualified**,
which is exactly the state that silently produces an untrainable model.

**Work in batches of 50–60 and verify the tally after each.** Order batches so that if one is lost
you notice: never leave a whole state class until last.

```js
const leads = await Xrm.WebApi.retrieveMultipleRecords('lead','?$select=statecode&$top=5000');
const c = {}; leads.entities.forEach(l => { c[l.statecode] = (c[l.statecode]||0)+1; });
console.log(c);   // { "0": open, "1": qualified, "2": disqualified }
```

### Verified end state (Aug 2026)

| Bucket | Count |
|---|---|
| Open (New / Contacted / Marketing Qualified) | 96 |
| **Qualified** | **120** |
| **Disqualified** (Canceled 61 · No Longer Interested 47 · Lost 44 · Cannot Contact 53) | **205** |
| **Total leads** | **421** |

That comfortably clears the training threshold for predictive lead scoring.

**Opportunities need the same treatment** — see the Win/Lose action warning below. 22 records with
8 Won / 1 Lost is not a trainable history.

### ⚠️ You cannot close an opportunity with `updateRecord`

Setting `statecode` 1 (Won) or 2 (Lost) on an opportunity via `Xrm.WebApi.updateRecord` **fails**,
and it fails with a maddening masked error:

```
An error has occurred. {1}{0}
```

That placeholder string is the whole message — there is no detail, and a raw `fetch` POST of the
*create* succeeds, which makes it look like the problem is elsewhere.

**Opportunities must be closed through their dedicated actions**, which also create the required
`opportunityclose` activity:

```js
const base = Xrm.Utility.getGlobalContext().getClientUrl();

// WIN
await fetch(base + '/api/data/v9.2/WinOpportunity', {
  method: 'POST',
  headers: { 'Content-Type':'application/json', 'OData-MaxVersion':'4.0',
             'OData-Version':'4.0', 'Accept':'application/json' },
  body: JSON.stringify({
    Status: 3,                                  // 3 = Won
    OpportunityClose: {
      "opportunityid@odata.bind": `/opportunities(${id})`,
      subject: "Closed as won",
      actualend: closedIso,
      actualrevenue: 250000,
      "@odata.type": "Microsoft.Dynamics.CRM.opportunityclose"
    }
  })
});

// LOSE — identical shape, Status 4 (Canceled) or 5 (Out-Sold), endpoint /LoseOpportunity
```

Both return **HTTP 204** on success.

> 🚨 **The failed loop still creates the records.** If your seeding script creates an opportunity
> and *then* fails to close it, the opportunity is left sitting in **Open**. Re-running the script
> blindly will duplicate every record. Observed: a 110-record run reported `ok=25 fail=85` —
> but all 110 opportunities existed, 85 of them simply stuck Open. **Check the actual state
> distribution before re-running anything.**

The recovery is straightforward: query the stranded Open records and close them in batches.

```js
const open = await Xrm.WebApi.retrieveMultipleRecords(
  'opportunity', "?$select=name,estimatedvalue&$filter=statecode eq 0&$top=40");
```

### Verified end state (Aug 2026)

| Bucket | Leads | Opportunities |
|---|---|---|
| Open | 96 | 43 |
| **Qualified / Won** | **120** | **49** |
| **Disqualified / Lost** | **205** | **41** |
| **Total** | **421** | **133** |

Both comfortably clear the training threshold.

### Then, and only then

Enable the models in **Sales Hub → change area → Sales Insights settings**:

- Predictive lead scoring
- Predictive opportunity scoring
- Relationship analytics / health
- Notes analysis, Who knows whom, premium Assistant, Premium forecasting

⚠️ All of the above are **Premium** and require accepting terms first (see Part B). Training is
asynchronous and takes time. **Apply the standard rule of this playbook: check the admin surface
for status, not a spinner.** A model that reports low accuracy is usually telling you the truth
about your seed data, not misbehaving — go back and widen the feature spread between your
qualified and disqualified populations.

---

## ✅ Getting a Sales agent actually running — the full chain (verified Aug 2026)

The hub-level prerequisites are only the beginning. This is the complete sequence that worked:

```
Entra app registration  →  Dataverse app user  →  AISalesperson role
        →  Manual setup  →  11 config sections  →  Save  →  Start agent
```

### 1. Assisted setup does not work — use Manual setup

`Assisted setup` opens a Copilot pane that fails with:

```
Error code: LatestPublishedVersionNotFound
```

This is the **same server-side publish defect** behind the `PvaPublish` 409 documented in
`02-connections-and-agents.md` — the setup agent itself is unpublished. Do not spend time on it.

> **Clicking `Manual setup` is what unlocks the rest of the form.** Until you do, General, Guidance
> and Knowledge all read *"Section will be unlocked after you make a choice"* and `Save` stays
> disabled. That is the single least obvious step in the whole flow.

### 2. Create the Entra app registration

The prerequisite's `Set up` link goes to the app-registration blade. Name it, leave it
**single-tenant**, no redirect URI. Record the **Application (client) ID**.

> ⚠️ **Click `Register` through the accessibility tree, not a scripted `.click()`.** The Azure
> portal's button is a `div[role=button]` wrapping a `span.fxs-button-text`; clicking the span
> dismisses the blade and **silently creates nothing**, and the app list defaults to the
> *Owned applications* filter so it still looks empty afterwards. I created two orphan
> registrations this way before noticing.

### 3. Create the Dataverse app user

PPAC → Environments → *your env* → Settings → **Application users** → New app user. Pick the app by
its **client ID** (names are not unique), set the business unit, add a security role, Create.

### 4. 🚨 System Administrator is NOT enough — it needs `AISalesperson`

The agent form validates the role explicitly:

```
Agent user doesn't have the AISalesperson role.
Change its permissions in Dataverse or pick another user.
```

Fastest fix is the API rather than the PPAC role picker (which is a 300-row virtualised list):

```js
await fetch(base + `systemusers(${userId})/systemuserroles_association/$ref`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ '@odata.id': base + `roles(${aiSalespersonRoleId})` })
});
```

**The warning is cached client-side — reload the page to clear it.** Re-picking the user in the
dropdown does not re-run the check.

### 5. The eleven sections, and the two that trip you up

| Section | Note |
|---|---|
| Automation | Research is checked and locked; Engage is the only real choice |
| Agent profile | Name, description, **Agent user** |
| Company info | Company name + website |
| Products | Value proposition, 2000 char max — drives handoff suggestions |
| Selection criteria | Conditions come **pre-populated** (Status=Open, Rating=Cold); only needs a name |
| Email instructions | Satisfied by defaults — just visit it |
| Email address validation | Satisfied by defaults — just visit it |
| **Handoff criteria** | Five ICP prompts; **at least one is required to start** |
| **Assignment rules** | See below |
| Research / Agent emails | Already filled |

Several sections mark themselves *filled* merely by being **visited**, so click through every one.

> ⚠️ **Assignment rules: the seller lookup returns "No results found"** on a demo tenant, because it
> filters to users holding the *salesperson* role and a demo org usually has none. Do not go
> creating users — change **"AI agent should hand leads over to?"** from `Specific seller` to
> **`Any seller`**. The supervisor lookup is unfiltered and the admin appears there.

### 6. Start

`Start agent` → confirmation dialog → status goes **`Starting agent`**, then Active. Verify in the
agent list grid, not the record header — the header lags.

### ⚠️ The first `Start agent` may silently revert to Draft — just retry

> **🛑 SUPERSEDED — read ["✅ CORRECTION — `Start agent` needs a SECOND confirmation click"](#-correction--start-agent-needs-a-second-confirmation-click) before acting on this section.**
>
> The "silent revert to Draft" described below was **misdiagnosed**. The real cause is that
> `Start agent` opens a *second* confirmation prompt **below the fold**, which we never clicked —
> so the agent was never actually started. The retry "worked" only because the second attempt
> happened to scroll far enough. Keep this section for the symptom description; take the fix
> from the correction.

Verified Aug 2026. After a successful `Start agent`, the grid showed **`Starting agent`** and stayed
there. Overnight it was reverted to **`Draft`**, with `Modified by` = **SYSTEM** — no error, no
notification, nothing in the record.

**The configuration survived intact** — every section still validated and `Start agent` was enabled
again. A straight retry worked and the agent reached **`On`** in roughly ten minutes.

> Do not start unpicking the config when this happens. **Retry once first.** Only if it reverts
> repeatedly should you suspect the deeper cause: the agent's Dataverse application user has **no
> Exchange mailbox**, and the prerequisite blurb does say *"set up a mailbox, a Dataverse app
> user…"*. An application user never has a mailbox by default.

Check the status in the **agent list grid**, not the record header — the header lags behind and
showed `Draft` while the grid already read `Starting agent`.

---

# Completing the CE demo: queues, SLA business hours, the sales lifecycle, agreements

A tenant can look finished and still be missing the records that make each app *demoable*. Caldova
had 421 leads, 133 opportunities and 72 cases — but **zero queue items, zero quotes, zero orders,
zero invoices, zero territories and zero goals**. The pipeline stopped at Opportunity and the
service queues were empty shells.

## Customer Service

### Business hours — your SLAs are running 24/7 without them

`calendars` with `type = 1` is the Customer Service business-hours calendar. Zero existed, so all
three SLAs computed against a 24/7 clock and nothing ever breached believably.

```jsonc
// POST /api/data/v9.2/calendars
{ "name":"Caldova Service Hours", "type":1,
  "businessunitid@odata.bind":"/businessunits(<root-bu-id>)" }
```

> 🔴 **`calendars` requires an explicit `businessunitid`.** Without it:
> `Expecting business column to be set for Creating business owned entities, business column is null`.
> Get the root BU with `businessunits?$filter=_parentbusinessunitid_value eq null`.

Attaching it to an SLA hits the **Draft-only edit rule** documented earlier in this file — an Active
SLA rejects the PATCH. Deactivate → patch `businesshoursid` → reactivate:

```
PATCH slas(<id>)  { "statecode":0, "statuscode":1 }              // back to Draft
PATCH slas(<id>)  { "businesshoursid@odata.bind":"/calendars(<cal>)" }
PATCH slas(<id>)  { "statecode":1, "statuscode":2 }              // Active again
```

### Routing cases into queues

> 🔴 **The `AddToQueue` action 404s** — `Resource not found for the segment 'AddToQueue'`. It is a
> *bound* action and the unbound POST does not exist.
>
> **Just create the `queueitems` row directly** — simpler and it works:

```jsonc
// POST /api/data/v9.2/queueitems
{ "queueid@odata.bind":"/queues(<queue>)",
  "objectid_incident@odata.bind":"/incidents(<case>)" }
```

All 25 active Caldova cases were split across the two renamed queues in one pass.

## Sales — the quote → order → invoice chain

### ⚠️ Price list entries come first

Quote/order/invoice **lines silently have nothing to price against** unless the products are on the
price list. Caldova's list had **9 entries, all pointing at legacy Contoso product IDs** — none of
the 60 reskinned products were priced.

```jsonc
// POST /api/data/v9.2/productpricelevels
{ "pricelevelid@odata.bind":"/pricelevels(<pl>)",
  "productid@odata.bind":"/products(<product>)",
  "uomid@odata.bind":"/uoms(<product default uom>)",
  "transactioncurrencyid@odata.bind":"/transactioncurrencies(<usd>)",
  "pricingmethodcode":1, "amount":10.90 }
```

Take `uomid` from the product's own `_defaultuomid_value`. Re-running is safe — duplicates return
`412 A record with matching key values already exists`, which is a clean skip signal.

### Then the documents

Headers need customer + price list + currency, and link back to the opportunity:

```jsonc
// POST quotes  (same shape for salesorders and invoices)
{ "name":"…", "customerid_account@odata.bind":"/accounts(<a>)",
  "pricelevelid@odata.bind":"/pricelevels(<pl>)",
  "transactioncurrencyid@odata.bind":"/transactioncurrencies(<usd>)",
  "opportunityid@odata.bind":"/opportunities(<opp>)" }

// POST quotedetails / salesorderdetails / invoicedetails
{ "quoteid@odata.bind":"/quotes(<q>)",
  "productid@odata.bind":"/products(<p>)",
  "uomid@odata.bind":"/uoms(<u>)", "quantity":240 }
```

Line pricing is inherited from the price list — do **not** set `priceperunit` unless you want an
override. Caldova ended with **56 quotes / 135 lines / 35 orders / 20 invoices** off 49 won
opportunities, tapering deliberately (every won opp quotes, ~70% order, ~50% of those invoice) so the
funnel looks real rather than uniform.

### Territories and goals

`territories` is a plain create. For goals, **do not create a metric** — the org already ships
`Revenue`, `No. of Product Units` and `No. of Cases`, and a hand-rolled `metrics` POST returns a bare
`400 Error identified in Payload`. Query `metrics` and reuse:

```jsonc
{ "title":"Caldova US East FY26 revenue",
  "metricid@odata.bind":"/metrics(<stock Revenue metric>)",
  "goalstartdate":"2026-01-01T00:00:00Z", "goalenddate":"2026-12-31T00:00:00Z",
  "isfiscalperiodgoal":false, "targetmoney":4200000 }
```

## Field Service agreements

> 🔴 **`msdyn_serviceterritories` does not exist** (404). Field Service reuses the **`territory`**
> entity, so the Sales territories above cover it.

Agreements need a price list, which is not obvious from the form:

```
400 The price list must be set.
```

```jsonc
// POST /api/data/v9.2/msdyn_agreements
{ "msdyn_name":"…",
  "msdyn_serviceaccount@odata.bind":"/accounts(<a>)",
  "msdyn_billingaccount@odata.bind":"/accounts(<a>)",
  "msdyn_pricelist@odata.bind":"/pricelevels(<pl>)",
  "msdyn_startdate":"2026-01-01T00:00:00Z", "msdyn_enddate":"2026-12-31T00:00:00Z" }
```

Target accounts that **already have work orders** (`msdyn_workorders` →
`_msdyn_serviceaccount_value`) so the agreement sits on a customer with real service history.

## ⏱️ Long OData batches: the call times out, the work does not stop

Creating ~150 records in one `browser_evaluate` exceeded the MCP timeout. **The browser kept
running it.** A count check right after showed 38 quotes; minutes later it was 56.

So: **never assume a timed-out batch failed, and never blindly re-run it** — you will double up.
Re-query counts first, then resume idempotently by diffing against what exists:

```js
const quoted = new Set((await g("quotes?$select=_opportunityid_value&$top=200"))
                        .map(q => q._opportunityid_value));
const todo = wonOpps.filter(o => !quoted.has(o.opportunityid));
```

Chunk large seeds into batches of ~20 records and let each call return.

---

## ✅ CORRECTION — "Start agent" needs a SECOND confirmation click

An earlier note in this file said the first Start "silently reverts to Draft (Modified by SYSTEM) —
just retry". **That diagnosis was wrong**, and retrying blindly wastes ten minutes a go.

`Start agent` in the header **opens a confirmation prompt further down the page**:

> **Start agent?**
> Once the agent is on, it'll begin researching opportunities and gathering insights.
> **[ Start agent ]  [ Dismiss ]**

The prompt renders **below the fold**, and the header status stays `Draft` until you confirm it. Two
starts in a row appeared to do nothing for exactly this reason.

**There are therefore two buttons with the identical accessible name `Start agent`.** Under
automation, enumerate them and click the **last one in DOM order**:

```js
const btns = [...document.querySelectorAll('button')]
  .filter(b => b.textContent.trim() === 'Start agent');
btns[btns.length - 1].click();     // the confirmation, not the header
```

**Confirm in the agent-list grid, not the record header** — the header lagged on `Draft` while the
grid already read `Starting agent`.

### Diagnosing a start that genuinely will not take

Before retrying, check the section tabs' **`aria-label`**, which carries the validation state
explicitly:

```
"Component Agent profile under General is filled"
"Component Selection criteria under General is filled"
```

If every tab reads *is filled*, the configuration is complete and the problem is the unconfirmed
prompt — not the config.

## The four Sales agent types

The `Create` gallery offers four, and **only one agent per type is allowed**:

| Agent | What it does | Caldova |
|---|---|---|
| **Sales Qualification** | researches leads, drafts outreach, detects intent, qualifies and hands off | ✅ `Caldova Lead Qualification Agent` — **On** |
| **Sales Opportunity** | personalises high-value opps, flags risk/competitors/disengaged stakeholders | ✅ `Caldova Opportunity Insights Agent` |
| **Sales Close** | automated outbound follow-ups, objection handling, product recommendations | ⚠️ **not created — sends email autonomously, needs explicit authorisation** |
| **Data Enrichment** | enriches CRM records | not created |

### Opportunity Agent setup notes

Prerequisites arrive **mostly pre-satisfied** on a CDX tenant — Bing search and Dataverse Search both
showed `Done`; only *Microsoft 365 Services* needed its checkbox. `Continue` then unlocks
General/Advanced, which otherwise read *"Section will be unlocked after you make a choice."*

Three sections to complete:

- **Company info** — name, website, and a value proposition (2000 char max). Worth writing properly:
  the agent uses it to frame research insights, so a generic line produces generic output.
- **Agent profile** — defaults to the type name (`Sales Opportunity Agent`); rename it.
- **Selection criteria** — needs a **Segment name** (required) and ships with sensible defaults
  (`Status = Open`, `Est. revenue > 4,999`, 10-opportunity cap, 2160/day refresh limit). Check the
  revenue floor suits your seeded deal sizes.

> ⚠️ **The Sales Close Agent automates outbound email.** Same standing rule as the Journeys live
> send: do not switch it on without explicit sign-off from the tenant owner.

## There are FIVE agent types, and one provisions itself

The `Create` gallery lists five, not four:

| Agent | Setup flow | Caldova |
|---|---|---|
| Sales Qualification | tabbed config | ✅ `Caldova Lead Qualification Agent` — On |
| Sales Opportunity | tabbed config | ✅ `Caldova Opportunity Insights Agent` |
| **Data Enrichment** | **guided wizard** with defaults pre-loaded | ✅ created |
| **Recommended Actions** | — | ✅ **auto-created by SYSTEM**, already `On` |
| Sales Close | tabbed config | ⚠️ withheld — automates outbound email |

> **Creating Data Enrichment silently provisions a `Recommended Actions Agent` as well**, owned by
> `SYSTEM` and started automatically. You do not choose it and are not asked. Expect the agent list
> to gain **two** rows, and don't mistake the extra one for a duplicate.

### Data Enrichment uses a different setup pattern

Unlike the Qualification/Opportunity agents' tabbed config, this one is a **linear wizard** —
`Next → Next → Next → Finish` — that opens with *"Default settings loaded successfully"*. The
defaults are sane and need no editing:

```
User access     All premium users in your organization
Target records  Active Opportunities created in the last 30 days
Action          Enrich BANT-related fields in the opportunity record
Schedule        Read-only · Runs everyday
Behavior        Suggests field updates based on email conversations
                (seller approval required)
Data sources    Read-only · Outlook
```

**Note the safety posture**: read-only sources and *seller approval required*, so it proposes rather
than writes. That makes it safe to enable without the authorisation conversation the Close Agent
needs.

The prerequisite gate arrives **at the end** here (after `Finish`, not before `Continue`), and only
*Microsoft 365 services* needs ticking — Dataverse search already reads `Done`. The final button is
**`Create agent`**, then a summary card with its own **`Start agent`**.

---

# The Sales Close Agent (Preview) — the only agent that sends mail on its own

Verified Aug 2026 in Caldova. This is the fifth and highest-consequence Sales agent. Everything
above proposes; **this one composes and sends outbound customer email autonomously, on a fixed
multi-touch cadence.** Treat its setup as a different class of task.

## Seven sections, not eleven

Once prerequisites pass, the Close Agent unlocks a **seven**-tab configurator, not the eleven-section
layout the Qualification agent uses:

```
Prerequisites · Agent profile · Products · Target customer · Email delivery · Email content · Knowledge sources
```

Three of the four prerequisites are already satisfied if you built the **Lead Qualification agent**
first — the Entra app, the Dataverse application user and the `AISalesperson` role are shared. Tick
them and `Continue`.

> **Build the Qualification agent first.** It front-loads the identity plumbing that the Close Agent
> would otherwise make you do under time pressure.

## Read validation state from `aria-label`, not from the icons

Each tab exposes its own validation verdict, which is far more reliable than reading the UI:

```js
Array.from(document.querySelectorAll('[role="tab"]'))
  .map(t => ({ tab: t.textContent.trim(), state: t.getAttribute('aria-label') }));
// "Component Products under General is having errors"
// "Component Email delivery under Guidance is filled"
```

Three verdicts appear: `is filled`, `is not filled`, `is having errors`. Only `having errors` blocks
you; `not filled` can still be optional (Knowledge sources ends on `not filled` and saves fine).

## ⚠️ The dropdowns are `role="menu"`, not `role="listbox"`

This one will waste your afternoon. The Close Agent's pickers render options as
**`role="menuitemcheckbox"` inside `.fui-Combobox__listbox`** — so the usual probe returns nothing
and the field looks broken:

```js
document.querySelectorAll('[role="option"]').length   // 0 — WRONG, looks empty
document.querySelector('.fui-Combobox__listbox').children.length  // 22 — the real options
```

Two further rules for these pickers:

- **They are search-as-you-type.** Clicking alone renders no options; you must type a term first,
  and you must type it with a real `fill()` — a synthetic React value-setter does not trigger the
  query.
- **`.click()` *does* work on `menuitemcheckbox` items.** This is a rare exception to the "scripted
  click fails on Fluent components" rule elsewhere in this skill. Selecting an item also clears the
  search box, so re-type for each subsequent selection.

**Do not guess record names.** Type the brand, read back what the menu actually contains, then match
exactly. Guessing cost eight failed selections here (`Kestrel Energy 16oz` does not exist;
`Kestrel Energy Original 12oz` does).

## ⚠️ Email signature is a full Dataverse record, not a text box

`Email signature*` is mandatory and its textarea is **permanently `disabled`**. The only way in is
the **`Modify signature`** button, which opens a complete *Email Signature* entity form —
Name, Language, Set as default, and a **CKEditor** rich-text body.

- The signature body is a `contenteditable`, so synthetic setters are useless. Use real keyboard
  input: click in, `Control+A`, `Delete`, then `type()` with `Shift+Enter` between lines.
- Finish with **`Save & Close`**. The text then flows back into the (still disabled) textarea on the
  agent form, which is how you confirm it took.

## ⚠️ Products: the agent validates that your chosen fields contain *data*

Selecting products is not enough. The Products tab goes to **`having errors`** with:

> *"Couldn't find relevant data in some product fields. You can add them from here or in CRM."*

Do **not** guess what is missing. There is an **`Add missing info`** button that opens an
`Update fields` grid — one row per selected product, with exactly the columns the agent requires:

| Column | Caldova result |
|---|---|
| Product name | pre-filled |
| Price | pre-filled *(only because the price list entries existed — see the sales-lifecycle section)* |
| **Product page URL** | **empty — this was the actual blocker** |

`Product page URL` is required on **every** selected product and rejects anything that is not a
well-formed URL (`Invalid URL` shown inline until it parses). Fill it and `Update`, and the tab flips
straight to `is filled`.

> This grid is also a free data-quality audit. It surfaced two Caldova products whose prices were
> **inverted** (a 12-pack priced above a 24-pack club pack). You can correct prices inline here.

Descriptions, incidentally, were *not* what it wanted — but populating `description` on the selected
products is still worth doing, because `Description` is one of the three fields you can nominate
under *"What should we use to search your product knowledge?"* (we used `Description` + `Name`).

## 🚨 Email delivery — read this before you start the agent

This tab is where the Close Agent differs from every other agent in the suite. It has only three
fields (`Compliance profile` = `default`, `Purpose` = `Commercial`, `Topic` = disabled/derived) and
they self-satisfy — the tab reads `is filled` without you typing anything.

**What it does not have is an autonomy switch.** There is no draft-for-approval option and no send
toggle. The cadence is fixed and stated in the UI:

```
Initial outreach
  → no response: follow-ups at 2, 3, 4 and 5 days
  → buying interest then silence: follow-up after 7 days
  → out-of-office reply: handled and re-queued
```

So a started Close Agent is a **five-touch autonomous outbound email sequence** per matching record.

### Check who it would actually email — before starting

The default target criteria is broader than it looks:

```
Record type : Opportunity
Filter      : Related entity → Contact (Contact) → Email → Contains data
```

That is *every open opportunity whose contact has any email address*. Verify the real blast radius
with a query rather than trusting the record count:

```js
const r = await fetch("/api/data/v9.2/opportunities?$select=name&$filter=statecode eq 0"
  + "&$expand=parentcontactid($select=fullname,emailaddress1)&$top=400",
  { headers:{ Accept:'application/json' }});
const opps = (await r.json()).value.filter(o => o.parentcontactid?.emailaddress1);
// then group by domain
```

**Caldova result: 43 open opportunities, only 5 with a contact email — and all 5 on `.example`
domains.** `.example` is reserved by RFC 2606 and is guaranteed non-routable, so nothing can escape
the demo tenant.

> **Seed demo contacts on `.example` / `.invalid` domains.** It is the difference between an agent
> you can safely demonstrate live and one you dare not start. Run the domain query above on any
> tenant before enabling this agent — a single real address in the target set turns a demo into an
> outbound campaign.

## Email content

`Outreach email template*` is mandatory and offers **only stock templates** — none are reskinned, so
this is another **config-layer leak** to check against your customer narrative. `Follow-Up to Our
Meeting` is the best generic fit for a close motion.

`Tone for emails` is free text and is where the demo actually gets its voice. Give it the seller
persona, the commercial levers that matter in the industry, the brand names, and an explicit
*do-not-use* list (hype phrases, competitor names, unsupported figures).

## Knowledge sources

Two parts, both effectively pre-wired:

- **Agent playbook** — defaults to `Default Agent playbook`, auto-provisioned into the tenant's
  SharePoint (`https://<tenant>.sharepoint.com/Shared%20Documents/Org-<envid>/…`). Nothing to do.
- **Product documentation** — optional PDF uploads, now redirected to **Microsoft Copilot Studio**.

The tab settles on `is not filled` and **`Save` still succeeds** ("Changes saved"). Do not chase it.

## Order of operations that works

1. Build the **Lead Qualification agent** first (shares the identity prerequisites).
2. Prerequisites → tick the three inherited items → `Continue`.
3. **Agent profile** — name, agent user, AI disclaimer, then `Modify signature` → CKEditor →
   `Save & Close`.
4. **Products** — type-search each brand, select by exact name, write the value proposition, nominate
   `Description` + `Name`, then `Add missing info` → fill every **Product page URL** → `Update`.
5. **Email delivery** — verify the blast radius with the OData query. Do not skip this.
6. **Email content** — template + tone instructions.
7. **Knowledge sources** — leave the defaults.
8. `Save` → confirm **"Changes saved"**.
9. **`Start agent` only as a deliberate, separately-authorised decision** — and remember it needs the
   **second confirmation click below the fold**.

> **A saved Close Agent sends nothing.** Configuration and activation are cleanly separated here, so
> it is perfectly reasonable to leave a fully-configured Close Agent in `Draft` for a demo and start
> it only when the audience is watching.

## Caveat still open: can it send at all?

The agent user is a **Dataverse application user** whose address is synthetic, not a real Exchange
mailbox. Server-side sync has **not** been verified end-to-end in Caldova. Expect that a started
Close Agent may generate email activities that never leave Dataverse. For a demo that is usually
*fine* — the artefacts are visible in the timeline — but do not promise live delivery you have not
proven.

---

## 🛑 BLOCKER — `Knowledge sources` can spin forever and lock `Start agent`

Verified Aug 2026 in Caldova, after exhausting every supported remedy. **Budget for this: the Close
Agent may not be startable at all in your tenant.**

### The symptom

Six of the seven sections earn a green check. `Knowledge sources` shows a **spinner that never
resolves** — and because the section never reports complete, **`Start agent` stays permanently
greyed** with the tooltip *"Complete setup and save your changes to start agent."*

> The `aria-label` reads `"…Knowledge sources under Knowledge is not filled"`. **That wording is
> misleading — it is the *loading* state, not a validation failure.** There is no error, no red
> field, and nothing in the browser console. `Save` still succeeds and reports "Changes saved".
> **This corrects the earlier claim in this file that `not filled` on this tab is merely optional —
> it is optional for *Save*, but it is fatal for *Start*.**

Take a screenshot rather than reasoning from extracted text. The spinner is obvious visually and
invisible in `innerText`.

### What does NOT fix it

All of the following were done, verified, and made no difference:

| Attempted fix | Verified outcome | Still blocked? |
|---|---|---|
| Tick the 4th prerequisite (`Configure server side sync`) + `Save` | Prerequisites → `is filled` | ✅ yes |
| Confirm the Copilot Studio **consent prompt** on the agent | Consent accepted | ✅ yes |
| Upload a **file** knowledge source (PDF) via Copilot Studio | Reached `Ready`, visible in `botcomponents` | ✅ yes |
| **Publish** the Copilot Studio agent (it ships *Not published*) | `Published by … 2:39 PM` | ✅ yes |
| Repeated hard reloads + reopening from the grid | — | ✅ yes |
| Re-save after every change above | "Changes saved" each time | ✅ yes |

### Diagnostics worth running (they rule things out fast)

The section's data genuinely exists — the failure is client-side:

```js
// the call the control makes; returns 200 with all components incl. your uploaded file
await (await fetch("/api/data/v9.2/botcomponents"
  + "?$filter=_parentbotid_value eq <copilotStudioBotId>"
  + "&$select=name,componenttype,statecode", {headers:{Accept:'application/json'}})).json();
```

Two useful facts this establishes:

- **`_parentbotid_value eq '<guid>'` works quoted *and* unquoted** here — a quoted GUID is not the bug.
- The uploaded PDF appears as a `botcomponents` row alongside `Orchestrator`, `Product KB search`,
  `Email Draft for Engage` etc. — so ingestion succeeded and the D365 control simply never renders it.

Also check `msdyn_salesagentconfigurations` — in Caldova it stays **empty (count 0)** even for a saved
agent, so **there is no supported API record to flip in order to start the agent manually.** Do not
go looking for one.

### The `SharePoint location` red herring

The playbook field carries a tooltip *"Add the path for the playbook file, not the root folder."*,
which looks like the smoking gun. It is not — the value is already a **full file path** ending in the
generated workbook:

```
https://<tenant>.sharepoint.com/Shared%20Documents/Org-<envId>/Sales%20Close%20Agent%20-%20Engage%20playbook-<yyyymmdd-hhmmss>.xlsx
```

Clicking **`Default Agent playbook`** *downloads* `Agent Playbook.xlsx` — it is a download link, not
a picker, which also proves the file exists.

### Recommended posture

Treat a **fully configured Close Agent sitting in `Draft`** as an acceptable, demoable end state:

- Every other section is green and the configuration is real and saved.
- You can walk an audience through profile, products, targeting, the email cadence and the guardrails
  without ever starting it — which is the safer demo anyway.
- If you need a *running* agent on screen, use the four that do start reliably (Lead Qualification,
  Opportunity Insights, Data Enrichment, Recommended Actions).

The agent is flagged **Preview**. Re-test after a service update before assuming your tenant is at
fault, and do not burn demo-prep time on it.

### Reusable asset

`assets/caldova-product-catalog.html` + `assets/Caldova-Product-Catalog.pdf` are a ready-made Caldova
product/trade catalog for the Product-documentation knowledge source. Regenerate the PDF from the
HTML with Playwright (LibreOffice and `markitdown` both fail on Windows — see the troubleshooting
reference):

```js
const p = await context.newPage();
await p.goto('file:///…/caldova-product-catalog.html');
await p.pdf({ path:'…/Caldova-Product-Catalog.pdf', format:'Letter', printBackground:true,
              margin:{top:'0.5in',bottom:'0.5in',left:'0.5in',right:'0.5in'} });
```

### Uploading a knowledge file (for when you do need it)

Copilot Studio → agent → **Knowledge** → **Add knowledge** → *Upload file*. Two gotchas:

- The page has **two `input[type=file]`** elements. Index **0 is the chat sendbox attachment**;
  index **1** is the knowledge uploader (its `accept` lists `.pdf`, `.docx`, `.md`, …). Target it by
  `accept`, not by position alone.
- The confirm control is a **split button** — `getByRole('button', {name:'Add to agent'})` matches
  two elements and throws in strict mode. Click `#splitButton-…__primaryActionButton`.

Indexing takes ~1 minute; the row moves `In progress` → **`Ready`**.

---

# Filling the empty CE tabs — knowledge, campaigns, lists, literature, contracts

A tenant can pass every earlier check and still open **empty nav items** in front of a customer.
Audit with a single count sweep before you demo:

```js
const q = async set => (await (await fetch(
  `/api/data/v9.2/${set}?$count=true&$top=1`,{headers:{Accept:'application/json'}})).json())['@odata.count'];
for (const s of ['knowledgearticles','contracts','campaigns','lists','salesliteratures',
                 'msdyn_forecastconfigurations','competitors','msdyn_sequences','templates']) {
  console.log(s, await q(s));
}
```

## ⚠️ Knowledge articles: the count lies, the STATE is what matters

Caldova showed **118 knowledge articles** and looked well-stocked. It was not:

| State | Count |
|---|---|
| **Published** | **6** ← the only ones knowledge search and agent assist can use |
| Draft / Proposed | **41** |
| Archived | 58 |
| Expired | 13 |

Forty-one properly written, correctly reskinned CPG articles were sitting in **Draft**, invisible to
every surface that matters. Always filter by state, never by row count:

```js
// the real number
`/api/data/v9.2/knowledgearticles?$filter=statecode eq 3 and islatestversion eq true&$count=true`
```

**Publishing is a plain PATCH** — no special action needed, and it flips `islatestversion` for you:

```js
await fetch(`/api/data/v9.2/knowledgearticles(${id})`, { method:'PATCH',
  headers:{'Content-Type':'application/json','OData-Version':'4.0'},
  body: JSON.stringify({ statecode: 3, statuscode: 7 }) });   // Published / Published
```

That took Caldova from 6 to **47 searchable articles** in one pass.

## ⚠️ Marketing list members: `AddMemberList`, not `$ref` and not `AddListMembersList`

Three approaches, only one works:

| Attempt | Result |
|---|---|
| `POST lists(<id>)/listmember_association/$ref` | **400** — *"The URI segment '$ref' is invalid after the segment 'listmember_association'"* |
| `POST lists(<id>)/Microsoft.Dynamics.CRM.AddListMembersList` `{MemberIds:[…]}` | **404** — not bound in this org |
| `POST lists(<id>)/Microsoft.Dynamics.CRM.AddMemberList` `{EntityId:'<guid>'}` | **200 ✅** |

Use the single-member action in a loop. Creating a `listmember` row directly also fails validation.

## ⚠️ `contacts` has no expandable `parentcustomerid`

```
$expand=parentcustomerid   -> 400 "Could not find a property named 'parentcustomerid'"
$expand=parentcustomerid_account($select=name)   ✅
```

It is a polymorphic customer lookup, so you must expand the **typed** navigation property. Same
pattern applies to `customerid` on contracts and orders.

Segment your lists off the **account name**, and remember most demo contacts have *no* parent
account at all — those are your consumer/loyalty population, which is exactly the right membership
for a B2C loyalty list.

## ⚠️ Contracts: `description` is rejected, silently poisoning the whole payload

The identical contract payload fails with `description` present and succeeds without it:

```
POST /api/data/v9.2/contracts  { title, description, customerid_account@odata.bind, … }
-> 400 "Error identified in Payload provided by the user for Entity :'contracts'"

same payload minus description -> 204 ✅
```

The error names the entity but not the field, so **bisect the payload** rather than reading the
message. Required shape:

```js
{ title,
  'customerid_account@odata.bind':        `/accounts(${id})`,
  'billingcustomerid_account@odata.bind': `/accounts(${id})`,
  'contracttemplateid@odata.bind':        `/contracttemplates(${templateId})`,
  activeon:'2026-07-01', expireson:'2027-06-30',
  billingstarton:'2026-07-01', billingendon:'2027-06-30',
  billingfrequencycode: 4 }
```

A stock **`Service`** contract template ships with the org — query `contracttemplates` rather than
creating one.

## 🛑 Forecast configuration is wizard-only — do not fake it over the API

`msdyn_forecastconfiguration` requires `msdyn_hierarchyentity`, `msdyn_hierarchyrelationship`,
`msdyn_rollupentity`, `msdyn_forecastcategoryattribute`, `msdyn_periodtype`, `msdyn_rootentityrecordid`,
start/end dates and recurrence — **plus** column definitions and a recalculation job it does not own.

A partially-created configuration renders a **broken** Forecasts tab, which is worse than an empty
one. Use **Sales Hub → App Settings → Forecast configuration**. (The guessed control name
`MscrmControls.ForecastConfiguration.ForecastConfigurationHome` returns an error page.)

---

# Contact Center / Omnichannel — the channels ship OFF, and enabling one provisions the runtime

Verified Sept 2026. Every Dataverse-side indicator says Omnichannel is installed — tables, config
record, 14 workstream templates, four telephony solutions, both Copilot Service apps. And yet
nothing connects, because **the runtime service instance has not been created yet**.

> ## ✅ THE FIX — it is one checkbox
>
> **Copilot Service admin center → Channels → Manage channels.** All five toggles (Voice, Chat,
> Social, SMS, Microsoft Teams) ship **OFF**. Tick **Chat**, press **Save**, and the Omnichannel
> runtime provisions for the org. It took **~90 seconds** in the verified case, after which the chat
> widget went live and reported *"We're online."*
>
> **⚠️ The Save looks like it failed.** Reload the page during provisioning and the checkbox is
> **unchecked again**, with no error and no toast. It is not a failed save — it is the UI reading a
> state that has not settled. Do not re-toggle it in a loop; poll the API instead (below) and give it
> a couple of minutes.
>
> Everything below about the runtime being absent is the *symptom of that switch being off*, not a
> dead environment. **Check the toggles before concluding anything.**

## Watch the provisioning happen

Two unauthenticated-looking Dataverse actions expose the real state — far better than the UI:

```powershell
dataverse api request --target dataverse --path "/api/data/v9.0/CCaaS_GetContactCenterState()"
dataverse api request --target dataverse --path "/api/data/v9.0/CCaaS_GetContactCenterChannels()"
```

Observed transition, polling every 45s:

| Time | Org state | Channel state |
|---|---|---|
| t+0s | `State=2 Status=2001` (provisioning) | `State=2 Status=2101` |
| t+90s | **`State=1 Status=2002`** (ready) | **`State=1 Status=1001`** |

While it is provisioning those same endpoints return **404** from the browser and the admin center
logs `The remote name could not be resolved: e-<orgid>.us.omnichannelengagementhub.com`. That 404 is
transient — do not read it as "unsupported".

## The DNS check (use it as a readiness probe, not a verdict)

```powershell
# the org-specific engagement hub, taken from any chat widget snippet.
# BOTH prefixes exist: m- (messaging) and e- (engagement hub). Check either.
Resolve-DnsName "m-<organizationid>.us.omnichannelengagementhub.com"
# "DNS name does not exist."  -> runtime not provisioned YET (go enable a channel)
# resolves                    -> runtime is up

Resolve-DnsName "oc-cdn-ocprod.azureedge.net"   # control: shared CDN, always resolves
```

Run this **before** building any Contact Center configuration, and again **after** enabling a
channel as your readiness probe — it flips from "does not exist" to resolving once provisioning
lands. Five seconds, and it tells you exactly which side of the switch you are on.

> **Do not read a DNS failure as "this tenant cannot do Omnichannel."** That was the wrong
> conclusion on the first pass here. It means the channel switch has not been thrown yet.

## What lies to you

| Indicator | What it suggests | Reality |
|---|---|---|
| `msdyn_omnichannelconfiguration` record exists | Omnichannel is provisioned | It is a **settings row**, written at environment setup. It holds feature flags and **no endpoint** |
| All `msdyn_oc*` / `msdyn_liveworkstream` tables present | The service is live | Tables ship with the **solutions**, independent of the service instance |
| 14 workstreams already listed | Someone configured channels | Stock **templates**, one per channel, all `Legacy` mode, untouched |
| Telephony solutions installed | Voice is available | `OmnichannelTelephony`, `OmnichannelPrimeTelephony`, `msdyn_OmnichannelCCaaSVoiceAPI`, `msdyn_OmnichannelVoiceRuntime__Public` all install without a working runtime |
| Copilot Service admin center + workspace apps present | Full contact centre | Apps are just UI over the same absent backend |
| **A chat widget you create gets a real `msdyn_widgetappid` and a full embed snippet** | The widget works | The snippet is **generated locally by a Dataverse plugin**. It points at an endpoint that does not exist |

That last row is the cruel one. Creating `msdyn_livechatconfig` over the Web API genuinely returns a
populated `msdyn_widgetappid` and a complete `<script>` snippet with org ID and org URL baked in. It
looks completely legitimate. Drop it on a page and the widget bootstraps, builds its
`Microsoft_Omnichannel_LCWidget_Chat_Iframe_Window` iframe — and then every call to
`/livechatconnector/config/...` fails with **`ERR_NAME_NOT_RESOLVED`**. The chat button never paints.

> **Diagnose from the browser console, not the screenshot.** The page looks merely "empty" — no error,
> no broken image, just no chat button. Only the console names the cause.

## What you CAN build over the API (all verified working)

If the runtime *is* provisioned, everything below works headlessly with no admin-center UI:

**1. Messaging queues** — copy the shape from the stock `Default messaging queue`:

```jsonc
{ "name": "…", "msdyn_queuetype": 192350000,      // Messaging
  "msdyn_isomnichannelqueue": true,
  "msdyn_assignmentstrategy": 192350000,           // platform may override → 192350003 Longest Idle
  "queueviewtype": 1, "msdyn_priority": 1 }
```

**2. Workstreams** in Unified Routing mode — `msdyn_mode` **717210001 = Simplified** (Legacy is
`717210000`). Required fields the error messages do not volunteer: `msdyn_capacityrequired`,
`msdyn_allowedpresences` (**multi-select** — `"192360000,192360001"`), `msdyn_autocloseafterinactivity`,
`msdyn_enablevoicev2`, `msdyn_restrictdownloadrecording`, `msdyn_restrictdownloadtranscript`.
Bind `msdyn_defaultqueue` to the messaging queue.

**3. Chat widgets** — `msdyn_livechatconfig`. Despite `msdyn_azurenotificationhubid` being declared
`NOT NULL` with **zero** rows in the table, the create **succeeds** and the platform fills the app ID
and snippet. Do not let the schema talk you out of trying it.

**4. Queue membership** — the association endpoint works here (unlike marketing lists):

```
POST /api/data/v9.2/queues(<queueid>)/queuemembership_association/$ref
{ "@odata.id": "https://<org>.crm.dynamics.com/api/data/v9.2/systemusers(<userid>)" }
→ 204
```

> Contrast with marketing lists, where `$ref` on `listmember_association` **fails** and you must use
> the `AddMemberList` action. There is no consistent rule — test the association first with one record.

## Voice / telephony

Two things are missing even when the runtime exists:

| Object | Count | Meaning |
|---|---|---|
| `msdyn_occommunicationprovidersetting` | 0 | No ACS resource connected |
| `msdyn_ocphonenumber` | 0 | No number acquired |

**Do not fake a phone number row.** Acquiring a number is a *purchase/provisioning transaction*
against Azure Communication Services through the admin-center wizard, and it can incur charges.
A hand-written `msdyn_ocphonenumber` record produces a number that renders as configured and dies on
the first call — strictly worse than an empty list, because it fails in front of the customer.

## Recommended order

1. **Enable the channel first.** Copilot Service admin center → Channels → **Manage channels** →
   tick **Chat** → **Save**. Then poll `CCaaS_GetContactCenterState()` until `State=1`, or watch for
   the engagement-hub hostname to start resolving. **~90 seconds.** Ignore the checkbox reverting.
2. Queues → workstreams → widgets → queue membership (all API, minutes — see recipes above).
3. Validate by hosting the widget snippet on a scratch HTML page. Success looks like a chat bubble
   reading **"We're online"** and **zero** console errors. Watch the **console**, not the screenshot —
   a broken widget renders as a blank corner with no visible error.
4. Voice last, interactively, and only with the tenant owner's explicit go-ahead — enabling the Voice
   channel and acquiring a number are ACS provisioning actions that can incur charges.

> **Order matters for a reason.** Steps 2–3 succeed perfectly happily against an unprovisioned
> runtime — you get real records, a real widget app ID and a real-looking snippet, and none of it
> connects. Throw the switch first.
