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
├── svelte.config.js
└── 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> with hyphens replaced by underscores, so my-plugin gets plugin_my_plugin

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', now()::TIMESTAMP::DATE)
GROUP BY account_name ORDER BY spent DESC
-- Transaction count per tag
SELECT tag, COUNT(*) as cnt
FROM (SELECT UNNEST(tags) as tag 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>.*

Your plugin’s UI only runs inside the desktop app. Its schema, on the other hand, is readable everywhere. So health checks are published as a view: create plugin_<id>.doctor in a migration and Treeline picks it up automatically in tl doctor, tl doctor --json, and the MCP doctor tool.

{
version: 2,
name: "create_doctor_view",
up: `
CREATE OR REPLACE VIEW plugin_my_plugin.doctor AS
SELECT
'stale_items' AS check_id,
'Stale items' AS name,
CASE WHEN COUNT(*) = 0 THEN 'pass' ELSE 'warning' END AS status,
COUNT(*) || ' item(s) not updated in 90 days' AS message,
list({'id': id, 'name': name}) AS details
FROM plugin_my_plugin.items
WHERE updated_at < CURRENT_DATE - INTERVAL 90 DAY
`,
}

One row per check:

ColumnTypeDescription
check_idVARCHARStable snake_case id, unique within your plugin
nameVARCHARHuman label
statusVARCHARpass, warning, or error
messageVARCHAROne line summarizing the result
detailsanyOptional supporting rows, usually a list of structs. Omit the column if unused

Checks appear as <plugin_id>.<check_id>, so the example above shows up as my-plugin.stale_items.

  • Create it in a migration - Treeline never runs plugin code to gather checks; it only reads the view.
  • SQL only - a check can look at anything in the database. It cannot make network calls or run JavaScript.
  • Always return a row - a check that returns nothing is simply absent. Use CASE to report pass, as above.
  • Keep details small - only the first 50 entries are kept.
  • An unknown status is reported as an error, and a view that fails to query reports one error check rather than breaking the rest of tl doctor.
  • Before your first launch - if the plugin is installed but the app has never loaded it, its schema doesn’t exist yet and doctor reports <plugin_id>.initialized as a warning.
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)
);
}