wizards 0.2.25 → 0.2.27

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 (126) hide show
  1. package/apps/runtime/dist/agents/architect.js +5 -0
  2. package/apps/runtime/dist/agents/project-assistant.js +10 -1
  3. package/apps/runtime/dist/authoring/guide.js +61 -8
  4. package/apps/runtime/dist/connectors/builtin.js +17 -1
  5. package/apps/runtime/dist/connectors/index.js +43 -14
  6. package/apps/runtime/dist/connectors/plugin-connections.js +248 -0
  7. package/apps/runtime/dist/connectors/via-account.js +63 -0
  8. package/apps/runtime/dist/credits/estimate.js +12 -2
  9. package/apps/runtime/dist/db/migrations/tenant/0010_plugin_connection.sql +15 -0
  10. package/apps/runtime/dist/db/migrations/tenant/meta/0010_snapshot.json +2942 -0
  11. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  12. package/apps/runtime/dist/db/schema.js +21 -0
  13. package/apps/runtime/dist/engenty/connections-google/connectors/calendar.js +25 -3
  14. package/apps/runtime/dist/engenty/connections-microsoft/outlook.js +29 -1
  15. package/apps/runtime/dist/engine/decide.js +82 -0
  16. package/apps/runtime/dist/engine/input.js +21 -5
  17. package/apps/runtime/dist/engine/requirements.js +80 -15
  18. package/apps/runtime/dist/engine/runner.js +252 -6
  19. package/apps/runtime/dist/engine/steps.js +206 -18
  20. package/apps/runtime/dist/engine/surface.js +90 -0
  21. package/apps/runtime/dist/mcp/tools.js +3 -2
  22. package/apps/runtime/dist/plugins/loader.js +22 -1
  23. package/apps/runtime/dist/plugins/registry.js +10 -0
  24. package/apps/runtime/dist/render/downloads.js +7 -0
  25. package/apps/runtime/dist/routes/runs.js +28 -7
  26. package/apps/runtime/dist/routes/studio.js +3 -1
  27. package/apps/runtime/dist/services/marketplace.js +134 -3
  28. package/apps/runtime/dist/services/relevance.js +17 -49
  29. package/apps/runtime/dist/services/runs.js +43 -15
  30. package/apps/runtime/dist/services/wizards.js +4 -2
  31. package/apps/runtime/dist/tools/pages.js +31 -6
  32. package/apps/runtime/dist/tools/plugin.js +29 -1
  33. package/apps/web/dist/assets/Account-DW396ig5.js +1 -0
  34. package/apps/web/dist/assets/{ChangelogDialog-B7C0r9dF.js → ChangelogDialog-BdZ1SmEd.js} +1 -1
  35. package/apps/web/dist/assets/CreditsDialog-fOYdoev3.js +1 -0
  36. package/apps/web/dist/assets/EditorPage-CAARFsaI.js +3 -0
  37. package/apps/web/dist/assets/{FlowPreview-D0m5Qk6S.js → FlowPreview-U2AEnKlX.js} +1 -1
  38. package/apps/web/dist/assets/{Harness-bIL3pHB_.js → Harness-CtDlm_2b.js} +2 -2
  39. package/apps/web/dist/assets/HomePage-Bd-mkLxW.js +1 -0
  40. package/apps/web/dist/assets/ImportWizard-BMPGjd_t.js +1 -0
  41. package/apps/web/dist/assets/LocalRuntime-DjSNCRnV.js +1 -0
  42. package/apps/web/dist/assets/NewWizardPage-DZPcDR9i.js +2 -0
  43. package/apps/web/dist/assets/ProjectPage-_LAtG3tn.js +2 -0
  44. package/apps/web/dist/assets/PublicRunner-6Iqp0DmU.js +1 -0
  45. package/apps/web/dist/assets/RunDrawer-CtQoY3Ha.js +4 -0
  46. package/apps/web/dist/assets/RunnerView-BLOCNZaZ.js +2 -0
  47. package/apps/web/dist/assets/SettingsPage-Z6skBf6I.js +1 -0
  48. package/apps/web/dist/assets/SetupPage-4-Lic4pT.js +1 -0
  49. package/apps/web/dist/assets/SharePage-DfdhXxZm.js +1 -0
  50. package/apps/web/dist/assets/arrow-right-BOtqnsA1.js +1 -0
  51. package/apps/web/dist/assets/brand-CCAI9p0f.js +2 -0
  52. package/apps/web/dist/assets/changelog-DQM0oz9S.js +1 -0
  53. package/apps/web/dist/assets/circle-alert-BC6xmJ9r.js +1 -0
  54. package/apps/web/dist/assets/definition-D5V79jHA.js +1 -0
  55. package/apps/web/dist/assets/esm-FEWi61F_.js +7 -0
  56. package/apps/web/dist/assets/external-link-wKL1HE6G.js +1 -0
  57. package/apps/web/dist/assets/image-BMdSChOR.js +1 -0
  58. package/apps/web/dist/assets/index-BOp2fz-2.css +1 -0
  59. package/apps/web/dist/assets/index-YJMooz0J.js +132 -0
  60. package/apps/web/dist/assets/lock-BpqzLMuv.js +1 -0
  61. package/apps/web/dist/assets/meta-BqZ7SlZz.js +1 -0
  62. package/apps/web/dist/assets/pen-line-DM-u0dES.js +1 -0
  63. package/apps/web/dist/assets/refresh-cw-Bk7HkOIM.js +1 -0
  64. package/apps/web/dist/assets/settings-menu-BM_NfZfn.js +1 -0
  65. package/apps/web/dist/assets/star-JHzYYl5R.js +1 -0
  66. package/apps/web/dist/assets/trash-CdLg-Ela.js +1 -0
  67. package/apps/web/dist/assets/ui-8QmvuC9u.js +9 -0
  68. package/apps/web/dist/assets/upload-CL1R6shM.js +1 -0
  69. package/apps/web/dist/assets/video-BioLmXNO.js +1 -0
  70. package/apps/web/dist/assets/volume-2-_IOfPNCv.js +1 -0
  71. package/apps/web/dist/assets/wand-sparkles-DYtDQSJZ.js +1 -0
  72. package/apps/web/dist/index.html +4 -5
  73. package/apps/web/dist/mcp/flow.html +61 -61
  74. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +85 -0
  75. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +23 -0
  76. package/node_modules/@engenty-wizards/shared/dist/decision.d.ts +76 -0
  77. package/node_modules/@engenty-wizards/shared/dist/decision.js +147 -0
  78. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +1076 -51
  79. package/node_modules/@engenty-wizards/shared/dist/definition.js +597 -44
  80. package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +19 -2
  81. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +6 -1
  82. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +55 -0
  83. package/node_modules/@engenty-wizards/shared/dist/run.js +3 -0
  84. package/node_modules/@engenty-wizards/shared/dist/surface.d.ts +89 -0
  85. package/node_modules/@engenty-wizards/shared/dist/surface.js +198 -0
  86. package/package.json +1 -1
  87. package/apps/web/dist/assets/Account-CpxsROpe.js +0 -1
  88. package/apps/web/dist/assets/CreditsDialog-Com2Y3g8.js +0 -1
  89. package/apps/web/dist/assets/EditorPage-BCagWjrz.js +0 -2
  90. package/apps/web/dist/assets/HomePage-DPE_Z9HB.js +0 -1
  91. package/apps/web/dist/assets/ImportWizard-Dz2uKznp.js +0 -1
  92. package/apps/web/dist/assets/LocalRuntime-wMKUVZOu.js +0 -1
  93. package/apps/web/dist/assets/NewWizardPage-BOxU2jWq.js +0 -2
  94. package/apps/web/dist/assets/ProjectPage-BJp2wqIk.js +0 -5
  95. package/apps/web/dist/assets/PublicRunner-BsrqVK0J.js +0 -1
  96. package/apps/web/dist/assets/RunDrawer-CLfJ0W_V.js +0 -1
  97. package/apps/web/dist/assets/RunnerView-BXMELFC4.js +0 -70
  98. package/apps/web/dist/assets/SettingsPage-BkuUm-bf.js +0 -1
  99. package/apps/web/dist/assets/SetupPage-CoyXpcvS.js +0 -1
  100. package/apps/web/dist/assets/SharePage-BuR-he1L.js +0 -1
  101. package/apps/web/dist/assets/api-rJBQEu1G.js +0 -10
  102. package/apps/web/dist/assets/arrow-right-6FyN3e7D.js +0 -1
  103. package/apps/web/dist/assets/changelog-Bmw6dg-T.js +0 -1
  104. package/apps/web/dist/assets/check-CUpby_J9.js +0 -1
  105. package/apps/web/dist/assets/chevron-down-DymW-2BP.js +0 -1
  106. package/apps/web/dist/assets/circle-alert-D2SCwFT-.js +0 -1
  107. package/apps/web/dist/assets/createLucideIcon-q3MZMmnf.js +0 -1
  108. package/apps/web/dist/assets/definition-BznNdlhf.js +0 -55
  109. package/apps/web/dist/assets/esm-BrGYYg2h.js +0 -7
  110. package/apps/web/dist/assets/external-link-MAL7vJYj.js +0 -1
  111. package/apps/web/dist/assets/image-CC_2mJd6.js +0 -1
  112. package/apps/web/dist/assets/index-BZs1IxAk.css +0 -1
  113. package/apps/web/dist/assets/index-Co5QOA9_.js +0 -10
  114. package/apps/web/dist/assets/lock-DZL2N67v.js +0 -1
  115. package/apps/web/dist/assets/meta-B3jzXP27.js +0 -1
  116. package/apps/web/dist/assets/pen-line-NK46C_nG.js +0 -1
  117. package/apps/web/dist/assets/plus-Du-Jx8aI.js +0 -1
  118. package/apps/web/dist/assets/refresh-cw-BNtHis1x.js +0 -1
  119. package/apps/web/dist/assets/settings-menu-CCNUb0Fd.js +0 -1
  120. package/apps/web/dist/assets/table-2-BdDuBcF2.js +0 -1
  121. package/apps/web/dist/assets/trash-0IafyhZB.js +0 -1
  122. package/apps/web/dist/assets/ui-DJOZSSKo.js +0 -1
  123. package/apps/web/dist/assets/upload-DNRENWzQ.js +0 -1
  124. package/apps/web/dist/assets/video-9fDPRPCv.js +0 -1
  125. package/apps/web/dist/assets/volume-2-DE67_rue.js +0 -1
  126. package/apps/web/dist/assets/wand-sparkles-Clj3VRB5.js +0 -1
