handoff-app
v2.0.0
Published
Automated documentation toolchain for building client side documentation from figma
Readme
Handoff - Design System Runtime
Handoff turns design tokens, components, and patterns into working documentation and distributable artifacts. You can serve the documentation from a local workspace, a static site, or a shared registry with a PostgreSQL database.
Recipes: RECIPES.md has complete examples for project layouts, catalog items, profiles, registry hosting, asset storage, email, and the AI assistant.
Upgrade from version 1.x.x: read the migration guide for the breaking changes and the migration steps.
Contents
- How Handoff works
- Requirements
- Installation
- Configuration
- Authoring
- Running the workspace
- Building
- Registry
- Connecting a workspace to a registry
- MCP
- AI assistant
- CLI reference
How Handoff works
Runtime modes
- Workspace is the default mode. Local files are the source of truth, and no database is necessary. You use it to author content.
- Registry is a deployed catalog with a PostgreSQL database. It stores and serves only the content that a workspace publishes to it.
- A connected workspace is a workspace that can publish to a registry and check out from it. It is not a third mode.
The runtime.mode value in the config sets the mode. A registry build sets
registry mode automatically, so the base config stays in workspace mode.
Workflow
Figma ── fetch ──▶ exported/ tokens, CSS, Sass, assets
catalog, pages ── start ──▶ local documentation at http://localhost:3000
── build ──▶ out/static (static site) or out/registry (registry server)
workspace ── publish ─▶ registry ── checkout ─▶ workspaceThe workspace is the only place that builds content. A registry never builds and never reads workspace source.
Terms
- Working directory: the directory that holds
handoff.config.ts. Every command runs against it. - Catalog item: one documented UI entry. It has a declaration file, a stable id, and previews.
- Component: a catalog item with an implementation (React, Handlebars, or CSF).
- Pattern: a catalog item that is a composition of other catalog items.
- Foundations: the tokens and assets that
fetchgets from Figma. - Page: a Markdown documentation page under
pages/. - Profile: a config file that merges onto the base config for one environment.
Requirements
- Node.js 22 or newer. We recommend Node.js 24 LTS.
- npm 10 or newer.
- A paid Figma account, to fetch a Figma library.
- PostgreSQL, to run a registry.
Installation
New project
Create a project with the interactive wizard:
npx handoff-app init
cd my-handoff-projectThe wizard asks for the project name, sample content, JavaScript or
TypeScript, an optional Vercel setup, and optional Figma credentials. It
creates the starter files and installs handoff-app in the project.
Use the generated npm scripts. They run the project version of handoff-app,
so local development and CI use the same version:
npm run fetch # optional: fetch Figma foundations
npm run start # local server with Handoff file watchers
npm run dev # local development server without Handoff file watchers
npm run build # static build by default
npm run publish # publish to a registry
npm run checkout # check out from a registry
npm run login # sign in to a registry
npm run logout # sign out of a registry
npm run db:migrate # registry database migrationsProject layout
<working directory>/
├─ handoff.config.ts
├─ .env
├─ components/
├─ patterns/
├─ pages/
├─ exported/ # fetched tokens and assets; commit this directory
├─ public/api/ # generated docs API; gitignored
├─ out/ # build output; gitignored
├─ .vercel/ # `--package vercel` output; gitignored
└─ .handoff/ # local Handoff state; gitignoredIn a project that init creates, the working directory is the repository
root. HANDOFF_WORKING_PATH sets a different working directory.
Adding Handoff to an existing application
Declarations sit next to the components that they document. Thus the catalog points into the application source:
catalog: { include: ['src/components', 'src/blocks'] },Two rules apply to every layout.
handoff-app must be a dependency of the package that holds the
*.handoff.ts files. A declaration imports handoff-app/react. Handoff
resolves that import from the directory of the declaration, not from the
directory of the config. With npm, hoisting usually hides a missing dependency.
With pnpm, Handoff skips the affected items with
Could not resolve "handoff-app/react", and the build still exits with code 0.
In a workspace-package layout, the application package and the handoff/
package must both declare handoff-app.
Two tsconfig.json files do different jobs:
- The config nearest to the declarations type checks them. An editor reads
only the nearest config. A config in a different directory that includes
src/does not count. - The config in the working directory makes the property tables. Without it,
Handoff shows
TypeScript config not found, and every table is empty.
If the working directory is not the project root, you need both files.
Three layouts work. Each keeps one dependency tree, so the catalog renders the
real components with the same React instance. Handoff does not support a
separate install with its own node_modules outside that tree.
| Layout | Working directory | Use when |
| --- | --- | --- |
| Subdirectory (recommended) | handoff/, set by HANDOFF_WORKING_PATH | The application must not change |
| Project root | The project root | The bundler can move its public/ directory (not Next.js) |
| Workspace package | handoff/, with its own package.json | The repository is already a workspace |
In the project-root layout, Handoff writes the generated documentation API to
public/api. Thus a bundler that serves public/ copies it into the
production build of the application. Move the bundler to a different
directory. A Next.js application cannot move its public directory, so it
cannot use this layout.
With pnpm, build --target registry cannot follow the pnpm symlinks. Set
nodeLinker: hoisted in pnpm-workspace.yaml. The other builds work with the
default pnpm layout.
To add Handoff in the recommended subdirectory layout:
Install
handoff-appin the application package:npm install handoff-appCreate
handoff/handoff.config.ts. Point the catalog at the application source, and load the global stylesheet of the application into the previews:import { defineConfig } from 'handoff-app'; export default defineConfig({ app: { title: 'Acme Design System' }, catalog: { include: ['../src/components'] }, entries: { scss: '../src/styles/main.scss' }, });Paths are relative to the working directory.
npx handoff-app eject:configwrites the full default config instead.Create
handoff/tsconfig.json, so that Handoff can make the property tables:{ "extends": "../tsconfig.json", "include": ["../src", "handoff.config.ts"] }Add the scripts to the
package.jsonof the application:{ "scripts": { "handoff:dev": "HANDOFF_WORKING_PATH=handoff handoff-app start", "handoff:build": "HANDOFF_WORKING_PATH=handoff handoff-app build" } }Write a declaration next to a component, for example
src/components/button/button.handoff.ts. The Catalog items section shows the format.Start the documentation:
npm run handoff:devAdd
handoff/public/api,handoff/out,handoff/.handoff, andhandoff/.vercelto.gitignore. Commithandoff/exportedif you fetch from Figma.
Recipes: Subdirectory · Project root · Workspace package · pnpm · npm scripts on Windows
Configuration
Config file
Handoff reads handoff.config.ts, .js, .cjs, or .json, in that order.
-c, --config <file> loads one specific file instead. defineConfig gives
typed authoring:
// handoff.config.ts
import { defineConfig, fromEnv } from 'handoff-app';
export default defineConfig({
app: { title: 'Acme Design System' },
catalog: { include: ['components', 'patterns'] },
runtime: {
mode: 'workspace',
workspace: { declarationFormat: 'ts' },
registry: {
database: { url: fromEnv('DATABASE_URL'), driver: 'pg' },
},
},
});Values merge onto the defaults. Plain objects merge recursively. Arrays,
scalars, null, and functions replace the default.
Main settings
| Setting | Purpose |
| --- | --- |
| app.title, app.client | Name of the documentation site and of the client |
| app.theme | Theme of the documentation app. npx handoff-app eject:theme copies it so that you can change it. |
| app.basePath | URL path of the site when you serve it under a sub-path, for example /design-system |
| app.breakpoints | Preview widths. A declared block merges onto the defaults. |
| app.ports | Workspace server ports, { app, websocket }. The defaults are 3000 and 3001. |
| catalog.include | Catalog directories. See Registration. |
| entries.scss, entries.js | Global stylesheet and script that Handoff builds and loads into every preview |
| integrations.figma | Figma file and token. See Figma foundations. |
| exportsOutputDirectory | Directory for fetched foundations. The default is exported. |
| sitesOutputDirectory | Directory for build output. The default is out. |
| runtime.mode | workspace (default) or registry |
| runtime.workspace.declarationFormat | Format of the declarations that checkout writes: ts, js, or cjs |
| runtime.registryConnection | Registry URL and token of a connected workspace |
| runtime.registry | Registry database, asset storage, and email. See Registry. |
| runtime.mcp | MCP endpoint on or off. See MCP. |
| runtime.ai | AI assistant. See AI assistant. |
| hooks | Build hooks, documented in docs/api.md |
The Config type documents every setting. Several settings read an environment
variable by default. Environment variables lists them.
Build-time and runtime values
fromEnv('NAME') refers to an environment variable. It does not copy the
value into the config.
- A literal value is baked into the build.
- For a
fromEnv()value, the build keeps only the variable name. A deployed registry reads the value at request time. Thus you can change it without a rebuild. - Secrets must use
fromEnv().
Some settings change the shape of the build. These settings are baked, so a change to a deployed registry needs a rebuild:
runtime.moderuntime.mcpruntime.ai.enabled- The database driver
- The asset storage provider and the email provider
- Every custom server module
Profiles
A profile is a config file that merges onto the base config. It holds only what changes between environments. Thus a project keeps one shared config, not a second full copy.
- The base config is
handoff.config.ts,.js,.cjs, or.json. When no profile is selected, Handoff loads only this file. - A profile file is
handoff.config.<profile>.*, with the same four extensions. - Select a profile with
--profile <name>or withHANDOFF_PROFILE.--profilehas priority overHANDOFF_PROFILE. - Profile names are not predefined. A name can contain lowercase letters, numbers, and hyphens.
- A selected profile must exist. If no
handoff.config.<name>.*file exists, the command stops with an error. login,logout,publish, andcheckoutalso accept a profile that has only a saved registry login. For these commands, the profile selects the registry. Every other command needs the config file.- Layers apply in this order: defaults, base config, profile, then programmatic config. The merge rules apply to each layer.
- A selected profile also reads
.env.<profile>on top of.env, if that file exists. Handoff reads both files from the directory where the command runs. A variable that is already in the environment has priority over both files.
npx handoff-app build --target registry --profile registryA profile is a good place for the registry build settings: the database
driver, the database variable name, the asset storage, the email provider, and
MCP. build --target registry always builds a registry-mode application, so
the profile does not set runtime.mode. A hosting provider or a CI job selects
the profile with HANDOFF_PROFILE.
defineConfig types a profile and a base config the same way. The generated
.gitignore lists handoff.config.local.*. Thus local is the usual name for
a profile that stays on one machine.
Arrays replace. Thus a profile that declares catalog.include also controls
what make:component and checkout register.
Handoff reads .env.<profile> once at startup and does not watch it. It cannot
set HANDOFF_WORKING_PATH, because Handoff finds the working path before it
knows the profile.
Recipes: Local profile · Registry build profile · One workspace, several registries
Environment variables
Handoff reads two kinds of variables:
- Fixed variables. Handoff always reads these names.
- Setting defaults. Each one is the default
fromEnv()name of a setting. To use a different name, set the setting tofromEnv('YOUR_NAME').
This section does not list the variables that you name in the config. Examples
are the keys of a custom asset storage provider or of an AI connection. A
standalone registry build writes out/registry/README.md, which lists every
variable that the build needs, with the names from your config.
Fixed variables
| Variable | Purpose |
| --- | --- |
| HANDOFF_PROFILE | Config profile merged onto the base config |
| HANDOFF_WORKING_PATH | Directory that holds handoff.config.ts. The default is the current directory. |
| HANDOFF_LOG_LEVEL | debug, info, warn, error, or silent. The default is info. |
| HANDOFF_LOG_SCOPES | Comma-separated log sources: handoff, vite, next. The default is all three. |
| HANDOFF_LOGIN_NO_BROWSER | Set to true to stop login from opening a browser, the same as --no-browser |
| HANDOFF_CREATE_ASSETS_ZIP_FILES | Set to false to skip the icon and logo zip files in fetch |
| HANDOFF_SYNC_SECRET | Optional deployment-wide credential with read and write access. The registry accepts it. A workspace uses it only when it has no token and no login. |
| AUTH_SECRET | Registry session-signing secret, at least 32 characters. Required by a registry. |
| AUTH_URL | Canonical public registry URL, with the base path. Required by a registry. |
| PORT | Standalone registry server port |
| HOSTNAME | Standalone registry server bind hostname |
| HANDOFF_AI_KEY_SECRET | Encrypts the AI provider keys of readers, at least 32 characters |
| HANDOFF_AI_CONNECTIONS | JSON array of AI connections, merged over the built-in list by id |
Setting defaults
| Variable | Setting | Purpose |
| --- | --- | --- |
| HANDOFF_FIGMA_PROJECT_ID | integrations.figma.projectId | Figma file ID that fetch uses |
| HANDOFF_DEV_ACCESS_TOKEN | integrations.figma.accessToken | Figma personal access token that fetch uses |
| HANDOFF_REGISTRY_URL | runtime.registryConnection.url | Registry URL of a connected workspace |
| HANDOFF_REGISTRY_ACCESS_TOKEN | runtime.registryConnection.accessToken | Registry access token of a connected workspace |
| DATABASE_URL | runtime.registry.database.url | PostgreSQL connection string. Required by a registry. |
| HANDOFF_APP_PORT | app.ports.app | Workspace documentation server port. The default is 3000. |
| HANDOFF_WEBSOCKET_PORT | app.ports.websocket | Workspace live-reload server port. The default is 3001. |
| HANDOFF_OUTPUT_DIR | exportsOutputDirectory | Fetched output directory. The default is exported. |
| HANDOFF_SITES_DIR | sitesOutputDirectory | Build output directory. The default is out. |
Keep the name HANDOFF_OUTPUT_DIR. The documentation app also reads this
variable directly.
Authoring
Catalog items
Every documented UI entry is a catalog item. An item declares one of these:
- An
implementation, which names a renderer: React, Handlebars, or CSF. The registry stores the item as a component. - A
compositionof other items. The registry stores the item as a pattern.
An item never declares both. defineCatalogItem comes from the module of the
renderer. The declaration gives the stable id, the documentation metadata, the
source entries, and the previews.
React. Handoff finds the implementation file from its import, so you do not
repeat the path. Handoff builds the CSS of the item from the stylesheets that
the import chain loads, descendants included. Previews are named exports.
Preview<typeof item> gives them the argument type of the implementation.
// components/example/Component.tsx
export type ComponentProps = {
label: string;
onSelect?: () => void;
};
export default function Component({ label, onSelect }: ComponentProps) {
return <div onClick={onSelect}>{label}</div>;
}// components/example/example.handoff.ts
import { defineCatalogItem, type Preview } from 'handoff-app/react';
import Component from './Component';
const item = defineCatalogItem({
id: 'component-id',
name: 'Component name',
description: 'Usage guidance for the component.',
group: 'Group name',
implementation: Component,
});
export default item;
type ComponentPreview = Preview<typeof item>;
export const Default = {
args: { label: 'Example' },
} satisfies ComponentPreview;The export name is the preview name. name sets a different display title.
Handoff serializes the arguments. Thus a preview that needs an icon, a
callback, JSX children, or its own state declares a render function. A
declaration that holds JSX must use the .handoff.tsx extension.
Handlebars. The implementation is the path to the template:
implementation: './Badge.hbs', from handoff-app/handlebars.
CSF. A React item uses an existing story file with
implementation: fromCSF('./Card.stories.tsx'). The story file does not
change, and its named exports are the previews. The server renders a CSF
preview, and the browser does not hydrate it. The browser hydrates a direct
React implementation.
Compositions. A composition references other items by stable id. Each
reference can select a named preview and override its arguments. A composition
needs no renderer, so it uses defineCatalogItem from handoff-app/pattern:
// patterns/example/example.handoff.ts
import { defineCatalogItem } from 'handoff-app/pattern';
export default defineCatalogItem({
id: 'pattern-id',
name: 'Pattern name',
description: 'Purpose and usage of the composition.',
group: 'Group name',
composition: [
{ ref: 'component-id', preview: 'Default' },
{ ref: 'badge', args: { children: 'Send' } },
],
});Ids. The id addresses the item everywhere: its artifacts, its
documentation URL, and the publish and checkout commands. Components and
patterns share one namespace, so each id must be unique across both. If two
declarations use one id, Handoff keeps the first, skips the second, and names
both files in a warning. publish does not run until each id is unique.
Terms such as atom, element, and block are optional classification metadata on
type and categories.
Recipes: React preview with a render function · Handlebars item · CSF stories · Composition with preview overrides
Registration
catalog.include in the config registers the catalog directories. Each path is
one of these:
- An item directory.
- A collection directory. Each subdirectory is an item.
catalog: {
include: ['components', 'patterns'],
},npx handoff-app make:component <name> creates a Handlebars item and adds its
directory to catalog.include when necessary.
Pages
Custom documentation pages are Markdown files under pages/. The relative
path of a page is its route and its registry id.
npx handoff-app make:page <name> [parent] creates a page.
npx handoff-app eject:pages copies the default pages so that you can change
them.
Figma foundations
The Figma integration is optional. If you enter Figma credentials during
init, the wizard writes them to .env, and fetch is ready to run.
To add or change the Figma source, set these values in .env:
HANDOFF_FIGMA_PROJECT_ID=figma-file-id
HANDOFF_DEV_ACCESS_TOKEN=figma-personal-access-tokenThe personal access token must have the file_content:read and
library_content:read scopes. Fetch the library:
npm run fetchfetch writes tokens, CSS, Sass, and the available assets to exported/.
Commit exported/ so that local builds and CI builds use the same inputs. To
make sure that the fetch worked, look for exported/tokens.json. An empty
foundation page does not prove that the fetch worked.
If a value is empty, fetch asks for it in the terminal. It can save the value
to .env, or to .env.<profile> when a profile is selected. Without a
terminal, for example in CI, fetch stops with an error that names the
missing variable.
After a design change, publish the Figma library again. Then run fetch again
to get the latest foundations.
These variable names are the defaults of integrations.figma. Set the block
only to use different variable names, to commit the file ID, or to change the
file for a profile. The access token accepts only an environment reference:
integrations: {
figma: {
projectId: fromEnv('HANDOFF_FIGMA_PROJECT_ID', { default: null }),
accessToken: fromEnv('HANDOFF_DEV_ACCESS_TOKEN', { default: null }),
},
},Running the workspace
Start the documentation server and the Handoff file watchers:
npm run startWhile the command runs, Handoff rebuilds changed files and updates the
documentation. If you do not need the file watchers, run npm run dev
instead.
Open http://localhost:3000 and make sure that:
- The app shows
Workspaceas the active runtime mode. - The component list shows your components.
- Each component page renders all of its previews.
- Each pattern renders the components that it references.
- The foundation pages show the fetched values.
Building
Static site. Build the documentation as a static site:
npm run buildThe output goes to out/static. A static file server or a CDN can serve it. A
static site has no API routes. Thus it has no MCP endpoint and no AI
assistant.
Build cache. Handoff does not build a component again if its files did not
change since the last build. The cache in .handoff/.cache/ records the files
of each component and every file that its last build read, Sass partials
included. The lockfile covers files under node_modules. The cache does not
track assets that a stylesheet loads through url(). Build every component
again:
npm run build -- --forceRegistry server. Build a standalone registry application:
npm run build -- --target registryThe command sets registry mode in the generated application. It writes the
Next.js server bundle to out/registry. It does not compile or serve the
workspace source. The Registry section explains how to deploy it.
Vercel. Add --package vercel to either target to make a Vercel Build
Output bundle in .vercel/output:
npm run build -- --target registry --package vercelRegistry
A registry is a deployed catalog with a PostgreSQL database. It serves only the records that a workspace publishes. It never reads the workspace directly.
Deploy a registry
Apply the database migrations from the workspace or a CI checkout, where the project config is available:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff?schema=public" \ npm run db:migrateMigrations are a controlled release step. The build, the server start, and the installer never run them. Run
db:migrateagain each time you upgradehandoff-app, before you deploy the new registry build.Select the database driver, the asset storage, and the email provider. The build bakes these choices, so set them before you build. The defaults work without more configuration.
Build the registry:
npm run build -- --target registryStart
out/registry/server.jswith these environment variables:PORT=4000 HOSTNAME=0.0.0.0 DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff?schema=public" AUTH_SECRET="replace-with-at-least-32-random-characters" AUTH_URL="http://localhost:4000"DATABASE_URL,AUTH_SECRET, andAUTH_URLare required. The standalone server also readsPORTandHOSTNAME. For Vercel, build with--package verceland set these variables in the deployment environment.Open http://localhost:4000/install and create the first administrator. The installer examines the deployment, but it never runs migrations.
CAUTION: Complete the installation immediately after the deployment. The first visitor can claim a registry that is not installed.
Recipes: Local registry · Vercel and Neon
Database
PostgreSQL is the supported database. driver selects how the registry
connects. The default is pg. Use neon for Neon PostgreSQL:
runtime: {
registry: {
database: {
url: fromEnv('DATABASE_URL'),
driver: 'neon',
},
},
},Both drivers use the same schema and the same db:migrate step.
Asset storage
By default, the registry stores asset blobs in PostgreSQL. Each blob can be at
most 4 MB. assetStorage.maxInlineBytes changes this limit.
A custom server module can store blobs in a different service:
runtime: {
registry: {
assetStorage: {
provider: 'custom',
module: './server/storage/s3.mjs',
options: { bucket: 'handoff-assets', secretKey: fromEnv('S3_SECRET_KEY') },
},
},
},The module default-exports a defineStorageProvider() result with these
methods:
putstores a blob and returns thestorageRefthat the registry records.getreturns a blob by itsstorageRef, as{ kind: 'stream', stream },{ kind: 'bytes', bytes }, or{ kind: 'redirect', url }.deleteremoves a blob by itsstorageRef.createUploadis optional. It enables direct uploads.
Upload paths. publish uploads only the blobs that the registry does not
have. The active provider decides which of two paths each blob takes. No
setting changes the path.
| Path | When | Hash check | Size limit |
| --- | --- | --- | --- |
| Through the registry | The database provider, or a custom provider without createUpload | The registry | The request limit of the host, about 4.5 MB on Vercel. The database provider also applies maxInlineBytes. |
| Direct to storage | A custom provider with createUpload | Storage, through the signed URL | The limit of the storage |
For a direct upload, the registry gives the CLI a signed URL, and the CLI sends
the blob to storage. The registry records the blob only after get finds the
stored object.
- The machine that runs
publishmust be able to connect to the storage URL. - If a direct upload fails,
publishstops. It does not try again through the registry. - The signed URL must make storage reject bytes that do not match
hash. - The registry does not delete an uploaded object that it never records.
Download paths. The result of get controls how a reader receives a blob.
With { kind: 'redirect' }, the registry sends the reader to storage. With
bytes or stream, the registry sends the blob itself.
Changing the provider. The registry records which provider stores each blob.
Blobs in PostgreSQL stay readable after you select a custom provider. A publish
does not move blobs. If you change the custom provider or its storage, copy the
stored objects first. The new provider must find each object by the
storageRef that the previous provider returned. After a change back to
database, the registry cannot read the blobs of the custom provider.
Recipes: S3-compatible storage · Vercel Blob
The registry sends invitation and password-reset emails when email.from is
set. Without it, an administrator sees each invitation link one time and sends
it manually, and password reset is not available.
runtime: {
registry: {
email: {
from: 'Handoff <[email protected]>',
provider: 'smtp',
options: { host: fromEnv('SMTP_HOST') },
},
},
},provider selects the delivery service. It is required when you set email.
smtpsends through an SMTP server, for example Amazon SES, SendGrid, or Microsoft 365.customnames a custom server module that default-exportsdefineEmailProvider(). Use it for an HTTP API, for example Resend or Postmark.
apiKey and password must use fromEnv(). A profile can set a different
sender or provider. When a profile changes the provider, it replaces
options.
Recipes: SMTP · Resend · Custom email provider
Custom server modules
Asset storage, email, and AI connections can name a custom server module. Each module must obey these rules:
- The path is relative to the working directory.
- The file is
.jsor.mjs. - The default export is the object, or a factory that returns it.
- The module imports its define helper from
handoff-app/providers. The registry build stops when a module importshandoff-app. - The project installs the packages that the module imports. The registry build copies them into the bundle.
The factory gets options with each fromEnv() value resolved at request
time.
Connecting a workspace to a registry
A connected workspace publishes content to a registry and checks content out from it. It needs a registry URL and an access token. You can supply them with a device login or with environment variables. Both use the same kind of token. You can see and revoke tokens under Account → Access tokens in the registry.
Device login
Start the authorization from the workspace:
npm run login -- --url http://localhost:4000In the browser, sign in to the registry, enter the device code, and approve the
CLI. The CLI saves the credential in .handoff/cli-auth.json for that exact
registry URL.
The CLI saves one credential for each profile. Thus a workspace can stay signed in to several registries:
npm run login -- --profile staging --url https://staging.example.com
npm run publish -- all --profile stagingA login profile name is free. It needs no handoff.config.<name>.* file, and
login is the only command that accepts a new name. A profile without its own
login uses the default login. Thus one npm run login is enough for a
workspace with one registry.
npm run logout revokes and removes the login of the selected profile.
--all removes every saved login.
Access token
Create a token in the registry under Account → Access tokens. To publish, the
token needs read and write access. To check out, read access is enough. Set the
token and the registry URL in .env:
HANDOFF_REGISTRY_URL=http://localhost:4000
HANDOFF_REGISTRY_ACCESS_TOKEN=hnd_...These two variables are the defaults of runtime.registryConnection, so no
config entry is necessary. A selected profile reads .env.<profile> on top of
.env. Thus each profile can address a different registry. In CI, the job
environment supplies the same two variables.
Environment values have priority over a saved device login. Thus a CI job gives
the same result on a computer where a developer is signed in. A token works
only for the registry URL that issued it. If the environment names a different
registry than the login of the selected profile, publish and checkout
report which login they skipped and why.
A config block is necessary only to keep the URL in the repository, or to read the values from variables with different names:
runtime: {
registryConnection: {
url: 'https://registry.example.com',
accessToken: fromEnv('HANDOFF_REGISTRY_ACCESS_TOKEN'),
},
},Publishing
Publish every kind of content in dependency order:
npm run publish -- allPublish one kind:
npm run publish -- catalog
npm run publish -- pages
npm run publish -- tokens
npm run publish -- assetscatalog covers every catalog item. The declaration of each item decides if
the registry stores it as a component or as a pattern. Thus the command names
items, not component or pattern kinds.
tokens and assets run the Figma pipeline before the upload. Thus the
Figma credentials must be available.
Add one or more ids to publish only those entities:
npm run publish -- catalog item-id another-id--dry-run lists the content that a real publish uploads. It does not connect
to a registry. Thus it needs no registry URL and no token. It still runs the build,
which updates the generated output on disk. --no-build skips the build and
publishes the existing output. Together, the two options do not change the
workspace:
npm run publish -- all --dry-run
npm run publish -- catalog --no-buildAfter a publish, reload the registry to see the published items and foundations.
Checkout
checkout writes registry content into the workspace. It takes the same
all, kind, id, and --dry-run forms as publish:
npm run checkout -- all
npm run checkout -- catalog item-id
npm run checkout -- all --dry-runA dry-run checkout reads from the registry and lists the files that a real
checkout creates or overwrites. It writes nothing. Checkout writes declarations in the
format that runtime.workspace.declarationFormat sets.
Recipes: One workspace, several registries · Publish from GitHub Actions
MCP
The documentation app serves a Model Context Protocol endpoint at /api/mcp/.
Coding agents use it to find the pages, components, and tokens that exist. Thus
they do not invent markup and values. The endpoint uses stateless Streamable
HTTP over POST. There is nothing to start and no extra port.
| Target | Endpoint | Credential |
| --- | --- | --- |
| Workspace (dev / start) | http://localhost:3000/api/mcp/ | None, as with /api/docs/* |
| Registry (standalone or Vercel) | <registry url>/api/mcp/ | An access token. Read access is enough. |
| Static site | Not available | Not applicable |
Keep the trailing slash. The app sets trailingSlash: true, so /api/mcp
answers with a 308 redirect. Registry tokens come from handoff-app login or
from Account → Access tokens in the registry.
The documentation app shows its endpoint and a ready-made client config for Claude Code, Cursor, and VS Code behind the plug icon in the header.
MCP is on by default. To turn it off, set runtime.mcp: false. The build bakes
this value. A build with MCP off answers 404 on the route, leaves the MCP SDK
out of the bundle, and shows no connect control.
Recipes: Connect a client by hand · Turn off MCP
AI assistant
The documentation app can answer questions about the design system. The
assistant reads through the same MCP tools. Thus each answer comes from the
catalog and links to the pages and components that it read. Open the assistant
from the search control in the header, or with ⌘K.
The ai block turns on the assistant, and the deployment selects the
provider:
runtime: {
ai: {
connections: [
{
id: 'gateway',
label: 'Acme LiteLLM',
baseUrl: 'https://llm.acme.internal/v1',
apiKey: fromEnv('LITELLM_API_KEY'),
models: ['gpt-4o', 'claude-sonnet-4-5'],
},
],
defaultModel: 'gateway/claude-sonnet-4-5',
},
},ai: { enabled: false } turns off the assistant. The build bakes this flag.
A build without the assistant serves no chat route and shows no control.
Each connection uses one of these credentials:
- Service key:
apiKeynames a deployment key. The deployment pays for every reader. - No credential: the endpoint needs no key, for example a local Ollama.
- Reader key:
credential: 'user'asks each reader for their own key.
The declared connections are the full surface. A reader can add a key only
for a connection that the config declares. Thus the server never calls a URL
that a reader selected. Without a credential: 'user' connection, readers have
no AI setting.
Each connection uses the OpenAI-compatible /chat/completions API. This does
not limit the models. Ollama serves an OpenAI-compatible API at /v1. LiteLLM,
OpenRouter, vLLM, LM Studio, and Azure OpenAI each give access to Anthropic,
Google, and xAI models. For a provider without this API, name a
custom server module that default-exports
defineAiProvider(), instead of baseUrl.
The gateway controls cost. LiteLLM and OpenRouter both apply budgets and rate limits for each key. The assistant limits only the number of steps for one question.
Reader keys
A reader adds a key under Account → AI providers. The registry encrypts keys
with HANDOFF_AI_KEY_SECRET. It does not hash them, because the server must
send them to the provider. Set this variable to a random value of at least 32
characters. A key is write-only through the API. After a save, a read reports
only that a key exists.
Reader keys work only in registry mode. A workspace has one user, whose config
file and .env hold their keys. Thus a workspace supplies a key through
apiKey.
Changing connections without a rebuild
The connection list is deployment data, not build shape. Thus a registry can
change it without a rebuild. HANDOFF_AI_CONNECTIONS holds a JSON array. The
registry reads it at request time and merges it over the built-in list by id.
Each entry names its key variable with apiKeyEnv, not the key value.
A connection in this list can use a module only if a connection in the config
names the same module. The build copies only those modules into the registry.
Recipes: Service key through a gateway · Local Ollama · Reader keys · Custom AI provider · Change connections without a rebuild
CLI reference
| Command | Description |
| --- | --- |
| npx handoff-app init | Create a project with the interactive wizard |
| npm run fetch | Fetch design tokens and assets from Figma |
| npm run start | Start the workspace server and the Handoff file watchers |
| npm run dev | Start the workspace server without the file watchers |
| npm run build -- [--target static\|registry] [--package vercel] | Build the static site or the registry server |
| npm run db:migrate | Apply the registry database migrations |
| npm run publish -- <kind\|all> [id...] | Publish catalog items, pages, tokens, or assets |
| npm run checkout -- <kind\|all> [id...] | Write published content into the workspace |
| npm run login -- [--profile <name>] --url <url> | Authorize the CLI through the registry device flow |
| npm run logout -- [--profile <name>] [--all] | Revoke and remove saved CLI credentials |
| npx handoff-app validate:components | Validate the catalog items |
| npx handoff-app make:component <name> | Create a Handlebars catalog item |
| npx handoff-app make:page <name> [parent] | Create a documentation page |
| npx handoff-app scaffold | Create catalog item stubs for fetched Figma components |
| npx handoff-app eject:config | Write the default config to the working directory |
Every command except init accepts -c, --config, --profile, -d, --debug,
and -f, --force. publish and checkout also accept --dry-run, and
publish accepts --no-build.
npm forwards the arguments after -- to the CLI. To see the options of a
command, add --help after --. docs/cli.md lists every
command and option. docs/api.md documents the JavaScript API and
the build hooks.
Maintainers
Contributing
Issues and pull requests are welcome.
Handoff follows the Contributor Covenant Code of Conduct.
License
MIT ©Convertiv
