@foldspace_npm/harness 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -5
- package/bin/attach.mjs +37 -9
- package/bin/observe.mjs +4 -3
- package/package.json +1 -1
- package/recipes/INDEX.md +6 -2
- package/recipes/account-overview/README.md +26 -0
- package/recipes/account-overview/agent/accounts.ts +159 -0
- package/recipes/account-overview/agent/actions/show_account_overview.ts +61 -0
- package/recipes/account-overview/agent/api/accounts.ts +59 -0
- package/recipes/account-overview/agent/views/brand.ts +22 -0
- package/recipes/account-overview/agent/views/overview.ts +303 -0
- package/recipes/account-overview/fixtures/overview.empty.json +12 -0
- package/recipes/account-overview/fixtures/overview.ok.json +30 -0
- package/recipes/account-overview/fixtures/overview.unsigned.json +9 -0
- package/recipes/account-overview/recipe.json +10 -0
- package/recipes/opportunities-at-risk/README.md +26 -0
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +49 -0
- package/recipes/opportunities-at-risk/agent/api/opportunities.ts +30 -0
- package/recipes/opportunities-at-risk/agent/opportunities.ts +75 -0
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +115 -0
- package/recipes/opportunities-at-risk/agent/views/brand.ts +20 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.empty.json +4 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.ok.json +25 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.unsigned.json +3 -0
- package/recipes/opportunities-at-risk/recipe.json +10 -0
- package/recipes/prepare-for-a-meeting/README.md +27 -0
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +70 -0
- package/recipes/prepare-for-a-meeting/agent/api/meetings.ts +24 -0
- package/recipes/prepare-for-a-meeting/agent/meetings.ts +66 -0
- package/recipes/prepare-for-a-meeting/fixtures/prep.ok.json +24 -0
- package/recipes/prepare-for-a-meeting/fixtures/prep.unsigned.json +9 -0
- package/recipes/prepare-for-a-meeting/recipe.json +10 -0
- package/recipes/update-meeting-notes/README.md +22 -0
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +36 -0
- package/recipes/update-meeting-notes/agent/api/meetings.ts +22 -0
- package/recipes/update-meeting-notes/fixtures/note.ok.json +3 -0
- package/recipes/update-meeting-notes/recipe.json +10 -0
- package/recipes/upload-contacts/README.md +25 -0
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +77 -0
- package/recipes/upload-contacts/agent/api/contacts.ts +29 -0
- package/recipes/upload-contacts/agent/contacts.ts +133 -0
- package/recipes/upload-contacts/agent/views/brand.ts +21 -0
- package/recipes/upload-contacts/agent/views/uploader.ts +394 -0
- package/recipes/upload-contacts/fixtures/import.ok.json +9 -0
- package/recipes/upload-contacts/recipe.json +10 -0
- package/src/attach-preflight.mjs +9 -0
- package/src/keep-focus.mjs +49 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// The account card from the production build. textContent only — never innerHTML
|
|
2
|
+
// with API data. Styles go in `header`. Routes under `/__observe_me` are
|
|
3
|
+
// placeholders: replace them with the app's own paths.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
formatMoney,
|
|
7
|
+
formatPipeline,
|
|
8
|
+
formatWhen,
|
|
9
|
+
titleCase,
|
|
10
|
+
type AccountOverview,
|
|
11
|
+
type CaseSummary,
|
|
12
|
+
type ContactSummary,
|
|
13
|
+
type MeetingSummary,
|
|
14
|
+
type OpportunitySummary,
|
|
15
|
+
} from "../accounts";
|
|
16
|
+
import { brand } from "./brand";
|
|
17
|
+
|
|
18
|
+
const SHOWN = 5;
|
|
19
|
+
|
|
20
|
+
const CSS = `
|
|
21
|
+
.fs-ov{background:${brand.surface};border-radius:${brand.radius};font-family:${brand.font};color:${brand.text};
|
|
22
|
+
border:1px solid ${brand.border};overflow:hidden;width:100%;box-sizing:border-box}
|
|
23
|
+
.fs-ov *{box-sizing:border-box}
|
|
24
|
+
.fs-ov-head{padding:16px;background:linear-gradient(135deg, ${brand.headerStart} 0%, ${brand.surface} 100%);border-bottom:1px solid ${brand.border}}
|
|
25
|
+
.fs-ov-title{font-size:18px;font-weight:600;color:${brand.textStrong};margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
|
|
26
|
+
.fs-ov-name{display:flex;align-items:center}
|
|
27
|
+
.fs-ov-mark{width:28px;height:28px;background:linear-gradient(135deg, ${brand.accent} 0%, ${brand.accentDeep} 100%);border-radius:6px;
|
|
28
|
+
display:flex;align-items:center;justify-content:center;margin-right:10px;color:#fff;font-weight:600;font-size:11px;flex-shrink:0}
|
|
29
|
+
.fs-ov-open{background:${brand.accent};color:#fff;border:none;padding:6px 12px;border-radius:4px;font-size:11px;font-weight:500;
|
|
30
|
+
cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:4px}
|
|
31
|
+
.fs-ov-open svg{width:12px;height:12px}
|
|
32
|
+
.fs-ov-sub{font-size:12px;color:${brand.muted};margin-bottom:12px;line-height:1.3}
|
|
33
|
+
.fs-ov-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
|
34
|
+
.fs-ov-label{font-size:10px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
|
|
35
|
+
.fs-ov-value{font-size:12px;color:#E2E8F0;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
36
|
+
.fs-ov-value a{color:${brand.accent};text-decoration:none}
|
|
37
|
+
.fs-ov-metrics{display:grid;grid-template-columns:repeat(4, 1fr);gap:1px;background:${brand.border}}
|
|
38
|
+
.fs-ov-metric{padding:12px;background:${brand.surface};display:flex;flex-direction:column;gap:2px;text-align:center;align-items:center}
|
|
39
|
+
.fs-ov-metric b{font-size:20px;font-weight:600}
|
|
40
|
+
.fs-ov-deals{color:${brand.deals}}
|
|
41
|
+
.fs-ov-pipeline{color:${brand.money}}
|
|
42
|
+
.fs-ov-contacts{color:${brand.contacts}}
|
|
43
|
+
.fs-ov-cases{color:${brand.cases}}
|
|
44
|
+
.fs-ov-tabs{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid ${brand.border}}
|
|
45
|
+
.fs-ov-tab{padding:12px 16px;background:transparent;border:none;color:${brand.muted};font-size:13px;font-weight:500;cursor:pointer;
|
|
46
|
+
border-bottom:2px solid transparent;text-align:center}
|
|
47
|
+
.fs-ov-tab.is-active{color:${brand.accent};border-bottom-color:${brand.accent}}
|
|
48
|
+
.fs-ov-panel{padding:16px}
|
|
49
|
+
.fs-ov-list{display:flex;flex-direction:column;gap:10px}
|
|
50
|
+
.fs-ov-row{background:${brand.row};border-radius:6px;padding:12px;border-left:3px solid ${brand.accent};color:inherit;display:block;text-decoration:none}
|
|
51
|
+
.fs-ov-row-title{font-size:13px;font-weight:600;color:${brand.textStrong};margin-bottom:4px}
|
|
52
|
+
.fs-ov-row-meta{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
|
|
53
|
+
.fs-ov-detail{font-size:11px;color:${brand.muted};line-height:1.3}
|
|
54
|
+
.fs-ov-money{font-weight:600;color:${brand.money};font-size:12px}
|
|
55
|
+
.fs-ov-badge{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;font-weight:500}
|
|
56
|
+
.fs-ov-stage-proposal{background:#6B21A8;color:#D8B4FE}
|
|
57
|
+
.fs-ov-stage-qualified,.fs-ov-stage-qualification,.fs-ov-stage-discovery{background:#1E40AF;color:#DBEAFE}
|
|
58
|
+
.fs-ov-stage-negotiation{background:#B45309;color:#FED7AA}
|
|
59
|
+
.fs-ov-stage-closed-won,.fs-ov-stage-closed_won{background:#166534;color:#DCFCE7}
|
|
60
|
+
.fs-ov-stage-closed-lost,.fs-ov-stage-closed_lost{background:#374151;color:#9CA3AF}
|
|
61
|
+
.fs-ov-priority-critical{background:#DC2626;color:#FEE2E2}
|
|
62
|
+
.fs-ov-priority-high{background:#EA580C;color:#FFEDD5}
|
|
63
|
+
.fs-ov-priority-medium{background:#CA8A04;color:#FEF9C3}
|
|
64
|
+
.fs-ov-priority-low{background:#2563EB;color:#DBEAFE}
|
|
65
|
+
.fs-ov-empty{text-align:center;padding:20px 16px;color:${brand.faint};font-style:italic;font-size:12px}
|
|
66
|
+
.fs-ov-more{display:flex;justify-content:flex-end;margin-bottom:12px}
|
|
67
|
+
.fs-ov-more a{font-size:11px;color:${brand.accent};text-decoration:none}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
type Tab = "opportunities" | "meetings" | "cases" | "contacts";
|
|
71
|
+
|
|
72
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
73
|
+
const node = document.createElement(tag);
|
|
74
|
+
node.className = className;
|
|
75
|
+
if (text !== undefined) node.textContent = text;
|
|
76
|
+
return node;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function attr(node: HTMLElement, name: string, value: string): void {
|
|
80
|
+
if (typeof node.setAttribute === "function") node.setAttribute(name, value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The production build's single-page app listened for this message and routed
|
|
84
|
+
// on it. No other app does — on any other product these links do nothing.
|
|
85
|
+
// Open a record with a navigation route instead (CLAUDE.md, "Product defaults").
|
|
86
|
+
function go(path: string): void {
|
|
87
|
+
const top = window.top;
|
|
88
|
+
if (top && typeof top.postMessage === "function") {
|
|
89
|
+
top.postMessage({ type: "NAVIGATE", path }, "*");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function link(className: string, path: string, text?: string): HTMLAnchorElement {
|
|
94
|
+
const node = el("a", className, text) as HTMLAnchorElement;
|
|
95
|
+
node.href = path;
|
|
96
|
+
node.addEventListener("click", (event) => {
|
|
97
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
98
|
+
go(path);
|
|
99
|
+
});
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function initials(name: string): string {
|
|
104
|
+
const letters = name
|
|
105
|
+
.split(" ")
|
|
106
|
+
.map((word) => word[0])
|
|
107
|
+
.join("")
|
|
108
|
+
.slice(0, 2)
|
|
109
|
+
.toUpperCase();
|
|
110
|
+
return letters || "AC";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function openIcon(): SVGElement | null {
|
|
114
|
+
if (typeof document.createElementNS !== "function") return null;
|
|
115
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
116
|
+
attr(svg as unknown as HTMLElement, "viewBox", "0 0 24 24");
|
|
117
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
118
|
+
attr(path as unknown as HTMLElement, "fill", "currentColor");
|
|
119
|
+
attr(
|
|
120
|
+
path as unknown as HTMLElement,
|
|
121
|
+
"d",
|
|
122
|
+
"M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z",
|
|
123
|
+
);
|
|
124
|
+
svg.appendChild(path);
|
|
125
|
+
return svg;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function field(label: string, value: HTMLElement | string): HTMLElement {
|
|
129
|
+
const item = el("div", "fs-ov-field");
|
|
130
|
+
item.append(el("div", "fs-ov-label", label));
|
|
131
|
+
item.append(typeof value === "string" ? el("div", "fs-ov-value", value) : value);
|
|
132
|
+
return item;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function opportunityRow(row: OpportunitySummary): HTMLElement {
|
|
136
|
+
const node = link("fs-ov-row", `/__observe_me/opportunities/${row.id}`);
|
|
137
|
+
node.append(el("div", "fs-ov-row-title", row.name));
|
|
138
|
+
const meta = el("div", "fs-ov-row-meta");
|
|
139
|
+
meta.append(el("span", "fs-ov-money", formatMoney(row.value)));
|
|
140
|
+
node.append(meta);
|
|
141
|
+
const stage = row.stage ? row.stage.replaceAll("_", " ") : "unknown";
|
|
142
|
+
const close = formatWhen(row.closeDate);
|
|
143
|
+
node.append(el("div", "fs-ov-detail", close ? `Stage: ${stage} • Close: ${close}` : `Stage: ${stage}`));
|
|
144
|
+
return node;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function contactRow(row: ContactSummary): HTMLElement {
|
|
148
|
+
const node = link("fs-ov-row", `/__observe_me/contacts/${row.id}`);
|
|
149
|
+
node.append(el("div", "fs-ov-row-title", row.name));
|
|
150
|
+
node.append(el("div", "fs-ov-detail", row.title || "No title"));
|
|
151
|
+
const phone = row.phone ? ` • ${row.phone}` : "";
|
|
152
|
+
node.append(el("div", "fs-ov-detail", `${row.email ?? ""}${phone}`.trim()));
|
|
153
|
+
return node;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function caseRow(row: CaseSummary): HTMLElement {
|
|
157
|
+
const node = link("fs-ov-row", `/__observe_me/cases/${row.id}`);
|
|
158
|
+
node.append(el("div", "fs-ov-row-title", row.title));
|
|
159
|
+
if (row.priority) {
|
|
160
|
+
const meta = el("div", "fs-ov-row-meta");
|
|
161
|
+
const badge = el("span", `fs-ov-badge fs-ov-priority-${row.priority.toLowerCase()}`, titleCase(row.priority));
|
|
162
|
+
meta.append(badge);
|
|
163
|
+
node.append(meta);
|
|
164
|
+
}
|
|
165
|
+
const created = formatWhen(row.createdAt) ?? "Unknown";
|
|
166
|
+
const status = row.status ? titleCase(row.status) : "Open";
|
|
167
|
+
node.append(el("div", "fs-ov-detail", `Created: ${created} • ${status}`));
|
|
168
|
+
return node;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function meetingRow(row: MeetingSummary): HTMLElement {
|
|
172
|
+
const node = link("fs-ov-row", `/__observe_me/meetings/${row.id}`);
|
|
173
|
+
node.append(el("div", "fs-ov-row-title", row.title));
|
|
174
|
+
const when = formatWhen(row.startTime, true);
|
|
175
|
+
if (when) node.append(el("div", "fs-ov-detail", when));
|
|
176
|
+
const count = row.attendees;
|
|
177
|
+
node.append(el("div", "fs-ov-detail", `${count} attendee${count === 1 ? "" : "s"}`));
|
|
178
|
+
return node;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function mountAccountOverview(host: HTMLElement, header: HTMLElement, overview: AccountOverview): void {
|
|
182
|
+
const style = el("style", "");
|
|
183
|
+
style.textContent = CSS;
|
|
184
|
+
header.append(style);
|
|
185
|
+
|
|
186
|
+
const { account, metrics } = overview;
|
|
187
|
+
const card = el("div", "fs-ov");
|
|
188
|
+
|
|
189
|
+
const head = el("div", "fs-ov-head");
|
|
190
|
+
const title = el("div", "fs-ov-title");
|
|
191
|
+
const name = el("div", "fs-ov-name");
|
|
192
|
+
name.append(el("div", "fs-ov-mark", initials(account.name)), el("span", "fs-ov-account", account.name));
|
|
193
|
+
const open = link("fs-ov-open", `/__observe_me/accounts/${account.id}`);
|
|
194
|
+
const icon = openIcon();
|
|
195
|
+
if (icon) open.append(icon);
|
|
196
|
+
open.append(el("span", "fs-ov-open-label", "Open Account"));
|
|
197
|
+
title.append(name, open);
|
|
198
|
+
head.append(title);
|
|
199
|
+
|
|
200
|
+
const size = account.employeeCount === null ? "Size unknown" : `${account.employeeCount.toLocaleString()} employees`;
|
|
201
|
+
head.append(el("div", "fs-ov-sub", `${account.industry || "Industry not specified"} • ${size}`));
|
|
202
|
+
|
|
203
|
+
const grid = el("div", "fs-ov-grid");
|
|
204
|
+
grid.append(field("Phone", account.phone || "Not provided"));
|
|
205
|
+
const website = el("div", "fs-ov-value");
|
|
206
|
+
if (account.website) {
|
|
207
|
+
const site = el("a", "", account.website) as HTMLAnchorElement;
|
|
208
|
+
site.href = account.website;
|
|
209
|
+
attr(site, "target", "_blank");
|
|
210
|
+
website.append(site);
|
|
211
|
+
} else {
|
|
212
|
+
website.textContent = "Not provided";
|
|
213
|
+
}
|
|
214
|
+
grid.append(field("Website", website));
|
|
215
|
+
grid.append(field("Annual Revenue", account.revenue === null ? "Not disclosed" : formatMoney(account.revenue)));
|
|
216
|
+
grid.append(field("Employees", account.employeeCount === null ? "Not specified" : account.employeeCount.toLocaleString()));
|
|
217
|
+
head.append(grid);
|
|
218
|
+
card.append(head);
|
|
219
|
+
|
|
220
|
+
const metricsBar = el("div", "fs-ov-metrics");
|
|
221
|
+
const metric = (label: string, value: string, tone: string) => {
|
|
222
|
+
const item = el("div", "fs-ov-metric");
|
|
223
|
+
item.append(el("div", "fs-ov-label", label), el("b", tone, value));
|
|
224
|
+
return item;
|
|
225
|
+
};
|
|
226
|
+
metricsBar.append(
|
|
227
|
+
metric("Open Deals", String(metrics.openOpportunities), "fs-ov-deals"),
|
|
228
|
+
metric("Pipeline", formatPipeline(metrics.pipelineValue), "fs-ov-pipeline"),
|
|
229
|
+
metric("Contacts", String(metrics.contacts), "fs-ov-contacts"),
|
|
230
|
+
metric("Open Cases", String(metrics.openCases), "fs-ov-cases"),
|
|
231
|
+
);
|
|
232
|
+
card.append(metricsBar);
|
|
233
|
+
|
|
234
|
+
const tabs: { id: Tab; label: string; count: number; empty: string; rows: HTMLElement[] }[] = [
|
|
235
|
+
{
|
|
236
|
+
id: "opportunities",
|
|
237
|
+
label: "Opportunities",
|
|
238
|
+
count: overview.opportunities.length,
|
|
239
|
+
empty: "No open opportunities",
|
|
240
|
+
rows: overview.opportunities.slice(0, SHOWN).map(opportunityRow),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "meetings",
|
|
244
|
+
label: "Meetings",
|
|
245
|
+
count: overview.recentMeetings.length,
|
|
246
|
+
empty: "No recent meetings",
|
|
247
|
+
rows: overview.recentMeetings.slice(0, SHOWN).map(meetingRow),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "cases",
|
|
251
|
+
label: "Cases",
|
|
252
|
+
count: overview.openCases.length,
|
|
253
|
+
empty: "No open cases",
|
|
254
|
+
rows: overview.openCases.slice(0, SHOWN).map(caseRow),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "contacts",
|
|
258
|
+
label: "Contacts",
|
|
259
|
+
count: overview.contacts.length,
|
|
260
|
+
empty: "No contacts",
|
|
261
|
+
rows: overview.contacts.slice(0, SHOWN).map(contactRow),
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
const tabBar = el("div", "fs-ov-tabs");
|
|
266
|
+
const panel = el("div", "fs-ov-panel");
|
|
267
|
+
const buttons = new Map<Tab, HTMLButtonElement>();
|
|
268
|
+
|
|
269
|
+
const show = (id: Tab) => {
|
|
270
|
+
const tab = tabs.find((item) => item.id === id);
|
|
271
|
+
if (!tab) return;
|
|
272
|
+
for (const item of tabs) {
|
|
273
|
+
const button = buttons.get(item.id);
|
|
274
|
+
if (button) button.className = item.id === id ? "fs-ov-tab is-active" : "fs-ov-tab";
|
|
275
|
+
}
|
|
276
|
+
panel.textContent = "";
|
|
277
|
+
if (tab.count > SHOWN) {
|
|
278
|
+
const more = el("div", "fs-ov-more");
|
|
279
|
+
more.append(link("", `/__observe_me/${tab.id}?accountId=${account.id}`, "View All"));
|
|
280
|
+
panel.append(more);
|
|
281
|
+
}
|
|
282
|
+
if (tab.rows.length === 0) {
|
|
283
|
+
panel.append(el("div", "fs-ov-empty", tab.empty));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const list = el("div", "fs-ov-list");
|
|
287
|
+
for (const row of tab.rows) list.append(row);
|
|
288
|
+
panel.append(list);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
for (const tab of tabs) {
|
|
292
|
+
const button = el("button", "fs-ov-tab", `${tab.label} (${tab.count})`) as HTMLButtonElement;
|
|
293
|
+
button.type = "button";
|
|
294
|
+
button.addEventListener("click", () => show(tab.id));
|
|
295
|
+
buttons.set(tab.id, button);
|
|
296
|
+
tabBar.append(button);
|
|
297
|
+
}
|
|
298
|
+
card.append(tabBar, panel);
|
|
299
|
+
show("opportunities");
|
|
300
|
+
|
|
301
|
+
host.textContent = "";
|
|
302
|
+
host.append(card);
|
|
303
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"account": {
|
|
3
|
+
"id": "acct_1",
|
|
4
|
+
"name": "Northwind",
|
|
5
|
+
"industry": "Logistics",
|
|
6
|
+
"website": "https://northwind.example",
|
|
7
|
+
"phone": "(415) 555-0148",
|
|
8
|
+
"revenue": 2400000,
|
|
9
|
+
"employeeCount": 180
|
|
10
|
+
},
|
|
11
|
+
"opportunities": [
|
|
12
|
+
{ "id": "opp_1", "name": "Renewal", "value": 12000, "stage": "proposal", "closeDate": "2026-10-15" },
|
|
13
|
+
{ "id": "opp_2", "name": "Expansion", "value": 6000, "stage": "qualified", "closeDate": "2026-11-02" }
|
|
14
|
+
],
|
|
15
|
+
"contacts": [
|
|
16
|
+
{ "id": "ct_1", "name": "Dana Reyes", "email": "dana@example.com", "phone": "(415) 555-0199", "title": "Buyer" },
|
|
17
|
+
{ "id": "ct_2", "name": "Sam Lee", "email": "sam@example.com", "phone": "", "title": "Ops" }
|
|
18
|
+
],
|
|
19
|
+
"openCases": [
|
|
20
|
+
{ "id": "case_1", "title": "Billing question", "priority": "high", "status": "open", "createdAt": "2026-09-12" }
|
|
21
|
+
],
|
|
22
|
+
"recentMeetings": [
|
|
23
|
+
{
|
|
24
|
+
"id": "mtg_1",
|
|
25
|
+
"title": "Quarterly review",
|
|
26
|
+
"startTime": "2026-09-22T15:00:00Z",
|
|
27
|
+
"attendees": ["Dana Reyes", "Sam Lee", "Pat"]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Account overview",
|
|
3
|
+
"level": "L2",
|
|
4
|
+
"family": "show-a-record",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "show_account_overview",
|
|
7
|
+
"entry": "agent/actions/show_account_overview.ts",
|
|
8
|
+
"outcome": "One account drawn as a card: identity, pipeline, contacts, cases, meetings",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Opportunities at risk — L2
|
|
2
|
+
|
|
3
|
+
**The user says** "which deals are at risk?". **The user sees** one card: how many, their total value, the average days stalled, and a row per opportunity with the reasons the app flagged it.
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build.**
|
|
6
|
+
|
|
7
|
+
The app decides what "at risk" means, on the server. In that build it was: stalled 14+ days, past its close date, low probability in a late stage, critical support cases on the account, no recent meetings — each reason comes back as a short label and the card shows them as tags. The action does not re-judge anything; it draws what the API says.
|
|
8
|
+
|
|
9
|
+
The card is view-only. `awaitUserInput` stays off. For a component that only shows something, the model is told that the information was displayed — nothing `execute` returned and nothing the card drew. So the empty state ("No opportunities are at risk.") is on the card, not in a message. The count, the total and the average are computed in code from the rows.
|
|
10
|
+
|
|
11
|
+
In Agent Studio: an action with key `show_opportunities_at_risk` and no parameters.
|
|
12
|
+
|
|
13
|
+
## Adapt it
|
|
14
|
+
|
|
15
|
+
| File | Change |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `agent/api/opportunities.ts` | Mock path and envelope. Find the app's own "needs attention" list — the one with reasons per row |
|
|
18
|
+
| `agent/opportunities.ts` | Which fields a row carries. Totals stay computed here |
|
|
19
|
+
| `agent/views/at-risk.ts` | The card. Re-sample `brand.ts` from the host page |
|
|
20
|
+
|
|
21
|
+
## What that build learned the hard way
|
|
22
|
+
|
|
23
|
+
- **A list has nothing to prove who it belongs to.** An account has an id and a name; a list does not. The build answered signed-out calls with a 401, which `apiFetch` already reports as `signed_out`. What the code still checks is that a 200 is the list at all — the rows array has to be there. ⚠️ If the app you are on answers a signed-out call with a 200 and an empty list, that check is not enough; find something in the response that only a signed-in user gets.
|
|
24
|
+
- **Empty is an answer.** Zero at risk is drawn as a card that says so — never as a failure, and never as a hint about what to do next.
|
|
25
|
+
- **Rows are not links.** The production build made every name clickable through a message its own single-page app listened for. No other app does; on any other product the click would do nothing. Open a record with a navigation route instead.
|
|
26
|
+
- **The agent cannot see the card.** In the production build the render also handed the rows to the agent through `callback`, so it could answer "tell me more about the second one". That path is not in this recipe: with `awaitUserInput` off the platform does not deliver it, and a view-only action's data does not reach the model by design. If the agent needs the rows, that is a second, data-only action.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// View-only card. Do not set awaitUserInput: the model is told only that the
|
|
2
|
+
// information was displayed, so the card carries every fact — including "nothing
|
|
3
|
+
// is at risk". A failed or signed-out call is drawn with renderFailure, never as
|
|
4
|
+
// a count of zero.
|
|
5
|
+
|
|
6
|
+
import { getOpportunitiesAtRisk } from "../api/opportunities";
|
|
7
|
+
import { isAtRiskList, toAtRisk, type AtRiskOverview } from "../opportunities";
|
|
8
|
+
import { renderFailure, type FailureReason } from "../utils";
|
|
9
|
+
import { mountOpportunitiesAtRisk } from "../views/at-risk";
|
|
10
|
+
|
|
11
|
+
type ShowResult = {
|
|
12
|
+
success: boolean;
|
|
13
|
+
message?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
reason?: FailureReason;
|
|
16
|
+
data?: AtRiskOverview;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const show_opportunities_at_risk = {
|
|
20
|
+
execute: async (): Promise<ShowResult> => {
|
|
21
|
+
const res = await getOpportunitiesAtRisk();
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
console.warn("[show_opportunities_at_risk]", res.status, res.reason, res.detail);
|
|
24
|
+
return { success: false, error: res.error, reason: res.reason };
|
|
25
|
+
}
|
|
26
|
+
if (!isAtRiskList(res.data)) {
|
|
27
|
+
return { success: false, error: "Not signed in.", reason: "signed_out" };
|
|
28
|
+
}
|
|
29
|
+
// `message` never reaches the model for a view-only action; it is for the console.
|
|
30
|
+
return { success: true, message: "Displayed.", data: toAtRisk(res.data) };
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
render: (result: ShowResult | undefined, host: HTMLElement, header: HTMLElement) => {
|
|
34
|
+
if (!result?.success || !result.data) {
|
|
35
|
+
renderFailure(
|
|
36
|
+
host,
|
|
37
|
+
{
|
|
38
|
+
ok: false,
|
|
39
|
+
status: 0,
|
|
40
|
+
error: result?.error ?? "The opportunities could not be loaded.",
|
|
41
|
+
reason: result?.reason ?? "server",
|
|
42
|
+
},
|
|
43
|
+
{ notFound: "The at-risk list could not be found." },
|
|
44
|
+
);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
mountOpportunitiesAtRisk(host, header, result.data);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Mock API. This path is not a real product. Find an API that returns the open
|
|
2
|
+
// opportunities the app itself considers at risk — each with the reasons — and
|
|
3
|
+
// replace the path and the envelope with what you observed. `__observe_me`
|
|
4
|
+
// cannot succeed until you do.
|
|
5
|
+
//
|
|
6
|
+
// The production build decided "at risk" on the server: stalled 14+ days, past
|
|
7
|
+
// its close date, low probability in a late stage, critical support cases on
|
|
8
|
+
// the account, no recent meetings. The card only shows what the API says.
|
|
9
|
+
|
|
10
|
+
import { apiFetch, type ApiResult } from "../utils";
|
|
11
|
+
|
|
12
|
+
export type AtRiskRecord = {
|
|
13
|
+
id?: string | null;
|
|
14
|
+
name?: string | null;
|
|
15
|
+
account?: { id?: string | null; name?: string | null } | null;
|
|
16
|
+
value?: number | string | null;
|
|
17
|
+
stage?: string | null;
|
|
18
|
+
closeDate?: string | null;
|
|
19
|
+
daysSinceUpdate?: number | null;
|
|
20
|
+
risks?: string[] | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AtRiskPayload = {
|
|
24
|
+
summary?: { dealCount?: number | null; totalValue?: number | null; avgDaysStalled?: number | null } | null;
|
|
25
|
+
deals?: AtRiskRecord[] | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function getOpportunitiesAtRisk(): Promise<ApiResult<AtRiskPayload>> {
|
|
29
|
+
return apiFetch<AtRiskPayload>("/__observe_me/opportunities/at-risk");
|
|
30
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The count, the total and the average are computed here, from the rows the API
|
|
2
|
+
// returned. The model does not add them, and the card does not trust a total the
|
|
3
|
+
// server sent alongside rows that may disagree with it.
|
|
4
|
+
|
|
5
|
+
import type { AtRiskPayload } from "./api/opportunities";
|
|
6
|
+
|
|
7
|
+
export type AtRiskOpportunity = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
accountName: string | null;
|
|
11
|
+
value: number;
|
|
12
|
+
stage: string | null;
|
|
13
|
+
closeDate: string | null;
|
|
14
|
+
daysSinceUpdate: number | null;
|
|
15
|
+
risks: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type AtRiskOverview = {
|
|
19
|
+
metrics: { count: number; totalValue: number; avgDaysStalled: number };
|
|
20
|
+
opportunities: AtRiskOpportunity[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A list has no id or name of its own to prove it belongs to a signed-in user.
|
|
25
|
+
* The production build answered a signed-out call with a 401, which `apiFetch`
|
|
26
|
+
* already maps to `signed_out`. What this guards against is a 200 that is not
|
|
27
|
+
* the list at all — a login page, an empty object — being drawn as "nothing at
|
|
28
|
+
* risk". The rows array has to be there; an empty one is a real answer.
|
|
29
|
+
*/
|
|
30
|
+
export function isAtRiskList(payload: AtRiskPayload): boolean {
|
|
31
|
+
return Array.isArray(payload.deals);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatMoney(value: number): string {
|
|
35
|
+
const rounded = Math.round(value);
|
|
36
|
+
const sign = rounded < 0 ? "-" : "";
|
|
37
|
+
const digits = String(Math.abs(rounded));
|
|
38
|
+
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
39
|
+
return `${sign}$${grouped}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function stageLabel(stage: string | null): string {
|
|
43
|
+
return stage ? stage.replace(/_/g, " ") : "unknown";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toNumber(value: number | string | null | undefined): number {
|
|
47
|
+
const parsed = typeof value === "string" ? Number(value) : value;
|
|
48
|
+
return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function toAtRisk(payload: AtRiskPayload): AtRiskOverview {
|
|
52
|
+
const opportunities = (payload.deals ?? []).map((row) => ({
|
|
53
|
+
id: row.id?.trim() || "",
|
|
54
|
+
name: row.name?.trim() || "Untitled",
|
|
55
|
+
accountName: row.account?.name?.trim() || null,
|
|
56
|
+
value: toNumber(row.value),
|
|
57
|
+
stage: row.stage?.trim() || null,
|
|
58
|
+
closeDate: row.closeDate?.trim() || null,
|
|
59
|
+
daysSinceUpdate:
|
|
60
|
+
typeof row.daysSinceUpdate === "number" && Number.isFinite(row.daysSinceUpdate) ? row.daysSinceUpdate : null,
|
|
61
|
+
risks: (row.risks ?? []).filter((risk): risk is string => typeof risk === "string" && risk.trim() !== ""),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const stalled = opportunities.map((row) => row.daysSinceUpdate).filter((days): days is number => days !== null);
|
|
65
|
+
const avgDaysStalled = stalled.length ? Math.round(stalled.reduce((sum, days) => sum + days, 0) / stalled.length) : 0;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
metrics: {
|
|
69
|
+
count: opportunities.length,
|
|
70
|
+
totalValue: opportunities.reduce((sum, row) => sum + row.value, 0),
|
|
71
|
+
avgDaysStalled,
|
|
72
|
+
},
|
|
73
|
+
opportunities,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The at-risk card from the production build, laid out as rows instead of the
|
|
2
|
+
// build's five-column table: the chat is ~400px wide and a table clipped the
|
|
3
|
+
// reasons. textContent only — never innerHTML with API data. Styles go in
|
|
4
|
+
// `header`.
|
|
5
|
+
//
|
|
6
|
+
// Rows are not links. The production build made each name clickable through a
|
|
7
|
+
// message its own single-page app listened for; no other app does. To open a
|
|
8
|
+
// record from here, use a navigation route (see CLAUDE.md, "Product defaults").
|
|
9
|
+
|
|
10
|
+
import { formatMoney, stageLabel, type AtRiskOverview } from "../opportunities";
|
|
11
|
+
import { brand } from "./brand";
|
|
12
|
+
|
|
13
|
+
const CSS = `
|
|
14
|
+
.fs-risk{background:${brand.surface};border-radius:${brand.radius};font-family:${brand.font};color:${brand.text};
|
|
15
|
+
border:1px solid ${brand.border};overflow:hidden;width:100%;box-sizing:border-box}
|
|
16
|
+
.fs-risk *{box-sizing:border-box;margin:0}
|
|
17
|
+
.fs-risk-head{padding:16px;background:linear-gradient(135deg, ${brand.headerStart} 0%, ${brand.surface} 100%);border-bottom:1px solid ${brand.border}}
|
|
18
|
+
.fs-risk-title{font-size:16px;font-weight:600;color:${brand.textStrong};display:flex;align-items:center;gap:8px}
|
|
19
|
+
.fs-risk-mark{width:18px;height:18px;color:${brand.warn};flex-shrink:0}
|
|
20
|
+
.fs-risk-stats{display:grid;grid-template-columns:repeat(3, 1fr);gap:1px;background:${brand.border};border-bottom:1px solid ${brand.border}}
|
|
21
|
+
.fs-risk-stat{padding:12px;background:${brand.surface};display:flex;flex-direction:column;gap:2px;text-align:center;align-items:center}
|
|
22
|
+
.fs-risk-stat b{font-size:20px;font-weight:600;color:${brand.textStrong}}
|
|
23
|
+
.fs-risk-stat b.is-money{color:${brand.money}}
|
|
24
|
+
.fs-risk-label{font-size:10px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px}
|
|
25
|
+
.fs-risk-list{display:flex;flex-direction:column;gap:10px;padding:16px}
|
|
26
|
+
.fs-risk-row{background:${brand.row};border-radius:6px;padding:12px;border-left:3px solid ${brand.warn}}
|
|
27
|
+
.fs-risk-line{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:4px}
|
|
28
|
+
.fs-risk-name{font-size:13px;font-weight:600;color:${brand.textStrong};overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
29
|
+
.fs-risk-money{font-weight:600;color:${brand.money};font-size:12px;white-space:nowrap}
|
|
30
|
+
.fs-risk-detail{font-size:11px;color:${brand.muted};line-height:1.3;margin-bottom:8px}
|
|
31
|
+
.fs-risk-stage{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;font-weight:500;background:${brand.headerStart};
|
|
32
|
+
color:${brand.accent};text-transform:capitalize;white-space:nowrap;margin-left:6px}
|
|
33
|
+
.fs-risk-tags{display:flex;flex-wrap:wrap;gap:4px}
|
|
34
|
+
.fs-risk-tag{display:inline-block;padding:2px 8px;border-radius:8px;font-size:10px;font-weight:500;background:${brand.warnInk};color:${brand.warn}}
|
|
35
|
+
.fs-risk-empty{text-align:center;padding:24px 16px;color:${brand.faint};font-style:italic;font-size:12px}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const WARN_PATH =
|
|
39
|
+
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z";
|
|
40
|
+
|
|
41
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
42
|
+
const node = document.createElement(tag);
|
|
43
|
+
node.className = className;
|
|
44
|
+
if (text !== undefined) node.textContent = text;
|
|
45
|
+
return node;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function warnIcon(): SVGElement | null {
|
|
49
|
+
if (typeof document.createElementNS !== "function") return null;
|
|
50
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
51
|
+
svg.setAttribute("class", "fs-risk-mark");
|
|
52
|
+
svg.setAttribute("fill", "none");
|
|
53
|
+
svg.setAttribute("stroke", "currentColor");
|
|
54
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
55
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
56
|
+
path.setAttribute("stroke-linecap", "round");
|
|
57
|
+
path.setAttribute("stroke-linejoin", "round");
|
|
58
|
+
path.setAttribute("stroke-width", "2");
|
|
59
|
+
path.setAttribute("d", WARN_PATH);
|
|
60
|
+
svg.appendChild(path);
|
|
61
|
+
return svg;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mountOpportunitiesAtRisk(host: HTMLElement, header: HTMLElement, overview: AtRiskOverview): void {
|
|
65
|
+
const style = el("style", "");
|
|
66
|
+
style.textContent = CSS;
|
|
67
|
+
header.append(style);
|
|
68
|
+
|
|
69
|
+
const { metrics, opportunities } = overview;
|
|
70
|
+
const card = el("div", "fs-risk");
|
|
71
|
+
|
|
72
|
+
const head = el("div", "fs-risk-head");
|
|
73
|
+
const title = el("div", "fs-risk-title");
|
|
74
|
+
const mark = warnIcon();
|
|
75
|
+
if (mark) title.append(mark);
|
|
76
|
+
title.append(el("span", "", "Opportunities at risk"));
|
|
77
|
+
head.append(title);
|
|
78
|
+
card.append(head);
|
|
79
|
+
|
|
80
|
+
const stats = el("div", "fs-risk-stats");
|
|
81
|
+
const stat = (label: string, value: string, money = false) => {
|
|
82
|
+
const node = el("div", "fs-risk-stat");
|
|
83
|
+
node.append(el("b", money ? "is-money" : "", value), el("div", "fs-risk-label", label));
|
|
84
|
+
return node;
|
|
85
|
+
};
|
|
86
|
+
stats.append(
|
|
87
|
+
stat("At risk", String(metrics.count)),
|
|
88
|
+
stat("Total value", formatMoney(metrics.totalValue), true),
|
|
89
|
+
stat("Avg days stalled", String(metrics.avgDaysStalled)),
|
|
90
|
+
);
|
|
91
|
+
card.append(stats);
|
|
92
|
+
|
|
93
|
+
if (opportunities.length === 0) {
|
|
94
|
+
card.append(el("div", "fs-risk-empty", "No opportunities are at risk."));
|
|
95
|
+
} else {
|
|
96
|
+
const list = el("div", "fs-risk-list");
|
|
97
|
+
for (const row of opportunities) {
|
|
98
|
+
const item = el("div", "fs-risk-row");
|
|
99
|
+
const line = el("div", "fs-risk-line");
|
|
100
|
+
line.append(el("div", "fs-risk-name", row.name), el("div", "fs-risk-money", formatMoney(row.value)));
|
|
101
|
+
item.append(line);
|
|
102
|
+
const detail = el("div", "fs-risk-detail", row.accountName ?? "No account");
|
|
103
|
+
detail.append(el("span", "fs-risk-stage", stageLabel(row.stage)));
|
|
104
|
+
item.append(detail);
|
|
105
|
+
const tags = el("div", "fs-risk-tags");
|
|
106
|
+
for (const risk of row.risks) tags.append(el("span", "fs-risk-tag", risk));
|
|
107
|
+
item.append(tags);
|
|
108
|
+
list.append(item);
|
|
109
|
+
}
|
|
110
|
+
card.append(list);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
host.textContent = "";
|
|
114
|
+
host.append(card);
|
|
115
|
+
}
|