@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
@@ -5,21 +5,22 @@ list, picks one, and the choice goes back to the agent.
5
5
 
6
6
  **Proven by 1 production build**, which split this from
7
7
  [`find-by-name`](../find-by-name/) on purpose: *asking is a decision the agent
8
- makes, not a side effect of looking something up.* A lookup that draws a widget
8
+ makes, not a side effect of looking something up.* A lookup that draws a card
9
9
  puts a selection in front of someone who only asked a question.
10
10
 
11
11
  In Agent Studio: an action with key `choose_project`, optional string parameters
12
12
  `name` (narrow the list to what the user said) and `purpose` (shown above the
13
- list: "Which project for *the export*?").
13
+ list: "Which project for *the export*?"). Instructions to set: "No emoji. Quote
14
+ totals and counts exactly as returned; never work them out."
14
15
 
15
16
  ## What it does
16
17
 
17
18
  | Situation | What happens |
18
19
  |---|---|
19
- | Projects to choose from | the card; the agent **waits** (`awaitUserInput: true`) |
20
+ | Projects to choose from | an input card: `render` uses `callback`, so the agent **waits** until the user picks one, chooses "None of these" or cancels - each calls `callback` once |
20
21
  | The name matched nothing | falls back to the whole list — an empty picker cannot be answered |
21
- | The account has no projects | **no card**; the result goes straight back |
22
- | The list could not be loaded | **no card**, `success: false` — never "no projects" for someone who has hundreds |
22
+ | The account has no projects | a one-line card that says so; the result goes straight back |
23
+ | The list could not be loaded | the failure, drawn with `renderFailure`, goes straight back with `success: false` - never "no projects" for someone who has hundreds |
23
24
  | The user picks one | `chosenByUser: true` and the project |
24
25
  | "None of these" | what was offered, so the agent can ask what they meant |
25
26
  | Cancel | `cancelled: true` — and the card says so, so it is not left looking live |
@@ -30,7 +31,7 @@ list: "Which project for *the export*?").
30
31
  |---|---|
31
32
  | `agent/api/projects.ts`, `agent/projects.ts` | Same as `find-by-name` — keep the two recipes on one copy so their shortlists agree |
32
33
  | `agent/actions/choose_project.ts` | The entity and the wording |
