Skip to content

System Overview

kasas is one Go process. Inside it, a handful of focused packages wire together into a pipeline: data comes in through a pluggable source (SimpleFIN today), lands in a database, and is served out over several surfaces — with a canonical event stream threaded through the middle so changes can fan out to consumers.

Components

flowchart TB
    subgraph ext[" "]
        SF([SimpleFIN bridge]):::ext
        VAULT([HashiCorp Vault<br/>optional]):::ext
    end

    subgraph proc["kasas process"]
        direction TB

        CFG[config<br/>TOML + env]:::infra
        SEC[vault / secrets<br/>store]:::infra

        subgraph ingest["Ingest"]
            direction TB
            SRC[source<br/>SimpleFIN: bridge client]
            POLL[poller<br/>ingestion engine + gocron]
            SRC -->|ImportBatch| POLL
        end

        STORE[(db.Store<br/>SQLite · Postgres)]:::data

        subgraph core["Pure core logic"]
            SEARCH[search]:::pure
            RULES[rules]:::pure
            LABELS[labels]:::pure
            EXT[extensions]:::pure
        end

        subgraph evt["Event-driven core"]
            EMIT[events.Emitter]
            BUS[events.Bus]
            EMIT --> BUS
        end

        subgraph surfaces["Surfaces"]
            API[api<br/>chi: REST + MCP]
            DASH[dashboard<br/>go-app / WASM]
        end

        subgraph consumers["Async consumers"]
            WH[webhooks.Dispatcher]
            PL[plugins.Manager]
        end

        AUTH[auth + apikeys]:::infra
    end

    APPS([Your apps & agents]):::apps

    SF -->|fetch| SRC
    POLL --> STORE
    POLL -. emit .-> EMIT
    API --> STORE
    API -. emit .-> EMIT
    API --- SEARCH & RULES & LABELS & EXT
    API --- AUTH
    DASH --> API
    EMIT --> STORE
    BUS --> WH
    BUS --> PL
    PL -. writes .-> EMIT
    SEC -.-> VAULT
    AUTH --- SEC
    SRC --- SEC
    API --> APPS
    WH --> APPS
    PL --> APPS

    classDef ext stroke:#5b7fa6,stroke-width:1px;
    classDef apps stroke:#29a8cc,stroke-width:2px;
    classDef data stroke:#b9770e,stroke-width:2px;
    classDef pure stroke:#3a7d44,stroke-width:2px;
    classDef infra stroke:#7a68b8,stroke-width:2px;
