handoff-app 2.0.0-11 → 2.0.0-13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{Changelog.md → CHANGELOG.md} +84 -0
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +760 -801
- package/RECIPES.md +975 -0
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/define.d.ts +1 -1
- package/dist/ai/define.js +1 -1
- package/dist/ai/types.d.ts +1 -1
- package/dist/ai/types.js +1 -1
- package/dist/app-builder/build.js +16 -16
- package/dist/app-builder/client-config.js +1 -1
- package/dist/config/loader.js +1 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/providers.d.ts +10 -0
- package/dist/{define.js → providers.js} +4 -4
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +8 -11
- package/dist/registry/asset-storage/resolve.js +12 -13
- package/dist/registry/asset-storage/types.d.ts +12 -12
- package/dist/registry/asset-storage/types.js +2 -2
- package/dist/registry/db/schema.d.ts +2 -2
- package/dist/registry/db/schema.js +2 -2
- package/dist/registry/email/define.d.ts +1 -1
- package/dist/registry/email/define.js +1 -1
- package/dist/registry/email/resolve.d.ts +7 -8
- package/dist/registry/email/resolve.js +21 -23
- package/dist/registry/email/types.d.ts +1 -1
- package/dist/registry/email/types.js +1 -1
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/types/config.d.ts +4 -13
- package/docs/cli.md +275 -54
- package/package.json +8 -9
- package/src/ai/define.ts +1 -1
- package/src/ai/types.ts +1 -1
- package/src/app/components/Auth/AccountLayout.tsx +1 -1
- package/src/app/components/Auth/AuthShell.tsx +10 -4
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +0 -2
- package/src/app/components/util/index.ts +0 -11
- package/src/app/lib/asset-storage.ts +25 -25
- package/src/app/lib/auth/email.ts +4 -5
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +8 -8
- package/src/app/lib/mcp/components.ts +33 -29
- package/src/app/lib/mcp/pages.ts +26 -19
- package/src/app/lib/mcp/server.ts +33 -9
- package/src/app/lib/mcp/tokens.ts +22 -16
- package/src/app/lib/mcp/tool.ts +19 -0
- package/src/app/lib/registry-api/asset-transfer.ts +25 -25
- package/src/app/lib/server-module.ts +1 -1
- package/src/app/lib/utils.ts +7 -0
- package/src/app/next.config.mjs +2 -2
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/account/ai.tsx +1 -1
- package/src/app/pages/account/index.tsx +1 -1
- package/src/app/pages/account/tokens.tsx +1 -1
- package/src/app/pages/account/users.tsx +1 -1
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +1 -1
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/login.tsx +10 -5
- package/src/app/pages/reset-password.tsx +7 -2
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/build.ts +12 -12
- package/src/app-builder/client-config.ts +1 -1
- package/src/config/loader.ts +1 -2
- package/src/index.ts +6 -6
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/{define.ts → providers.ts} +8 -8
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +16 -20
- package/src/registry/asset-storage/types.ts +12 -12
- package/src/registry/db/schema.ts +3 -3
- package/src/registry/email/define.ts +1 -1
- package/src/registry/email/resolve.ts +23 -23
- package/src/registry/email/types.ts +1 -1
- package/src/store/registry.ts +7 -7
- package/src/types/config.ts +4 -14
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/define.d.ts +0 -10
- package/dist/registry/email/adapters/resend.d.ts +0 -7
- package/dist/registry/email/adapters/resend.js +0 -35
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/registry/email/adapters/resend.ts +0 -22
- /package/dist/registry/email/{adapters → providers}/smtp.d.ts +0 -0
- /package/dist/registry/email/{adapters → providers}/smtp.js +0 -0
- /package/src/registry/email/{adapters → providers}/smtp.ts +0 -0
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.
|