@foldspace_npm/harness 0.1.27 → 0.1.28

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.
Files changed (118) hide show
  1. package/CLAUDE.md +301 -188
  2. package/README.md +54 -36
  3. package/bin/ask.mjs +9 -7
  4. package/bin/attach.mjs +80 -51
  5. package/bin/badge.mjs +28 -8
  6. package/bin/cli.mjs +23 -6
  7. package/bin/inject.mjs +3 -4
  8. package/bin/observe.mjs +22 -6
  9. package/bin/published.mjs +1 -1
  10. package/package.json +6 -2
  11. package/recipes/INDEX.md +13 -8
  12. package/recipes/README.md +30 -7
  13. package/recipes/account-overview/README.md +6 -6
  14. package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
  15. package/recipes/account-overview/agent/views/brand.ts +23 -14
  16. package/recipes/account-overview/agent/views/overview.ts +13 -17
  17. package/recipes/bottom-bar/README.md +12 -9
  18. package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
  19. package/recipes/create-a-record/README.md +97 -0
  20. package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
  21. package/recipes/create-a-record/agent/api/projects.ts +62 -0
  22. package/recipes/create-a-record/agent/projects.ts +145 -0
  23. package/recipes/create-a-record/agent/views/approval.ts +181 -0
  24. package/recipes/create-a-record/agent/views/brand.ts +35 -0
  25. package/recipes/create-a-record/agent/views/fields.ts +252 -0
  26. package/recipes/create-a-record/agent/views/project-card.ts +253 -0
  27. package/recipes/create-a-record/agent/views/steps.ts +92 -0
  28. package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
  29. package/recipes/create-a-record/fixtures/project.created.json +1 -0
  30. package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
  31. package/recipes/create-a-record/recipe.json +13 -0
  32. package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
  33. package/recipes/find-by-name/README.md +3 -1
  34. package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
  35. package/recipes/lookup-then-view/README.md +73 -0
  36. package/recipes/lookup-then-view/agent/accounts.ts +174 -0
  37. package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
  38. package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
  39. package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
  40. package/recipes/lookup-then-view/agent/views/account.ts +183 -0
  41. package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
  42. package/recipes/lookup-then-view/agent/views/format.ts +73 -0
  43. package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
  44. package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
  45. package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
  46. package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
  47. package/recipes/lookup-then-view/recipe.json +11 -0
  48. package/recipes/lookup-then-view/studio/find_account.json +10 -0
  49. package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
  50. package/recipes/opportunities-at-risk/README.md +5 -5
  51. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
  52. package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
  53. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
  54. package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
  55. package/recipes/pick-from-a-list/README.md +7 -6
  56. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
  57. package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
  58. package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
  59. package/recipes/prepare-for-a-meeting/README.md +4 -4
  60. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
  61. package/recipes/records-list/README.md +6 -6
  62. package/recipes/records-list/agent/accounts.ts +3 -1
  63. package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
  64. package/recipes/records-list/agent/views/brand.ts +26 -14
  65. package/recipes/server-rendered-forms/README.md +35 -0
  66. package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
  67. package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
  68. package/recipes/server-rendered-forms/agent/forms.ts +192 -0
  69. package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
  70. package/recipes/server-rendered-forms/fixtures/form.html +17 -0
  71. package/recipes/server-rendered-forms/fixtures/list.html +9 -0
  72. package/recipes/server-rendered-forms/fixtures/record.html +4 -0
  73. package/recipes/server-rendered-forms/recipe.json +10 -0
  74. package/recipes/several-hosts/README.md +10 -13
  75. package/recipes/several-hosts/agent/hosts.ts +19 -136
  76. package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
  77. package/recipes/several-hosts/recipe.json +3 -3
  78. package/recipes/swap-the-login-method/README.md +16 -5
  79. package/recipes/swap-the-login-method/agent/utils.ts +52 -3
  80. package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
  81. package/recipes/update-meeting-notes/README.md +10 -4
  82. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
  83. package/recipes/upload-contacts/README.md +5 -5
  84. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
  85. package/recipes/upload-contacts/agent/views/brand.ts +24 -14
  86. package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
  87. package/recipes/who-is-the-user/README.md +7 -3
  88. package/src/ask-core.mjs +3 -3
  89. package/src/attach-daemon.mjs +9 -2
  90. package/src/attach-helpers.mjs +105 -0
  91. package/src/attach-preflight.mjs +34 -11
  92. package/src/badge-core.mjs +1 -1
  93. package/src/cdp-client.mjs +14 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +65 -46
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/lint/index.mjs +8 -1
  100. package/src/lint/rules.mjs +240 -43
  101. package/src/observe-core.mjs +163 -34
  102. package/src/region.mjs +1 -1
  103. package/src/runtime/agent.ts +16 -12
  104. package/src/runtime/http.ts +25 -15
  105. package/src/runtime/match.ts +1 -1
  106. package/src/runtime/render.ts +4 -4
  107. package/src/transports/README.md +2 -2
  108. package/src/upgrade.mjs +33 -10
  109. package/templates/agent-starter/CLAUDE.md +8 -5
  110. package/templates/agent-starter/README.md +32 -33
  111. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  112. package/templates/agent-starter/agent/actions/index.ts +2 -2
  113. package/templates/agent-starter/agent/constants.ts +4 -3
  114. package/templates/agent-starter/agent/utils.ts +15 -18
  115. package/templates/agent-starter/claude-settings.json +1 -1
  116. package/templates/agent-starter/docs/app-profile.md +19 -14
  117. package/templates/agent-starter/foldspace.dev.json +1 -1
  118. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