@@ -1,4 +1,5 @@
1
1
  import { anthropic } from "@ai-sdk/anthropic";
2
+ import { SURFACE_GUIDE } from "@engenty-wizards/shared/surface";
2
3
  import { isTextMime } from "@engenty-wizards/shared/workspace";
3
4
  import { Agent } from "@mastra/core/agent";
4
5
  import { createTool } from "@mastra/core/tools";
@@ -27,6 +28,9 @@ ${SCHEMA_DOC}
27
28
  ${PRINCIPLES}
28
29
  ${WIDGET_GUIDE}
29
30
 
31
+ Surface catalog (for surface steps and output.surface):
32
+ ${SURFACE_GUIDE}
33
+
30
34
  ${mcpServersLine(mcp)}
31
35
 
32
36
  ${connectorsLine(connectors)}${pluginToolsLine(pluginTools)}
@@ -40,6 +44,7 @@ HOW YOU WORK
40
44
  - You change the wizard ONLY through tools: edit_wizard (small ops) or replace_wizard (new or mostly rewritten). Every write returns the validator's issues — fix them before you finish.
41
45
  - Widgets and their data live in the workspace: list_files, read_file, write_file, delete_file, download_files. Then check_widget, look at the screenshot, fix, check again.
42
46
  - Order: 1. write the wizard's steps, 2. fetch only the reference data the widget truly needs (one download_files call for all of it), 3. write the widget and its sample.json, 4. check_widget and fix, 5. answer. You have about 30 tool calls — never spend them on exploring.
