@foldspace_npm/harness 0.1.16 → 0.1.18
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 +55 -21
- package/README.md +1 -1
- package/bin/attach.mjs +73 -20
- package/bin/badge.mjs +50 -0
- package/bin/inject.mjs +2 -2
- package/bin/observe.mjs +4 -3
- package/package.json +1 -1
- package/recipes/INDEX.md +7 -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/bottom-bar/README.md +43 -0
- package/recipes/bottom-bar/agent/bottomBar.ts +94 -0
- package/recipes/bottom-bar/fixtures/configuration.sent.json +17 -0
- package/recipes/bottom-bar/recipe.json +9 -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/badge-core.mjs +22 -0
- package/src/cli-registry.mjs +32 -3
- package/src/keep-focus.mjs +49 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Bottom bar with page starters",
|
|
3
|
+
"level": "L0",
|
|
4
|
+
"family": "entry-point",
|
|
5
|
+
"kind": "page-setup",
|
|
6
|
+
"entry": "agent/bottomBar.ts",
|
|
7
|
+
"outcome": "The agent's resting entry point is a bar at the bottom of the page whose starters change with the page the user is on",
|
|
8
|
+
"provenBy": 2
|
|
9
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Palette sampled from the production build this recipe came from (a dark CRM).
|
|
2
|
+
// Re-sample these from the host page when that product's brand differs, and
|
|
3
|
+
// record where each value came from in docs/app-profile.md.
|
|
4
|
+
|
|
5
|
+
export const brand = {
|
|
6
|
+
surface: "#0E1729",
|
|
7
|
+
headerStart: "#1a2332",
|
|
8
|
+
border: "#344256",
|
|
9
|
+
text: "#E0E0E0",
|
|
10
|
+
textStrong: "#FFFFFF",
|
|
11
|
+
muted: "#94A3B8",
|
|
12
|
+
faint: "#6B7280",
|
|
13
|
+
accent: "#60A5FA",
|
|
14
|
+
money: "#34D399",
|
|
15
|
+
warn: "#FBBF24",
|
|
16
|
+
warnInk: "#78350F",
|
|
17
|
+
row: "rgba(255, 255, 255, 0.03)",
|
|
18
|
+
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
19
|
+
radius: "8px",
|
|
20
|
+
} as const;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"summary": { "dealCount": 2, "totalValue": 30000, "avgDaysStalled": 15 },
|
|
3
|
+
"deals": [
|
|
4
|
+
{
|
|
5
|
+
"id": "opp_1",
|
|
6
|
+
"name": "Renewal",
|
|
7
|
+
"account": { "id": "acct_1", "name": "Northwind" },
|
|
8
|
+
"value": 18000,
|
|
9
|
+
"stage": "negotiation",
|
|
10
|
+
"closeDate": "2026-09-01T00:00:00.000Z",
|
|
11
|
+
"daysSinceUpdate": 21,
|
|
12
|
+
"risks": ["Stalled 21 days", "20 days past close date", "Low probability in late stage"]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "opp_2",
|
|
16
|
+
"name": "Expansion",
|
|
17
|
+
"account": { "id": "acct_2", "name": "Contoso" },
|
|
18
|
+
"value": "12000",
|
|
19
|
+
"stage": "proposal",
|
|
20
|
+
"closeDate": null,
|
|
21
|
+
"daysSinceUpdate": 9,
|
|
22
|
+
"risks": ["1 critical case"]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Opportunities at risk",
|
|
3
|
+
"level": "L2",
|
|
4
|
+
"family": "show-a-list",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "show_opportunities_at_risk",
|
|
7
|
+
"entry": "agent/actions/show_opportunities_at_risk.ts",
|
|
8
|
+
"outcome": "Every open opportunity the app flags as at risk, on one card: count, total value, and why each one is at risk",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Prepare for a meeting — L2
|
|
2
|
+
|
|
3
|
+
**The user says** "prepare me for this meeting". **The agent gets** a summary, plus the facts the summary was written from. There is no card.
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build.**
|
|
6
|
+
|
|
7
|
+
One request loads the meeting, the account, the opportunities, the open cases, and past notes. Counts and the pipeline total are computed in code. Those facts are passed to `runTask` as `data`. There is no `prompt`: the task agent's instructions are created in Agent Studio. The key in this recipe, `__observe_me_summary`, is a placeholder — replace it with the key you publish. ⚠️ Check `list_task_agents` first; if the MCP has no tool to create one, the task agent is created by hand in Agent Studio and this recipe waits for it.
|
|
8
|
+
|
|
9
|
+
If `runTask` is missing or throws, `summary` is null and the message says a summary was not produced. The gathered facts still come back.
|
|
10
|
+
|
|
11
|
+
A meeting with no account id and no account name is `signed_out`, not a pipeline of zero.
|
|
12
|
+
|
|
13
|
+
In Agent Studio: an action with key `prepare_for_meeting` and one string parameter `meetingId`. Also a task agent whose key matches `TASK_KEY`.
|
|
14
|
+
|
|
15
|
+
## Adapt it
|
|
16
|
+
|
|
17
|
+
| File | Change |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `agent/api/meetings.ts` | Mock path and envelope. Find a real API that returns a similar shape |
|
|
20
|
+
| `agent/meetings.ts` | Which facts the summary needs. Totals stay computed here |
|
|
21
|
+
| `agent/actions/prepare_for_meeting.ts` | `TASK_KEY`, after the task agent exists in Agent Studio |
|
|
22
|
+
|
|
23
|
+
## What that build learned the hard way
|
|
24
|
+
|
|
25
|
+
- **The task agent receives facts, not instructions.** `prompt` in `data` is a second instruction channel. The wording belongs on the task agent.
|
|
26
|
+
- **Keep the facts when the summary fails.** A task-agent outage is not a lost meeting.
|
|
27
|
+
- **Add the pipeline in code** before it is handed to the task agent, so the summary is not asked to do the arithmetic.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Gather the meeting, then ask the task agent to summarise it. `data` is facts
|
|
2
|
+
// only. The task agent's instructions live in Agent Studio, under the key below.
|
|
3
|
+
// If the task agent does not answer, the facts still come back and summary is null.
|
|
4
|
+
|
|
5
|
+
import { getMeetingPrep } from "../api/meetings";
|
|
6
|
+
import { belongsToSignedInUser, toPrep, type MeetingPrep } from "../meetings";
|
|
7
|
+
import { getAgent } from "../utils";
|
|
8
|
+
|
|
9
|
+
/** Replace with the task-agent key created in Agent Studio. */
|
|
10
|
+
const TASK_KEY = "__observe_me_summary";
|
|
11
|
+
|
|
12
|
+
type PrepResult = {
|
|
13
|
+
success: boolean;
|
|
14
|
+
summary?: string | null;
|
|
15
|
+
message?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
reason?: string;
|
|
18
|
+
data?: MeetingPrep;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function readSummary(taskResult: unknown): string | null {
|
|
22
|
+
if (typeof taskResult === "string") return taskResult.trim() || null;
|
|
23
|
+
if (!taskResult || typeof taskResult !== "object") return null;
|
|
24
|
+
const record = taskResult as { summary?: unknown; result?: unknown };
|
|
25
|
+
if (typeof record.summary === "string" && record.summary.trim()) return record.summary.trim();
|
|
26
|
+
const inner = record.result;
|
|
27
|
+
if (typeof inner === "string") return inner.trim() || null;
|
|
28
|
+
if (inner && typeof inner === "object" && typeof (inner as { summary?: unknown }).summary === "string") {
|
|
29
|
+
return (inner as { summary: string }).summary.trim() || null;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const prepare_for_meeting = {
|
|
35
|
+
execute: async (params: { meetingId?: string }): Promise<PrepResult> => {
|
|
36
|
+
const meetingId = typeof params?.meetingId === "string" ? params.meetingId.trim() : "";
|
|
37
|
+
if (!meetingId) {
|
|
38
|
+
return { success: false, error: "meetingId was not provided.", reason: "config" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const res = await getMeetingPrep(meetingId);
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
console.warn("[prepare_for_meeting]", res.status, res.reason, res.detail);
|
|
44
|
+
return { success: false, error: res.error, reason: res.reason };
|
|
45
|
+
}
|
|
46
|
+
if (!belongsToSignedInUser(res.data)) {
|
|
47
|
+
return { success: false, error: "Not signed in.", reason: "signed_out" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const facts = toPrep(res.data);
|
|
51
|
+
const agent = getAgent();
|
|
52
|
+
if (!agent || typeof agent.runTask !== "function") {
|
|
53
|
+
return { success: true, summary: null, message: "A summary was not produced.", data: facts };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const taskResult = await agent.runTask({ taskKey: TASK_KEY, data: facts });
|
|
58
|
+
const summary = readSummary(taskResult);
|
|
59
|
+
return {
|
|
60
|
+
success: true,
|
|
61
|
+
summary,
|
|
62
|
+
message: summary ? "A meeting summary was produced." : "A summary was not produced.",
|
|
63
|
+
data: facts,
|
|
64
|
+
};
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.warn("[prepare_for_meeting]", error);
|
|
67
|
+
return { success: true, summary: null, message: "A summary was not produced.", data: facts };
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Mock API. This path is not a real product. Find an API that returns a similar
|
|
2
|
+
// shape — the meeting, its account, opportunities, open cases, and past notes —
|
|
3
|
+
// and replace the path and the envelope with what you observed. `__observe_me`
|
|
4
|
+
// cannot succeed until you do.
|
|
5
|
+
|
|
6
|
+
import { apiFetch, type ApiResult } from "../utils";
|
|
7
|
+
|
|
8
|
+
export type PrepPayload = {
|
|
9
|
+
meeting?: {
|
|
10
|
+
id?: string | null;
|
|
11
|
+
title?: string | null;
|
|
12
|
+
startTime?: string | null;
|
|
13
|
+
attendees?: string[] | null;
|
|
14
|
+
notes?: { content?: string | null }[] | null;
|
|
15
|
+
} | null;
|
|
16
|
+
account?: { id?: string | null; name?: string | null; industry?: string | null } | null;
|
|
17
|
+
opportunities?: { name?: string | null; value?: number | null; stage?: string | null }[] | null;
|
|
18
|
+
openCases?: { title?: string | null; priority?: string | null }[] | null;
|
|
19
|
+
pastMeetings?: { title?: string | null; notes?: string | null }[] | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function getMeetingPrep(meetingId: string): Promise<ApiResult<PrepPayload>> {
|
|
23
|
+
return apiFetch<PrepPayload>(`/__observe_me/meetings/${encodeURIComponent(meetingId)}/prep`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Counts and the pipeline total are computed here. The task agent receives facts, not a prompt.
|
|
2
|
+
|
|
3
|
+
import type { PrepPayload } from "./api/meetings";
|
|
4
|
+
|
|
5
|
+
export type MeetingPrep = {
|
|
6
|
+
meeting: {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
startTime: string | null;
|
|
10
|
+
attendees: string[];
|
|
11
|
+
notes: string[];
|
|
12
|
+
};
|
|
13
|
+
account: { id: string; name: string; industry: string | null };
|
|
14
|
+
metrics: { openOpportunities: number; pipelineValue: number; openCases: number };
|
|
15
|
+
opportunities: { name: string; value: number; stage: string | null }[];
|
|
16
|
+
openCases: { title: string; priority: string | null }[];
|
|
17
|
+
pastMeetings: { title: string; notes: string | null }[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function belongsToSignedInUser(payload: PrepPayload): boolean {
|
|
21
|
+
const id = payload.account?.id;
|
|
22
|
+
const name = payload.account?.name;
|
|
23
|
+
return typeof id === "string" && id.trim() !== "" && typeof name === "string" && name.trim() !== "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toPrep(payload: PrepPayload): MeetingPrep {
|
|
27
|
+
const meeting = payload.meeting ?? {};
|
|
28
|
+
const account = payload.account ?? {};
|
|
29
|
+
const opportunities = (payload.opportunities ?? []).map((row) => ({
|
|
30
|
+
name: row.name?.trim() || "Untitled",
|
|
31
|
+
value: typeof row.value === "number" && Number.isFinite(row.value) ? row.value : 0,
|
|
32
|
+
stage: row.stage?.trim() || null,
|
|
33
|
+
}));
|
|
34
|
+
const openCases = (payload.openCases ?? []).map((row) => ({
|
|
35
|
+
title: row.title?.trim() || "Untitled",
|
|
36
|
+
priority: row.priority?.trim() || null,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
meeting: {
|
|
41
|
+
id: meeting.id?.trim() || "",
|
|
42
|
+
title: meeting.title?.trim() || "Untitled",
|
|
43
|
+
startTime: meeting.startTime?.trim() || null,
|
|
44
|
+
attendees: (meeting.attendees ?? []).filter((name): name is string => typeof name === "string" && name.trim() !== ""),
|
|
45
|
+
notes: (meeting.notes ?? [])
|
|
46
|
+
.map((note) => note.content?.trim() || "")
|
|
47
|
+
.filter((content) => content !== ""),
|
|
48
|
+
},
|
|
49
|
+
account: {
|
|
50
|
+
id: String(account.id),
|
|
51
|
+
name: String(account.name).trim(),
|
|
52
|
+
industry: account.industry?.trim() || null,
|
|
53
|
+
},
|
|
54
|
+
metrics: {
|
|
55
|
+
openOpportunities: opportunities.length,
|
|
56
|
+
pipelineValue: opportunities.reduce((sum, row) => sum + row.value, 0),
|
|
57
|
+
openCases: openCases.length,
|
|
58
|
+
},
|
|
59
|
+
opportunities,
|
|
60
|
+
openCases,
|
|
61
|
+
pastMeetings: (payload.pastMeetings ?? []).map((row) => ({
|
|
62
|
+
title: row.title?.trim() || "Untitled",
|
|
63
|
+
notes: row.notes?.trim() || null,
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meeting": {
|
|
3
|
+
"id": "mtg_1",
|
|
4
|
+
"title": "Quarterly review",
|
|
5
|
+
"startTime": "2026-09-22T15:00:00Z",
|
|
6
|
+
"attendees": ["Dana Reyes"],
|
|
7
|
+
"notes": [{ "content": "Send the renewal numbers." }]
|
|
8
|
+
},
|
|
9
|
+
"account": {
|
|
10
|
+
"id": "acct_1",
|
|
11
|
+
"name": "Northwind",
|
|
12
|
+
"industry": "Logistics"
|
|
13
|
+
},
|
|
14
|
+
"opportunities": [
|
|
15
|
+
{ "id": "opp_1", "name": "Renewal", "value": 12000, "stage": "proposal" },
|
|
16
|
+
{ "id": "opp_2", "name": "Expansion", "value": 6000, "stage": "qualified" }
|
|
17
|
+
],
|
|
18
|
+
"openCases": [
|
|
19
|
+
{ "id": "case_1", "title": "Billing question", "priority": "high" }
|
|
20
|
+
],
|
|
21
|
+
"pastMeetings": [
|
|
22
|
+
{ "id": "mtg_0", "title": "Intro", "notes": "They asked about onboarding." }
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Prepare for a meeting",
|
|
3
|
+
"level": "L2",
|
|
4
|
+
"family": "task-agent",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "prepare_for_meeting",
|
|
7
|
+
"entry": "agent/actions/prepare_for_meeting.ts",
|
|
8
|
+
"outcome": "Meeting facts are gathered, then a task agent writes the summary",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Update meeting notes — L4
|
|
2
|
+
|
|
3
|
+
**The user says** "add this to the meeting notes". **The agent gets** the new note id. There is no card.
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build.**
|
|
6
|
+
|
|
7
|
+
A missing `meetingId` or empty notes fails and names the field that was absent. A missing meeting comes back as `not_found`. The saved result is the note id and the meeting id.
|
|
8
|
+
|
|
9
|
+
In Agent Studio: an action with key `update_meeting_notes` and string parameters `meetingId`, `notes`, and optional `author`.
|
|
10
|
+
|
|
11
|
+
## Adapt it
|
|
12
|
+
|
|
13
|
+
| File | Change |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `agent/api/meetings.ts` | Mock path and body. Find a real API that saves a note and returns its id |
|
|
16
|
+
| `agent/actions/update_meeting_notes.ts` | The field names, if the product calls them something else |
|
|
17
|
+
|
|
18
|
+
## What that build learned the hard way
|
|
19
|
+
|
|
20
|
+
- **Return the id the server created.** "Saved" without an id is not something the next action can use.
|
|
21
|
+
- **Name the missing field.** An empty notes string and a missing meeting are different failures.
|
|
22
|
+
- **This is a live write.** Ask before publishing an edit to an action that already has users.
|