@foldspace_npm/harness 0.1.17 → 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 +9 -5
- package/bin/attach.mjs +31 -6
- 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,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.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// A write, with no card. The result is what was saved: the note id and the
|
|
2
|
+
// meeting id. A missing field is named. A missing meeting is not_found.
|
|
3
|
+
|
|
4
|
+
import { saveMeetingNote } from "../api/meetings";
|
|
5
|
+
|
|
6
|
+
export const update_meeting_notes = {
|
|
7
|
+
execute: async (params: { meetingId?: string; notes?: string; author?: string }) => {
|
|
8
|
+
const meetingId = typeof params?.meetingId === "string" ? params.meetingId.trim() : "";
|
|
9
|
+
const notes = typeof params?.notes === "string" ? params.notes.trim() : "";
|
|
10
|
+
const author = typeof params?.author === "string" ? params.author.trim() : "";
|
|
11
|
+
|
|
12
|
+
if (!meetingId) {
|
|
13
|
+
return { success: false, error: "meetingId was not provided.", reason: "config" };
|
|
14
|
+
}
|
|
15
|
+
if (!notes) {
|
|
16
|
+
return { success: false, error: "notes were not provided.", reason: "config" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const res = await saveMeetingNote(meetingId, notes, author || undefined);
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
console.warn("[update_meeting_notes]", res.status, res.reason, res.detail);
|
|
22
|
+
return { success: false, error: res.error, reason: res.reason };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const noteId = res.data.note?.id?.trim() || "";
|
|
26
|
+
if (!noteId) {
|
|
27
|
+
return { success: false, error: "The note was not saved.", reason: "server" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
success: true,
|
|
32
|
+
message: "A note was saved.",
|
|
33
|
+
data: { noteId, meetingId },
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Mock API. This path is not a real product. Find an API that saves a note on a
|
|
2
|
+
// meeting and returns the new note id, then replace the path and the envelope
|
|
3
|
+
// with what you observed. `__observe_me` cannot succeed until you do.
|
|
4
|
+
|
|
5
|
+
import { apiFetch, type ApiResult } from "../utils";
|
|
6
|
+
|
|
7
|
+
export type NotePayload = {
|
|
8
|
+
note?: { id?: string | null } | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function saveMeetingNote(
|
|
12
|
+
meetingId: string,
|
|
13
|
+
content: string,
|
|
14
|
+
author?: string,
|
|
15
|
+
): Promise<ApiResult<NotePayload>> {
|
|
16
|
+
const body: { content: string; author?: string } = { content };
|
|
17
|
+
if (author) body.author = author;
|
|
18
|
+
return apiFetch<NotePayload>(`/__observe_me/meetings/${encodeURIComponent(meetingId)}/notes`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
body: JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Update meeting notes",
|
|
3
|
+
"level": "L4",
|
|
4
|
+
"family": "write",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "update_meeting_notes",
|
|
7
|
+
"entry": "agent/actions/update_meeting_notes.ts",
|
|
8
|
+
"outcome": "A note is saved on a meeting and the new note id comes back",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Upload contacts — L4
|
|
2
|
+
|
|
3
|
+
**The user says** "upload these contacts". **The user sees** the import card: a drop zone, a review table, then the result. Nothing is written until they choose Import. Done finishes the card.
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build.**
|
|
6
|
+
|
|
7
|
+
`awaitUserInput` is set, so the agent is paused until `callback` runs. Cancel, an empty file, a failed import, and a finished import each call `callback`. The callback states what happened: created, skipped, and a reason on each skipped row.
|
|
8
|
+
|
|
9
|
+
In Agent Studio: an action with key `upload_contacts` and no parameters. The file comes from the card, not from the model.
|
|
10
|
+
|
|
11
|
+
## Adapt it
|
|
12
|
+
|
|
13
|
+
| File | Change |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `agent/api/contacts.ts` | Mock import path. Find a real API that accepts a list of contacts and returns created and skipped rows |
|
|
16
|
+
| `agent/contacts.ts` | The columns the file actually has |
|
|
17
|
+
| `agent/views/uploader.ts` | The card. `brand.ts` is the live import card; re-sample it when the host brand differs |
|
|
18
|
+
|
|
19
|
+
## What that build learned the hard way
|
|
20
|
+
|
|
21
|
+
- **Preview before the write.** Choosing a file is not a request to create records. The write runs when they choose Import.
|
|
22
|
+
- **Invalid rows stay out of the request.** They are reported as skipped, with the reason, and the valid rows are what get posted.
|
|
23
|
+
- **Every path calls `callback`.** Cancel and an empty file included. Leaving the card up without calling it holds the conversation.
|
|
24
|
+
- **A failed import is not "Created 0".** Draw the failure and return its `reason`.
|
|
25
|
+
- **"View Contacts" only works where the app listens.** The build's single-page app routed on a message the card posts; other apps do not. ⚠️ Keep the button only if you wire that message, or open the list with a navigation route.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The agent waits while the user picks a file, reviews the rows, and imports.
|
|
2
|
+
// Calling callback is mandatory on every finished path. The write happens only
|
|
3
|
+
// after Import. Done is what finishes the card.
|
|
4
|
+
|
|
5
|
+
import type { ImportOutcome } from "../contacts";
|
|
6
|
+
import { renderFailure, type ApiFailure, type FailureReason } from "../utils";
|
|
7
|
+
import { mountContactUpload } from "../views/uploader";
|
|
8
|
+
|
|
9
|
+
type UploadResult = {
|
|
10
|
+
success: boolean;
|
|
11
|
+
message?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
reason?: FailureReason;
|
|
14
|
+
data?: { cancelled?: boolean; created?: number; skipped?: number; rows?: ImportOutcome["rows"] };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function done(outcome: ImportOutcome): UploadResult {
|
|
18
|
+
return {
|
|
19
|
+
success: true,
|
|
20
|
+
message: `Created ${outcome.created}. Skipped ${outcome.skipped}.`,
|
|
21
|
+
data: { cancelled: false, created: outcome.created, skipped: outcome.skipped, rows: outcome.rows },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const upload_contacts = {
|
|
26
|
+
execute: async (): Promise<UploadResult> => {
|
|
27
|
+
return { success: true, message: "A contact upload is on screen.", data: { cancelled: false } };
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
awaitUserInput: true,
|
|
31
|
+
|
|
32
|
+
render: (
|
|
33
|
+
result: UploadResult | undefined,
|
|
34
|
+
host: HTMLElement,
|
|
35
|
+
header: HTMLElement,
|
|
36
|
+
callback: (value: unknown, disableOnSubmit?: boolean) => void,
|
|
37
|
+
) => {
|
|
38
|
+
if (!result?.success) {
|
|
39
|
+
renderFailure(host, {
|
|
40
|
+
ok: false,
|
|
41
|
+
status: 0,
|
|
42
|
+
error: result?.error ?? "The upload could not be opened.",
|
|
43
|
+
reason: result?.reason ?? "server",
|
|
44
|
+
});
|
|
45
|
+
callback(result, true);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
mountContactUpload(host, header, {
|
|
50
|
+
onCancel: () => {
|
|
51
|
+
callback(
|
|
52
|
+
{ success: true, message: "The user closed the upload.", data: { cancelled: true } },
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
onEmpty: () => {
|
|
57
|
+
callback(
|
|
58
|
+
{
|
|
59
|
+
success: true,
|
|
60
|
+
message: "The file had no contacts.",
|
|
61
|
+
data: { cancelled: false, created: 0, skipped: 0, rows: [] },
|
|
62
|
+
},
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
onDone: (outcome) => {
|
|
67
|
+
callback(done(outcome), true);
|
|
68
|
+
},
|
|
69
|
+
onFailed: (failure: ApiFailure) => {
|
|
70
|
+
callback(
|
|
71
|
+
{ success: false, error: failure.error, reason: failure.reason, data: { cancelled: false } },
|
|
72
|
+
true,
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Mock API. This path is not a real product. Find an API that accepts a list of
|
|
2
|
+
// contacts and returns how many were created and which rows were skipped, then
|
|
3
|
+
// 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 ContactDraft = {
|
|
9
|
+
firstName: string;
|
|
10
|
+
lastName: string;
|
|
11
|
+
email: string;
|
|
12
|
+
phone: string | null;
|
|
13
|
+
title: string | null;
|
|
14
|
+
accountName: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ImportPayload = {
|
|
18
|
+
created?: number;
|
|
19
|
+
skipped?: number;
|
|
20
|
+
createdContacts?: { name?: string | null; email?: string | null }[] | null;
|
|
21
|
+
skippedContacts?: { name?: string | null; email?: string | null; reason?: string | null }[] | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function importContacts(contacts: ContactDraft[]): Promise<ApiResult<ImportPayload>> {
|
|
25
|
+
return apiFetch<ImportPayload>("/__observe_me/contacts/import", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
body: JSON.stringify({ contacts }),
|
|
28
|
+
});
|
|
29
|
+
}
|