33
- | `agent/views/picker.ts` | What a row shows. Re-sample `brand.ts` from the host page |
34
+ | `agent/views/picker.ts` | What a row shows. `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 |
34
35
 
35
36
  ## What that build learned the hard way
36
37
 
@@ -1,11 +1,13 @@
1
- // Asks the user which project to work with, and waits for the answer.
1
+ // An input card: asks the user which project to work with, and the agent
2
+ // waits for the answer.
2
3
  //
3
4
  // This exists so that asking is a decision the agent makes, not a side effect of
4
5
  // looking something up. find_project_id answers questions and must never put a
5
- // widget in front of someone who only asked one.
6
+ // card in front of someone who only asked one.
6
7
 
7
8
  import { listProjects } from "../api/projects";
8
9
  import { toSummary, type ProjectSummary } from "../projects";
10
+ import { renderEmpty, renderFailure, type FailureReason } from "../utils";
9
11
  import { mountProjectPicker } from "../views/picker";
10
12
 
11
13
  /** One screen of projects. */
@@ -25,7 +27,7 @@ type ChooseProjectResult = {
25
27
  success: boolean;
26
28
  message?: string;
27
29
  error?: string;
28
- reason?: string;
30
+ reason?: FailureReason;
29
31
  data?: ChooseProjectData;
30
32
  };
31
33
 
@@ -67,7 +69,8 @@ export const choose_project = {
67
69
  if (projects.length === 0) {
68
70
  return {
69
71
  success: false,
70
- error: "The project list could not be loaded just now.",
72
+ error: "The project list could not be loaded.",
73
+ reason: "server",
71
74
  data: { askable: false, total },
72
75
  };
73
76
  }
@@ -89,9 +92,10 @@ export const choose_project = {
89
92
  };
90
93
  },
91
94
 
92
- awaitUserInput: true,
93
-
94
- // `result` is what execute RETURNED, not the action's params.
95
+ // `result` is what execute RETURNED, not the action's params. An input
96
+ // card: render uses callback, so the agent waits for it. Every path calls
97
+ // it once - a failure or an empty account at once, the list when the user
98
+ // picks, chooses "None of these" or cancels.
95
99
  render: (
96
100
  result: ChooseProjectResult | undefined,
97
101
  host: HTMLElement,
@@ -101,9 +105,20 @@ export const choose_project = {
101
105
  const data = result?.data;
102
106
  const projects = data?.projects ?? [];
103
107
 
104
- // Nothing to ask about — hand the result back rather than showing an empty
105
- // card the user cannot answer.
106
- if (!result || result.success === false || data?.askable === false || projects.length === 0) {
108
+ // Nothing to ask about: draw one line that says why, never an empty picker
109
+ // the user cannot answer, and hand the result straight back.
110
+ if (!result || result.success === false) {
111
+ renderFailure(host, {
112
+ ok: false,
113
+ status: 0,
114
+ error: result?.error ?? "The project list could not be loaded.",
115
+ reason: result?.reason ?? "server",
116
+ });
117
+ callback(result, true);
118
+ return;
119
+ }
120
+ if (data?.askable === false || projects.length === 0) {
121
+ renderEmpty(host, "This account has no projects yet.");
107
122
  callback(result, true);
108
123
  return;
109
124
  }
@@ -113,7 +128,7 @@ export const choose_project = {
113
128
  // Say what is actually on screen.
114
129
  const subtitle =
115
130
  data?.narrowed && data.query
116
- ? `${showing} project${showing === 1 ? "" : "s"} match "${data.query}".`
131
+ ? `Showing ${showing} project${showing === 1 ? "" : "s"} matching "${data.query}".`
117
132
  : showing < total
118
133
  ? `Showing ${showing} of ${total}.`
119
134
  : `${total} project${total === 1 ? "" : "s"} to choose from.`;
@@ -150,7 +165,7 @@ export const choose_project = {
150
165
  callback(
151
166
  {
152
167
  success: true,
153
- message: "The user rejected every project on the list.",
168
+ message: "The user chose none of the projects on the list.",
154
169
  data: { chosenByUser: false, offered: offered.map((p) => p.name) },
155
170
  },
156
171
  true,
@@ -1,14 +1,35 @@
1
- // Neutral placeholders. SAMPLE the real values from computed styles on the host
2
- // page and record where each came from in docs/app-profile.md. Never edit by eye.
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.
3
4
 
4
5
  export const brand = {
5
- text: "rgb(17, 24, 39)",
6
- muted: "rgb(107, 114, 128)",
7
- primary: "rgb(37, 99, 235)",
8
- border: "rgb(229, 231, 235)",
9
- tint: "rgb(243, 244, 246)",
10
- surface: "rgb(255, 255, 255)",
6
+ surface: "#FFFFFF",
7
+ surface2: "#F5F6FA",
8
+ text: "#111827",
9
+ muted: "#6B7280",
10
+ border: "#E5E7EB",
11
+ accent: "#2563EB",
12
+ accentText: "#FFFFFF",
13
+ font: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
11
14
  radius: "8px",
12
15
  size: { base: "14px", small: "12px" },
13
- font: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
16
+ textStrong: "#111827",
17
+ faint: "#9CA3AF",
18
+ row: "#F9FAFB",
19
+ tableHead: "#F3F4F6",
20
+ accentDeep: "#2538D6",
21
+ accentSoft: "#EEF0FE",
22
+ headerStart: "#F0F1FE",
23
+ good: "#047857",
24
+ warn: "#B45309",
25
+ warnInk: "#FEF3C7",
26
+ bad: "#B91C1C",
27
+ primary: "#2563EB",
28
+ tint: "#F3F4F6",
29
+ disabled: "#9CA3AF",
30
+ money: "#047857",
31
+ ok: "#047857",
32
+ deals: "#2563EB",
33
+ contacts: "#2538D6",
34
+ cases: "#B45309",
14
35
  } as const;
@@ -81,7 +81,7 @@ export function mountProjectPicker(host: HTMLElement, header: HTMLElement, optio
81
81
  }
82
82
 
83
83
  const none = button("fs-pick-row", "None of these");
84
- none.append(el("span", "fs-pick-meta", "Tell the assistant what you meant"));
84
+ none.append(el("span", "fs-pick-meta", "Say in the chat what you meant"));
85
85
  none.addEventListener("click", () => select(none, { kind: "other" }));
86
86
  rows.push(none);
87
87
  card.append(none);
@@ -96,8 +96,8 @@ export function mountProjectPicker(host: HTMLElement, header: HTMLElement, optio
96
96
  title.textContent = "Project selected";
97
97
  subtitle.textContent = done.project.name;
98
98
  } else if (done.kind === "other") {
99
- title.textContent = "Sent to the assistant";
100
- subtitle.textContent = "None of these projects.";
99
+ title.textContent = "None of these";
100
+ subtitle.textContent = "No project chosen from this list.";
101
101
  } else {
102
102
  title.textContent = "Cancelled";
103
103
  subtitle.textContent = "No project chosen.";
@@ -4,13 +4,13 @@
4
4
 
5
5
  **Proven by 1 production build.**
6
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.
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 instructions live on the task agent. `TASK_KEY` (`__observe_me_summary`) is a placeholder for the key of a published task agent - `runTask` resolves no other. Check `list_task_agents` first. One fits if it summarises a meeting from the facts in `data` and answers in text, or in JSON with a `summary` string - `readSummary` takes the text or that field, and a JSON answer without it gives `summary: null`. If one fits, put its key in `TASK_KEY`, and publish it with `publish_task_agent` if it is still a draft. If none fits, create one with `create_task_agent`: `response_type` `TEXT`, and instructions that write a meeting summary from the facts in `data`. It starts as a draft: publish it with `publish_task_agent`, then put its key (`api_name`) in `TASK_KEY`. Creating and publishing each change the customer's Foldspace account: ask the human before each.
8
8
 
9
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
10
 
11
- A meeting with no account id and no account name is `signed_out`, not a pipeline of zero.
11
+ A meeting whose account lacks an id or a name is `signed_out`, not a pipeline of zero.
12
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`.
13
+ In Agent Studio: an action with key `prepare_for_meeting` and one string parameter `meetingId`, described as "The meeting's id. Never taken from the user's words: get it from `<meetings action key>`." Replace `<meetings action key>` with the key of this agent's action that returns meeting ids; if the agent has none, build that action first - `find-by-name` has the shape. Instructions to set: "No emoji. Quote totals and counts exactly as returned; never work them out." The task agent is the one in `TASK_KEY` (above).
14
14
 