47
+ - A message that starts "New step" or "Neuer Schritt" comes from the studio's step panel or diagram: add exactly that one step in the place it names – of the kind in brackets, or the kind that fits best when none is named – and build it completely: a page's fields, an AI step's instructions and output, a view's components and data, a widget with its HTML and sample.json. Read what it needs from the steps before it, and leave the other steps as they are unless the new step needs a change there. With "Only if:" / "Nur wenn:" or "AI decides:" / "KI entscheidet:" the new step is a branch that only some runs take: put it right after the step before it, and give that step a rule to the new step (a "when" condition written from the admin's words against the data known there, or an "ask" statement for AI decides) plus an "otherwise" rule (only "goto") to the step every other run went to before; the new step then goes on to that same step as the next in the list. Keep that step's other rules.
43
48
  - Call independent tools in the same step (they run together).
44
49
  - Never retype data you downloaded. Reshape it with run_script (output to a file), or let the widget read the raw files with wizard.json() and transform them when it starts.
45
50
  - Work in few, purposeful steps: every tool call costs the admin time and credits.
@@ -12,7 +12,7 @@ import { brandColorSchema, ownedProject, updateProject } from "../services/proje
12
12
  import { categoryRows } from "../services/space-categories.js";
13
13
  import { currentTenant } from "../tenants/tenant.js";
14
14
  import { safeFetch } from "../tools/net-guard.js";
15
- import { assistantToolsOf } from "../tools/plugin.js";
15
+ import { assistantPageOf, assistantToolsOf } from "../tools/plugin.js";
16
16
  import { readWebsite } from "./website.js";
17
17
  const MAX_STEPS = 24;
18
18
  const SYSTEM = `You help the admin of "engenty wizards" set up a SPACE: what every wizard of the space draws on — who they are (title, about), the brand (logos, colours), assets (images, graphics, videos), documents, and facts (label → value: address, VAT id, phone, opening hours — anything).
@@ -337,7 +337,16 @@ export async function runProjectAssistant(input) {
337
337
  model: resolved.model,
338
338
  tools,
339
339
  });
340
+ const page = input.plugin
341
+ ? await assistantPageOf(currentTenant(), input.plugin, { id: project.id, name: project.name })
342
+ : null;
340
343
  const context = [
344
+ page
345
+ ? [
346
+ `The admin talks to you from the page of "${page.name}" in the studio${page.description ? `: ${page.description}` : "."} They most likely want to change what that page holds: use its tools (their names begin with "${input.plugin?.replace(/-/g, "_")}_"). Space settings stay possible when they ask for them.`,
347
+ ...page.blocks,
348
+ ].join("\n\n")
349
+ : "",
341
350
  `The project now:\n\`\`\`json\n${JSON.stringify(await projectState(input.userId, input.projectId))}\n\`\`\``,
342
351
  input.part === "knowledge"
343
352
  ? "The admin talks to you from Wissen: they want the wizards to know something."
@@ -1,3 +1,4 @@
1
+ import { SURFACE_GUIDE } from "@engenty-wizards/shared/surface";
1
2
  import { sandboxGuideLine } from "../sandbox/index.js";
2
3
  import { EXAMPLE_WIZARD } from "./example.js";
3
4
  /**
@@ -50,14 +51,34 @@ type Connection =
50
51
  // "write" that in truth only looks something up (MCP servers often leave the mark out).
51
52
 
52
53
  // Every step: { id (camelCase, unique), title, description?, next?: Branch[] }
53
- // Branch = { when: { field, op: "equals"|"notEquals"|"in"|"notEmpty"|"empty", value? }, goto: stepId | "end" }
54
- // field is a page field id, or an output field of this or an earlier agent step as "steps.<stepId>.<fieldId>"
55
- // first matching branch wins, otherwise the next step in the list.
54
+ // Branch = { when: Condition | Condition[], goto: stepId | "end", max? }
55
+ // | { ask: "a statement about the run", goto: stepId | "end", max? }
56
+ // | { goto: stepId | "end" } // otherwise: where the run goes when no other branch holds (one per step)
57
+ // ask: a decision reads the person's answers and what the steps made so far and takes the branch when the
58
+ // statement is true ("The person wants a refund, not an exchange"). All ask branches of a step are decided
59
+ // together in one cheap call, after no "when" branch matched; if none clearly holds the next step follows.
60
+ // Use ask where a rule on one field cannot say it; never one decision step per branch.
61
+ // max: how often the branch may be taken in one run. A branch BACK to an earlier step is a loop ("improve until
62
+ // the check passes": a decision step branches back to the writing step) — give it max (at most 10 without);
63
+ // the step that runs again sees what it made last time.
64
+ // Condition = { field, op: "equals"|"notEquals"|"in"|"notEmpty"|"empty"|"gt"|"lt"|"contains", value? }
65
+ // field is a page field id, an output field of this or an earlier agent step as "steps.<stepId>.<fieldId>",
66
+ // or how many rows a stored list has as "lists.<listId>.count"
67
+ // gt / lt compare numbers (value is a number); contains looks into a multiselect, a list output or a text;
68
+ // in takes a list of values. A list of conditions holds when every one holds.
69
+ // first matching branch wins, otherwise the step's "otherwise" branch, else the next step in the list.
70
+ // A step only some runs take sits right after the step that branches to it; that step's otherwise branch
71
+ // leads every other run past it, and the step itself goes on as the next in the list.
56
72
 
57
- type PageStep = { type: "page", fields: Field[] (1–5 per page), cta?: string }
73
+ type PageStep = { type: "page", fields: Field[] (1–5 per page), groups?: { id, instructions, optional? }[], cta?: string }
74
+ // Decided fields: a field with ask: "statement" is shown when a decision finds the statement true of the run;
75
+ // fields with group: groupId are alternatives of which the decision shows one ("How should we reach the person?"),
76
+ // or none when the group is optional. Decided once as the run reaches the page. Such a page has up to 12 fields
77
+ // and shows at most 5. Use them where the earlier answers decide which question makes sense and no single field
78
+ // rule can say it; "when" stays the first choice.
58
79
  type Field = {
59
80
  id (camelCase, unique across the WHOLE wizard), label, kind, required?, placeholder?, help?,
60
- options?: string[] // select / multiselect
81
+ options?: string[] // select / multiselect / slot
61
82
  default?: string|number|boolean|string[]
62
83
  columns?: { id, label, kind: "text"|"number"|"money" }[] // items only; row amount = product of number/money columns
63
84
  vat?: { rate?: number, field?: fieldId } // items only; VAT % fixed or read from a field
@@ -74,9 +95,15 @@ type Field = {
74
95
  list?: listId // kind "list": the page shows that stored list for the person to check, correct and extend
75
96
  prefill?: "steps.<id>.<key>" // what the field starts with, from an earlier step. An "items" field takes a table whose
76
97
  // columns are exactly its column ids — the step proposes the positions, the person corrects them
98
+ when?: Condition | Condition[] // the field is shown only while this holds: it reads another field of the SAME page (decided as
99
+ // the person types) or anything known before the page (an earlier answer, steps.<id>.<field>).
100
+ // A hidden field is not required and not kept; its {{template}} is empty.
101
+ optionsFrom?: "steps.<id>.<key>" | "steps.<id>.<key>.<column>" | "lists.<id>.<column>"
102
+ // select / multiselect / slot: the choices come from earlier data — a "list" output, one column of a
103
+ // "table" output, or a column of a stored list. "options" stand in while that data is empty.
77
104
  }
78
105
  // kinds: text textarea number select multiselect date email url toggle color image file items connection list
79
- // location audio signature
106
+ // location audio signature slot
80
107
  // location: where the person is — the phone's position on a tap (with its accuracy), or an address they type.
81
108
  // {{field}} → "Herrengasse 16, 8010 Graz (47.07071, 15.43950, ±12 m)" · {{field.lat}} {{field.lng}}
82
109
  // {{field.accuracy}} (metres) · {{field.label}} (the address line) · {{field.map}} (a map link).
@@ -85,6 +112,10 @@ type Field = {
85
112
  // {{field}} is what the person said, as text. {{field.seconds}} is its length.
86
113
  // signature: drawn with the finger — a picture (dark ink, clear ground). A document step can place it:
87
114
  // {{field}} → asset://ID, used as <img src="asset://ID">.
115
+ // slot: the person picks one appointment time, shown by day. Options are ISO 8601 date-times with their offset
116
+ // ("2026-10-12T09:00:00+02:00"), nearly always from optionsFrom; {{field}} is the chosen one, as offered.
117
+ // Booking: agent step "free" with tools ["appointments.availability"] outputs a table "slots" with a "start" column;
118
+ // then a page { kind: "slot", optionsFrom: "steps.free.slots.start" }; then an agent step with appointments.book.
88
119
 
89
120
  type AgentStep = {
90
121
  type: "agent"
@@ -92,7 +123,12 @@ type AgentStep = {
92
123
  tools: ("web_search"|"web_fetch"|"browser"|"sandbox"|"image"|"http"|"pages")[]
93
124
  mcp?: string[] // ids of the project's MCP servers this step may use
94
125
  connections?: string[] // ids of wizard connections this step may read (mail → mail_search, mail_read, mail_save)
95
- output: { format: "text"|"markdown"|"json", fields?: { id, kind: "text"|"number"|"list"|"table"|"yesno"|"choice", description, columns?: string[], options?: string[] }[] }
126
+ output: { format: "text"|"markdown"|"json", fields?: { id, kind: "text"|"number"|"list"|"table"|"yesno"|"choice"|"score", description, columns?: string[], options?: string[] }[], surface?: boolean }
127
+ // "score" answers its description on the scale in options, lowest first ("low", "medium", "high")
128
+ each?: "steps.<id>.<key>" | "lists.<id>"
129
+ // ONE RUN PER ENTRY (at most 20): the rows of a table output, the entries of a list output, or the
130
+ // rows of a stored list; the instructions read {{item.<column>}}, {{index}}, {{count}}. The output is
131
+ // one table, steps.<id>.rows: the entry's columns plus the step's output fields, a row per entry.
96
132
  // a table with columns gives rows as objects with exactly those keys — what widgets read
97
133
  // "yesno" answers the question in its description with true/false; "choice" picks one of its options
98
134
  model?: "classifier"|"standard"|"high"|"highest" // the kind of model, see "Models"; default "high"
@@ -110,6 +146,19 @@ type AgentStep = {
110
146
  // browser_request_user (the person solves a captcha or "continue with Google" in a picture of the page),
111
147
  // browser_download (keeps a file from a signed-in site).
112
148
 
149
+ type SurfaceStep = {
150
+ type: "surface"
151
+ components: SurfaceComponent[] // a view from the surface catalog (below), written once; every run brings new data
152
+ data: { [key]: "fieldId" | "steps.<id>" | "steps.<id>.<key>" | "lists.<id>" | "brand.name" | "today" }
153
+ // what the components read by path: {"path": "/<key>/..."}
154
+ // or instead of components — pieces a decision picks per run (no chart when there is nothing to chart):
155
+ candidates?: { id, description, components: SurfaceComponent[] (the first has the piece's id), group?, required? }[]
156
+ groups?: { id, instructions, optional? }[]
157
+ }
158
+ // A SURFACE is a native view in the runner — facts, a list of results, key figures, a chart — themed, on phones, free per run.
159
+ // Prefer it to a widget for such views; a widget is for what the catalog cannot show (a map, an animation).
160
+ // An agent step with output.format "json" can also set output.surface: true: it composes such a view of its own result.
161
+ // Reviews and the result show a step's view instead of its plain table.
113
162
  type GenerateStep = {
114
163
  type: "generate"
115
164
  asset: "image"|"video"|"voice"|"document"|"dashboard"
@@ -186,6 +235,7 @@ A template may only use fields asked and steps run EARLIER.`;
186
235
  export const PRINCIPLES = `
187
236
  How good wizards look:
188
237
  - For non-technical end users. Plain words, friendly titles phrased as questions ("Wofür ist der Post?").
238
+ - A page whose fields depend on an earlier answer uses "when" on its fields, never one copy of the page per answer and a branch to each copy. Choices that depend on what an earlier step found use "optionsFrom", never one page per possible result.
189
239
  - Few pages, 1–5 fields each, one theme per page. Only ask what the result really needs; prefer selects with sensible defaults.
190
240
  - Put a review step before anything expensive (video) and before the final result, so people can correct or regenerate.
191
241
  - Money/totals: use an "items" field with vat — the runner computes totals; documents must use {{items}} verbatim. Never let a model compute totals.
@@ -277,13 +327,16 @@ ${SCHEMA_DOC}
277
327
  ${PRINCIPLES}
278
328
  ${WIDGET_GUIDE}
279
329
 
330
+ Surface catalog (for surface steps and output.surface):
331
+ ${SURFACE_GUIDE}
332
+
280
333
  ${mcpServersLine(mcp)}
281
334
 
282
335
  ${connectorsLine(connectors)}${pluginToolsLine(pluginTools)}
283
336
 
284
337
  Models: a step names the KIND of model it needs, never a model. Pick the cheapest class that does the job:
285
338
  - "classifier": routing, yes/no, picking from options, pulling a few values out of text.
286
- A DECISION is a "classifier" step with no tools whose json output has only "yesno" and "choice" fields: it is answered in one fast, very cheap call. Write the question into each field's description, give the step everything it must judge in its instructions via {{templates}}, and branch on the answer with "next" (e.g. { when: { field: "steps.check.refund", op: "equals", value: true }, goto: "refund" }).
339
+ A DECISION is a "classifier" step with no tools whose json output has only "yesno", "choice" and "score" fields: it is answered in one fast, very cheap call. A branch can also read how sure it is: "steps.<id>.<field>.p" is the probability of its answer (a number 0–1; 1 or 0 where no decision model is set up), e.g. { field: "steps.check.urgent.p", op: "gt", value: 0.8 }. Write the question into each field's description, give the step everything it must judge in its instructions via {{templates}}, and branch on the answer with "next" (e.g. { when: { field: "steps.check.refund", op: "equals", value: true }, goto: "refund" }).
287
340
  - "standard": short copy, calling an API, reformatting, simple summaries.
288
341
  - "high": research with tools, long documents, reasoning over many sources (the default).
289
342
  - "highest": hard reasoning or writing code; rare in a run, several times the cost of "high".
@@ -1,6 +1,7 @@
1
1
  import { createGuardedFetch } from "../engenty/connections-external/net/guarded-fetch.js";
2
2
  import { githubConnector } from "../engenty/connections-github/connector.js";
3
3
  import { googleConnectors } from "../engenty/connections-google/definitions.js";
4
+ import { GOOGLE_OAUTH2 } from "../engenty/connections-google/shared.js";
4
5
  import { hubspotConnector } from "../engenty/connections-hubspot/connector.js";
5
6
  import { microsoftOneDriveConnector } from "../engenty/connections-microsoft/onedrive.js";
6
7
  import { microsoftOutlookConnector } from "../engenty/connections-microsoft/outlook.js";
@@ -8,9 +9,11 @@ import { s3Connector } from "../engenty/connections-s3/s3.js";
8
9
  import { hasOAuth2ClientCredentials, } from "../engenty/connections-sdk/oauth2.js";
9
10
  import { registerConnectorDefinition } from "../engenty/connections-sdk/registry.js";
10
11
  import { slackConnector } from "../engenty/connections-slack/connector.js";
12
+ import { managed } from "../manage.js";
11
13
  import { gmailConnector as mailGmail } from "./gmail.js";
12
14
  import { imapConnector } from "./imap.js";
13
15
  import { outlookConnector as mailOutlook } from "./outlook.js";
16
+ import { googleThroughAccount } from "./via-account.js";
14
17
  /**
15
18
  * engenty's own connectors, as engenty ships them: Gmail, Drive, Calendar, Contacts, Outlook,
16
19
  * OneDrive, Slack, GitHub, HubSpot, S3. Every project has them; an OAuth one can be connected
@@ -53,7 +56,20 @@ export async function usable(connector) {
53
56
  }
54
57
  // A connector that registers its own OAuth client needs none configured.
55
58
  return (connector.auth.oauth2.dynamicClientRegistration === true ||
56
- hasOAuth2ClientCredentials(connector.auth.oauth2, resolveEnv));
59
+ (await hasOAuth2ClientCredentials(connector.auth.oauth2, resolveEnv)) ||
60
+ viaAccount(connector));
61
+ }
62
+ /**
63
+ * A Google connector on a local install without a Google client of its own: it connects through
64
+ * the Manage-App of the linked account, when that one offers it. Offered before an account is
65
+ * linked too; connecting then asks to link it first.
66
+ */
67
+ export async function viaAccount(connector) {
68
+ return (!managed &&
69
+ connector.auth.kind === "oauth2" &&
70
+ connector.auth.oauth2 === GOOGLE_OAUTH2 &&
71
+ !(await hasOAuth2ClientCredentials(connector.auth.oauth2, resolveEnv)) &&
72
+ (await googleThroughAccount()));
57
73
  }
58
74
  /** What the server's owner has to set for an OAuth connector that is not usable yet. */
59
75
  export function missingSetup(connector) {
@@ -5,8 +5,9 @@ import { scopesForGroups, } from "../engenty/connections-sdk/types.js";
5
5
  import { env } from "../env.js";
6
6
  import { seal, unseal } from "../secrets/crypto.js";
7
7
  import { deleteSecret, getSecret, putSecret } from "../store/index.js";
8
- import { connectorFetch, MAIL_CONNECTORS, resolveEnv, usable } from "./builtin.js";
8
+ import { connectorFetch, MAIL_CONNECTORS, resolveEnv, usable, viaAccount } from "./builtin.js";
9
9
  import { resolveConnector } from "./external.js";
10
+ import { AccountConnectError, redeemTicket, refreshThroughAccount, startThroughAccount, } from "./via-account.js";
10
11
  /** The connectors a person can pick for each kind of connection. */
11
12
  const CONNECTORS = { mail: MAIL_CONNECTORS };
12
13
  const HINTS = {
@@ -95,6 +96,24 @@ export async function startOAuth(scope, projectId, runId, connection, connectorI
95
96
  if (connector.auth.kind !== "oauth2") {
96
97
  throw new Error(`${connector.name} is not connected by signing in.`);
97
98
  }
99
+ if (await viaAccount(connector)) {
100
+ // The Manage-App keeps the PKCE verifier; ours stays empty.
101
+ const state = {
102
+ scope,
103
+ projectId,
104
+ runId,
105
+ connectionId: connection.id,
106
+ connectorId,
107
+ verifier: "",
108
+ exp: Date.now() + 15 * 60_000,
109
+ };
110
+ try {
111
+ return await startThroughAccount(scopesFor(connection, connector), seal(state), connectRedirectUri());
112
+ }
113
+ catch (err) {
114
+ throw err instanceof AccountConnectError ? new ConnectError(err.message) : err;
115
+ }
116
+ }
98
117
  // Servers that hand out OAuth clients on request (most MCP servers): get one on first use.
99
118
  await connector.auth.oauth2.registerClient?.();
100
119
  const pkce = createOAuth2Pkce();
@@ -120,7 +139,11 @@ export async function startOAuth(scope, projectId, runId, connection, connectorI
120
139
  export function oauthStateRun(rawState) {
121
140
  return unseal(rawState)?.runId ?? null;
122
141
  }
123
- export async function finishOAuth(code, rawState) {
142
+ /**
143
+ * Google came back: with a code, or — connected through the account's Manage-App — with a
144
+ * ticket that holds the tokens.
145
+ */
146
+ export async function finishOAuth(answer, rawState) {
124
147
  const state = unseal(rawState);
125
148
  if (!state || state.exp < Date.now()) {
126
149
  throw new Error("Die Anmeldung ist abgelaufen. Bitte noch einmal verbinden.");
@@ -134,13 +157,15 @@ export async function finishOAuth(code, rawState) {
134
157
  if (connector.auth.kind !== "oauth2") {
135
158
  throw new Error("not an oauth connector");
136
159
  }
137
- const tokens = await exchangeAuthorizationCode({
138
- code,
139
- codeVerifier: state.verifier,
140
- config: connector.auth.oauth2,
141
- redirectUri: connectRedirectUri(),
142
- resolveEnv,
143
- });
160
+ const tokens = "ticket" in answer
161
+ ? await redeemTicket(answer.ticket)
162
+ : await exchangeAuthorizationCode({
163
+ code: answer.code,
164
+ codeVerifier: state.verifier,
165
+ config: connector.auth.oauth2,
166
+ redirectUri: connectRedirectUri(),
167
+ resolveEnv,
168
+ });
144
169
  // A provider that will not name the account still connected it.
145
170
  const account = (await connector.auth.oauth2
146
171
  .resolveAccount?.(tokens.accessToken, fetch)
@@ -150,6 +175,7 @@ export async function finishOAuth(code, rawState) {
150
175
  refreshToken: tokens.refreshToken,
151
176
  expiresAt: tokens.expiresAt?.toISOString() ?? null,
152
177
  scopes: tokens.grantedScopes,
178
+ ...("ticket" in answer ? { via: "account" } : {}),
153
179
  };
154
180
  await putSecret(state.scope, slot(connection.id), connector.id, account.label, stored);
155
181
  return state;
@@ -227,12 +253,15 @@ export async function connectionContext(scope, connection, projectId) {
227
253
  data.refreshToken &&
228
254
  expires !== null &&
229
255
  expires - Date.now() < 5 * 60_000) {
230
- const tokens = await refreshAccessToken({
231
- config: connector.auth.oauth2,
232
- refreshToken: data.refreshToken,
233
- resolveEnv,
234
- });
256
+ const tokens = data.via === "account"
257
+ ? await refreshThroughAccount(data.refreshToken)
258
+ : await refreshAccessToken({
259
+ config: connector.auth.oauth2,
260
+ refreshToken: data.refreshToken,
261
+ resolveEnv,
262
+ });
235
263
  data = {
264
+ ...(data.via ? { via: data.via } : {}),
236
265
  accessToken: tokens.accessToken,
237
266
  refreshToken: tokens.refreshToken ?? data.refreshToken,
238
267
  expiresAt: tokens.expiresAt?.toISOString() ?? null,
@@ -0,0 +1,248 @@
1
+ import { and, desc, eq } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import { db, schema } from "../db/client.js";
4
+ import { buildAuthorizationUrl, createOAuth2Pkce, exchangeAuthorizationCode, refreshAccessToken, } from "../engenty/connections-sdk/oauth2.js";
5
+ import { seal, unseal } from "../secrets/crypto.js";
6
+ import { currentTenant } from "../tenants/tenant.js";
7
+ import { builtinConnector, connectorFetch, resolveEnv, usable, viaAccount } from "./builtin.js";
8
+ import { connectRedirectUri as redirectUri } from "./index.js";
9
+ import { AccountConnectError, redeemTicket, refreshThroughAccount, startThroughAccount, } from "./via-account.js";
10
+ function oauthConnector(id) {
11
+ const connector = builtinConnector(id);
12
+ if (connector?.auth.kind !== "oauth2") {
13
+ throw new Error(`"${id}" is not a built-in connector that signs in.`);
14
+ }
15
+ return connector;
16
+ }
17
+ /** The provider rights the named actions need, and the connector's own base rights. */
18
+ function scopesOf(connector, actions) {
19
+ const scopes = new Set(connector.auth.kind === "oauth2" ? connector.auth.oauth2.baseScopes : []);
20
+ for (const id of actions) {
21
+ const action = connector.actions.find((a) => a.id === id);
22
+ if (!action) {
23
+ throw new Error(`${connector.name} has no action "${id}".`);
24
+ }
25
+ for (const scope of action.providerScopes ?? []) {
26
+ scopes.add(scope);
27
+ }
28
+ }
29
+ return [...scopes];
30
+ }
31
+ const view = (row) => ({
32
+ id: row.id,
33
+ space: row.projectId,
34
+ connector: row.connector,
35
+ label: row.label,
36
+ actions: row.actions,
37
+ createdAt: row.createdAt.toISOString(),
38
+ });
39
+ /** A state the callback can tell is a plugin's: the tenant to finish it in, or null. */
40
+ export function pluginStateTenant(rawState) {
41
+ const state = unseal(rawState);
42
+ return state && typeof state.plugin === "string" ? state.tenantId : null;
43
+ }
44
+ /**
45
+ * The provider came back for a plugin's sign-in: keeps the account. A second sign-in to the
46
+ * same account for the same space replaces the first one's tokens.
47
+ */
48
+ export async function finishPluginOAuth(answer, rawState) {
49
+ const state = unseal(rawState);
50
+ if (!state?.plugin || state.exp < Date.now()) {
51
+ throw new Error("Die Anmeldung ist abgelaufen. Bitte noch einmal verbinden.");
52
+ }
53
+ const connector = oauthConnector(state.connectorId);
54
+ if (connector.auth.kind !== "oauth2") {
55
+ throw new Error("not an oauth connector");
56
+ }
57
+ const tokens = "ticket" in answer
58
+ ? await redeemTicket(answer.ticket)
59
+ : await exchangeAuthorizationCode({
60
+ code: answer.code,
61
+ codeVerifier: state.verifier,
62
+ config: connector.auth.oauth2,
63
+ redirectUri: redirectUri(),
64
+ resolveEnv,
65
+ });
66
+ const account = (await connector.auth.oauth2
67
+ .resolveAccount?.(tokens.accessToken, fetch)
68
+ .catch(() => null)) ?? { label: connector.name };
69
+ const stored = {
70
+ accessToken: tokens.accessToken,
71
+ refreshToken: tokens.refreshToken,
72
+ expiresAt: tokens.expiresAt?.toISOString() ?? null,
73
+ scopes: tokens.grantedScopes,
74
+ ...("ticket" in answer ? { via: "account" } : {}),
75
+ };
76
+ const same = await db.query.pluginConnection.findFirst({
77
+ where: and(eq(schema.pluginConnection.plugin, state.plugin), eq(schema.pluginConnection.projectId, state.projectId), eq(schema.pluginConnection.connector, connector.id), eq(schema.pluginConnection.label, account.label)),
78
+ });
79
+ if (same) {
80
+ await db
81
+ .update(schema.pluginConnection)
82
+ .set({ actions: state.actions, data: seal(stored), updatedAt: new Date() })
83
+ .where(eq(schema.pluginConnection.id, same.id));
84
+ }
85
+ else {
86
+ await db.insert(schema.pluginConnection).values({
87
+ id: nanoid(14),
88
+ plugin: state.plugin,
89
+ projectId: state.projectId,
90
+ connector: connector.id,
91
+ label: account.label,
92
+ actions: state.actions,
93
+ data: seal(stored),
94
+ });
95
+ }
96
+ return { plugin: state.plugin, projectId: state.projectId };
97
+ }
98
+ /** The tokens of a connection, refreshed when they run out within five minutes. */
99
+ async function freshTokens(row, connector) {
100
+ let data = unseal(row.data);
101
+ if (!data) {
102
+ throw new Error("The connection's keys cannot be read here. Connect the account again.");
103
+ }
104
+ const expires = data.expiresAt ? Date.parse(data.expiresAt) : null;
105
+ if (connector.auth.kind === "oauth2" &&
106
+ data.refreshToken &&
107
+ expires !== null &&
108
+ expires - Date.now() < 5 * 60_000) {
109
+ const tokens = data.via === "account"
110
+ ? await refreshThroughAccount(data.refreshToken)
111
+ : await refreshAccessToken({
112
+ config: connector.auth.oauth2,
113
+ refreshToken: data.refreshToken,
114
+ resolveEnv,
115
+ });
116
+ data = {
117
+ ...(data.via ? { via: data.via } : {}),
118
+ accessToken: tokens.accessToken,
119
+ refreshToken: tokens.refreshToken ?? data.refreshToken,
120
+ expiresAt: tokens.expiresAt?.toISOString() ?? null,
121
+ scopes: tokens.grantedScopes.length ? tokens.grantedScopes : data.scopes,
122
+ };
123
+ await db
124
+ .update(schema.pluginConnection)
125
+ .set({ data: seal(data), updatedAt: new Date() })
126
+ .where(eq(schema.pluginConnection.id, row.id));
127
+ }
128
+ return data;
129
+ }
130
+ function contextOf(row, connector, data) {
131
+ const ctx = {
132
+ accessToken: data.accessToken,
133
+ fetchImpl: connectorFetch,
134
+ log: () => undefined,
135
+ connection: {
136
+ id: row.id,
137
+ connector_id: connector.id,
138
+ auth_kind: connector.auth.kind,
139
+ autonomous_mode: "read_only",
140
+ connected_by: row.createdBy,
141
+ created_at: row.createdAt.toISOString(),
142
+ display_name: row.label,
143
+ error_message: null,
144
+ external_account: row.label,
145
+ granted_scopes: data.scopes,
146
+ owner_user_id: null,
147
+ space_id: row.projectId,
148
+ status: "active",
149
+ tenant_id: currentTenant(),
150
+ },
151
+ };
152
+ return ctx;
153
+ }
154
+ /** `server.connections` of one plugin; every call runs inside the current tenant. */
155
+ export function pluginConnections(plugin) {
156
+ const own = (id) => db.query.pluginConnection.findFirst({
157
+ where: and(eq(schema.pluginConnection.id, id), eq(schema.pluginConnection.plugin, plugin)),
158
+ });
159
+ return {
160
+ async available(ids) {
161
+ const out = [];
162
+ for (const id of ids) {
163
+ const connector = builtinConnector(id);
164
+ if (connector?.auth.kind === "oauth2" && (await usable(connector))) {
165
+ out.push({ id, name: connector.name });
166
+ }
167
+ }
168
+ return out;
169
+ },
170
+ async start({ space, connector: connectorId, actions }) {
171
+ const connector = oauthConnector(connectorId);
172
+ if (connector.auth.kind !== "oauth2") {
173
+ throw new Error("not an oauth connector");
174
+ }
175
+ const project = await db.query.project.findFirst({
176
+ where: eq(schema.project.id, space),
177
+ columns: { id: true },
178
+ });
179
+ if (!project) {
180
+ throw new Error(`There is no space "${space}".`);
181
+ }
182
+ const scopes = scopesOf(connector, actions);
183
+ const base = {
184
+ plugin,
185
+ tenantId: currentTenant(),
186
+ projectId: space,
187
+ connectorId,
188
+ actions: [...new Set(actions)],
189
+ exp: Date.now() + 15 * 60_000,
190
+ };
191
+ if (await viaAccount(connector)) {
192
+ // The Manage-App keeps the PKCE verifier; ours stays empty.
193
+ try {
194
+ const url = await startThroughAccount(scopes, seal({ ...base, verifier: "" }), redirectUri());
195
+ return { url };
196
+ }
197
+ catch (err) {
198
+ throw err instanceof AccountConnectError
199
+ ? Object.assign(new Error(err.message), { status: 409, code: "account" })
200
+ : err;
201
+ }
202
+ }
203
+ await connector.auth.oauth2.registerClient?.();
204
+ const pkce = createOAuth2Pkce();
205
+ const url = await buildAuthorizationUrl({
206
+ connector,
207
+ pkce,
208
+ redirectUri: redirectUri(),
209
+ scopes,
210
+ state: seal({ ...base, verifier: pkce.codeVerifier }),
211
+ resolveEnv,
212
+ });
213
+ return { url };
214
+ },
215
+ async list(space) {
216
+ const rows = await db.query.pluginConnection.findMany({
217
+ where: and(eq(schema.pluginConnection.plugin, plugin), eq(schema.pluginConnection.projectId, space)),
218
+ orderBy: [desc(schema.pluginConnection.createdAt)],
219
+ });
220
+ return rows.map(view);
221
+ },
222
+ async get(id) {
223
+ const row = await own(id);
224
+ return row ? view(row) : null;
225
+ },
226
+ async call(id, actionId, input) {
227
+ const row = await own(id);
228
+ if (!row) {
229
+ throw new Error(`There is no connection "${id}".`);
230
+ }
231
+ if (!row.actions.includes(actionId)) {
232
+ throw new Error(`The connection was not made for "${actionId}". Connect it again.`);
233
+ }
234
+ const connector = oauthConnector(row.connector);
235
+ const action = connector.actions.find((a) => a.id === actionId);
236
+ if (!action) {
237
+ throw new Error(`${connector.name} has no action "${actionId}".`);
238
+ }
239
+ const data = await freshTokens(row, connector);
240
+ return (await action.handler(action.inputSchema.parse(input), contextOf(row, connector, data)));
241
+ },
242
+ async remove(id) {
243
+ await db
244
+ .delete(schema.pluginConnection)
245
+ .where(and(eq(schema.pluginConnection.id, id), eq(schema.pluginConnection.plugin, plugin)));
246
+ },
247
+ };
248
+ }
@@ -0,0 +1,63 @@
1
+ import { accountToken } from "../auth/account.js";
2
+ import { env } from "../env.js";
3
+ /*
4
+ * Google accounts on a local install without an OAuth client of its own: the Manage-App of the
5
+ * linked account runs the consent at Google with its client and hands the tokens over, and
6
+ * refreshes them later (docs/manage-contract.md, "Google accounts through the Manage-App").
7
+ */
8
+ const base = () => `${env.local.accountUrl}/v1/connect`;
9
+ /** Something the person can mend or retry; the message is for them. */
10
+ export class AccountConnectError extends Error {
11
+ }
12
+ let offered = null;
13
+ /** Whether the Manage-App connects Google accounts; asked at most every ten minutes. */
14
+ export async function googleThroughAccount() {
15
+ if (offered && Date.now() - offered.at < (offered.google ? 600_000 : 60_000)) {
16
+ return offered.google;
17
+ }
18
+ const google = await fetch(base(), { signal: AbortSignal.timeout(5000) })
19
+ .then(async (res) => res.ok ? (await res.json()).google === true : false)
20
+ .catch(() => false);
21
+ offered = { at: Date.now(), google };
22
+ return google;
23
+ }
24
+ async function call(path, body) {
25
+ const token = await accountToken();
26
+ if (!token) {
27
+ throw new AccountConnectError("Verbinde zuerst dein engenty-Konto (Einstellungen → Konto), dann lässt sich Google hier verbinden.");
28
+ }
29
+ const res = await fetch(`${base()}${path}`, {
30
+ method: "POST",
31
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
32
+ body: JSON.stringify(body),
33
+ signal: AbortSignal.timeout(20_000),
34
+ }).catch(() => null);
35
+ if (!res) {
36
+ throw new AccountConnectError("Dein engenty-Konto ist gerade nicht erreichbar. Bitte später noch einmal.");
37
+ }
38
+ const data = (await res.json().catch(() => ({})));
39
+ if (!res.ok) {
40
+ throw new AccountConnectError(data.code === "invalid_grant"
41
+ ? "Google nimmt diese Verbindung nicht mehr an. Bitte das Konto neu verbinden."
42
+ : data.code === "not_configured"
43
+ ? "Google lässt sich über dein engenty-Konto gerade nicht verbinden."
44
+ : `Verbinden über dein engenty-Konto hat nicht geklappt (${data.code ?? res.status}).`);
45
+ }
46
+ return data;
47
+ }
48
+ const tokensOf = (t) => ({
49
+ accessToken: t.access_token,
50
+ refreshToken: t.refresh_token ?? null,
51
+ expiresAt: t.expires_in ? new Date(Date.now() + t.expires_in * 1000) : null,
52
+ grantedScopes: (t.scope ?? "").split(/\s+/).filter(Boolean),
53
+ });
54
+ /** Google's consent page for these scopes; Google comes back to `returnUrl` with a ticket. */
55
+ export async function startThroughAccount(scopes, state, returnUrl) {
56
+ return (await call("/google/start", { scopes, state, returnUrl })).url;
57
+ }
58
+ export async function redeemTicket(ticket) {
59
+ return tokensOf(await call("/google/redeem", { ticket }));
60
+ }
61
+ export async function refreshThroughAccount(refreshToken) {
62
+ return tokensOf(await call("/google/refresh", { refreshToken }));
63
+ }