package/bin/cli.mjs CHANGED
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runInit } from "../src/init.mjs";
8
+ import { isWriteFlag } from "../src/observe-core.mjs";
8
9
  import {
9
10
  checkForUpdate,
10
11
  gateForCommand,
@@ -17,7 +18,6 @@ import {
17
18
  runAttachControl,
18
19
  } from "../src/attach-daemon.mjs";
19
20
  import {
20
- CLI_COMMANDS,
21
21
  createCliRegistry,
22
22
  commandByName,
23
23
  } from "../src/cli-registry.mjs";
@@ -75,13 +75,17 @@ function printHelp(topic, json) {
75
75
  );
76
76
  return;
77
77
  }
78
- const update = checkForUpdate(resolveProjectDir());
79
78
  if (json) {
80
- const payload = topic ? document : { ...document, update };
79
+ // help --json is the first thing the manual reads, so it asks the registry
80
+ // now (as upgrade does) rather than trusting a 12-hour-old answer; offline,
81
+ // it falls back to that answer. One command's contract carries no update.
82
+ const payload = topic
83
+ ? document
84
+ : { ...document, update: checkForUpdate(resolveProjectDir(), { preferFresh: true }) };
81
85
  console.log(JSON.stringify(payload, null, 2));
82
86
  return;
83
87
  }
84
- nagIfOutdated(update);
88
+ nagIfOutdated(checkForUpdate(resolveProjectDir()));
85
89
  console.log(
86
90
  topic ? renderCommandHelp(registry, topic) : renderGeneralHelp(registry),
87
91
  );