15
15
  ## Adapt it
16
16
 
@@ -18,7 +18,7 @@ In Agent Studio: an action with key `prepare_for_meeting` and one string paramet
18
18
  |---|---|
19
19
  | `agent/api/meetings.ts` | Mock path and envelope. Find a real API that returns a similar shape |
20
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 |
21
+ | `agent/actions/prepare_for_meeting.ts` | `TASK_KEY`: the published task agent's key |
22
22
 
23
23
  ## What that build learned the hard way
24
24
 
@@ -1,12 +1,12 @@
1
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.
2
+ // only. The instructions live on the task agent whose key is below.
3
3
  // If the task agent does not answer, the facts still come back and summary is null.
4
4
 
5
5
  import { getMeetingPrep } from "../api/meetings";
6
6
  import { belongsToSignedInUser, toPrep, type MeetingPrep } from "../meetings";
7
7
  import { getAgent } from "../utils";
8
8
 
9
- /** Replace with the task-agent key created in Agent Studio. */
9
+ /** Replace with the published task agent's key (its `api_name`) - see the README. */
10
10
  const TASK_KEY = "__observe_me_summary";
11
11
 
12
12
  type PrepResult = {
@@ -35,7 +35,7 @@ export const prepare_for_meeting = {
35
35
  execute: async (params: { meetingId?: string }): Promise<PrepResult> => {
36
36
  const meetingId = typeof params?.meetingId === "string" ? params.meetingId.trim() : "";
37
37
  if (!meetingId) {
38
- return { success: false, error: "meetingId was not provided.", reason: "config" };
38
+ return { success: false, error: "No meeting was given.", reason: "config" };
39
39
  }
40
40
 
41
41
  const res = await getMeetingPrep(meetingId);
@@ -4,11 +4,11 @@
4
4
 
5
5
  **Proven by 1 production build**, where the first version was a five-column table: at chat width (~400px) the columns collapsed and it looked wrong. Rows fixed it. This is the base layout for any list of records — contacts, projects, tickets — not just accounts.
6
6
 
7
- 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 "No accounts yet." is on the card, not in a message. The count and the total are computed in code from the rows.
7
+ 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 count, the total and the rows (no rows when the card says "No accounts yet."), or the failure (`success: false`, `error` and `reason`). So `execute` returns the list the card draws, with each row's id, and nothing else. The count and the total are computed in code from the rows.
8
8
 
9
- The palette matches the agent's panel (white, with the agent's primary colour for the marks), not the page: a card sampled from a dark app sits badly in a white panel. Put the agent's own `primaryColor` in `brand.ts`.
9
+ `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.
10
10
 
11
- In Agent Studio: an action with key `list_accounts` and no parameters.
11
+ In Agent Studio: an action with key `list_accounts` and no parameters. Its `instructions`: "Answer follow-up questions about these accounts 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,12 +16,12 @@ In Agent Studio: an action with key `list_accounts` and no parameters.
16
16
  |---|---|
17
17
  | `agent/api/accounts.ts` | Mock path and envelope. Find the call the app's own list screen makes — a bare array or a wrapped one both work |
18
18
  | `agent/accounts.ts` | The two facts and the number a row carries |
19
- | `agent/views/list.ts` | The card. `brand.ts`: the agent's primary colour in `accent` |
19
+ | `agent/views/list.ts` | The card |
20
20
 
21
21
  ## What that build learned the hard way
22
22
 
23
23
  - **Rows, not a table.** Five columns in a 400px panel is a table nobody can read. Name, two facts, one number.
24
24
  - **A list has nothing to prove whose it is.** An account has an id and a name; a list does not. The build answered signed-out calls with a 401 (which `apiFetch` maps to `signed_out`); what the code still checks is that a 200 is the list at all. ⚠️ If the app answers a signed-out call with a 200 and an empty array, find something in the response only a signed-in user gets.
25
25
  - **Empty is an answer.** Zero rows draws a card that says so — never a failure.
26
- - **Match the panel.** The first card copied the app's dark theme (sampled with `observe styles` while the app was in dark mode) and landed in a white panel.
27
- - **Rows are not links.** Open a record with a navigation route.
26
+ - **Match the panel.** The first card copied the app's dark theme and landed in the agent's white panel. `observe styles --write` keeps the panel's white surface and, on a dark app, neutral text and borders - so keep the file it wrote.
27
+ - **Rows are not links.** Open a record with a navigation route, with the id the agent got for that row.
@@ -1,5 +1,7 @@
1
1
  // Row shapes, and the total the header shows - computed here from the rows,
2
- // never by the model.
2
+ // never by the model. A row carries what the card draws, plus the record's id
3
+ // so the agent can open it with a navigation route; the agent gets all of it
4
+ // once the card is drawn. The card shows no ids.
3
5
 
4
6
  import type { AccountsPayload } from "./api/accounts";
5
7
 
@@ -1,7 +1,9 @@
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 "no
3
- // accounts yet". A failed or signed-out call is drawn with renderFailure,
4
- // never as an empty list.
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 list the card
4
+ // draws ("no accounts yet" included), with each row's id, and nothing else,
5
+ // or the failure the card draws instead. A failed or signed-out call is drawn with renderFailure, never
6
+ // as an empty list.
5
7
 
6
8
  import { getAccounts } from "../api/accounts";
7
9
  import { rowsOf, toList, type AccountsList } from "../accounts";
@@ -10,7 +12,6 @@ import { mountAccountsList } from "../views/list";
10
12
 
11
13
  type ListResult = {
12
14
  success: boolean;
13
- message?: string;
14
15
  error?: string;
15
16
  reason?: FailureReason;
16
17
  data?: AccountsList;
@@ -29,8 +30,7 @@ export const list_accounts = {
29
30
  if (rowsOf(res.data) === null) {
30
31
  return { success: false, error: "Not signed in.", reason: "signed_out" };
31
32
  }
32
- // `message` never reaches the model for a view-only action; it is for the console.
33
- return { success: true, message: "Displayed.", data: toList(res.data) };
33
+ return { success: true, data: toList(res.data) };
34
34
  },
35
35
 
36
36
  render: (result: ListResult | undefined, host: HTMLElement, header: HTMLElement) => {
@@ -1,23 +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",
18
- money: "#047857",
19
- row: "#F9FAFB",
20
- rowHover: "#F3F4F6",
12
+ accentText: "#FFFFFF",
21
13
  font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
22
14
  radius: "8px",
15
+ size: { base: "14px", small: "12px" },
16
+ textStrong: "#111827",
17
+ faint: "#9CA3AF",
18
+ row: "#F9FAFB",
19
+ tableHead: "#F3F4F6",
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",
30
+ money: "#047857",
31
+ ok: "#047857",
32
+ deals: "#3248F2",
33
+ contacts: "#2538D6",
34
+ cases: "#B45309",
23
35
  } as const;
@@ -0,0 +1,35 @@
1
+ # Server-rendered pages and forms - L4
2
+
3
+ **The product renders its pages on the server.** There is no JSON API behind the screen: the page is the data, and a record is created by posting the same form a person fills in. Many admin tools and multi-page apps work this way.
4
+
5
+ **Proven by 1 production build**: three kinds of record created and messages sent through server-rendered forms, each verified by a real chat turn and the result appearing in the app.
6
+
7
+ ## What this recipe gives
8
+
9
+ | Piece | What it does |
10
+ |---|---|
11
+ | `agent/api/pages.ts` | `readPage(path)` and `submitForm(action, body)` - each returns the page the app answered with and its address, read from inside the app on its own host, or a failure with its `reason`. A refused form (400 or 422) is an answer, not a failure |
12
+ | `agent/forms.ts` → `findForm`, `fieldByLabel`, `formBody` | Recognise the form by a field's **label**, fill values by label, and build the body the browser would send, file inputs left out: the app's own hidden fields (its token, its form id) go along unchanged; an unchecked box, a radio group nobody chose, a multiple list with nothing chosen and a field in a disabled fieldset send nothing. Field names are never guessed |
13
+ | `agent/forms.ts` → `judgeSubmit`, `formErrors` | Judge the answer page: `created` or `not_created`. A 400 or 422 answer is always `not_created`. The app's own validation messages come back as data |
14
+ | `agent/forms.ts` → `tableRows` | A list page's table as rows, for a card like `records-list` |
15
+ | `agent/actions/create_record_via_form.ts` | The write, end to end. It hands back `created` with the address of the record's page, `not_created` with the app's messages, or a failure with its `reason` |
16
+
17
+ **Always behind approval.** This is a write. Before you publish it, either run the write from the approve button of an input card (`upload-contacts` writes only on Import), or set `confirmation_required: true` on the action. Such a card calls `callback` with what the write returned, and `callback` or `cancel` on every other path - declining, closing, failing. That is what makes the agent wait for the user; a path that never calls back leaves the agent waiting until the user presses stop. Testing it writes too, in their account: run it only as **Ask only what reads** (**Rules**, 4) allows.
18
+
19
+ In Agent Studio: an action with key `create_record_via_form`, a required string parameter `title` and an optional string parameter `description`.
20
+
21
+ ## Adapt it
22
+
23
+ | File | Change |
24
+ |---|---|
25
+ | `agent/actions/create_record_via_form.ts` | `FORM_PATH` (the form's page), `KNOWN_BY` (the label the form is recognised by) and the patterns in `LABELS` (the labels the values go to). Each key in `LABELS` is a parameter of the action: a field of your own needs a parameter in Agent Studio, a key in `LABELS` and its value in `given`. An answer with a password field reads as a sign-in page (`signed_out`): if the app's record page has one of its own (a user's page with a set-password form), match that check to the app's sign-in path instead |
26
+ | `agent/forms.ts` → `formErrors` | When you know the app's own error markup, add it; otherwise keep the defaults. Never submit the form yourself: ask the human to submit it once in the test window with a required field left empty, then run `npx foldspace observe screenshot` to see what the app says. A screenshot shows the words, not the markup, and no harness command reads a page's markup, so whether the defaults catch those words cannot be checked. If the browser itself stops the empty field (its own bubble on the field), nothing reaches the app and there are no words to see. Either way it is a gap: register it (**Rules**, 9), with the app's words when you saw them |
27
+
28
+ ## What that build learned the hard way
29
+
30
+ - **Read the form at run time.** The token changes per page. Load the form, take every hidden field from it, post it back.
31
+ - **Fill by label, not by name.** Names like `record[title]` are the app's business; the label is what a person sees, and what you record in `docs/app-profile.md`.
32
+ - **Created means the app moved on and showed no error.** `judgeSubmit` says `created` only when all four hold: the app did not refuse the form (400 or 422); the answer came from a different path than the form page; the same form (found by `KNOWN_BY`, posting where this one posted) is not on it; and `formErrors` found nothing there. The action then reads a created answer with a password field as a sign-in page (`signed_out`). Three answers still read wrong. As created: a 200 page from another path that turns the form down without showing it, in error markup the defaults miss. As not created, though the record was made: a confirmation in an `.alert` or `[role=alert]` box with no success, info or notice class and without the word "success"; and any page that carries the same form again, such as a list page with its own add form. Before publishing, fit `formErrors` to the app as **Adapt it** says. If the app answers a create with a page that carries the form again, also make `judgeSubmit` read the app's own sign of success - its success banner, or the new record's address.
33
+ - **A form built by script is a different case.** When the served HTML has only a shell and the fields appear after a script runs, `findForm` finds no form in what `readPage` returns, and the action stops with "The form was not on its page." (`server`) before posting anything. Such a form usually sends its own fetch or XHR call, and `foldspace observe` names a write it sees only by method and path (`writesSeen`), never its body, so what the form sends cannot be read. That is a gap (**Rules**, 9).
34
+ - **One host at a time.** Code on a page reads its own host only. For a product on several hosts, see `several-hosts`: an action asked from the wrong host says which page it needs.
35
+ - **A multi-page app reloads on every navigation.** The agent carries on by itself, on the same host and across hosts.
@@ -0,0 +1,62 @@
1
+ // A write on a server-rendered app: load the form a person would fill in, put
2
+ // the values in by label, post it as the browser would, and judge the answer
3
+ // with judgeSubmit. No card here, so before publishing put it behind approval:
4
+ // the approve button of a card that calls callback with the write's result,
5
+ // and callback or cancel on every other path (the agent waits until one of
6
+ // them runs), or confirmation_required: true on the action.
7
+ // The result is state: created with the address of the record's page, or not
8
+ // created, with the app's own validation messages.
9
+
10
+ import { parsePage, readPage, submitForm } from "../api/pages";
11
+ import { asMultipart, fieldByLabel, findForm, formAction, formBody, formEnctype, judgeSubmit } from "../forms";
12
+
13
+ /** ADAPT: the form page, the field the form is recognised by, and the labels the values go to - one key per parameter. */
14
+ export const FORM_PATH = "/__observe_me/records/new";
15
+ const KNOWN_BY = /^title/;
16
+ const LABELS = { title: /^title/, description: /^description/ } as const;
17
+
18
+ export const create_record_via_form = {
19
+ execute: async (params: { title?: string; description?: string }) => {
20
+ const title = typeof params?.title === "string" ? params.title.trim() : "";
21
+ const description = typeof params?.description === "string" ? params.description.trim() : "";
22
+ if (!title) return { success: false, error: "No title was given.", reason: "config" };
23
+
24
+ const page = await readPage(FORM_PATH);
25
+ if (!page.ok) return { success: false, error: page.error, reason: page.reason };
26
+ const doc = parsePage(page.data.html);
27
+ const found = findForm(doc, KNOWN_BY);
28
+ if (!found) {
29
+ // Never guess field names. Sent to another page, or shown a password field
30
+ // on this one: a sign-in page, so signed out. Otherwise the served page has
31
+ // no such form: the app changed, or it builds the form by script (README).
32
+ const movedAway = new URL(page.data.url).pathname !== new URL(FORM_PATH, page.data.url).pathname;
33
+ const signIn = movedAway || doc.querySelector("input[type=password]") !== null;
34
+ return signIn
35
+ ? { success: false, error: "The app showed another page instead of the form.", reason: "signed_out", data: { url: page.data.url } }
36
+ : { success: false, error: "The form was not on its page.", reason: "server", data: { url: page.data.url } };
37
+ }
38
+ // Each LABELS key takes the parameter of the same name; a key with no value here leaves its field as the form served it.
39
+ const given: Record<string, string> = { title, description };
40
+ const values: Record<string, string> = {};
41
+ for (const [key, pattern] of Object.entries(LABELS)) {
42
+ const field = fieldByLabel(found, pattern);
43
+ const value = given[key] ?? "";
44
+ if (field && value) values[field.name] = value;
45
+ }
46
+ if (!Object.keys(values).length) {
47
+ return { success: false, error: "The form did not offer the fields this action fills.", reason: "config" };
48
+ }
49
+ const body = formBody(found, values);
50
+ const action = formAction(found, page.data.url);
51
+ const answer = await submitForm(action, formEnctype(found) === "multipart" ? asMultipart(body) : body);
52
+ if (!answer.ok) return { success: false, error: answer.error, reason: answer.reason };
53
+ const outcome = judgeSubmit(page.data.url, answer.data, KNOWN_BY, action);
54
+ // A password field on an answer that reads as created is a sign-in page, not
55
+ // the record. ADAPT when the app's record page has one of its own (README).
56
+ if (outcome.status === "created" && parsePage(answer.data.html).querySelector("input[type=password]")) {
57
+ return { success: false, error: "The app showed a sign-in page instead of the record.", reason: "signed_out", data: { url: answer.data.url } };
58
+ }
59
+ return { success: true, message: outcome.status === "created" ? "Created." : "Not created.", data: outcome };
60
+ },
61
+ };
62
+
@@ -0,0 +1,57 @@
1
+ // A product that renders its pages on the server: there is no JSON API to call,
2
+ // the page IS the data, and a record is created by posting the same form a
3
+ // person fills in. Everything here runs inside the page with the user's own
4
+ // session - no separate browser, no copied cookies. It can only reach the
5
+ // host it runs on; for a product on several hosts see recipes/several-hosts.
6
+
7
+ import { refusedForm } from "../forms";
8
+ import { httpFailure, networkFailure, type ApiResult } from "../utils";
9
+
10
+ export type Page = { html: string; url: string; status: number };
11
+
12
+ /** GET a page as the browser would - same session, redirects followed. */
13
+ export async function readPage(path: string, timeoutMs = 20_000): Promise<ApiResult<Page>> {
14
+ return request(path, { headers: { accept: "text/html" } }, timeoutMs, "The page could not be loaded.", false);
15
+ }
16
+
17
+ /**
18
+ * POST a form as the browser would. The answer is the page that came back, and
19
+ * where it came from. A refused form (400 or 422) is an answer too: its page
20
+ * carries the app's messages, and judgeSubmit reads them.
21
+ */
22
+ export async function submitForm(action: string, body: FormData | URLSearchParams, timeoutMs = 20_000): Promise<ApiResult<Page>> {
23
+ return request(action, { method: "POST", body, headers: { accept: "text/html" } }, timeoutMs, "The app answered the form with an error.", true);
24
+ }
25
+
26
+ async function request(url: string, init: RequestInit, timeoutMs: number, failed: string, isForm: boolean): Promise<ApiResult<Page>> {
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
29
+ try {
30
+ const res = await fetch(url, { ...init, credentials: "include", redirect: "follow", signal: controller.signal });
31
+ const html = await res.text();
32
+ if (!res.ok && !(isForm && refusedForm(res.status))) {
33
+ const failure = httpFailure(res, failed);
34
+ // The agent gets what happened, never what to do.
35
+ if (failure.reason === "signed_out") failure.error = "Your session has expired.";
36
+ if (failure.reason === "rate_limited") failure.error = "Too many requests right now.";
37
+ return failure;
38
+ }
39
+ return { ok: true, status: res.status, data: { html, url: res.url, status: res.status } };
40
+ } catch (error) {
41
+ // A form post with no answer, or an answer that could not be read (a redirect
42
+ // to another host is blocked), may still have landed, so both its sentences
43
+ // say that is not known.
44
+ return networkFailure(
45
+ error,
46
+ isForm ? "The app did not answer in time, so it is not known whether the form went through." : "The page took too long.",
47
+ isForm ? "No answer from the app could be read, so it is not known whether the form went through." : "The page could not be read.",
48
+ );
49
+ } finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
53
+
54
+ /** The page as a document, parsed off-screen - nothing is added to the page the user sees. */
55
+ export function parsePage(html: string): Document {
56
+ return new DOMParser().parseFromString(html, "text/html");
57
+ }