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)
├── assets/ # Private static assets (optional)
├── 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.7.0",
"minWealthfolioVersion": "3.7.0",
"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;
Wealthfolio 3.7 automatically indexes assets/** and dist/assets/**; there is
no assets manifest field or permission. Load a hand-authored image, JSON file,
font, media file, or Wasm module through the private registry:
const logoUrl = await ctx.assets.getUrl("assets/logo.png");
const configBlob = await ctx.assets.getBlob("assets/config.json");
const config = JSON.parse(await configBlob.text());
Use Blob URLs only for the current addon lifetime. The sandbox revokes them on
reload or disable. Local CSS url(...) references are resolved relative to the
stylesheet automatically, while remote CSS URLs and @import are rejected.
JavaScript/JSX strings are not rewritten, so images rendered by components must
use getUrl().
ctx.assets contains package files; ctx.api.assets manages Wealthfolio
financial instruments. Addons using ctx.assets must set sdkVersion and
minWealthfolioVersion to 3.7.0. See the
v3.6 to v3.7 guide for limits and
compatibility.
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.7.0",
"minWealthfolioVersion": "3.7.0",
"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:3001/runtime-package generations for code, CSS, and private assetsWealthfolio 3.7 requires @wealthfolio/addon-dev-tools 3.7 or newer. If the
host reports that the server does not support v3.7 runtime packages, upgrade the
package and restart pnpm dev:server.
Recommended 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-addon 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/ assets/"
}
}
{
"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 module
// and one QueryClient scoped to this addon). 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: {
target: ["chrome107", "edge107", "firefox104", "safari16"],
lib: {
entry: "src/addon.tsx",
formats: ["es"],
fileName: () => "addon.js",
},
outDir: "dist",
minify: true,
sourcemap: false,
rollupOptions: {
external: hostProvidedDependencies,
},
},
});
You now understand:
Continue with: