Creating Plugins
Plugins are built with TypeScript and Svelte 5. The CLI scaffolds everything you need to get started.
Prerequisites
Section titled “Prerequisites”- Node.js 18 or later
- npm
- The
tlCLI (installation guide)
Create a New Plugin
Section titled “Create a New Plugin”tl plugin new my-plugincd my-pluginnpm installThis creates a ready-to-build plugin with:
manifest.json- Plugin metadata (customized with your plugin name)src/index.ts- Entry point (customized IDs, names, and schema)src/MyPluginView.svelte- Example Svelte component (named after your plugin)vite.config.ts- Build configuration (supports hot-reload dev mode)scripts/release.sh- Release automation.github/workflows/release.yml- GitHub Actions workflow
All files are customized with your plugin’s name — no manual renaming needed.
Project Structure
Section titled “Project Structure”my-plugin/├── manifest.json # Plugin identity and permissions├── package.json # Dependencies (includes plugin-sdk)├── src/│ ├── index.ts # Entry point - registers views, commands│ └── MyPluginView.svelte # Svelte 5 component├── dist/│ └── index.js # Built bundle (generated)├── scripts/│ └── release.sh # Tag and push releases├── .github/│ └── workflows/│ └── release.yml # GitHub Actions build├── vite.config.ts└── tsconfig.jsonmanifest.json
Section titled “manifest.json”The manifest defines your plugin’s identity and permissions:
{ "id": "my-plugin", "name": "My Plugin", "version": "0.1.0", "description": "What your plugin does", "author": "Your Name", "main": "index.js", "permissions": { "read": ["transactions", "accounts"], "schemaName": "plugin_my_plugin" }}Fields
Section titled “Fields”| Field | Required | Description |
|---|---|---|
id | Yes | Unique identifier (lowercase, hyphens allowed) |
name | Yes | Display name shown in the UI |
version | Yes | Semantic version (e.g., 0.1.0) |
description | Yes | Short description of what the plugin does |
author | Yes | Your name or organization |
main | Yes | Entry point file (always index.js) |
permissions | No | Data access permissions |
Permissions
Section titled “Permissions”read- Core tables your plugin can SELECT from (e.g.,transactions,accounts)write- Tables outside your schema that your plugin can write to (optional)schemaName- Your plugin’s database schema (defaults toplugin_<id>)
Your plugin automatically has full read/write access to its own schema. You don’t need to declare write permissions for tables in your schema. If your plugin needs to write to tables outside its schema, list them explicitly in permissions.write.
Entry Point (index.ts)
Section titled “Entry Point (index.ts)”The entry point exports a plugin object that implements the Plugin interface:
import type { Plugin, PluginContext, PluginSDK, PluginMigration } from "@treeline-money/plugin-sdk";import MyPluginView from "./MyPluginView.svelte";import { mount, unmount } from "svelte";
// Database migrations (optional)const migrations: PluginMigration[] = [ { version: 1, name: "create_items_table", up: ` CREATE TABLE IF NOT EXISTS plugin_my_plugin.items ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `, },];
export const plugin: Plugin = { // Must match manifest.json manifest: { id: "my-plugin", name: "My Plugin", version: "0.1.0", description: "What your plugin does", author: "Your Name", permissions: { read: ["transactions", "accounts"], schemaName: "plugin_my_plugin", }, },
// Database migrations migrations,
// Called when the plugin loads activate(context: PluginContext) { // Register a view context.registerView({ id: "my-plugin-view", name: "My Plugin", icon: "target", mount: (target: HTMLElement, props: { sdk: PluginSDK }) => { const instance = mount(MyPluginView, { target, props }); return () => unmount(instance); }, });
// Add to sidebar context.registerSidebarItem({ sectionId: "main", id: "my-plugin", label: "My Plugin", icon: "target", viewId: "my-plugin-view", });
// Register a command (appears in command palette) context.registerCommand({ id: "my-plugin.do-something", name: "Do Something", execute: () => { // Your command logic }, }); },
// Called when the plugin unloads (optional) deactivate() { // Cleanup if needed },};Plugin Lifecycle
Section titled “Plugin Lifecycle”- Load - App reads
manifest.jsonand loadsdist/index.js - Migrations - Any pending migrations run in order
- Activate -
plugin.activate(context)is called - Mount - When user opens a view,
mount()is called with SDK - Deactivate -
plugin.deactivate()is called when app closes or plugin is disabled
Svelte Views
Section titled “Svelte Views”Views are Svelte 5 components that receive the SDK via props:
<script lang="ts"> import { onMount, onDestroy } from "svelte"; import type { PluginSDK } from "@treeline-money/plugin-sdk";
interface Props { sdk: PluginSDK; } let { sdk }: Props = $props();
// State using Svelte 5 runes let items = $state<any[]>([]); let isLoading = $state(true);
// Cleanup function for subscriptions let unsubscribe: (() => void) | null = null;
onMount(async () => { // Subscribe to data refresh events unsubscribe = sdk.onDataRefresh(() => loadData());
// Load initial data await loadData(); });
onDestroy(() => { if (unsubscribe) unsubscribe(); });
async function loadData() { isLoading = true; try { items = await sdk.sql( "SELECT description, amount, posted_date, account_name FROM transactions ORDER BY posted_date DESC LIMIT 10" ); } catch (e) { sdk.toast.error("Failed to load", e instanceof Error ? e.message : String(e)); } finally { isLoading = false; } }</script>
<!-- Use .tl-view as root container — inherits app theme automatically --><div class="tl-view"> <div class="tl-header"> <div class="tl-header-left"> <h1 class="tl-title">My Plugin</h1> <p class="tl-subtitle">Recent transactions</p> </div> </div>
<div class="tl-content"> {#if isLoading} <div class="tl-loading"> <div class="tl-spinner"></div> <span>Loading...</span> </div> {:else} <table class="tl-table"> <thead> <tr> <th>Date</th> <th>Description</th> <th>Account</th> <th style="text-align: right">Amount</th> </tr> </thead> <tbody> {#each items as item} <tr> <td class="tl-cell-date">{item.posted_date}</td> <td>{item.description}</td> <td class="tl-muted">{item.account_name}</td> <td class={item.amount >= 0 ? "tl-cell-positive" : "tl-cell-negative"}> {sdk.currency.format(item.amount)} </td> </tr> {/each} </tbody> </table> {/if} </div></div>No custom CSS needed — the .tl-* classes handle layout, colors, and theme switching automatically.
Svelte 5 Runes
Section titled “Svelte 5 Runes”Treeline plugins use Svelte 5 with runes for reactivity:
$state()- Reactive state$derived()- Computed values$props()- Component props$effect()- Side effects
See the Svelte 5 documentation for details.
Theme Support
Section titled “Theme Support”Plugins inherit the app’s theme automatically through CSS variables. Use the built-in .tl-* CSS classes and CSS variables — they adapt to light and dark mode without any JavaScript:
<div class="tl-view"> <div class="tl-content"> <!-- These adapt to the theme automatically --> <div class="tl-card"> <span class="tl-card-label">Total</span> <span class="tl-card-value">{sdk.currency.format(total)}</span> </div> </div></div>
<style> /* For custom styles, use CSS variables instead of hardcoded colors */ .custom-section { background: var(--bg-secondary); border: 1px solid var(--border-primary); border-radius: var(--radius-md, 6px); color: var(--text-primary); }</style>If you need to know the current theme for non-CSS purposes (e.g., configuring a chart library), use sdk.theme.current() and sdk.theme.subscribe().
Styling
Section titled “Styling”Treeline provides CSS classes prefixed with tl- that match the app’s look and feel. Always use these instead of hardcoding colors.
Layout
Section titled “Layout”| Class | Purpose |
|---|---|
.tl-view | Root container (full height, themed background) |
.tl-header | Header bar with bottom border |
.tl-header-left / .tl-header-right | Header layout sections |
.tl-title | Section title (16px, semibold) |
.tl-subtitle | Muted subtitle (13px) |
.tl-content | Scrollable content area with padding |
Components
Section titled “Components”| Class | Purpose |
|---|---|
.tl-cards | Grid container for stat cards |
.tl-card / .tl-card-label / .tl-card-value | Stat card with label and value |
.tl-btn + .tl-btn-primary | Primary action button |
.tl-btn + .tl-btn-secondary | Secondary button |
.tl-btn + .tl-btn-danger | Destructive action button |
.tl-table | Styled data table |
.tl-input / .tl-select | Form elements |
.tl-badge | Status badge |
.tl-empty | Empty state container |
.tl-loading / .tl-spinner | Loading state with spinner |
CSS Variables
Section titled “CSS Variables”For custom styles beyond what the built-in classes provide:
/* Backgrounds */var(--bg-primary) /* Main background */var(--bg-secondary) /* Cards, sections */var(--bg-tertiary) /* Subtle backgrounds */
/* Text */var(--text-primary) /* Main text */var(--text-secondary) /* Secondary text */var(--text-muted) /* Labels, hints */
/* Borders & accents */var(--border-primary) /* Standard borders */var(--accent-primary) /* Primary accent (green) */var(--accent-danger) /* Error/danger */
/* Semantic colors */var(--color-positive) /* Income (green) */var(--color-negative) /* Expense (red) */
/* Spacing & layout */var(--spacing-xs) /* 4px */var(--spacing-sm) /* 8px */var(--spacing-md) /* 12px */var(--spacing-lg) /* 16px */var(--spacing-xl) /* 24px */var(--radius-sm) /* 4px */var(--radius-md) /* 6px */
/* Typography */var(--font-sans) /* System font */var(--font-mono) /* Monospace font */Database Schema
Section titled “Database Schema”Plugins query these views (not the underlying sys_* tables). Declare which views you need in permissions.read.
transactions
Section titled “transactions”| Column | Type | Description |
|---|---|---|
transaction_id | VARCHAR | Primary key |
account_id | VARCHAR | Foreign key to accounts |
amount | DECIMAL(15,2) | Amount (negative = expense, positive = income) |
description | VARCHAR | Transaction description |
posted_date | DATE | Date posted to account |
transaction_date | DATE | Date of transaction |
tags | VARCHAR[] | Array of tag strings |
source | VARCHAR | Origin: ‘simplefin’, ‘csv_import’, ‘manual’, etc. |
account_name | VARCHAR | Account display name |
account_type | VARCHAR | Account type |
currency | VARCHAR | ISO 4217 currency code |
institution_name | VARCHAR | Bank/provider name |
Note: Use transaction_id — there is no id column.
accounts
Section titled “accounts”| Column | Type | Description |
|---|---|---|
account_id | VARCHAR | Primary key |
name | VARCHAR | Account display name |
account_type | VARCHAR | Type: depository, investment, credit, loan |
currency | VARCHAR | ISO 4217 currency code |
balance | DECIMAL(15,2) | Latest synced balance |
institution_name | VARCHAR | Bank/provider name |
classification | VARCHAR | ’asset’ or ‘liability’ |
is_manual | BOOLEAN | True if manually created |
Note: Use account_id — there is no id column.
balance_snapshots
Section titled “balance_snapshots”| Column | Type | Description |
|---|---|---|
snapshot_id | VARCHAR | Primary key |
account_id | VARCHAR | Foreign key to accounts |
balance | DECIMAL(15,2) | Balance at snapshot time |
snapshot_time | TIMESTAMP | When this balance was recorded |
source | VARCHAR | ’sync’, ‘manual’, or ‘backfill’ |
account_name | VARCHAR | Account display name |
Example Queries
Section titled “Example Queries”-- Recent transactionsSELECT description, amount, posted_date, account_nameFROM transactions ORDER BY posted_date DESC LIMIT 20
-- Monthly spending by accountSELECT account_name, SUM(-amount) as spentFROM transactionsWHERE amount < 0 AND posted_date >= DATE_TRUNC('month', CURRENT_DATE)GROUP BY account_name ORDER BY spent DESC
-- Transaction count per tagSELECT UNNEST(tags) as tag, COUNT(*) as cntFROM transactions GROUP BY tag ORDER BY cnt DESC
-- Account summarySELECT name, account_type, classification, institution_nameFROM accounts ORDER BY nameDatabase Migrations
Section titled “Database Migrations”Migrations create and modify your plugin’s database tables. They run automatically in order when the plugin loads.
const migrations: PluginMigration[] = [ { version: 1, name: "initial_schema", up: ` CREATE TABLE plugin_my_plugin.items ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, amount DOUBLE ) `, }, { version: 2, name: "add_created_at", up: ` ALTER TABLE plugin_my_plugin.items ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP `, }, { version: 3, name: "add_index", up: ` CREATE INDEX idx_items_created ON plugin_my_plugin.items (created_at) `, },];Migration Rules
Section titled “Migration Rules”- Version numbers must be unique and should increment
- Migrations are one-way - there’s no down migration
- Migrations run once - Treeline tracks which versions have run in
plugin_<id>.schema_migrations - Use your schema name - All tables should be in
plugin_<id>.*
Build and Test
Section titled “Build and Test”Build Your Plugin
Section titled “Build Your Plugin”npm run buildThis creates dist/index.js.
Install Locally
Section titled “Install Locally”tl plugin install .Open the Treeline App
Section titled “Open the Treeline App”After building and installing, open the Treeline app (or restart it if already open) to load your plugin.
Development Workflow (Hot-Reload)
Section titled “Development Workflow (Hot-Reload)”For the fastest development experience, use hot-reload:
- Enable hot-reload in Treeline: Settings > Plugin Hot-Reload > On
- Start watch mode:
npm run devIn watch mode, vite builds directly to ~/.treeline/plugins/<id>/ on every save. With hot-reload enabled, Treeline picks up changes automatically — no restart needed.
Note: Database migrations are skipped during hot-reload to prevent half-typed migrations from executing. Restart the app after adding new migrations.
Use Lucide icon names for sidebar items and views:
icon: "target" // Target/crosshairicon: "piggy-bank" // Savingsicon: "credit-card" // Paymentsicon: "calendar" // Schedulingicon: "chart-line" // AnalyticsCommon icons: target, repeat, shield, wallet, credit-card, chart-line, tag, tags, calendar, settings, search, plus, check, x, alert-triangle, info, activity, gift, piggy-bank.
Debugging
Section titled “Debugging”Console Logs
Section titled “Console Logs”Use the browser developer tools to see console output:
- Open View > Toggle Developer Tools
- Go to the Console tab
- Your
console.log()statements appear here
Error Handling
Section titled “Error Handling”Always handle errors gracefully:
try { const data = await sdk.sql("SELECT * FROM transactions");} catch (e) { sdk.toast.error( "Query failed", e instanceof Error ? e.message : String(e) );}Next Steps
Section titled “Next Steps”- SDK Reference - Full API documentation
- Publishing - Share your plugin with others