handoff-app 2.0.0-9 → 2.0.0

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 (303) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +813 -406
  4. package/RECIPES.md +975 -0
  5. package/UPGRADE.md +57 -9
  6. package/components.json +20 -20
  7. package/config/docs/foundations/colors/guidelines.md +0 -1
  8. package/config/docs/foundations/colors.md +0 -1
  9. package/config/docs/foundations/effects/guidelines.md +0 -1
  10. package/config/docs/foundations/effects/resources.md +0 -1
  11. package/config/docs/foundations/effects.md +0 -1
  12. package/config/docs/foundations/fonts.md +0 -1
  13. package/config/docs/foundations/grid.md +0 -1
  14. package/config/docs/foundations/icon.md +0 -1
  15. package/config/docs/foundations/icons/guidelines.md +0 -1
  16. package/config/docs/foundations/icons.md +0 -1
  17. package/config/docs/foundations/images/guidelines.md +0 -1
  18. package/config/docs/foundations/images/resources.md +0 -1
  19. package/config/docs/foundations/images.md +0 -1
  20. package/config/docs/foundations/logo/resources.md +0 -1
  21. package/config/docs/foundations/logo.md +0 -1
  22. package/config/docs/foundations/typography/guidelines.md +0 -1
  23. package/config/docs/foundations/typography/resources.md +0 -1
  24. package/config/docs/foundations/typography.md +0 -1
  25. package/config/docs/foundations.md +0 -1
  26. package/config/docs/guidelines.md +0 -1
  27. package/config/docs/index.md +0 -1
  28. package/config/docs/system/tokens/foundations/colors.md +0 -4
  29. package/config/docs/system/tokens/foundations/effects.md +0 -4
  30. package/config/docs/system/tokens/foundations/typography.md +0 -2
  31. package/config/docs/system.md +0 -2
  32. package/config/templates/page.md +2 -1
  33. package/config/templates/page.mdx +2 -1
  34. package/dist/ai/connections.d.ts +66 -0
  35. package/dist/ai/connections.js +149 -0
  36. package/dist/ai/define.d.ts +10 -0
  37. package/dist/ai/define.js +13 -0
  38. package/dist/ai/types.d.ts +26 -0
  39. package/dist/ai/types.js +10 -0
  40. package/dist/app-builder/build.d.ts +14 -7
  41. package/dist/app-builder/build.js +195 -66
  42. package/dist/app-builder/client-config.js +3 -2
  43. package/dist/app-builder/paths.d.ts +0 -12
  44. package/dist/app-builder/paths.js +3 -20
  45. package/dist/app-builder/static-export.js +1 -1
  46. package/dist/app-builder/vercel-output.d.ts +1 -1
  47. package/dist/app-builder/vercel-output.js +5 -4
  48. package/dist/cache/build-cache.d.ts +27 -4
  49. package/dist/cache/build-cache.js +114 -47
  50. package/dist/cache/build-inputs.d.ts +13 -0
  51. package/dist/cache/build-inputs.js +19 -0
  52. package/dist/cache/index.d.ts +2 -1
  53. package/dist/cache/index.js +5 -1
  54. package/dist/cli/create.js +1 -2
  55. package/dist/commands/build/index.d.ts +1 -1
  56. package/dist/commands/build/index.js +1 -1
  57. package/dist/commands/utils.d.ts +3 -3
  58. package/dist/config/defaults.js +7 -4
  59. package/dist/config/from-env.d.ts +4 -0
  60. package/dist/config/from-env.js +38 -1
  61. package/dist/config/helpers.js +2 -2
  62. package/dist/config/index.d.ts +0 -1
  63. package/dist/config/index.js +1 -4
  64. package/dist/config/loader.js +26 -2
  65. package/dist/config/resolve-env.d.ts +2 -1
  66. package/dist/config/resolve-env.js +28 -12
  67. package/dist/config/runtime.js +1 -1
  68. package/dist/index.d.ts +12 -7
  69. package/dist/index.js +18 -22
  70. package/dist/nav/index.d.ts +2 -2
  71. package/dist/nav/registry.d.ts +3 -3
  72. package/dist/nav/registry.js +1 -1
  73. package/dist/nav/workspace.d.ts +3 -3
  74. package/dist/nav/workspace.js +1 -1
  75. package/dist/pipeline/figma.d.ts +13 -2
  76. package/dist/pipeline/figma.js +86 -46
  77. package/dist/pipeline/styles.js +1 -1
  78. package/dist/pipeline/validation.d.ts +1 -1
  79. package/dist/pipeline/validation.js +3 -3
  80. package/dist/providers.d.ts +10 -0
  81. package/dist/providers.js +13 -0
  82. package/dist/registry/asset-storage/define.d.ts +5 -5
  83. package/dist/registry/asset-storage/define.js +4 -4
  84. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  85. package/dist/registry/asset-storage/resolve.js +33 -21
  86. package/dist/registry/asset-storage/types.d.ts +37 -17
  87. package/dist/registry/asset-storage/types.js +5 -5
  88. package/dist/registry/auth/ai-keys.d.ts +32 -0
  89. package/dist/registry/auth/ai-keys.js +69 -0
  90. package/dist/registry/auth/crypto.d.ts +17 -0
  91. package/dist/registry/auth/crypto.js +61 -1
  92. package/dist/registry/auth/index.d.ts +2 -1
  93. package/dist/registry/auth/index.js +11 -2
  94. package/dist/registry/checkout/index.d.ts +2 -1
  95. package/dist/registry/checkout/index.js +57 -127
  96. package/dist/registry/client.js +53 -17
  97. package/dist/registry/db/driver.js +3 -3
  98. package/dist/registry/db/schema.d.ts +261 -3
  99. package/dist/registry/db/schema.js +29 -4
  100. package/dist/registry/email/define.d.ts +10 -0
  101. package/dist/registry/email/define.js +13 -0
  102. package/dist/registry/email/providers/smtp.d.ts +10 -0
  103. package/dist/registry/email/providers/smtp.js +73 -0
  104. package/dist/registry/email/resolve.d.ts +28 -0
  105. package/dist/registry/email/resolve.js +74 -0
  106. package/dist/registry/email/types.d.ts +24 -0
  107. package/dist/registry/email/types.js +6 -0
  108. package/dist/registry/path.d.ts +2 -0
  109. package/dist/registry/path.js +11 -1
  110. package/dist/registry/publish/package.js +8 -0
  111. package/dist/registry/transfer.d.ts +4 -0
  112. package/dist/store/registry.d.ts +3 -3
  113. package/dist/store/registry.js +4 -4
  114. package/dist/transformers/preview/component/builder.d.ts +1 -9
  115. package/dist/transformers/preview/component/builder.js +17 -17
  116. package/dist/transformers/preview/component/css.d.ts +2 -1
  117. package/dist/transformers/preview/component/css.js +8 -5
  118. package/dist/transformers/preview/component/html.d.ts +3 -1
  119. package/dist/transformers/preview/component/html.js +6 -2
  120. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  121. package/dist/transformers/preview/component/javascript.js +10 -4
  122. package/dist/transformers/utils/build.d.ts +5 -0
  123. package/dist/transformers/utils/build.js +21 -4
  124. package/dist/types/config.d.ts +129 -18
  125. package/dist/utils/path.d.ts +1 -5
  126. package/dist/utils/path.js +6 -23
  127. package/docs/api.md +6 -4
  128. package/docs/cli.md +275 -54
  129. package/drizzle/0007_add_ai_keys.sql +11 -0
  130. package/drizzle/0008_add_source_dir.sql +2 -0
  131. package/drizzle/meta/0007_snapshot.json +2013 -0
  132. package/drizzle/meta/0008_snapshot.json +2025 -0
  133. package/drizzle/meta/_journal.json +14 -0
  134. package/package.json +19 -8
  135. package/src/ai/README.md +42 -0
  136. package/src/ai/connections.ts +189 -0
  137. package/src/ai/define.ts +15 -0
  138. package/src/ai/types.ts +30 -0
  139. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  140. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  141. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  142. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  143. package/src/app/components/AiAssistant/dockState.ts +86 -0
  144. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  145. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  146. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  147. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  148. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  149. package/src/app/components/Auth/AuthShell.tsx +27 -33
  150. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  151. package/src/app/components/Auth/installStatus.ts +29 -0
  152. package/src/app/components/Component/Preview.tsx +5 -2
  153. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  154. package/src/app/components/Layout/Header.tsx +21 -15
  155. package/src/app/components/Layout/Main.tsx +2 -2
  156. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  157. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  158. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  159. package/src/app/components/Navigation/SideNav.tsx +1 -15
  160. package/src/app/components/context/NavProvider.tsx +7 -3
  161. package/src/app/components/ui/attachment.tsx +50 -0
  162. package/src/app/components/ui/bubble.tsx +57 -0
  163. package/src/app/components/ui/button.tsx +3 -1
  164. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  165. package/src/app/components/ui/dialog.tsx +7 -2
  166. package/src/app/components/ui/marker.tsx +56 -0
  167. package/src/app/components/ui/sidebar.tsx +3 -1
  168. package/src/app/components/util/index.ts +3 -12
  169. package/src/app/css/index.css +64 -0
  170. package/src/app/lib/ai/auth.ts +33 -0
  171. package/src/app/lib/ai/context.ts +99 -0
  172. package/src/app/lib/ai/model.ts +79 -0
  173. package/src/app/lib/ai/prompt.ts +36 -0
  174. package/src/app/lib/ai/resolve.ts +97 -0
  175. package/src/app/lib/ai/tools.ts +120 -0
  176. package/src/app/lib/api/methods.ts +13 -0
  177. package/src/app/lib/asset-storage.ts +35 -36
  178. package/src/app/lib/auth/api.ts +4 -12
  179. package/src/app/lib/auth/email.ts +96 -23
  180. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  181. package/src/app/lib/docs-api/registry.ts +2 -3
  182. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  183. package/src/app/lib/mcp/components.ts +523 -0
  184. package/src/app/lib/mcp/page-url.ts +24 -0
  185. package/src/app/lib/mcp/pages.ts +84 -0
  186. package/src/app/lib/mcp/result.ts +33 -0
  187. package/src/app/lib/mcp/server.ts +39 -318
  188. package/src/app/lib/mcp/tokens.ts +334 -0
  189. package/src/app/lib/mcp/tool.ts +19 -0
  190. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  191. package/src/app/lib/registry-api/errors.ts +2 -0
  192. package/src/app/lib/registry-api/handler.ts +7 -1
  193. package/src/app/lib/registry-api/transfer.ts +28 -6
  194. package/src/app/lib/server-module.ts +34 -0
  195. package/src/app/lib/utils.ts +20 -0
  196. package/src/app/next.config.mjs +26 -24
  197. package/src/app/pages/[...slug]/index.tsx +2 -1
  198. package/src/app/pages/_app.tsx +6 -2
  199. package/src/app/pages/_document.tsx +6 -0
  200. package/src/app/pages/account/ai.tsx +183 -0
  201. package/src/app/pages/account/index.tsx +68 -68
  202. package/src/app/pages/account/tokens.tsx +37 -7
  203. package/src/app/pages/account/users.tsx +75 -15
  204. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  205. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  206. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  207. package/src/app/pages/api/admin/users/index.ts +2 -2
  208. package/src/app/pages/api/ai/chat.ts +115 -0
  209. package/src/app/pages/api/ai/connections.ts +23 -0
  210. package/src/app/pages/api/auth/request-reset.ts +14 -16
  211. package/src/app/pages/api/docs/nav.json.ts +1 -1
  212. package/src/app/pages/api/install.ts +3 -3
  213. package/src/app/pages/api/mcp.ts +6 -0
  214. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  215. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  216. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  217. package/src/app/pages/cli/device.tsx +10 -5
  218. package/src/app/pages/foundations/colors.tsx +9 -8
  219. package/src/app/pages/foundations/index.tsx +3 -3
  220. package/src/app/pages/index.tsx +2 -2
  221. package/src/app/pages/install.tsx +7 -2
  222. package/src/app/pages/login.tsx +32 -27
  223. package/src/app/pages/reset-password.tsx +21 -20
  224. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  225. package/src/app/pages/system/index.tsx +11 -11
  226. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  227. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  228. package/src/app-builder/README.md +2 -1
  229. package/src/app-builder/build.ts +218 -73
  230. package/src/app-builder/client-config.ts +3 -2
  231. package/src/app-builder/paths.ts +3 -22
  232. package/src/app-builder/static-export.ts +1 -1
  233. package/src/app-builder/vercel-output.ts +5 -4
  234. package/src/cache/README.md +4 -1
  235. package/src/cache/build-cache.ts +128 -43
  236. package/src/cache/build-inputs.ts +28 -0
  237. package/src/cache/index.ts +4 -0
  238. package/src/cli/create.ts +1 -2
  239. package/src/commands/build/index.ts +1 -1
  240. package/src/config/README.md +10 -9
  241. package/src/config/defaults.ts +7 -4
  242. package/src/config/from-env.ts +33 -0
  243. package/src/config/helpers.ts +1 -3
  244. package/src/config/index.ts +0 -3
  245. package/src/config/loader.ts +25 -3
  246. package/src/config/resolve-env.ts +28 -15
  247. package/src/config/runtime.ts +1 -1
  248. package/src/index.ts +33 -29
  249. package/src/nav/index.ts +2 -2
  250. package/src/nav/registry.ts +3 -3
  251. package/src/nav/workspace.ts +3 -3
  252. package/src/pipeline/README.md +1 -1
  253. package/src/pipeline/figma.ts +98 -49
  254. package/src/pipeline/styles.ts +1 -1
  255. package/src/pipeline/validation.ts +3 -3
  256. package/src/providers.ts +18 -0
  257. package/src/registry/asset-storage/define.ts +6 -6
  258. package/src/registry/asset-storage/resolve.ts +38 -37
  259. package/src/registry/asset-storage/types.ts +39 -17
  260. package/src/registry/auth/ai-keys.ts +62 -0
  261. package/src/registry/auth/crypto.ts +63 -1
  262. package/src/registry/auth/index.ts +11 -1
  263. package/src/registry/checkout/index.ts +60 -138
  264. package/src/registry/client.ts +60 -20
  265. package/src/registry/db/driver.ts +2 -2
  266. package/src/registry/db/schema.ts +49 -22
  267. package/src/registry/email/define.ts +15 -0
  268. package/src/registry/email/providers/smtp.ts +39 -0
  269. package/src/registry/email/resolve.ts +94 -0
  270. package/src/registry/email/types.ts +28 -0
  271. package/src/registry/path.ts +11 -0
  272. package/src/registry/publish/package.ts +9 -0
  273. package/src/registry/transfer.ts +4 -0
  274. package/src/store/registry.ts +7 -7
  275. package/src/transformers/preview/component/builder.ts +19 -25
  276. package/src/transformers/preview/component/css.ts +9 -4
  277. package/src/transformers/preview/component/html.ts +6 -2
  278. package/src/transformers/preview/component/javascript.ts +14 -4
  279. package/src/transformers/utils/build.ts +16 -3
  280. package/src/types/config.ts +142 -20
  281. package/src/utils/README.md +1 -1
  282. package/src/utils/path.ts +6 -29
  283. package/templates/default/handoff.config.js.tpl +10 -2
  284. package/templates/default/handoff.config.ts.tpl +10 -2
  285. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  286. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  287. package/templates/default/package.json.tpl +2 -2
  288. package/.editorconfig +0 -19
  289. package/.eslintrc.json +0 -4
  290. package/.prettierignore +0 -0
  291. package/.prettierrc.json +0 -8
  292. package/.vscode/settings.json +0 -17
  293. package/dist/config/validator.d.ts +0 -9
  294. package/dist/config/validator.js +0 -24
  295. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  296. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  297. package/src/app/components/old/Header.tsx +0 -89
  298. package/src/app/lib/mcp/shape.ts +0 -596
  299. package/src/app/pages/assets/fonts.tsx +0 -129
  300. package/src/app/pages/assets/index.tsx +0 -91
  301. package/src/app/pages/assets/logos.tsx +0 -75
  302. package/src/config/validator.ts +0 -22
  303. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