@@ -112,7 +116,18 @@ function normalizeCommandArgs(command, args) {
112
116
  const separator = token.indexOf("=");
113
117
  const name = separator > -1 ? token.slice(0, separator) : token;
114
118
  const match = options.get(name);
115
- if (!match) throw new Error(`unknown option '${name}'`);
119
+ if (!match) {
120
+ // The reason matters more than the refusal: observe never writes, and
121
+ // always prints JSON. The write reason fits only `observe read`.
122
+ const verb = args.find((item) => !item.startsWith("--"));
123
+ throw new Error(
124
+ command.name === "observe" && name === "--json"
125
+ ? "unknown option '--json': observe always prints JSON."
126
+ : command.name === "observe" && verb === "read" && isWriteFlag(name)
127
+ ? `unknown option '${name}': observe read is GET only - it never sets a method, a header or a body.`
128
+ : `unknown option '${name}'`,
129
+ );
130
+ }
116
131
  const { option, canonicalName } = match;
117
132
  if (seen.has(canonicalName)) {
118
133
  throw new Error(`option '${canonicalName}' was provided twice`);
@@ -233,7 +248,9 @@ if (!command || command === "--help" || command === "-h") {
233
248
  }
234
249
  }
235
250
  } else {
236
- const names = CLI_COMMANDS.map((entry) => entry.name).join(", ");
251
+ // The visible commands only: listing deploy here would offer a coding agent
252
+ // the Foldspace-internal command help hides.
253
+ const names = registry.commands.map((entry) => entry.name).join(", ");
237
254
  fail(
238
255
  `unknown command '${command}'. Available commands: ${names}. ` +
239
256
  `Run 'foldspace help'.`,
package/bin/inject.mjs CHANGED
@@ -235,8 +235,8 @@ const chromeArgs = [
235
235
  startUrl,
236
236
  ];
237
237
 
238
- console.log(`\nLaunching Chrome \u2014 profile ${path.relative(root, profileDir)}, devtools on :${debugPort}.`);
239
- console.log("Log in to the app once — the profile persists between runs.\n");
238
+ console.log(`\nOpening the test window - profile ${path.relative(root, profileDir)}, debugging port :${debugPort}.`);
239
+ console.log("The human signs in there; the profile keeps what the site remembers between runs.\n");
240
240
 
241
241
  const child = spawn(chrome, chromeArgs, { detached: true, stdio: "ignore" });
242
242
  child.unref();
@@ -254,8 +254,7 @@ if (!owned.ok) {
254
254
  }
255
255
  console.error(`inject: ${ownershipErrorMessage(owned.reason, {
256
256
  port: debugPort,
257
- profileDir,
258
- root,
257
+ command: "inject",
259
258
  })}`);
260
259
  process.exit(1);
261
260
  }
package/bin/observe.mjs CHANGED
@@ -22,6 +22,7 @@ import fs from "node:fs";
22
22
  import path from "node:path";
23
23
 
24
24
  import { CdpPage, parseArgs, printJson, readLaunchState, sleep } from "../src/cdp-client.mjs";
25
+ import { ownershipErrorMessage } from "../src/cdp-ownership.mjs";
25
26
  import { withoutStealingFocus } from "../src/keep-focus.mjs";
26
27
  import { withWorkingBadge } from "../src/badge-auto.mjs";
27
28
  import {
@@ -294,9 +295,7 @@ async function runPages() {
294
295
  try {
295
296
  targets = await (await fetch(`http://127.0.0.1:${state.port}/json/list`)).json();
296
297
  } catch {
297
- throw new Error(
298
- `The inject Chrome is not answering on :${state.port}. It was closed; run \`npm run inject\` again.`,
299
- );
298
+ throw new Error(ownershipErrorMessage("cdp_not_ready", { port: state.port }));
300
299
  }
301
300
  const real = targets.filter(
302
301
  (target) => target.type === "page" && !String(target.url).startsWith("devtools://"),
@@ -321,7 +320,7 @@ async function runPages() {
321
320
  ? {
322
321
  hostsHint:
323
322
  `The window is on ${off.host}, which this project does not cover (hosts: ${state.hosts.join(", ")}). ` +
324
- `If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`,
323
+ `If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then \`npx foldspace attach --stop\`, \`npm run inject\` and \`npx foldspace attach --daemon\` with the flags it ran with. If it is not, ask the human to go back to the product in the test window.`,
325
324
  }
326
325
  : {}),
327
326
  };
@@ -338,7 +337,7 @@ async function runMenu(page) {
338
337
  items.push(publicItem(entry));
339
338
  }
340
339
  const url = await page.evaluate("return location.href;");
341
- // Links that leave this host but stay in the product (analyze., admin. ...).
340
+ // Links that leave this host but stay in the product (reports., admin. ...).
342
341
  // Looked for across the whole page: product tiles are often not navigation.
343
342
  const leaving = await page
344
343
  .evaluate(
@@ -768,7 +767,24 @@ async function runWaitLogin(flags) {
768
767
  }
769
768
  await sleep(1500);
770
769
  }
771
- throw new ObserveExit({ ok: false, loggedIn: false, url, ...(lastError ? { lastError } : {}) }, 2);
770
+ // A last check that failed (window closed, or on a host this project does
771
+ // not cover) says what to do itself; asking the human would only loop.
772
+ const secs = Math.round(timeoutMs / 1000);
773
+ throw new ObserveExit(
774
+ {
775
+ ok: false,
776
+ loggedIn: false,
777
+ error: lastError
778
+ ? `No sign-in seen in ${secs} seconds. The last check said: ${lastError}`
779
+ : `No sign-in seen in ${secs} seconds: the test window still shows a sign-in page (${url}). ` +
780
+ "Ask the human whether they managed to sign in there. If they have not, run " +
781
+ "`npx foldspace observe wait-login` again while they do; if they say they are in, run " +
782
+ "`npx foldspace observe screenshot` and look at the page.",
783
+ url,
784
+ ...(lastError ? { lastError } : {}),
785
+ },
786
+ 2,
787
+ );
772
788
  }
773
789
 
774
790
  // -------------------------------------------------------------------- main
package/bin/published.mjs CHANGED
@@ -89,7 +89,7 @@ async function main() {
89
89
  finish({ ok: false, environment: environment.name, reason: ATTACH_RUNNING }, 1);
90
90
  }
91
91
 
92
- page = await CdpPage.open({ projectDir });
92
+ page = await CdpPage.open({ projectDir, command: "published" });
93
93
  const agent = projectAgent(page.info.target, configTarget(page.info.targetName));
94
94
 
95
95
  let seen = await readPage(agent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "./package.json": "./package.json"
16
16
  },
17
17
  "scripts": {
18
- "test": "node --test test/*.test.mjs"
18
+ "test": "node --test test/*.test.mjs",
19
+ "prepublishOnly": "npm test"
19
20
  },
20
21
  "files": [
21
22
  "bin",
@@ -35,5 +36,8 @@
35
36
  },
36
37
  "publishConfig": {
37
38
  "access": "public"
39
+ },
40
+ "devDependencies": {
41
+ "linkedom": "^0.18.13"
38
42
  }
39
43
  }
package/recipes/INDEX.md CHANGED
@@ -6,16 +6,21 @@ Read this whole table, then open the recipe closest to the outcome.
6
6
  |---|---|---|---|---|
7
7
  | [`who-is-the-user`](who-is-the-user/) | **L0** | Foldspace knows who is signed in — id, email, name, role, subscription — so conversations and analytics are not anonymous | Runs once at start-up. No action, no card | 4 production builds |
8
8
  | [`find-by-name`](find-by-name/) | **L2** | The agent turns a name the user said into the id the next action needs; a count when no name is given; every tie when the name is ambiguous; near-misses when nothing matches | A data action. **No card** | 3 production builds |
9
- | [`pick-from-a-list`](pick-from-a-list/) | **L2** | A clickable list, shown **only** when the user has to choose — with "none of these" and cancel | An action with a card that waits for the user | 1 production build |
10
- | [`bottom-bar`](bottom-bar/) | **L0** | The agent's resting entry point is a bar at the foot of the page whose starters change with the screen the user is on — configured from code, the way customers do it | Page set-up, runs once at start-up | 2 |
11
- | [`swap-the-login-method`](swap-the-login-method/) | any | `apiFetch` for an app that authenticates with a custom header, or with cookies, instead of a bearer token | A replacement `agent/utils.ts` | 2 production builds (3 builds, 3 different schemes) |
12
- | [`account-overview`](account-overview/) | **L2** | One account on a card: identity, pipeline total, contacts, cases, meetings. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
13
- | [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An action with a card that waits for the user | 1 production build |
9
+ | [`lookup-then-view`](lookup-then-view/) | **L2** | Show one account the user names - **start here**. One clear match: its card, no question. Several or none: the **pick-one** list - search, pages, one click - then the card: who it is, the money, open deals, people, open cases. The card action's **required** `accountId`, described as coming from the lookup, makes the agent call the lookup first. The app already hands over the id → `account-overview` | Two actions - an input card (`find_account`) and a view card (`show_account_overview`) - with their Agent Studio definitions word for word | 1 production build |
10
+ | [`pick-from-a-list`](pick-from-a-list/) | **L2** | A clickable list, shown **only** when the user has to choose — with "none of these" and cancel | An input card: the agent waits for the user's answer | 1 production build |
11
+ | [`bottom-bar`](bottom-bar/) | **L0** | The agent's resting entry point is a bar at the foot of the page whose starters change with the screen the user is on - configured from code, the way customers do it | Page set-up, runs once at start-up | 2 production builds |
12
+ | [`swap-the-login-method`](swap-the-login-method/) | any | `apiFetch` for an app that authenticates with a custom header, or with cookies, instead of a bearer token | A replacement `agent/utils.ts` | 2 production builds (a third uses the default bearer token) |
13
+ | [`account-overview`](account-overview/) | **L2** | One account on a card, when the app already hands over its id: identity, pipeline total, contacts, cases, meetings. The agent gets what the card shows. An account the user names → `lookup-then-view` | A view card: no `callback`, no `awaitUserInput` | 1 production build |
14
+ | [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An input card: the agent waits for the user's answer | 1 production build |
15
+ | [`create-a-record`](create-a-record/) | **L4** | One record created from what the user said: an editable card, pre-filled, with dropdowns read from the app, written only after they approve. The new id comes back. **Two versions**: everything on one card (default), or one question per card and then a final review, for when an answer changes what is asked next - the README says which | An action with a card that waits for the user | 2 production builds |
14
16
  | [`update-meeting-notes`](update-meeting-notes/) | **L4** | A note is saved on a meeting and the new note id comes back | A write. **No card** | 1 production build |
15
17
  | [`prepare-for-a-meeting`](prepare-for-a-meeting/) | **L2** | Meeting facts are gathered in code, then a task agent writes the summary | A data action plus a task agent. **No card** | 1 production build |
16
- | [`several-hosts`](several-hosts/) | any | The product lives on several hosts (`www.`, `analyze.`, `admin.` ...): an action asked from the wrong host says which page it needs, and the conversation carries on after the agent moves the user there. **The resume is a registered workaround** (PLG-5962) | Page set-up, plus a guard each action calls | 1 production build |
17
- | [`records-list`](records-list/) | **L2** | The user's records on one card: a row each with name, two facts and a number, matched to the agent's white panel. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
18
- | [`opportunities-at-risk`](opportunities-at-risk/) | **L2** | Every open opportunity the app flags as at risk, on one card: count, total, and why each one is at risk. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
18
+ | [`server-rendered-forms`](server-rendered-forms/) | **L4** | The product renders its pages on the server and has no JSON API: records are read off its pages, and a record is created through the same form a person fills in - fields found by label, the app's own validation messages back as data | A write. **No card of its own** - always behind approval: the approve button of a card, or `confirmation_required: true` | 1 production build |
19
+ | [`several-hosts`](several-hosts/) | any | The product lives on several hosts (`www.`, `app.`, `reports.`, `admin.` ...): an action asked from the wrong host says which page it needs. After the agent moves the user there, the SDK carries the user, their conversations and the agent's turn to that host when it shares the domain (PLG-5962) | A guard each action calls | 1 production build |
20
+ | [`records-list`](records-list/) | **L2** | The user's records on one card: a row each with name, two facts and a number, matched to the agent's white panel. The agent gets what the card shows | A view card: no `callback`, no `awaitUserInput` | 1 production build |
21
+ | [`opportunities-at-risk`](opportunities-at-risk/) | **L2** | Every open opportunity the app flags as at risk, on one card: count, total, and why each one is at risk. The agent gets what the card shows | A view card: no `callback`, no `awaitUserInput` | 1 production build |
22
+
23
+ Levels: L0 the agent is live · L1 the user's own settings · L2 look up records · L3 navigation · L4 a write.
19
24
 
20
25
  **Not here yet — no production build proves it:** the user's own plan or
21
26
  settings drawn as a card (L1). In production that data is sent to `identify`
package/recipes/README.md CHANGED
@@ -13,11 +13,21 @@ of a customer session.
13
13
  ## Use one
14
14
 
15
15
  1. Read `INDEX.md` and pick the closest outcome.
16
- 2. Copy its `agent/` files into your project's `agent/` — the layout is the
17
- same, so the imports already resolve.
18
- 3. Replace every `__observe_me` placeholder — paths, storage keys, claim names —
19
- with what you **observed** in the live app, and record how you know in
20
- `docs/app-profile.md`. The placeholders are deliberate: none can succeed.
16
+ 2. Copy its `agent/` files into your project's `agent/` - the layout is the
17
+ same, so the imports resolve. Never copy its `agent/views/brand.ts`: keep
18
+ the one `npx foldspace observe styles --write` wrote, and if the project
19
+ has none yet, run that command first. Do not overwrite any other file that
20
+ already exists: `agent/accounts.ts`, `agent/api/accounts.ts` and
21
+ `agent/api/meetings.ts` differ between recipes - add what the new recipe
22
+ imports to the file you have. Where the two files define the same name
23
+ (`AccountRecord`, `formatMoney`), keep one: one type with the fields of
24
+ both, one copy of the helper. The one file a recipe replaces is
25
+ `agent/utils.ts`, from `swap-the-login-method`.
26
+ 3. Replace every placeholder with what you **observed** in the live app - the
27
+ `__observe_me` paths and keys, and each default the recipe's **Adapt it**
28
+ table names (claim names, starters) - and record how you know in
29
+ `docs/app-profile.md`. The `__observe_me` ones cannot succeed; the defaults
30
+ can, so check each.
21
31
  4. Register actions in `agent/actions/index.ts` under the **same key** as in
22
32
  Agent Studio, then run the six gates.
23
33
 
@@ -29,10 +39,13 @@ Each recipe's README says how many production builds carry the pattern and what
29
39
  those builds learned the hard way. **What is taken is the shape** — the order of
30
40
  calls, what is returned to the agent, where the traps are. **What is not taken**
31
41
  is any customer's name, endpoint, identifier, styling or code. Contracts are the
32
- documented ones: `execute(params)` and
33
- `render(data, host, header, callback, cancel)`
42
+ SDK's: `execute(params)` and
43
+ `render(data, host, header, callback, cancel, readyState)`
34
44
  (<https://docs.foldspace.ai/guides/in-chat-ui/>), `foldspace.identify(context)`
35
45
  (<https://docs.foldspace.ai/start/user-context/>), and the harness runtime.
46
+ Where the in-chat UI page says to set `awaitUserInput`, do not - the SDK waits
47
+ when `render` uses `callback` or `cancel` (see the card bullet below). That
48
+ page does not list `readyState`; CLAUDE.md describes it, and no recipe uses it.
36
49
 
37
50
  Error handling is **not** taken from those builds. They predate `reason`: one
38
51
  turns every failure into "unexpected error", another reports a missing session
@@ -42,5 +55,15 @@ as "no projects found". Recipes use the runtime's failure reasons instead.
42
55
 
43
56
  - `execute` returns **data and facts** — never `detail`, never instructions.
44
57
  - A missing or expired session is `signed_out`, not an empty result.
58
+ - A **view card** shows something - an account, a list. Its
59
+ `render(data, host, header)` uses no `callback` and no `cancel`, and the
60
+ agent gets at once "Shown to the user. The user has not acted on it." with
61
+ exactly what `execute` returned. So `execute` returns the facts the card
62
+ shows: to change what the agent gets, change `execute`, not the card. An
63
+ **input card** asks the user: it calls `callback` (or `cancel`) on every
64
+ path - choosing, "none of these", closing, failing - and the agent waits
65
+ until it does. Never set `awaitUserInput`: an input card does not need it,
66
+ and on a view card in headless mode the agent waits for a callback that
67
+ never comes.
45
68
  - DOM is built with `textContent`, never `innerHTML` with API data, and styles
46
69
  go in `header` — `host` lives inside the component's frame.
@@ -4,11 +4,11 @@
4
4
 
5
5
  **Proven by 1 production build.**
6
6
 
7
- This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths. The palette is light because the card sits in the agent's white panel; put the agent's own primary colour in `brand.ts` (`accent`), and re-sample only font and radius from the page.
7
+ This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths. `brand.ts` here is a placeholder: keep the `agent/views/brand.ts` that `npx foldspace observe styles --write` wrote - it has every key the recipe cards read. If the project has none yet, run that command first; do not copy this one.
8
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. One tab is on screen at a time; an empty tab says so. A signed-out or failed call is drawn with `renderFailure`, never as zeros. A 200 full of nulls is signed out when the account has no id and no name.
9
+ A view card: `render` takes no `callback` and the action sets no `awaitUserInput`. As soon as the card is drawn, the agent gets "Shown to the user. The user has not acted on it." plus exactly what `execute` returned - the account, the totals and every row behind the tabs, or the failure (`success: false`, `error` and `reason`) - and it answers questions about the account from that. So `execute` returns the overview the card draws and nothing else. One tab is on screen at a time; an empty tab says so. A signed-out or failed call is drawn with `renderFailure`, never as zeros. A 200 is signed out unless the account has both an id and a name.
10
10
 
11
- In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`.
11
+ In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`, described as "The account's id. Never taken from the user's words: get it from `<lookup action key>`." Replace `<lookup action key>` with the key of this agent's action that returns account ids; if the agent has none, build that action first - `find-by-name` has the shape. Its `instructions`: "Answer follow-up questions about this account from the data you receive: quote money and counts exactly as given, and never add them up yourself. Right after the card, one short sentence at most - do not repeat what it shows. On a failure, the card already says why: say so in one short sentence and stop. No emoji."
12
12
 
13
13
  ## Adapt it
14
14
 
@@ -16,11 +16,11 @@ In Agent Studio: an action with key `show_account_overview` and one string param
16
16
  |---|---|
17
17
  | `agent/api/accounts.ts` | Mock path and envelope. Find a real API that returns a similar shape |
18
18
  | `agent/accounts.ts` | Which fields a summary carries. The pipeline total is summed here |
19
- | `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths. `brand.ts` matches the panel, not the page |
19
+ | `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths |
20
20
 
21
21
  ## What that build learned the hard way
22
22
 
23
- - **The card is the experience.** A rich object returned from `execute` does not reach the model when the action only draws.
23
+ - **The agent answers from what `execute` returns.** A view card hands the agent everything `execute` returned, so `execute` returns the card's facts as fields, totals included. The build that proved this card ran before the SDK did that: its agent was told only that something was displayed, and could not answer a question about the account.
24
24
  - **Add the money in code.** Line items summed by the model eventually come out wrong. The card shows the total the code computed.
25
- - **The links only work where the app listens.** "Open Account", the rows and "View All" post a message the build's single-page app routed on; other apps do not. ⚠️ Wire that message in the host app, or replace the links with a navigation route.
25
+ - **The links only work where the app listens.** "Open Account", the rows and "View All" post a message the build's single-page app routed on; other apps do not. ⚠️ Wire that message in the host app, or replace the links with a navigation route. The account's website is plain text, never a link out of the product.
26
26
  - **A signed-out response often looks empty.** Zeros and nulls on a 200 are not an account with no pipeline. Check for an id and a name before drawing numbers.
@@ -1,5 +1,7 @@
1
- // View-only card. Do not set awaitUserInput: the model is told only that the
2
- // information was displayed, so the card itself carries every fact. A failed
1
+ // A view card: render takes no callback and the action sets no
2
+ // awaitUserInput. As soon as the card is drawn, the agent is told it was shown
3
+ // and gets everything execute returned - so execute returns the overview the
4
+ // card draws and nothing else, or the failure the card draws instead. A failed
3
5
  // or signed-out call is drawn with renderFailure, never as a row of zeros.
4
6
 
5
7
  import { belongsToSignedInUser, toOverview, type AccountOverview } from "../accounts";
@@ -9,40 +11,33 @@ import { mountAccountOverview } from "../views/overview";
9
11
 
10
12
  type ShowResult = {
11
13
  success: boolean;
12
- message?: string;
13
14
  error?: string;
14
15
  reason?: FailureReason;
15
- data?: AccountOverview | { accountId: string };
16
+ data?: AccountOverview;
16
17
  };
17
18
 
18
- function isOverview(data: NonNullable<ShowResult["data"]>): data is AccountOverview {
19
- return "account" in data;
20
- }
21
-
22
19
  export const show_account_overview = {
23
20
  execute: async (params: { accountId?: string }): Promise<ShowResult> => {
24
21
  const accountId = typeof params?.accountId === "string" ? params.accountId.trim() : "";
25
22
  if (!accountId) {
26
- return { success: false, error: "accountId was not provided.", reason: "config" };
23
+ return { success: false, error: "No account was given.", reason: "config" };
27
24
  }
28
25
 
29
26
  const res = await getAccountOverview(accountId);
30
27
  if (!res.ok) {
31
28
  console.warn("[show_account_overview]", res.status, res.reason, res.detail);
32
- return { success: false, error: res.error, reason: res.reason, data: { accountId } };
29
+ return { success: false, error: res.error, reason: res.reason };
33
30
  }
34
31
 
35
32
  if (!belongsToSignedInUser(res.data)) {
36
- return { success: false, error: "Not signed in.", reason: "signed_out", data: { accountId } };
33
+ return { success: false, error: "Not signed in.", reason: "signed_out" };
37
34
  }
38
35
 
39
- // `message` never reaches the model for a view-only action; it is for the console.
40
- return { success: true, message: "Displayed.", data: toOverview(res.data) };
36
+ return { success: true, data: toOverview(res.data) };
41
37
  },
42
38
 
43
39
  render: (result: ShowResult | undefined, host: HTMLElement, header: HTMLElement) => {
44
- if (!result?.success || !result.data || !isOverview(result.data)) {
45
- const accountId = result?.data && "accountId" in result.data ? result.data.accountId : "";
40
+ if (!result?.success || !result.data) {
46
41
  renderFailure(
47
42
  host,
48
43
  {
@@ -51,7 +46,7 @@ export const show_account_overview = {
51
46
  error: result?.error ?? "The account could not be loaded.",
52
47
  reason: result?.reason ?? "server",
53
48
  },
54
- { notFound: accountId ? `No account ${accountId}.` : "No account." },
49
+ { notFound: "That account could not be found." },
55
50
  );
56
51
  return;
57
52
  }
@@ -1,26 +1,35 @@
1
- // The card lives in the agent's panel, which is white by default (the agent's
2
- // theme: backgroundColor #FFFFFF, primaryColor for accents) - so the palette
3
- // matches the panel, not the page. Plain hex/rgba only: the panel saves every
4
- // card to history as an image drawn with html2canvas, which cannot read
5
- // oklch()/lab()/color-mix(). Take `accent` from the agent's own primaryColor
6
- // (get_agent_settings); re-sample font and radius from the page if you want
7
- // them to match; swap for a dark set only when the panel itself is dark.
1
+ // Placeholder with the keys of the agent/views/brand.ts that
2
+ // `npx foldspace observe styles --write` writes. When you copy this recipe,
3
+ // keep the project's own brand.ts; if it has none yet, run that command.
8
4
 
9
5
  export const brand = {
10
6
  surface: "#FFFFFF",
11
- headerStart: "#F0F1FE",
12
- border: "#E5E7EB",
7
+ surface2: "#F5F6FA",
13
8
  text: "#374151",
14
- textStrong: "#111827",
15
9
  muted: "#6B7280",
16
- faint: "#9CA3AF",
10
+ border: "#E5E7EB",
17
11
  accent: "#3248F2",
12
+ accentText: "#FFFFFF",
13
+ font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
14
+ radius: "8px",
15
+ size: { base: "14px", small: "12px" },
16
+ textStrong: "#111827",
17
+ faint: "#9CA3AF",
18
+ row: "#F9FAFB",
19
+ tableHead: "#F3F4F6",
18
20
  accentDeep: "#2538D6",
21
+ accentSoft: "#EEF0FE",
22
+ headerStart: "#F0F1FE",
23
+ good: "#047857",
24
+ warn: "#B45309",
25
+ warnInk: "#FEF3C7",
26
+ bad: "#B91C1C",
27
+ primary: "#3248F2",
28
+ tint: "#F3F4F6",
29
+ disabled: "#9CA3AF",
19
30
  money: "#047857",
31
+ ok: "#047857",
20
32
  deals: "#2563EB",
21
33
  contacts: "#7C3AED",
22
34
  cases: "#B45309",
23
- row: "#F9FAFB",
24
- font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
25
- radius: "8px",
26
35
  } as const;
@@ -34,7 +34,6 @@ const CSS = `
34
34
  .fs-ov-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
35
35
  .fs-ov-label{font-size:10px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
36
36
  .fs-ov-value{font-size:12px;color:${brand.textStrong};font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
37
- .fs-ov-value a{color:${brand.accent};text-decoration:none}
38
37
  .fs-ov-metrics{display:grid;grid-template-columns:repeat(4, 1fr);gap:1px;background:${brand.border}}
39
38
  .fs-ov-metric{padding:12px;background:${brand.surface};display:flex;flex-direction:column;gap:2px;text-align:center;align-items:center}
40
39
  .fs-ov-metric b{font-size:20px;font-weight:600}
@@ -198,23 +197,20 @@ export function mountAccountOverview(host: HTMLElement, header: HTMLElement, ove
198
197
  title.append(name, open);
199
198
  head.append(title);
200
199
 
201
- const size = account.employeeCount === null ? "Size unknown" : `${account.employeeCount.toLocaleString()} employees`;
202
- head.append(el("div", "fs-ov-sub", `${account.industry || "Industry not specified"} • ${size}`));
200
+ // What is known, and nothing else: a missing value reads "Not provided" in
201
+ // the grid below.
202
+ const known = [
203
+ account.industry,
204
+ account.employeeCount === null ? null : `${account.employeeCount.toLocaleString()} employees`,
205
+ ].filter((fact): fact is string => Boolean(fact));
206
+ if (known.length) head.append(el("div", "fs-ov-sub", known.join(" • ")));
203
207
 
204
208
  const grid = el("div", "fs-ov-grid");
205
209
  grid.append(field("Phone", account.phone || "Not provided"));
206
- const website = el("div", "fs-ov-value");
207
- if (account.website) {
208
- const site = el("a", "", account.website) as HTMLAnchorElement;
209
- site.href = account.website;
210
- attr(site, "target", "_blank");
211
- website.append(site);
212
- } else {
213
- website.textContent = "Not provided";
214
- }
215
- grid.append(field("Website", website));
216
- grid.append(field("Annual Revenue", account.revenue === null ? "Not disclosed" : formatMoney(account.revenue)));
217
- grid.append(field("Employees", account.employeeCount === null ? "Not specified" : account.employeeCount.toLocaleString()));
210
+ // Plain text, never a link out of the product.
211
+ grid.append(field("Website", account.website || "Not provided"));
212
+ grid.append(field("Annual Revenue", account.revenue === null ? "Not provided" : formatMoney(account.revenue)));
213
+ grid.append(field("Employees", account.employeeCount === null ? "Not provided" : account.employeeCount.toLocaleString()));
218
214
  head.append(grid);
219
215
  card.append(head);
220
216
 
@@ -225,10 +221,10 @@ export function mountAccountOverview(host: HTMLElement, header: HTMLElement, ove
225
221
  return item;
226
222
  };
227
223
  metricsBar.append(
228
- metric("Open Deals", String(metrics.openOpportunities), "fs-ov-deals"),
224
+ metric("Opportunities", String(metrics.openOpportunities), "fs-ov-deals"),
229
225
  metric("Pipeline", formatPipeline(metrics.pipelineValue), "fs-ov-pipeline"),
230
226
  metric("Contacts", String(metrics.contacts), "fs-ov-contacts"),
231
- metric("Open Cases", String(metrics.openCases), "fs-ov-cases"),
227
+ metric("Cases", String(metrics.openCases), "fs-ov-cases"),
232
228
  );
233
229
  card.append(metricsBar);
234
230
 
@@ -14,8 +14,8 @@ product's own source, one from the agent project behind a feature flag).
14
14
  | In `agent/bottomBar.ts` | Change |
15
15
  |---|---|
16
16
  | `PAGE_STARTERS` | This product's screens (path prefixes) and, for each, two or three questions **its actions can answer**. A starter for something the agent cannot do is worse than none |
17
- | `DEFAULT_STARTERS` | What to show on any other page |
18
- | `BOTTOM_BAR_SETTINGS` | Leave as the docs' defaults unless the product asks |
17
+ | `DEFAULT_STARTERS` | What to show on any other page. It replaces the agent-wide starters there |
18
+ | `BOTTOM_BAR_SETTINGS` | The docs' settings, except two: `enabled: true` (the docs default it to `false`, and the bar then never renders) and `maxVisibleStarters: 3` (the docs default 5). Change any of them only if the human asks |
19
19
 
20
20
  Then call it from the bundle's entry point:
21
21
 
@@ -30,14 +30,17 @@ installBottomBar();
30
30
  - **`setConversationStarters` replaces the whole set for the page** — both
31
31
  groups. `null` restores Agent Studio's. So always pass the full list you
32
32
  want shown.
33
- - **In embedded mode the bar needs `openEmbeddedCallback`**, or nothing opens
34
- when a starter is clicked. This recipe is for the overlay agent; see the
35
- docs for embedded.
33
+ - **In embedded mode the bar needs `openEmbeddedCallback`**, or the bar does
34
+ not render at all. This recipe is for the overlay agent; see the docs for
35
+ embedded.
36
36
  - **Route changes in a single-page app don't reload**, so the starters must be
37
- re-applied on navigation — this recipe wraps `pushState` / `replaceState`
38
- and listens to `popstate`.
39
- - **Dark mode**: the SDK's default colours are being fixed (PLG-5891). Until
40
- then a product in dark mode may need `theme` / `darkModeSettings` set.
37
+ re-applied on navigation - this recipe wraps `pushState` / `replaceState`
38
+ and listens to `popstate`. It brings the bar back to full only when the
39
+ starters change; a navigation that keeps the same starters leaves a bar
40
+ that has folded to compact (after `idleTimeoutMs`) as it is.
41
+ - **Dark mode**: the SDK's default colours do not follow a dark theme yet
42
+ (PLG-5891, in the backlog); a product in dark mode may need `theme` /
43
+ `darkModeSettings` set.
41
44
 
42
45
  Docs: [Bottom Bar](https://docs.foldspace.ai/customize/bottom-bar/) ·
43
46
  [Conversation starters](https://docs.foldspace.ai/customize/conversation-starters/)
@@ -16,10 +16,12 @@ export const PAGE_STARTERS: Array<{ match: RegExp; starters: string[] }> = [
16
16
  ];
17
17
 
18
18
  /** Shown on any page not listed above. */
19
- export const DEFAULT_STARTERS: string[] = ["What can you do?", "Show my account"];
19
+ export const DEFAULT_STARTERS: string[] = ["What can you do?", "<A question one of its actions can answer>"];
20
20
 
21
- // The docs' own settings, unchanged. Change nothing here unless the product
22
- // asks for it: every field is documented on the Bottom Bar page.
21
+ // The docs' settings, except two: `enabled: true` (the docs default it to
22
+ // `false`, and the bar then never renders) and `maxVisibleStarters: 3` (the
23
+ // docs default 5). Change any of them only if the human asks: every field is
24
+ // documented on the Bottom Bar page.
23
25
  export const BOTTOM_BAR_SETTINGS = {
24
26
  enabled: true,
25
27
  maxVisibleStarters: 3,
@@ -39,7 +41,7 @@ export function startersForPath(pathname: string): string[] {
39
41
  const INSTALLED_FLAG = "__foldspace_bottom_bar__";
40
42
 
41
43
  /**
42
- * Turn the bar on and keep its starters matched to the page. Call once from
44
+ * Set up the bar and keep its starters matched to the page. Call once from
43
45
  * the bundle's entry point; safe to call again.
44
46
  *
45
47
  * `setConversationStarters` REPLACES the whole set for this page view (both
@@ -57,7 +59,10 @@ export function installBottomBar(): void {
57
59
  if (!agent) return;
58
60
  let current: string[] = [];
59
61
 
60
- const apply = () => {
62
+ // On a navigation, the bar is brought back to full only when its starters
63
+ // changed, so the new ones are seen. Same starters: nothing is sent, and a
64
+ // bar that has folded to compact stays compact.
65
+ const apply = (reopen: boolean) => {
61
66
  const next = startersForPath(window.location.pathname);
62
67
  if (next === current) return;
63
68
  current = next;
@@ -65,18 +70,15 @@ export function installBottomBar(): void {
65
70
  { KNOWLEDGE: next.map((title) => ({ title })), ACTION: [] },
66
71
  "KNOWLEDGE",
67
72
  );
73
+ if (reopen) agent.openBottomBar?.({ mode: "FULL" });
68
74
  };
69
75
 
70
76
  // One whole settings group per call, as the docs do.
71
77
  agent.setConfiguration?.({ bottomBarSettings: BOTTOM_BAR_SETTINGS });
72
- apply();
78
+ apply(false);
73
79
 
74
- // Single-page apps change the URL without a load: re-apply on navigation
75
- // and bring the bar back so the new starters are seen.
76
- const onRouteChange = () => {
77
- apply();
78
- agent.openBottomBar?.({ mode: "FULL" });
79
- };
80
+ // Single-page apps change the URL without a load: re-apply on navigation.
81
+ const onRouteChange = () => apply(true);
80
82
  window.addEventListener("popstate", onRouteChange);
81
83
  const history = window.history as any;
82
84
  for (const method of ["pushState", "replaceState"] as const) {