docs/addons/addon-getting-started.md
# Check Node.js version (requires 20+)
node --version
# Check pnpm
pnpm --version
# Install pnpm if needed
npm install -g pnpm
Requirements:
For the best development experience with live reload and testing, start Wealthfolio in addon development mode:
# Clone Wealthfolio repository (if not already done)
git clone https://github.com/wealthfolio/wealthfolio.git
cd wealthfolio
# Install dependencies
pnpm install
# Start in addon development mode
VITE_ENABLE_ADDON_DEV_MODE=true pnpm tauri dev
This enables:
Note: For browser-only development (without Tauri), you can use
pnpm dev:addonsinstead.
# Navigate to development directory
cd ~/Documents/WealthfolioAddons
# Create addon using CLI
npx @wealthfolio/addon-dev-tools create hello-world-addon
# Navigate and install
cd hello-world-addon
pnpm install
This will scaffold a new addon project with the following structure:
hello-world-addon/
├── src/
│ ├── addon.tsx # Main addon entry point
│ ├── components/ # React components
│ ├── hooks/ # React hooks
│ ├── pages/ # Addon pages
│ ├── utils/ # Utility functions
│ └── types/ # Type definitions
├── dist/ # Built files (generated)
├── manifest.json # Addon metadata and permissions
├── package.json # NPM package configuration
├── vite.config.ts # Build configuration
├── tsconfig.json # TypeScript configuration
└── README.md # Documentation
manifest.json defines metadata, the pages your addon contributes, and any data
permissions it needs:
{
"id": "hello-world-addon",
"name": "Hello World Addon",
"version": "1.0.0",
"description": "My first Wealthfolio addon",
"author": "Your Name",
"main": "dist/addon.js",
"sdkVersion": "3.6.1",
"minWealthfolioVersion": "3.6.1",
"enabled": true,
"contributes": {
"routes": [{ "id": "hello-world" }],
"links": {
"sidebar": [
{
"id": "hello-world",
"route": "hello-world",
"label": "Hello World",
"icon": "puzzle-piece",
"order": 100
}
]
}
},
"permissions": []
}
Navigation is declarative: the host reads contributes.routes and
contributes.links from the manifest and renders your sidebar entry without
running your addon (this is what enables lazy activation). A route is a
durable addon page; a link places a route into a host slot (only "sidebar"
is consumed today) and references a declared route by id. The runtime
router.add({ id }) in the next section MUST use the same id as the declared
route.
The host derives the route URL from the manifest id, so this root page is
mounted at /addons/hello-world-addon. Omit path for the root; nested pages
use a relative suffix such as "path": "reports/:year".
Permissions:
ui,query,toast,logger, andstorageare implicit baseline capabilities — you do not declare them. Only data categories (accounts,portfolio,activities, …) plusfiles,network,secrets,events,snapshots, andsettingsrequire a permission entry and user consent.
src/addon.tsx contains the addon logic:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { AddonContext, AddonEnableFunction } from '@wealthfolio/addon-sdk';
function HelloWorldPage() {
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-4">Hello Wealthfolio</h1>
<p className="text-xl mb-8">Your first addon is working.</p>
<div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-2">Success</h2>
<p>You've successfully created and loaded your first addon.</p>
</div>
</div>
);
}
// The host owns a single React root per addon and mounts the route `component`
// itself (with no ctx), so capture the context at enable time for the route
// wrapper to hand down. Do NOT call createRoot yourself.
let addonCtx: AddonContext | undefined;
const HelloWorldRoute = () => (
<QueryClientProvider client={addonCtx!.api.query.getClient() as QueryClient}>
<HelloWorldPage />
</QueryClientProvider>
);
const enable: AddonEnableFunction = (ctx) => {
addonCtx = ctx;
// The route `id` MUST match `contributes.routes[].id` in manifest.json.
// The sidebar entry is declared in the manifest, so there is no
// ctx.sidebar.addItem call here.
ctx.router.add({
id: 'hello-world',
path: '/addons/hello-world-addon',
component: HelloWorldRoute,
});
ctx.api.logger.info('Hello World addon loaded');
// The host owns the React root, so there is nothing to unmount here.
ctx.onDisable(() => {
addonCtx = undefined;
ctx.api.logger.info('Hello World addon disabled');
});
};
export default enable;
Do not call
createRootyourself. The host owns the single React root and mounts yourcomponentinto it. A per-routecreateRootleaves an orphaned React tree whose re-renders never reach the DOM — the classic "buttons do nothing" bug. The component receives a{ location }prop (anAddonRouteLocation), and the sandbox has no react-router provider, so do not calluseLocation()/useParams().renderstill exists as a legacy imperative escape hatch, butcomponentis preferred.
# Start development server (recommended)
pnpm dev:server
Output:
Wealthfolio Addon Development Server
Addon: hello-world-addon
Server: http://localhost:3001
Watching for changes...
src/ directorypnpm dev:server # Start development server (recommended)
pnpm build # Production build
pnpm type-check # Run TypeScript checks
pnpm lint # Run ESLint
pnpm format # Run Prettier
pnpm bundle # Bundle addon for distribution
Verify in Wealthfolio:
VITE_ENABLE_ADDON_DEV_MODE=true pnpm tauri devFor data access, it's recommended to use TanStack Query.
First, install TanStack Query in your addon:
pnpm add @tanstack/react-query@^5.90.0
Update src/addon.tsx to access portfolio data using TanStack Query:
import {
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/react-query';
import type {
Account,
AddonContext,
AddonEnableFunction,
} from '@wealthfolio/addon-sdk';
function HelloWorldPage({ ctx }: { ctx: AddonContext }) {
const {
data: accounts = [],
isLoading,
isError,
error,
refetch
} = useQuery<Account[]>({
queryKey: ['accounts'],
queryFn: () => ctx.api.accounts.getAll(),
onError: (error) => {
ctx.api.logger.error('Failed to load accounts:', error);
},
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-6">Hello Wealthfolio</h1>
<div className="border rounded-lg p-6 mb-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Portfolio Summary</h2>
<button
onClick={() => refetch()}
disabled={isLoading}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Refresh'}
</button>
</div>
{isLoading ? (
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900"></div>
<span>Loading accounts...</span>
</div>
) : isError ? (
<div className="text-red-600">
<p>Failed to load accounts: {error?.message}</p>
<button
onClick={() => refetch()}
className="mt-2 px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Try Again
</button>
</div>
) : (
<div>
<p className="mb-4">
You have {accounts.length} account{accounts.length !== 1 ? 's' : ''}:
</p>
{accounts.length > 0 ? (
<div className="grid gap-3">
{accounts.map((account) => (
<div key={account.id} className="border rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">{account.name}</h3>
<p className="text-sm text-muted-foreground">
{account.currency} • {account.isActive ? 'Active' : 'Inactive'}
</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold">
{account.totalValue?.toLocaleString() || 'N/A'}
</div>
<div className="text-sm text-muted-foreground">Total Value</div>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">
No accounts found. Add an account in Wealthfolio to see data.
</p>
)}
</div>
)}
</div>
</div>
);
}
// The host mounts this route component itself with no ctx, so the wrapper
// pulls the captured context and shares one QueryClient across navigations.
let addonCtx: AddonContext | undefined;
const HelloWorldRoute = () => (
<QueryClientProvider client={addonCtx!.api.query.getClient() as QueryClient}>
<HelloWorldPage ctx={addonCtx!} />
</QueryClientProvider>
);
const enable: AddonEnableFunction = (ctx) => {
addonCtx = ctx;
ctx.router.add({
id: 'hello-world',
path: '/addons/hello-world-addon',
component: HelloWorldRoute,
});
ctx.onDisable(() => {
addonCtx = undefined;
});
};
export default enable;
Update manifest.json to include account access. Each permission is an object
declaring a data category, the functions you call, and a human-readable
purpose shown to the user at install time:
{
"id": "hello-world-addon",
"name": "Hello World Addon",
"version": "1.0.0",
"description": "My first Wealthfolio addon",
"author": "Your Name",
"main": "dist/addon.js",
"sdkVersion": "3.6.1",
"minWealthfolioVersion": "3.6.1",
"enabled": true,
"contributes": {
"routes": [{ "id": "hello-world" }],
"links": {
"sidebar": [
{
"id": "hello-world",
"route": "hello-world",
"label": "Hello World",
"icon": "puzzle-piece",
"order": 100
}
]
}
},
"permissions": [
{
"category": "accounts",
"functions": ["getAll"],
"purpose": "Display account summary"
}
]
}
There is no ui permission — reading portfolio/UI navigation is part of the
baseline. Only the accounts data access needs declaring here.
# Build for production
pnpm build
# Package for distribution
pnpm bundle
Creates dist/hello-world-addon.zip for installation.
Access full debugging capabilities:
// Use console for debugging
ctx.api.logger.info("Debug message");
ctx.api.logger.error("Error message");
// Access React DevTools
// Components will show up in React DevTools extension
const enable: AddonEnableFunction = (ctx) => {
try {
// Your addon code
} catch (error) {
ctx.api.logger.error("Addon error: " + (error as Error).message);
// Handle gracefully
}
};
Sandbox error surfaces. Because addons run in an isolated sandbox, the host classifies common failures and surfaces them both as a toast and as an inline panel in the addon frame: blocked storage (a
localStoragecall — usectx.api.storageinstead), an unknown API (calling a method the host doesn't expose), or an unavailable route surface (navigating to a route whose runtimerouter.add({ id })doesn't match a declaredcontributes.routes[].id). Watch for these when a page renders blank or a control does nothing.
http://localhost:3001Recommended extensions:
Create .vscode/settings.json:
{
"typescript.preferences.importModuleSpecifier": "relative",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
# Type checking
pnpm type-check
# Linting
pnpm lint
# Formatting
pnpm format
{
"scripts": {
"dev:server": "wealthfolio dev",
"build": "vite build",
"type-check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"bundle": "pnpm build && zip -r addon.zip manifest.json dist/"
}
}
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
// Host-provided dependencies are marked `external` so they are NOT bundled —
// the sandbox provides the real instances at runtime (one shared React, one
// shared QueryClient). Keep this list aligned with `hostDependencies` in
// manifest.json.
const hostProvidedDependencies = [
"@tanstack/react-query",
"@wealthfolio/addon-sdk",
"@wealthfolio/addon-sdk/host-api",
"@wealthfolio/addon-sdk/host-dependencies",
"@wealthfolio/addon-sdk/manifest",
"@wealthfolio/addon-sdk/permissions",
"@wealthfolio/addon-sdk/types",
"@wealthfolio/addon-sdk/utils",
"@wealthfolio/ui",
"@wealthfolio/ui/chart",
"date-fns",
"lucide-react",
"react",
"react-dom",
"react-dom/client",
"react/jsx-dev-runtime",
"react/jsx-runtime",
"recharts",
];
export default defineConfig({
plugins: [react(), tailwindcss()],
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
build: {
lib: {
entry: "src/addon.tsx",
formats: ["es"],
fileName: () => "addon.js",
},
outDir: "dist",
minify: true,
sourcemap: false,
rollupOptions: {
external: hostProvidedDependencies,
},
},
});
You now understand:
Continue with: