Skip to content

Creating Plugins

Plugins are built with TypeScript and Svelte 5. The CLI scaffolds everything you need to get started.

Terminal window
tl plugin new my-plugin
cd my-plugin
npm install

This 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.

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.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"
}
}
FieldRequiredDescription
idYesUnique identifier (lowercase, hyphens allowed)
nameYesDisplay name shown in the UI
versionYesSemantic version (e.g., 0.1.0)
descriptionYesShort description of what the plugin does
authorYesYour name or organization
mainYesEntry point file (always index.js)
permissionsNoData access 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 to plugin_<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.

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
},
};
  1. Load - App reads manifest.json and loads dist/index.js
  2. Migrations - Any pending migrations run in order
  3. Activate - plugin.activate(context) is called
  4. Mount - When user opens a view, mount() is called with SDK
  5. Deactivate - plugin.deactivate() is called when app closes or plugin is disabled

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.

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.

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().

Treeline provides CSS classes prefixed with tl- that match the app’s look and feel. Always use these instead of hardcoding colors.

ClassPurpose
.tl-viewRoot container (full height, themed background)
.tl-headerHeader bar with bottom border
.tl-header-left / .tl-header-rightHeader layout sections
.tl-titleSection title (16px, semibold)
.tl-subtitleMuted subtitle (13px)
.tl-contentScrollable content area with padding
ClassPurpose
.tl-cardsGrid container for stat cards
.tl-card / .tl-card-label / .tl-card-valueStat card with label and value
.tl-btn + .tl-btn-primaryPrimary action button
.tl-btn + .tl-btn-secondarySecondary button
.tl-btn + .tl-btn-dangerDestructive action button
.tl-tableStyled data table
.tl-input / .tl-selectForm elements
.tl-badgeStatus badge
.tl-emptyEmpty state container
.tl-loading / .tl-spinnerLoading state with spinner

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 */

Plugins query these views (not the underlying sys_* tables). Declare which views you need in permissions.read.

ColumnTypeDescription
transaction_idVARCHARPrimary key
account_idVARCHARForeign key to accounts
amountDECIMAL(15,2)Amount (negative = expense, positive = income)
descriptionVARCHARTransaction description
posted_dateDATEDate posted to account
transaction_dateDATEDate of transaction
tagsVARCHAR[]Array of tag strings
sourceVARCHAROrigin: ‘simplefin’, ‘csv_import’, ‘manual’, etc.
account_nameVARCHARAccount display name
account_typeVARCHARAccount type
currencyVARCHARISO 4217 currency code
institution_nameVARCHARBank/provider name

Note: Use transaction_id — there is no id column.

ColumnTypeDescription
account_idVARCHARPrimary key
nameVARCHARAccount display name
account_typeVARCHARType: depository, investment, credit, loan
currencyVARCHARISO 4217 currency code
balanceDECIMAL(15,2)Latest synced balance
institution_nameVARCHARBank/provider name
classificationVARCHAR’asset’ or ‘liability’
is_manualBOOLEANTrue if manually created

Note: Use account_id — there is no id column.

ColumnTypeDescription
snapshot_idVARCHARPrimary key
account_idVARCHARForeign key to accounts
balanceDECIMAL(15,2)Balance at snapshot time
snapshot_timeTIMESTAMPWhen this balance was recorded
sourceVARCHAR’sync’, ‘manual’, or ‘backfill’
account_nameVARCHARAccount display name
-- Recent transactions
SELECT description, amount, posted_date, account_name
FROM transactions ORDER BY posted_date DESC LIMIT 20
-- Monthly spending by account
SELECT account_name, SUM(-amount) as spent
FROM transactions
WHERE amount < 0 AND posted_date >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY account_name ORDER BY spent DESC
-- Transaction count per tag
SELECT UNNEST(tags) as tag, COUNT(*) as cnt
FROM transactions GROUP BY tag ORDER BY cnt DESC
-- Account summary
SELECT name, account_type, classification, institution_name
FROM accounts ORDER BY name

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)
`,
},
];
  • 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>.*
Terminal window
npm run build

This creates dist/index.js.

Terminal window
tl plugin install .

After building and installing, open the Treeline app (or restart it if already open) to load your plugin.

For the fastest development experience, use hot-reload:

  1. Enable hot-reload in Treeline: Settings > Plugin Hot-Reload > On
  2. Start watch mode:
Terminal window
npm run dev

In 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/crosshair
icon: "piggy-bank" // Savings
icon: "credit-card" // Payments
icon: "calendar" // Scheduling
icon: "chart-line" // Analytics

Common icons: target, repeat, shield, wallet, credit-card, chart-line, tag, tags, calendar, settings, search, plus, check, x, alert-triangle, info, activity, gift, piggy-bank.

Use the browser developer tools to see console output:

  1. Open View > Toggle Developer Tools
  2. Go to the Console tab
  3. Your console.log() statements appear here

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)
);
}