package/RECIPES.md ADDED
@@ -0,0 +1,975 @@
1
+ # Handoff recipes
2
+
3
+ Each recipe is a complete example for one setup. The [README](README.md)
4
+ explains the concepts, the rules, and the full configuration. Read the related
5
+ README section before you use a recipe.
6
+
7
+ **Project layout**
8
+
9
+ - [Handoff in a subdirectory](#handoff-in-a-subdirectory)
10
+ - [Handoff in the project root](#handoff-in-the-project-root)
11
+ - [Handoff as a workspace package](#handoff-as-a-workspace-package)
12
+ - [pnpm](#pnpm)
13
+ - [npm scripts on Windows](#npm-scripts-on-windows)
14
+
15
+ **Catalog items**
16
+
17
+ - [React preview with a render function](#react-preview-with-a-render-function)
18
+ - [Handlebars item](#handlebars-item)
19
+ - [CSF stories](#csf-stories)
20
+ - [Composition with preview overrides](#composition-with-preview-overrides)
21
+
22
+ **Profiles**
23
+
24
+ - [Local profile](#local-profile)
25
+ - [Registry build profile](#registry-build-profile)
26
+ - [One workspace, several registries](#one-workspace-several-registries)
27
+
28
+ **Registry hosting**
29
+
30
+ - [Local registry](#local-registry)
31
+ - [Vercel and Neon](#vercel-and-neon)
32
+
33
+ **Asset storage**
34
+
35
+ - [S3-compatible storage](#s3-compatible-storage)
36
+ - [Vercel Blob](#vercel-blob)
37
+
38
+ **Email**
39
+
40
+ - [SMTP](#smtp)
41
+ - [Resend](#resend)
42
+ - [Custom email provider](#custom-email-provider)
43
+
44
+ **AI assistant**
45
+
46
+ - [Service key through a gateway](#service-key-through-a-gateway)
47
+ - [Local Ollama](#local-ollama)
48
+ - [Reader keys](#reader-keys)
49
+ - [Custom AI provider](#custom-ai-provider)
50
+ - [Change connections without a rebuild](#change-connections-without-a-rebuild)
51
+
52
+ **MCP**
53
+
54
+ - [Connect a client by hand](#connect-a-client-by-hand)
55
+ - [Turn off MCP](#turn-off-mcp)
56
+
57
+ **Continuous integration**
58
+
59
+ - [Publish from GitHub Actions](#publish-from-github-actions)
60
+
61
+ ---
62
+
63
+ ## Project layout
64
+
65
+ These recipes add Handoff to an existing application. The README section
66
+ [Adding Handoff to an existing application](README.md#adding-handoff-to-an-existing-application)
67
+ gives the two rules that apply to all of them.
68
+
69
+ ### Handoff in a subdirectory
70
+
71
+ This is the recommended layout. The application does not change.
72
+ `handoff-app` is a dependency of the application package.
73
+ `HANDOFF_WORKING_PATH` keeps the generated directories in `handoff/`.
74
+
75
+ ```json
76
+ {
77
+ "scripts": {
78
+ "handoff:dev": "HANDOFF_WORKING_PATH=handoff handoff-app start",
79
+ "handoff:build": "HANDOFF_WORKING_PATH=handoff handoff-app build"
80
+ }
81
+ }
82
+ ```
83
+
84
+ ```ts
85
+ // handoff/handoff.config.ts
86
+ import { defineConfig } from 'handoff-app';
87
+
88
+ export default defineConfig({
89
+ catalog: { include: ['../src/components', '../src/blocks'] },
90
+ entries: { scss: '../src/styles/main.scss' },
91
+ });
92
+ ```
93
+
94
+ ```jsonc
95
+ // handoff/tsconfig.json: Handoff reads this file to make the property tables.
96
+ {
97
+ "extends": "../tsconfig.json",
98
+ "include": ["../src", "handoff.config.ts"],
99
+ "exclude": ["node_modules", "out", "public"]
100
+ }
101
+ ```
102
+
103
+ Notes:
104
+
105
+ - The project root `tsconfig.json` type checks the declarations in `src/`.
106
+ - Catalog paths are relative to the working directory, so they start with
107
+ `../`.
108
+
109
+ ### Handoff in the project root
110
+
111
+ The working directory is the project root, so one `tsconfig.json` does both
112
+ jobs. Handoff writes to `public/api`. Thus a bundler that serves static files
113
+ from `public/` must use a different directory:
114
+
115
+ ```ts
116
+ // vite.config.ts
117
+ export default defineConfig({
118
+ publicDir: 'static',
119
+ });
120
+ ```
121
+
122
+ Notes:
123
+
124
+ - Without this change, the generated documentation API goes into the
125
+ production build of the application.
126
+ - A Next.js application cannot use this layout. Its `public` directory is a
127
+ fixed convention, and no setting moves it.
128
+
129
+ ### Handoff as a workspace package
130
+
131
+ Use this layout when the repository is already a workspace. `handoff/` has its
132
+ own `package.json` and scripts. The root forwards to it:
133
+
134
+ ```json
135
+ {
136
+ "workspaces": ["handoff"],
137
+ "scripts": { "handoff:dev": "npm run dev --workspace handoff" }
138
+ }
139
+ ```
140
+
141
+ ```json
142
+ // handoff/package.json
143
+ {
144
+ "name": "handoff",
145
+ "private": true,
146
+ "scripts": { "dev": "handoff-app start", "build": "handoff-app build" },
147
+ "dependencies": { "handoff-app": "^2.0.0" }
148
+ }
149
+ ```
150
+
151
+ Notes:
152
+
153
+ - The declarations in `src/` belong to the application package, not to the
154
+ workspace. Thus both packages declare `handoff-app`.
155
+
156
+ ### pnpm
157
+
158
+ `build` and `build --package vercel` work with the default pnpm layout.
159
+ `build --target registry` cannot follow pnpm symlinks when it copies its
160
+ runtime dependencies into a standalone bundle. Use a flat layout:
161
+
162
+ ```yaml
163
+ # pnpm-workspace.yaml
164
+ nodeLinker: hoisted
165
+ ```
166
+
167
+ Notes:
168
+
169
+ - With the default layout, Handoff skips a declaration that has no direct
170
+ dependency on `handoff-app`, with `Could not resolve "handoff-app/react"`.
171
+ The build still exits with code 0.
172
+
173
+ ### npm scripts on Windows
174
+
175
+ An inline variable does not work in an npm script on Windows. Set the variable
176
+ with `cross-env`:
177
+
178
+ ```bash
179
+ npm install --save-dev cross-env
180
+ ```
181
+
182
+ ```json
183
+ { "scripts": { "handoff:dev": "cross-env HANDOFF_WORKING_PATH=handoff handoff-app start" } }
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Catalog items
189
+
190
+ The README section [Catalog items](README.md#catalog-items) explains
191
+ declarations, ids, and previews.
192
+
193
+ ### React preview with a render function
194
+
195
+ Handoff serializes the arguments. A preview that needs an icon, a callback, JSX
196
+ children, or its own state uses `render`. The item continues to document the
197
+ implementation, not a wrapper.
198
+
199
+ ```tsx
200
+ // components/example/example.handoff.tsx
201
+ import { useState } from 'react';
202
+ import { defineCatalogItem, type Preview } from 'handoff-app/react';
203
+ import Component from './Component';
204
+
205
+ const item = defineCatalogItem({
206
+ id: 'component-id',
207
+ name: 'Component name',
208
+ implementation: Component,
209
+ });
210
+
211
+ export default item;
212
+
213
+ type ComponentPreview = Preview<typeof item>;
214
+
215
+ export const Default = {
216
+ args: { label: 'Example' },
217
+ } satisfies ComponentPreview;
218
+
219
+ export const Interactive = {
220
+ name: 'Interactive state',
221
+ render: function Interactive() {
222
+ const [label, setLabel] = useState('Example');
223
+ return <Component label={label} onSelect={() => setLabel('Selected')} />;
224
+ },
225
+ } satisfies ComponentPreview;
226
+ ```
227
+
228
+ Notes:
229
+
230
+ - The `render` function is a component. A name that starts with an uppercase
231
+ letter lets it use hooks.
232
+ - A declaration that holds JSX must use the `.handoff.tsx` extension.
233
+ - `name` sets a display title. Without it, the export name is the title.
234
+
235
+ ### Handlebars item
236
+
237
+ The implementation is the path to the template. The arguments are
238
+ `Record<string, unknown>` by default. Give an argument type to
239
+ `defineCatalogItem` to type them.
240
+
241
+ ```ts
242
+ // components/badge/badge.handoff.ts
243
+ import { defineCatalogItem, type Preview } from 'handoff-app/handlebars';
244
+
245
+ type BadgeArgs = { variant: 'primary' | 'secondary'; children: string };
246
+
247
+ const item = defineCatalogItem<BadgeArgs>({
248
+ id: 'badge',
249
+ name: 'Badge',
250
+ implementation: './Badge.hbs',
251
+ entries: { scss: './badge.scss' },
252
+ });
253
+
254
+ export default item;
255
+
256
+ type BadgePreview = Preview<typeof item>;
257
+
258
+ export const Primary = {
259
+ args: { variant: 'primary', children: 'New' },
260
+ } satisfies BadgePreview;
261
+ ```
262
+
263
+ Notes:
264
+
265
+ - `entries` names the supporting files. The template is already the
266
+ implementation.
267
+ - `npx handoff-app make:component badge` creates a Handlebars item with a
268
+ CommonJS declaration.
269
+
270
+ ### CSF stories
271
+
272
+ A React item can use an existing CSF story file. The story file does not
273
+ change. Its named exports are the previews.
274
+
275
+ ```ts
276
+ // components/card/card.handoff.ts
277
+ import { defineCatalogItem, fromCSF } from 'handoff-app/react';
278
+
279
+ export default defineCatalogItem({
280
+ id: 'card',
281
+ name: 'Card',
282
+ implementation: fromCSF('./Card.stories.tsx'),
283
+ });
284
+ ```
285
+
286
+ ```tsx
287
+ // components/card/Card.stories.tsx
288
+ import { type Meta, type StoryObj } from 'handoff-app/react';
289
+ import Card from './Card';
290
+
291
+ const meta = {
292
+ component: Card,
293
+ args: { tone: 'neutral' },
294
+ } satisfies Meta<typeof Card>;
295
+ export default meta;
296
+
297
+ export const Primary: StoryObj<typeof meta> = {
298
+ args: { title: 'Example' },
299
+ };
300
+ ```
301
+
302
+ Notes:
303
+
304
+ - The `args` of each story merge with `meta.args` and override them.
305
+ - Handoff keeps story names, `argTypes`, and `render` functions.
306
+ - Handoff finds the component through `meta.component`.
307
+ - `Meta` and `StoryObj` come from `handoff-app/react`, so the file needs no
308
+ Storybook dependency. A project with Storybook can keep its own types.
309
+ - The server renders a CSF preview, and the browser does not hydrate it.
310
+
311
+ ### Composition with preview overrides
312
+
313
+ A composition references other items by id. Each reference can select a named
314
+ preview and override its arguments.
315
+
316
+ ```ts
317
+ // patterns/signup/signup.handoff.ts
318
+ import { defineCatalogItem } from 'handoff-app/pattern';
319
+
320
+ export default defineCatalogItem({
321
+ id: 'signup',
322
+ name: 'Sign-up form',
323
+ description: 'Collects an email address for the newsletter.',
324
+ group: 'Forms',
325
+ composition: [
326
+ { ref: 'text-input', preview: 'Default', args: { label: 'Email' } },
327
+ { ref: 'button', preview: 'Primary', args: { children: 'Subscribe' } },
328
+ ],
329
+ });
330
+ ```
331
+
332
+ ---
333
+
334
+ ## Profiles
335
+
336
+ The README section [Profiles](README.md#profiles) explains how profiles merge
337
+ and how to select one.
338
+
339
+ ### Local profile
340
+
341
+ The generated `.gitignore` lists `handoff.config.local.*`. Thus a `local`
342
+ profile stays on one machine.
343
+
344
+ ```ts
345
+ // handoff.config.local.ts
346
+ import { defineConfig } from 'handoff-app';
347
+
348
+ export default defineConfig({
349
+ app: { title: 'Design System (local)' },
350
+ });
351
+ ```
352
+
353
+ ```dotenv
354
+ # .env
355
+ HANDOFF_PROFILE=local
356
+ ```
357
+
358
+ Notes:
359
+
360
+ - `HANDOFF_PROFILE` in `.env` selects the profile for every command.
361
+ - When this profile is selected, Handoff reads `.env.local` on top of `.env`.
362
+
363
+ ### Registry build profile
364
+
365
+ Put the registry build settings in a profile. The base config stays a
366
+ workspace config.
367
+
368
+ ```ts
369
+ // handoff.config.registry.ts
370
+ import { defineConfig, fromEnv } from 'handoff-app';
371
+
372
+ export default defineConfig({
373
+ runtime: {
374
+ mcp: true,
375
+ registry: {
376
+ database: { url: fromEnv('DATABASE_URL'), driver: 'pg' },
377
+ email: { from: 'Handoff <no-reply@example.com>' },
378
+ },
379
+ },
380
+ });
381
+ ```
382
+
383
+ ```bash
384
+ npm run build -- --target registry --profile registry
385
+ ```
386
+
387
+ Notes:
388
+
389
+ - `build --target registry` always builds a registry-mode application. The
390
+ profile does not set `runtime.mode`.
391
+ - On a host or in CI, set `HANDOFF_PROFILE=registry` instead of the flag.
392
+
393
+ ### One workspace, several registries
394
+
395
+ Each profile keeps its own registry login:
396
+
397
+ ```bash
398
+ npm run login -- --profile staging --url https://staging.example.com
399
+ npm run login -- --profile production --url https://registry.example.com
400
+ ```
401
+
402
+ ```bash
403
+ npm run publish -- all --profile staging
404
+ npm run checkout -- catalog button --profile production
405
+ ```
406
+
407
+ Notes:
408
+
409
+ - A login profile needs no `handoff.config.<name>.*` file.
410
+ - If a profile has no login of its own, it uses the default login.
411
+ - Instead of a login, `.env.staging` and `.env.production` can each set
412
+ `HANDOFF_REGISTRY_URL` and `HANDOFF_REGISTRY_ACCESS_TOKEN`.
413
+
414
+ ---
415
+
416
+ ## Registry hosting
417
+
418
+ The README section [Registry](README.md#registry) gives the deploy steps and
419
+ the runtime environment variables.
420
+
421
+ ### Local registry
422
+
423
+ This recipe runs a registry on your computer with PostgreSQL in Docker.
424
+
425
+ 1. Start PostgreSQL:
426
+
427
+ ```bash
428
+ docker run --name handoff-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handoff -p 5432:5432 -d postgres:17
429
+ ```
430
+
431
+ 2. Apply the migrations from the workspace:
432
+
433
+ ```bash
434
+ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff" npm run db:migrate
435
+ ```
436
+
437
+ 3. Build the registry:
438
+
439
+ ```bash
440
+ npm run build -- --target registry
441
+ ```
442
+
443
+ 4. Start the registry server:
444
+
445
+ ```bash
446
+ PORT=4000 \
447
+ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff" \
448
+ AUTH_SECRET="replace-with-at-least-32-random-characters" \
449
+ AUTH_URL="http://localhost:4000" \
450
+ node out/registry/server.js
451
+ ```
452
+
453
+ 5. Open http://localhost:4000/install and create the first administrator.
454
+
455
+ 6. Sign in from the workspace and publish:
456
+
457
+ ```bash
458
+ npm run login -- --url http://localhost:4000
459
+ npm run publish -- all
460
+ ```
461
+
462
+ ### Vercel and Neon
463
+
464
+ This recipe deploys the registry to Vercel with a Neon database. Each
465
+ integration also works alone.
466
+
467
+ 1. Select the Neon driver in a registry profile:
468
+
469
+ ```ts
470
+ // handoff.config.registry.ts
471
+ import { defineConfig, fromEnv } from 'handoff-app';
472
+
473
+ export default defineConfig({
474
+ runtime: {
475
+ registry: {
476
+ database: { url: fromEnv('DATABASE_URL'), driver: 'neon' },
477
+ },
478
+ },
479
+ });
480
+ ```
481
+
482
+ 2. Set the Vercel build command. `init` writes this file when you select the
483
+ Vercel option:
484
+
485
+ ```json
486
+ {
487
+ "framework": null,
488
+ "buildCommand": "npm run build -- --target registry --package vercel"
489
+ }
490
+ ```
491
+
492
+ 3. In the Vercel project, set these environment variables: `HANDOFF_PROFILE=registry`,
493
+ `DATABASE_URL`, `AUTH_SECRET`, and `AUTH_URL`.
494
+
495
+ 4. Apply the migrations before the first deployment and before each upgrade:
496
+
497
+ ```bash
498
+ DATABASE_URL="<neon connection string>" npm run db:migrate
499
+ ```
500
+
501
+ 5. Deploy, then open `<registry url>/install`.
502
+
503
+ Notes:
504
+
505
+ - Vercel limits one request to about 4.5 MB. To publish larger assets, use
506
+ [Vercel Blob](#vercel-blob) or [S3-compatible storage](#s3-compatible-storage)
507
+ with direct uploads.
508
+
509
+ ---
510
+
511
+ ## Asset storage
512
+
513
+ The README section [Asset storage](README.md#asset-storage) explains the
514
+ provider contract and direct uploads.
515
+
516
+ ### S3-compatible storage
517
+
518
+ This provider works with Amazon S3, Cloudflare R2, MinIO, SeaweedFS, and other
519
+ services with an S3 API. It uses `@aws-sdk/client-s3` and
520
+ `@aws-sdk/s3-request-presigner`.
521
+
522
+ ```ts
523
+ runtime: {
524
+ registry: {
525
+ assetStorage: {
526
+ provider: 'custom',
527
+ module: './server/storage/s3.mjs',
528
+ options: {
529
+ endpoint: 'http://127.0.0.1:8333',
530
+ bucket: 'handoff-assets',
531
+ accessKey: fromEnv('S3_ACCESS_KEY'),
532
+ secretKey: fromEnv('S3_SECRET_KEY'),
533
+ },
534
+ },
535
+ },
536
+ },
537
+ ```
538
+
539
+ ```js
540
+ // server/storage/s3.mjs
541
+ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
542
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
543
+ import { defineStorageProvider } from 'handoff-app/providers';
544
+
545
+ export default defineStorageProvider(({ options }) => {
546
+ const client = new S3Client({
547
+ endpoint: options.endpoint,
548
+ region: 'us-east-1',
549
+ forcePathStyle: true,
550
+ credentials: { accessKeyId: options.accessKey, secretAccessKey: options.secretKey },
551
+ });
552
+ const Bucket = options.bucket;
553
+ return {
554
+ async put({ hash, bytes, contentType }) {
555
+ await client.send(new PutObjectCommand({ Bucket, Key: hash, Body: bytes, ContentType: contentType }));
556
+ return { storageRef: hash };
557
+ },
558
+ async get(storageRef) {
559
+ const result = await client.send(new GetObjectCommand({ Bucket, Key: storageRef }));
560
+ return { kind: 'stream', stream: result.Body, contentType: result.ContentType };
561
+ },
562
+ async delete(storageRef) {
563
+ await client.send(new DeleteObjectCommand({ Bucket, Key: storageRef }));
564
+ },
565
+ async createUpload({ hash, contentType }) {
566
+ const checksum = Buffer.from(hash, 'hex').toString('base64');
567
+ const command = new PutObjectCommand({ Bucket, Key: hash, ContentType: contentType, ChecksumSHA256: checksum });
568
+ const url = await getSignedUrl(client, command, {
569
+ expiresIn: 900,
570
+ unhoistableHeaders: new Set(['x-amz-checksum-sha256']),
571
+ });
572
+ return { url, headers: { 'Content-Type': contentType, 'x-amz-checksum-sha256': checksum }, storageRef: hash };
573
+ },
574
+ };
575
+ });
576
+ ```
577
+
578
+ Notes:
579
+
580
+ - `createUpload` signs a SHA-256 checksum header. Thus storage rejects bytes
581
+ that do not match `hash`.
582
+ - The signed URL contains `endpoint`. Thus the machine that runs `publish`
583
+ must also be able to connect to `endpoint`. With `127.0.0.1`, `publish` and
584
+ storage must run on the same computer.
585
+
586
+ ### Vercel Blob
587
+
588
+ This provider stores blobs in a private Vercel Blob store. It uses
589
+ `@vercel/blob` 2.x. When the store is connected to the Vercel project, the SDK
590
+ reads the store credentials from the deployment environment. Thus the provider
591
+ needs no `options`.
592
+
593
+ ```ts
594
+ runtime: {
595
+ registry: {
596
+ assetStorage: {
597
+ provider: 'custom',
598
+ module: './server/storage/vercel-blob.mjs',
599
+ },
600
+ },
601
+ },
602
+ ```
603
+
604
+ ```js
605
+ // server/storage/vercel-blob.mjs
606
+ import { Readable } from 'node:stream';
607
+ import { del, get, issueSignedToken, parseStoreIdFromDelegationToken, presignUrl, put } from '@vercel/blob';
608
+ import { defineStorageProvider } from 'handoff-app/providers';
609
+
610
+ const access = 'private';
611
+ const pathnameFor = (hash) => `assets/${hash}`;
612
+
613
+ export default defineStorageProvider({
614
+ async put({ hash, bytes, contentType }) {
615
+ const blob = await put(pathnameFor(hash), bytes, { access, contentType, addRandomSuffix: false, allowOverwrite: true });
616
+ return { storageRef: blob.pathname };
617
+ },
618
+ async get(storageRef) {
619
+ const result = await get(storageRef, { access });
620
+ if (!result?.stream) {
621
+ throw new Error(`Blob "${storageRef}" does not exist.`);
622
+ }
623
+ return { kind: 'stream', stream: Readable.fromWeb(result.stream), contentType: result.blob.contentType };
624
+ },
625
+ async delete(storageRef) {
626
+ await del(storageRef);
627
+ },
628
+ async createUpload({ hash, size, contentType }) {
629
+ const pathname = pathnameFor(hash);
630
+ const token = await issueSignedToken({
631
+ pathname,
632
+ operations: ['put'],
633
+ validUntil: Date.now() + 15 * 60 * 1000,
634
+ allowedContentTypes: [contentType],
635
+ maximumSizeInBytes: size,
636
+ });
637
+ const { presignedUrl } = await presignUrl(token, { operation: 'put', pathname, access, allowOverwrite: true });
638
+ return {
639
+ url: presignedUrl,
640
+ headers: {
641
+ 'x-api-version': '12',
642
+ 'x-vercel-blob-store-id': parseStoreIdFromDelegationToken(token.delegationToken),
643
+ 'x-vercel-blob-access': access,
644
+ 'x-content-type': contentType,
645
+ },
646
+ storageRef: pathname,
647
+ };
648
+ },
649
+ });
650
+ ```
651
+
652
+ Notes:
653
+
654
+ - `get` converts the stream. The SDK returns a web stream, but the registry
655
+ needs a Node stream.
656
+ - A private blob has no public URL. Thus the registry sends each blob to the
657
+ reader.
658
+ - **Vercel Blob cannot reject bytes that do not match `hash`.** The signed URL
659
+ limits only the path, the size, and the content type. Thus the registry does
660
+ not verify a blob that the CLI uploads directly.
661
+ - Without `createUpload`, each blob goes through the registry, which verifies
662
+ its hash. The Vercel limit of about 4.5 MB then applies to each blob.
663
+ - The CLI sends a plain `PUT` request. Thus `createUpload` returns the headers
664
+ that the SDK sends for a presigned upload. These headers are not a documented
665
+ API, so a new version of `@vercel/blob` can change them.
666
+
667
+ ---
668
+
669
+ ## Email
670
+
671
+ The README section [Email](README.md#email) explains when the registry sends
672
+ email.
673
+
674
+ ### SMTP
675
+
676
+ Use this provider for an SMTP server, for example Amazon SES, SendGrid, or
677
+ Microsoft 365.
678
+
679
+ ```ts
680
+ email: {
681
+ from: 'Handoff <no-reply@example.com>',
682
+ provider: 'smtp',
683
+ options: {
684
+ host: fromEnv('SMTP_HOST'),
685
+ port: 465, // default, uses TLS. 587 uses STARTTLS.
686
+ user: fromEnv('SMTP_USER'),
687
+ password: fromEnv('SMTP_PASSWORD'),
688
+ },
689
+ },
690
+ ```
691
+
692
+ ### Resend
693
+
694
+ This custom module sends email through the Resend HTTP API. It uses `fetch`, so
695
+ it needs no package. Resend must verify the sender domain.
696
+
697
+ ```ts
698
+ email: {
699
+ from: 'Handoff <no-reply@example.com>',
700
+ provider: 'custom',
701
+ module: './server/email/resend.mjs',
702
+ options: { apiKey: fromEnv('RESEND_API_KEY') },
703
+ },
704
+ ```
705
+
706
+ ```js
707
+ // server/email/resend.mjs
708
+ import { defineEmailProvider } from 'handoff-app/providers';
709
+
710
+ export default defineEmailProvider(({ options }) => ({
711
+ async send({ from, to, subject, html, text }) {
712
+ if (!options.apiKey) throw new Error('RESEND_API_KEY is not set.');
713
+ const response = await fetch('https://api.resend.com/emails', {
714
+ method: 'POST',
715
+ headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': 'application/json' },
716
+ body: JSON.stringify({ from, to: [to], subject, html, text }),
717
+ });
718
+ if (!response.ok) {
719
+ throw new Error(`Resend rejected the message: ${response.status} ${await response.text()}`);
720
+ }
721
+ },
722
+ }));
723
+ ```
724
+
725
+ Notes:
726
+
727
+ - The registry does not check the options of a custom module. If
728
+ `RESEND_API_KEY` is empty, each invitation and password-reset email fails.
729
+
730
+ ### Custom email provider
731
+
732
+ This example sends email through the Postmark HTTP API. The factory gets
733
+ `options` with the `fromEnv()` values resolved.
734
+
735
+ ```ts
736
+ email: {
737
+ from: 'Handoff <no-reply@example.com>',
738
+ provider: 'custom',
739
+ module: './server/email/postmark.mjs',
740
+ options: { apiKey: fromEnv('POSTMARK_SERVER_TOKEN') },
741
+ },
742
+ ```
743
+
744
+ ```js
745
+ // server/email/postmark.mjs
746
+ import { defineEmailProvider } from 'handoff-app/providers';
747
+
748
+ export default defineEmailProvider(({ options }) => ({
749
+ async send({ from, to, subject, html, text }) {
750
+ const response = await fetch('https://api.postmarkapp.com/email', {
751
+ method: 'POST',
752
+ headers: {
753
+ Accept: 'application/json',
754
+ 'Content-Type': 'application/json',
755
+ 'X-Postmark-Server-Token': options.apiKey,
756
+ },
757
+ body: JSON.stringify({ From: from, To: to, Subject: subject, HtmlBody: html, TextBody: text }),
758
+ });
759
+ if (!response.ok) {
760
+ throw new Error(`Postmark rejected the message: ${response.status} ${await response.text()}`);
761
+ }
762
+ },
763
+ }));
764
+ ```
765
+
766
+ Notes:
767
+
768
+ - `send` must throw an error when the provider does not accept the message.
769
+
770
+ ---
771
+
772
+ ## AI assistant
773
+
774
+ The README section [AI assistant](README.md#ai-assistant) explains
775
+ connections and credentials. Each recipe adds one connection. A config can
776
+ declare many connections.
777
+
778
+ ### Service key through a gateway
779
+
780
+ The deployment key pays for every reader. The gateway enforces budgets and rate
781
+ limits.
782
+
783
+ ```ts
784
+ runtime: {
785
+ ai: {
786
+ connections: [
787
+ {
788
+ id: 'gateway',
789
+ label: 'Acme LiteLLM',
790
+ baseUrl: 'https://llm.acme.internal/v1',
791
+ apiKey: fromEnv('LITELLM_API_KEY'),
792
+ models: ['gpt-4o', 'claude-sonnet-4-5', 'grok-4'],
793
+ },
794
+ ],
795
+ defaultModel: 'gateway/claude-sonnet-4-5',
796
+ },
797
+ },
798
+ ```
799
+
800
+ ### Local Ollama
801
+
802
+ The endpoint needs no credential.
803
+
804
+ ```ts
805
+ runtime: {
806
+ ai: {
807
+ connections: [{ id: 'local', label: 'Ollama', baseUrl: 'http://127.0.0.1:11434/v1', models: ['llama3.1'] }],
808
+ defaultModel: 'local/llama3.1',
809
+ },
810
+ },
811
+ ```
812
+
813
+ ### Reader keys
814
+
815
+ The config sets the endpoint and the models. Each reader adds their own key
816
+ under Account → AI providers.
817
+
818
+ ```ts
819
+ runtime: {
820
+ ai: {
821
+ connections: [
822
+ {
823
+ id: 'openai',
824
+ label: 'OpenAI',
825
+ baseUrl: 'https://api.openai.com/v1',
826
+ credential: 'user',
827
+ models: ['gpt-4o', 'o3'],
828
+ },
829
+ ],
830
+ },
831
+ },
832
+ ```
833
+
834
+ ```dotenv
835
+ HANDOFF_AI_KEY_SECRET="replace-with-at-least-32-random-characters"
836
+ ```
837
+
838
+ Notes:
839
+
840
+ - Reader keys work only in registry mode.
841
+
842
+ ### Custom AI provider
843
+
844
+ Use a module when a provider does not serve the OpenAI-compatible API. This
845
+ example uses the Anthropic provider of the AI SDK.
846
+
847
+ ```ts
848
+ runtime: {
849
+ ai: {
850
+ connections: [
851
+ {
852
+ id: 'anthropic',
853
+ label: 'Anthropic',
854
+ module: './server/ai/anthropic.mjs',
855
+ apiKey: fromEnv('ANTHROPIC_API_KEY'),
856
+ models: ['claude-sonnet-4-5'],
857
+ },
858
+ ],
859
+ },
860
+ },
861
+ ```
862
+
863
+ ```js
864
+ // server/ai/anthropic.mjs
865
+ import { createAnthropic } from '@ai-sdk/anthropic';
866
+ import { defineAiProvider } from 'handoff-app/providers';
867
+
868
+ export default defineAiProvider(({ apiKey }) => createAnthropic({ apiKey }));
869
+ ```
870
+
871
+ Notes:
872
+
873
+ - The factory gets `apiKey`, which is the deployment key or the key of the
874
+ reader. It also gets `options` with the `fromEnv()` values resolved.
875
+ - The provider must return a language model for each model in `models`.
876
+ - Install a provider version that supports AI SDK 7, which `handoff-app` uses.
877
+
878
+ ### Change connections without a rebuild
879
+
880
+ `HANDOFF_AI_CONNECTIONS` holds a JSON array. The registry reads it at request
881
+ time and merges it over the built-in list by `id`. The array names each key
882
+ variable instead of holding the key:
883
+
884
+ ```dotenv
885
+ HANDOFF_AI_CONNECTIONS='[{"id":"gateway","label":"Acme LiteLLM","baseUrl":"https://llm.acme.internal/v1","apiKeyEnv":"LITELLM_API_KEY","models":["gpt-4o"]}]'
886
+ ```
887
+
888
+ Notes:
889
+
890
+ - A connection in this list can use a `module` only if a connection in the
891
+ config names the same module.
892
+
893
+ ---
894
+
895
+ ## MCP
896
+
897
+ The README section [MCP](README.md#mcp) gives the endpoints and credentials.
898
+
899
+ ### Connect a client by hand
900
+
901
+ The documentation app shows a ready-made client config behind the plug icon in
902
+ the header. To write it by hand:
903
+
904
+ ```json
905
+ {
906
+ "mcpServers": {
907
+ "handoff": {
908
+ "type": "http",
909
+ "url": "https://registry.example.com/api/mcp/",
910
+ "headers": { "Authorization": "Bearer hnd_..." }
911
+ }
912
+ }
913
+ }
914
+ ```
915
+
916
+ Notes:
917
+
918
+ - Keep the trailing slash. `/api/mcp` answers with a 308 redirect.
919
+ - A workspace endpoint, `http://localhost:3000/api/mcp/`, needs no
920
+ `Authorization` header.
921
+
922
+ ### Turn off MCP
923
+
924
+ ```ts
925
+ runtime: {
926
+ mcp: false,
927
+ },
928
+ ```
929
+
930
+ Notes:
931
+
932
+ - This value is baked at build time. Rebuild a deployed registry after you
933
+ change it.
934
+
935
+ ---
936
+
937
+ ## Continuous integration
938
+
939
+ ### Publish from GitHub Actions
940
+
941
+ This workflow publishes all content when a change goes into `main`.
942
+
943
+ ```yaml
944
+ # .github/workflows/publish.yml
945
+ name: Publish to registry
946
+ on:
947
+ push:
948
+ branches: [main]
949
+ jobs:
950
+ publish:
951
+ runs-on: ubuntu-latest
952
+ steps:
953
+ - uses: actions/checkout@v4
954
+ - uses: actions/setup-node@v4
955
+ with:
956
+ node-version: 24
957
+ cache: npm
958
+ - run: npm ci
959
+ - run: npm run publish -- all
960
+ env:
961
+ HANDOFF_REGISTRY_URL: ${{ vars.HANDOFF_REGISTRY_URL }}
962
+ HANDOFF_REGISTRY_ACCESS_TOKEN: ${{ secrets.HANDOFF_REGISTRY_ACCESS_TOKEN }}
963
+ HANDOFF_FIGMA_PROJECT_ID: ${{ vars.HANDOFF_FIGMA_PROJECT_ID }}
964
+ HANDOFF_DEV_ACCESS_TOKEN: ${{ secrets.HANDOFF_DEV_ACCESS_TOKEN }}
965
+ ```
966
+
967
+ Notes:
968
+
969
+ - The access token needs read and write access.
970
+ - `publish tokens` and `publish assets` run the Figma pipeline. Thus `all`
971
+ needs the Figma variables. To publish only catalog items and pages, run
972
+ `npm run publish -- catalog` and `npm run publish -- pages`, and remove the
973
+ Figma variables.
974
+ - Without a terminal, a missing Figma value stops the command with an error
975
+ that names the variable.