Package Responsibility
cmd/kasas Entry point and subcommands; wires every component together in run().
internal/config Loads configuration from TOML + environment (viper), with defaults and validation.
internal/vault Secret store: SimpleFIN access URL + dashboard token, in Vault KV v2 or a local 0600 file.
internal/auth, internal/apikeys The dashboard-token guard and scoped API keys behind the three auth tiers.
internal/source The ingestion SDK: the neutral ImportBatch, the source capability interfaces, and the self-registration registry.
internal/sources/* The built-in sources — simplefin today: a provider client that normalizes data into an ImportBatch.
internal/poller The ingestion engine: a gocron scheduler driving the configured source, plus the transactional persist.
internal/db The Store abstraction over SQLite and Postgres, with sqlc-generated queries.
internal/events The event stream: the transactional Emitter and the in-memory Bus.
internal/search, rules, labels, extensions Pure, dependency-light logic reused across every surface.
internal/api The chi REST API and the MCP server.
internal/dashboard The go-app dashboard (Go → WebAssembly), embedded in the binary.
internal/webhooks The webhook dispatcher: rides the bus, signs and delivers.
internal/plugins The plugin manager: loads and runs sandboxed plugins off the bus.
internal/selfupdate The self-update check + in-place apply.

Startup & lifecycle

run() constructs everything in dependency order; serve() then starts the background workers and the HTTP server and blocks until a signal arrives. Note how the event bus is the gate: when events.enabled is false, the bus and emitter stay nil, and the webhook dispatcher, plugin manager, history, and all event emission simply never happen.

flowchart TD
    A[config.Load] --> B[slog logger]
    B --> C[openDB<br/>SQLite WAL · Postgres pgx]
    C --> D[runMigrations<br/>embedded goose]
    D --> E[newStore<br/>db.Store]
    E --> F{events.enabled?}
    F -->|yes| G[NewBus + NewEmitter]
    F -->|no| H[bus = emitter = nil]
    G --> I
    H --> I[vault.New<br/>secret store]
    I --> J[auth.New<br/>dashboard-token guard]
    J --> K[poller.New]
    K --> L[dashboard handler<br/>if enabled]
    L --> M[update checker<br/>if update.check]
    M --> N{plugins.enabled<br/>&& bus?}
    N -->|yes| O[plugins.NewManager]
    N -->|no| P[manager = nil]
    O --> Q
    P --> Q[api.New]
    Q --> R{command}
    R -->|serve| S[serve&#40;&#41;]
    R -->|sync| T[one Sync, exit]
    R -->|migrate| U[exit]
    R -->|mcp| V[MCP over stdio]

    S --> S1[trap SIGINT/SIGTERM]
    S1 --> S2[start: update check ·<br/>retention pruners ·<br/>webhook dispatcher ·<br/>plugin manager]
    S2 --> S3[poller.Start<br/>+ run-on-start sync]
    S3 --> S4[http.Server.ListenAndServe]
    S4 --> S5{signal}
    S5 -->|received| S6[bus.Close<br/>unblock SSE]
    S6 --> S7[http Shutdown<br/>15s grace]
    S7 --> S8[poller.Stop]

Graceful shutdown order

On SIGINT/SIGTERM the event bus is closed first, so live SSE subscribers unblock and their handlers return — then the HTTP server drains within a 15-second grace window instead of waiting it out, and finally the poller stops. Events and changes are always written in one DB transaction, so an interrupt mid-sync leaves no partial state.

The request path

Every HTTP request passes through a small chi middleware stack before reaching a handler:

flowchart LR
    REQ([HTTP request]) --> RID[RequestID]
    RID --> LOG[request logger]
    LOG --> REC[Recoverer]
    REC --> GZ[Compress<br/>skips .wasm + SSE]
    GZ --> T{route}
    T -->|/healthz /readyz /metrics| OPEN[open handlers]
    T -->|/api/v1/events/stream| SSE[requireRead<br/>no timeout]
    T -->|/api/v1/...| TMO[Timeout 60s]
    TMO --> GATE{auth tier}
    GATE -->|requireRead| RH[read handlers]
    GATE -->|requireWrite| WH[write handlers]
    GATE -->|requireToken| AH[admin handlers]
    T -->|/mcp| MCP[requireToken → MCP]
    T -->|else| DASH[dashboard SPA]

The probes (/healthz, /readyz) and /metrics are always open for container orchestration and Prometheus. The live SSE tail is registered outside the 60-second request timeout so it can stay open. Everything under /api/v1 and /mcp is gated by an auth tier; unmatched paths fall through to the dashboard single-page app.

Three surfaces, one core

The most important structural property of kasas: REST, MCP, and the dashboard are thin presentations over the same core logic. Search, rules, labels, and the event emitter are written once, in pure packages, and every surface calls them.

flowchart TB
    subgraph S[Surfaces]
        REST[REST API<br/>JSON over HTTP]
        MCP[MCP server<br/>tools over HTTP / stdio]
        DASH[Dashboard<br/>Go → WASM in the browser]
    end

    subgraph C[Shared core]
        SEARCH[search<br/>query language]
        RULES[rules engine]
        LBL[labels / extensions]
        EMIT[events.Emitter]
    end

    STORE[(db.Store)]

    REST --> SEARCH & RULES & LBL & EMIT
    MCP --> SEARCH & RULES & LBL & EMIT
    DASH -->|same-origin REST| REST
    DASH -->|in-browser| SEARCH
    SEARCH & RULES & LBL & EMIT --> STORE

The search query language is the clearest example: it is a pure-Go package with no database dependency, so it runs server-side for REST and MCP and compiles to WebAssembly to run directly in the browser for the dashboard — the exact same grammar and matcher in all three places.

The event-driven core

Threaded through everything is the event stream. Whenever a sync, an API call, or a plugin changes data, the change and a corresponding event are written in the same database transaction; after that transaction commits, the event is published to an in-memory bus that fans out to SSE subscribers, the webhook dispatcher, and the plugin manager.

This is what makes kasas a platform rather than a database with an API: consumers don't poll for diffs, they subscribe to facts. The mechanics — the emit-then-publish boundary, sequence numbering, and the drop-and-replay catch-up that webhooks and plugins share — are covered in detail on the Event Stream page.

Where to go next

  • Data Model — the tables, the ER diagram, and the multi-dialect storage layer.
  • Sync Pipeline — how a single sync run actually works, step by step.
  • Event Stream — the transactional emit-then-publish core.