1. Project Overview & Quickstart (we-promise/sure)
File: README.md
Sure: The personal finance app for everyone
Get
involved: Discord β’ Website β’ Issues
[!IMPORTANT]
This repository is a community fork of the now-abandoned Maybe Finance project.
Learn more in their final release doc.
Backstory
The Maybe Finance (archived/abandoned repo) team spent most of 2021β2022 building a full-featured personal finance and wealth management app. It even included an βAsk an Advisorβ feature that connected users with a real CFP/CFA β all included with your subscription.
The business end of things didn't work out, and so they stopped developing the app in mid-2023.
After spending nearly $1 million on development (employees, contractors, data providers, infra, etc.), the team open-sourced the app. Their goal was to let users self-host it for free β and eventually launch a hosted version for a small fee.
They actually did launch that hosted version β¦ briefly.
That also didnβt work out β at least not as a sustainable B2C business β so now here we are: hosting a community-maintained fork to keep the codebase alive and see where this can go next.
Join us!
Hosting Sure
Sure is a fully working personal finance app that can be self hosted with Docker.
Forking and Attribution
This repo is a community fork of the archived Maybe Finance repo.
Youβre free to fork it under the AGPLv3 license β but weβd love it if you stuck around and contributed here instead.
To stay compliant and avoid trademark issues:
- Be sure to include the original AGPLv3 license and clearly state in your README that your fork is based on Maybe Finance but is not affiliated with or endorsed by Maybe Finance Inc.
- "Maybe" is a trademark of Maybe Finance Inc. and therefore, use of it is NOT allowed in forked repositories (or the logo)
Performance Issues
With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests seen on the demo site, along with the stacktraces to help debug them.
https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints
Any contributions that help improve performance are very much welcome.
Local Development Setup
If you are trying to self-host the app, read this guide to get started.
The instructions below are for developers to get started with contributing to the app.
Requirements
- See
.ruby-versionfile for required Ruby version - PostgreSQL >9.3 (latest stable version recommended)
- Redis > 5.4 (latest stable version recommended)
Getting Started
cd sure
cp .env.local.example .env.local
bin/setup
bin/dev
# Optionally, load demo data
rake demo_data:defaultVisit http://localhost:3000 to view the app.
If you loaded the optional demo data, log in with these credentials:
- Email:
[email protected] - Password:
Password1!
For further instructions, see guides below.
Setup Guides
- Mac dev setup
- Linux dev setup
- Windows dev setup
- Dev containers - visit this guide
One-click Install
Managed OpenClaw for Sure Finances
License and Trademarks
Maybe and Sure are both distributed under
an AGPLv3 license.
- "Maybe" is a trademark of Maybe Finance, Inc.
- "Sure" is not, and refers to this community fork.
File: charts/sure/README.md
Sure Helm Chart
Official Helm chart for deploying the Sure Rails application on Kubernetes. It supports web (Rails) and worker (Sidekiq) workloads, optional in-cluster PostgreSQL (CloudNativePG) and Redis subcharts for turnkey self-hosting, and production-grade features like pre-upgrade migrations, pod security contexts, HPAs, and optional ServiceMonitor.
Features
- Web (Rails) Deployment + Service and optional Ingress
- Worker (Sidekiq) Deployment
- Optional Helm-hook Job for db:migrate, or initContainer migration strategy
- Optional post-install/upgrade SimpleFin encryption backfill Job (idempotent; dry-run by default)
- Optional CronJobs for custom tasks
- Optional subcharts
- CloudNativePG (operator) + Cluster CR for PostgreSQL with HA support
- OT-CONTAINER-KIT redis-operator for Redis HA (replication by default, optional Sentinel)
- Optional Pipelock AI agent security proxy (forward proxy + MCP reverse proxy with DLP, prompt injection, and tool poisoning detection)
- Security best practices: runAsNonRoot, readOnlyRootFilesystem, optional existingSecret, no hardcoded secrets
- Scalability
- Replicas (web/worker), resources, topology spread constraints
- Optional HPAs for web/worker
- Affinity, nodeSelector, tolerations
Requirements
- Kubernetes >= 1.25
- Helm >= 3.10
- For subcharts: add repositories firstsh
helm repo add cloudnative-pg https://cloudnative-pg.github.io/charts helm repo add ot-helm https://ot-container-kit.github.io/helm-charts helm repo update
Quickstart (turnkey self-hosting)
This installs CNPG operator + a Postgres cluster and Redis managed by the OT redis-operator (replication mode by default). It also creates an app Secret if you provide values under rails.secret.values (recommended for quickstart only; prefer an existing Secret or External Secrets in production).
Important: For production stability, use immutable image tags (for example, set image.tag=v1.2.3) instead of latest.
# Namespace
kubectl create ns sure || true
# Install chart (example: provide SECRET_KEY_BASE and pin an immutable image tag)
helm upgrade --install sure charts/sure \
-n sure \
--set image.tag=v1.2.3 \
--set rails.secret.enabled=true \
--set rails.secret.values.SECRET_KEY_BASE=$(openssl rand -hex 32)Expose the app via an Ingress (see values) or kubectl port-forward svc/sure 8080:80 -n sure.
Using external Postgres/Redis
Disable the bundled CNPG/Redis resources and set URLs explicitly.
cnpg:
enabled: false
redisOperator:
managed:
enabled: false
redisSimple:
enabled: false
rails:
extraEnv:
DATABASE_URL: postgresql://user:[email protected]:5432/sure
REDIS_URL: redis://:[email protected]:6379/0Installation profiles
Deployment modes
| Mode | Description | Key values |
|---|---|---|
| Simple single-node | All-in-one, minimal HA | cnpg.cluster.instances=1, redisOperator.mode=replication |
| HA self-hosted (replication) | CNPG + RedisReplication spread over nodes | cnpg.cluster.instances=3, redisOperator.mode=replication |
| HA self-hosted (Sentinel) | Replication + Sentinel failover layer | redisOperator.mode=sentinel, redisOperator.sentinel.enabled=true |
| External DB/Redis | Use managed Postgres/Redis | cnpg.enabled=false, redisOperator.managed.enabled=false, set URLs envs |
Below are example value stubs you can start from, depending on whether you want a simple single-node setup or a more HA-oriented k3s cluster.
Simple single-node / low-resource profile
image:
tag: "v1.0.0" # pin a specific version in production
pullPolicy: IfNotPresent
rails:
existingSecret: sure-secrets
encryptionEnv:
enabled: true
settings:
SELF_HOSTED: "true"
cnpg:
enabled: true
cluster:
enabled: true
name: sure-db
instances: 1
storage:
size: 8Gi
storageClassName: longhorn
redisOperator:
enabled: true
managed:
enabled: true
mode: replication
sentinel:
enabled: false
replicas: 3
persistence:
enabled: true
className: longhorn
size: 8Gi
migrations:
strategy: job
simplefin:
encryption:
enabled: false # enable + backfill later once you're happy
backfill:
enabled: true
dryRun: trueHA k3s profile (example)
cnpg:
enabled: true
cluster:
enabled: true
name: sure-db
instances: 3
storage:
size: 20Gi
storageClassName: longhorn
# Optional: enable CNPG volume snapshot backups (requires a VolumeSnapshotClass)
backup:
method: volumeSnapshot
volumeSnapshot:
className: longhorn
# Synchronous replication for stronger durability
minSyncReplicas: 1
maxSyncReplicas: 2
# Spread CNPG instances across nodes (adjust selectors for your cluster)
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
cnpg.io/cluster: sure-db
redisOperator:
enabled: true
managed:
enabled: true
mode: replication
sentinel:
enabled: false
replicas: 3
persistence:
enabled: true
className: longhorn
size: 8Gi
migrations:
strategy: job
initContainer:
enabled: true # optional safety net on pod restarts (only migrates when pending)
simplefin:
encryption:
enabled: true
backfill:
enabled: true
dryRun: falseCloudNativePG notes
- The chart configures credentials via
spec.bootstrap.initdb.secretrather thanmanaged.roles. The operator expects the referenced Secret to containusernameandpasswordkeys (configurable via values). - This chart generates the application DB Secret when
cnpg.cluster.secret.enabled=trueusing the keys defined atcnpg.cluster.secret.usernameKey(defaultusername) andcnpg.cluster.secret.passwordKey(defaultpassword). If you use an existing Secret (cnpg.cluster.existingSecret), ensure it contains these keys. The Cluster CR references the Secret by name and maps the keys accordingly. - If the CNPG operator is already installed clusterβwide, you may set
cnpg.enabled=falseand keepcnpg.cluster.enabled=true. The chart will still render theClusterCR and compute the inβclusterDATABASE_URL. - For backups, CNPG requires
spec.backup.methodto be explicit (for examplevolumeSnapshotorbarmanObjectStore). This chart will infermethod: volumeSnapshotif abackup.volumeSnapshotblock is present.- For snapshot backups,
backup.volumeSnapshot.classNamemust be set (the chart will fail the render if it is missing). - The CNPG
spec.backupschema does not support keys likettlorvolumeSnapshot.enabled; this chart strips those keys to avoid CRD warnings. - Unknown
backup.methodvalues are passed through and left for CNPG to validate.
- For snapshot backups,
Example (barman-cloud plugin for WAL archiving + snapshot backups):
cnpg:
cluster:
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: minio-backups # references an ObjectStore CR
backup:
method: volumeSnapshot
volumeSnapshot:
className: longhornAdditional default hardening:
DATABASE_URLincludes?sslmode=prefer.- Init migrations run
db:create || truebeforedb:migratefor firstβboot convenience.
Redis URL and authentication
- When the OT redis-operator is used via this chart (see
redisOperator.managed.enabled=true),REDIS_URLresolves to the operator's stable master service. In shell contexts, this can be expressed as:redis://default:$(REDIS_PASSWORD)@<name>-redis-master.<namespace>.svc.cluster.local:6379/0(where<name>defaults to<fullname>-redisbut is overrideable viaredisOperator.name)
For Kubernetes manifests, do not inline shell expansion. Either let this chart constructREDIS_URLfor you automatically (recommended), or use a literal form with a placeholder password, e.g.:redis://default:@<name>-redis-master.<namespace>.svc.cluster.local:6379/0
- The
defaultusername is required with Redis 6+ ACLs. If you explicitly setREDIS_URLunderrails.extraEnv, your value takes precedence. - The Redis password is taken from
sure.redisSecretName(typically your app Secret, e.g.sure-secrets) using the key returned bysure.redisPasswordKey(defaultredis-password). - If you prefer a simple (nonβHA) inβcluster Redis, disable the operator-managed Redis (
redisOperator.managed.enabled=false) and enableredisSimple.enabled. The chart will deploy a single Redis Pod + Service and wireREDIS_URLaccordingly. Provide a password viaredisSimple.auth.existingSecret(recommended) or rely on your app secret mapping.
Using the OT redis-operator (Sentinel)
This chart can optionally install the OT-CONTAINER-KIT Redis Operator and/or render a RedisSentinel CR to manage Redis HA with Sentinel. This approach avoids templating pitfalls and provides stable failover.
Quickstart example (Sentinel, 3 replicas, Longhorn storage, reuse sure-secrets password):
redisOperator:
enabled: true # install operator subchart (or leave false if already installed cluster-wide)
operator:
resources: # optional: keep the operator light on small k3s nodes
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 100m
memory: 256Mi
managed:
enabled: true # render Redis CRs for in-cluster Redis
mode: sentinel # enables RedisSentinel CR in addition to RedisReplication
sentinel:
enabled: true # must be true when mode=sentinel
masterGroupName: mymaster
name: "" # defaults to <fullname>-redis
replicas: 3
auth:
existingSecret: sure-secrets
passwordKey: redis-password
persistence:
className: longhorn
size: 8GiNotes:
- When
redisOperator.mode=sentinelandredisOperator.sentinel.enabled=true, the chart automatically configures Sidekiq to use Redis Sentinel for high availability. - The application receives
REDIS_SENTINEL_HOSTS(comma-separated list of Sentinel endpoints) andREDIS_SENTINEL_MASTER(master group name) environment variables instead ofREDIS_URL. - Sidekiq will connect to Sentinel nodes for automatic master discovery and failover support.
- Both the Redis master and Sentinel nodes use the same password from
REDIS_PASSWORD(viaredisOperator.auth.existingSecret). - Sentinel authentication uses username "default" by default (configurable via
REDIS_SENTINEL_USERNAME). - The operator master service is
<name>-redis-master.<ns>.svc.cluster.local:6379. - The CR references your existing password secret via
kubernetesConfig.redisSecret { name, key }. - Provider precedence for auto-wiring is: explicit
rails.extraEnv.REDIS_URLβredisOperator.managed(with Sentinel if configured) βredisSimple. - Only one in-cluster Redis provider should be enabled at a time to avoid ambiguity.
HA scheduling and topology spreading
For resilient multi-node clusters, enforce one pod per node for critical components. Use topologySpreadConstraints with maxSkew: 1 and whenUnsatisfiable: DoNotSchedule. Keep selectors precise to avoid matching other apps.
Examples:
cnpg:
cluster:
instances: 3
minSyncReplicas: 1
maxSyncReplicas: 2
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
cnpg.io/cluster: sure-db
redisOperator:
managed:
enabled: true
replicas: 3
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/instance: sure # verify labels on your clusterSecurity note on label selectors:
- Choose selectors that uniquely match the intended pods to avoid cross-app interference. Good candidates are:
- CNPG:
cnpg.io/cluster: <cluster-name>(CNPG labels its pods) - RedisReplication:
app.kubernetes.io/instance: <release-name>orapp.kubernetes.io/name: <cr-name>
- CNPG:
Rolling update strategy
When using topology spread constraints with whenUnsatisfiable: DoNotSchedule, you must configure the Kubernetes rolling update strategy to prevent deployment deadlocks.
The chart now makes the rolling update strategy configurable for web and worker deployments. The defaults have been changed from Kubernetes defaults (maxUnavailable=0, maxSurge=25%) to:
web:
strategy:
rollingUpdate:
maxUnavailable: 1
maxSurge: 0
worker:
strategy:
rollingUpdate:
maxUnavailable: 1
maxSurge: 0Why these defaults?
With maxSurge=0, Kubernetes will terminate an old pod before creating a new one. This ensures that when all nodes are occupied (due to strict topology spreading), there is always space for the new pod to be scheduled.
If you use maxSurge > 0 with DoNotSchedule topology constraints and all nodes are occupied, Kubernetes cannot create the new pod (no space available) and cannot terminate the old pod (new pod must be ready first), resulting in a deployment deadlock.
Configuration examples:
For faster rollouts when not using strict topology constraints:
web:
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
worker:
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1For HA setups with topology spreading:
web:
replicas: 3
strategy:
rollingUpdate:
maxUnavailable: 1
maxSurge: 0
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: sure
app.kubernetes.io/component: webWarning: Using maxSurge > 0 with whenUnsatisfiable: DoNotSchedule can cause deployment deadlocks when all nodes are occupied. If you need faster rollouts, either:
- Use
whenUnsatisfiable: ScheduleAnywayinstead ofDoNotSchedule - Ensure you have spare capacity on your nodes
- Keep
maxSurge: 0and accept slower rollouts
Compatibility:
- CloudNativePG v1.27.1 supports
minSyncReplicas/maxSyncReplicasand standard k8s scheduling fields underspec. - OT redis-operator v0.21.0 supports scheduling under
spec.kubernetesConfig.
Testing and verification:
# Dry-run render with your values
helm template sure charts/sure -n sure -f ha-values.yaml --debug > rendered.yaml
# Install/upgrade in a test namespace
kubectl create ns sure-test || true
helm upgrade --install sure charts/sure -n sure-test -f ha-values.yaml --wait
# Verify CRs include your scheduling config
kubectl get cluster.postgresql.cnpg.io sure-db -n sure-test -o yaml \
| yq '.spec | {instances, minSyncReplicas, maxSyncReplicas, nodeSelector, affinity, tolerations, topologySpreadConstraints}'
# Default RedisReplication CR name is <fullname>-redis (e.g., sure-redis) unless overridden by redisOperator.name
kubectl get redisreplication sure-redis -n sure-test -o yaml \
| yq '.spec.kubernetesConfig | {nodeSelector, affinity, tolerations, topologySpreadConstraints}'
# After upgrade, trigger a gentle reschedule to apply spreads
# CNPG: delete one pod at a time or perform a switchover
kubectl delete pod -n sure-test -l cnpg.io/cluster=sure-db --wait=false --field-selector=status.phase=Running
# RedisReplication: delete one replica pod to let the operator recreate it under new constraints
kubectl delete pod -n sure-test -l app.kubernetes.io/component=redis --wait=false
# Confirm distribution across nodes
kubectl get pods -n sure-test -o wideExample app Secret (sure-secrets)
You will typically manage secrets via an external mechanism (External Secrets, Sealed Secrets, etc.), but for reference, below is an example Secret that provides the keys this chart expects by default:
apiVersion: v1
kind: Secret
metadata:
name: sure-secrets
type: Opaque
stringData:
# Rails secrets
SECRET_KEY_BASE: "__SET_SECRET__"
# Active Record Encryption keys (optional but recommended when using encryption features)
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: "__SET_SECRET__"
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: "__SET_SECRET__"
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: "__SET_SECRET__"
# Redis password used by operator-managed or simple Redis
redis-password: "__SET_SECRET__"
# Optional: CNPG bootstrap user/password if you are not letting the chart generate them
# username: "sure"
# password: "__SET_SECRET__"Note: These are non-sensitive placeholder values. Do not commit real secrets to version control. Prefer External Secrets, Sealed Secrets, or your platform's secret manager to source these at runtime.
Linting Helm templates and YAML
Helm template files under charts/**/templates/** contain template delimiters like {{- ... }} that raw YAML linters will flag as invalid. To avoid false positives in CI:
- Use Helm's linter for charts:
helm lint charts/sure
- Configure your YAML linter (e.g., yamllint) to ignore Helm template directories (exclude
charts/**/templates/**), or use a Helm-aware plugin that preprocesses templates before linting.
You can then point the chart at this Secret via:
rails:
existingSecret: sure-secrets
redisOperator:
managed:
enabled: true
auth:
existingSecret: sure-secrets
passwordKey: redis-password
cnpg:
cluster:
existingSecret: sure-secrets # if you are reusing the same Secret for DB creds
secret:
enabled: false # do not generate a second Secret when using existingSecretEnvironment variable ordering for shells:
- The chart declares
DB_PASSWORDbeforeDATABASE_URLandREDIS_PASSWORDbeforeREDIS_URLin all workloads so that shell expansion with$(...)works reliably.
Migrations
By default, this chart uses a Helm hook Job to prepare the database on post-install/upgrade using Rails' db:prepare, which will create the database (if needed) and apply migrations in one step. The Job waits for the database to be reachable via pg_isready before connecting.
Execution flow:
- CNPG Cluster (if enabled) and other resources are created.
sure-migrateJob (post-install/post-upgrade hook) waits for the RW service to accept connections.db:prepareruns; safe and idempotent across fresh installs and upgrades.- Optional data backfills (like SimpleFin encryption) run in their own post hooks.
To use the initContainer strategy instead (or in addition as a safety net):
migrations:
strategy: initContainer
initContainer:
enabled: trueSimpleFin encryption backfill
- SimpleFin encryption is optional. If you enable it, you must provide Active Record Encryption keys.
- The backfill Job runs a safe, idempotent Rake task to encrypt existing
access_urlvalues.
simplefin:
encryption:
enabled: true
backfill:
enabled: true
dryRun: true # set false to actually write changes
rails:
# Provide encryption keys via an existing secret or below values (for testing only)
existingSecret: my-app-secret
# or
secret:
enabled: true
values:
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: "..."
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: "..."
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: "..."Ingress
ingress:
enabled: true
className: "nginx"
hosts:
- host: finance.example.com
paths:
- path: /
pathType: Prefix
tls:
- hosts: [finance.example.com]
secretName: finance-tlsBoot-required secrets
The Rails initializer for Active Record Encryption loads on boot. To prevent boot crashes, ensure the following environment variables are present for ALL workloads (web, worker, migrate job/initContainer, CronJobs, and the SimpleFin backfill job):
SECRET_KEY_BASEACTIVE_RECORD_ENCRYPTION_PRIMARY_KEYACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEYACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
This chart wires these from your app Secret using secretKeyRef. Provide them via rails.existingSecret (recommended) or rails.secret.values (for testing only).
The injection of the three Active Record Encryption env vars can be toggled via:
rails:
encryptionEnv:
enabled: true # set to false to skip injecting the three AR encryption env varsNote: In self-hosted mode, if these env vars are not provided, they will be automatically generated from SECRET_KEY_BASE. In managed mode, these env vars must be explicitly provided via environment variables or Rails credentials.
Advanced environment variable injection
For simple string key/value envs, continue to use rails.extraEnv and the per-workload web.extraEnv / worker.extraEnv maps.
When you need valueFrom (e.g., Secret/ConfigMap references) or full EnvVar objects, use the new arrays:
rails:
extraEnvVars:
- name: SOME_FROM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: some-key
extraEnvFrom:
- secretRef:
name: another-secretThese are injected into web, worker, migrate job/initContainer, CronJobs, and the SimpleFin backfill job in addition to the simple maps.
Writable filesystem and /tmp
Rails and Sidekiq may require writes to /tmp during boot. The chart now defaults to:
securityContext:
readOnlyRootFilesystem: falseIf you choose to enforce a read-only root filesystem, you can mount an ephemeral /tmp via:
writableTmp:
enabled: trueThis will add an emptyDir volume mounted at /tmp for the web and worker pods.
Local images on k3s/k3d/kind (development workflow)
When using locally built images on single-node k3s/k3d/kind clusters:
- Consider forcing a never-pull policy during development:yaml
image: pullPolicy: Never - Load your local image into the cluster runtime:
- k3s (containerd):bash
# Export your image to a tar (e.g., from Docker or podman) docker save ghcr.io/we-promise/sure:dev -o sure-dev.tar # Import into each node's containerd sudo ctr -n k8s.io images import sure-dev.tar - k3d:bash
k3d image import ghcr.io/we-promise/sure:dev -c <your-cluster-name> - kind:bash
kind load docker-image ghcr.io/we-promise/sure:dev --name <your-cluster-name>
- k3s (containerd):
- Multi-node clusters require loading the image into every node or pushing to a registry that all nodes can reach.
HPAs
hpa:
web:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
worker:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70Pipelock (AI agent security proxy)
Pipelock is an optional security proxy that scans AI agent traffic for secret exfiltration, prompt injection, tool poisoning, and SSRF. It runs as a separate Deployment with two listeners:
- Forward proxy (port 8888): Scans outbound HTTPS from Faraday-based AI clients. Auto-injected via
HTTPS_PROXYenv vars when enabled. - MCP reverse proxy (port 8889): Scans inbound MCP traffic from external AI assistants.
Recent Pipelock releases add default-on flight recorder receipts, safe-by-default receipt verification, MCP defer authorization, request-policy scoring, request-body prompt-injection blocking, SPIFFE-strict inbound mediation envelopes, scanner attribution on MCP block receipts, trusted domain allowlisting, MCP tool redirect profiles, learn-and-lock behavioural contracts, the wedge-detection health watchdog, pipelock explain, pipelock keys status, pipelock support bundle, verified pipelock update, and pipelock doctor checks for inert exemptions. Browser Shield, process sandboxing, request policy, redaction, and attack assessment are available via extraConfig and CLI. See the Pipelock changelog for details.
Enabling Pipelock
pipelock:
enabled: true
image:
tag: "2.8.0"
mode: balanced # strict, balanced, or auditTrusted domains
In Kubernetes, services often have public DNS records that resolve to private IPs. Without trustedDomains, the SSRF scanner blocks this legitimate traffic. Add trusted domains to allow them through:
pipelock:
trustedDomains:
- "api.internal.example.com"
- "*.corp.example.com"MCP tool redirect profiles
Redirect profiles route matched MCP tool calls to an audited handler program instead of blocking. The handler returns a synthetic MCP response, keeping the agent's flow intact while enforcing policy:
pipelock:
mcpToolPolicy:
enabled: true
action: redirect # or use per-rule action overrides
rules:
- name: redirect-fetch
toolPattern: "^(fetch|web_fetch)$"
action: redirect
redirectProfile: safe-fetch
redirectProfiles:
safe-fetch:
exec: ["/pipelock", "internal-redirect", "fetch-proxy"]
reason: "Route fetch calls through audited proxy"Request body scanning (pipelock 2.5+)
Pipelock 2.5 added prompt-injection detection on outbound request bodies (JSON, form-encoded, raw text, WebSocket frames). When enabled, findings hard-block non-provider destinations even when action: warn; trusted provider hosts (OpenAI, Anthropic, etc.) remain exempt through the response-scanning exemption list.
pipelock:
requestBodyScanning:
enabled: true
action: warn # warn or block
maxBodyBytes: 5242880 # 5 MB; fail-closed above this
scanHeaders: true
headerMode: sensitive # "sensitive" or "all"Enabled by default with action: warn, matching Pipelock's balanced preset. Review findings in logs before flipping to action: block.
Health watchdog
The wedge-detection watchdog returns 503 on /health when a subsystem heartbeat (proxy hot path, MCP listener, rules-engine reload watcher) goes stale. Enabled by default in pipelock; the chart exposes the controls so operators can opt into per-subsystem detail in the health payload:
pipelock:
healthWatchdog:
enabled: true
intervalSeconds: 2
exposeSubsystems: true # adds per-subsystem boolean map to /healthSigned action receipts
The flight recorder writes hash-chained, Ed25519-signed action receipts. The chart renders the flight_recorder config, but recording is inert until you mount a writable evidence directory and the signing key named by signingKeyPath.
Generate the receipt key once:
# --out must be an absolute path; "$PWD/..." writes the key into the current directory.
pipelock signing key generate --purpose receipt-signing --out "$PWD/flight-recorder-signing.key" --id sure-k8s
kubectl create secret generic sure-pipelock-receipts \
--namespace sure \
--from-file=flight-recorder-signing.key=./flight-recorder-signing.keyIf you do not have the pipelock binary installed, generate the key with the image instead: docker run --rm -v "$PWD:/out" ghcr.io/luckypipewrench/pipelock:2.8.0 signing key generate --purpose receipt-signing --out /out/flight-recorder-signing.key --id sure-k8s.
Then mount persistent evidence storage and the key:
pipelock:
flightRecorder:
enabled: true
dir: /var/lib/pipelock/evidence
signingKeyPath: /run/secrets/pipelock/flight-recorder-signing.key
requireReceipts: false
redact: true
extraVolumes:
- name: pipelock-evidence
persistentVolumeClaim:
claimName: sure-pipelock-evidence
- name: pipelock-receipt-key
secret:
secretName: sure-pipelock-receipts
# 0440 (group-read), not 0400: the chart runs Pipelock as uid 1000 with
# fsGroup 1000, so the secret file is owned root:1000. A 0400 file would
# be unreadable by the non-root process and Pipelock would crash on
# startup with a key-load error. 0440 lets the fsGroup read it.
defaultMode: 0440
extraVolumeMounts:
- name: pipelock-evidence
mountPath: /var/lib/pipelock/evidence
- name: pipelock-receipt-key
# Do not mount this under /etc/pipelock; the chart already mounts the
# Pipelock ConfigMap there.
mountPath: /run/secrets/pipelock
readOnly: trueOnly set requireReceipts: true after confirming receipts are being written. With it enabled, allow-path traffic fails closed if Pipelock cannot sign or persist the receipt.
Validating your config
Pipelock includes CLI tools for config validation and deployment diagnostics:
# Create and run a broader assessment workspace
pipelock assess init --config pipelock.yaml
# Score your config's security posture (0-100)
pipelock audit score --config pipelock.yaml
# Report whether configured protections are actually enforceable
pipelock doctor
# Explain a block and the exact config knob that scanner reads
pipelock explain --config pipelock.yaml "https://example.com/path"Exposing MCP to external AI assistants
When running in Kubernetes, external AI agents need network access to the MCP reverse proxy port. Enable the Pipelock Ingress:
pipelock:
enabled: true
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: pipelock.example.com
paths:
- path: /
pathType: Prefix
tls:
- hosts: [pipelock.example.com]
secretName: pipelock-tlsSecurity: The Ingress routes to port mcp (8889). Ensure MCP_API_TOKEN is set so the MCP endpoint requires authentication. The Ingress itself does not add auth.
Metrics (Prometheus)
Pipelock exposes /metrics on the forward proxy port. Enable scraping with a ServiceMonitor:
pipelock:
serviceMonitor:
enabled: true
interval: 30s
portName: proxy # matches Service port name for 8888
additionalLabels:
release: prometheus # match your Prometheus Operator selectorPodDisruptionBudget
Protect Pipelock from node drains:
pipelock:
pdb:
enabled: true
maxUnavailable: 1 # safe for single-replica; use minAvailable when replicas > 1Note: Setting minAvailable with replicas=1 blocks eviction entirely. Use maxUnavailable for single-replica deployments.
Structured logging
pipelock:
logging:
format: json # json or text
output: stdout
includeAllowed: false
includeBlocked: trueExtra config (escape hatch)
For Pipelock config sections not covered by structured values (session profiling, data budgets, kill switch, sandbox, reverse proxy, adaptive enforcement, request policy, redaction, etc.), use extraConfig:
pipelock:
extraConfig:
session_profiling:
enabled: true
max_sessions: 1000
adaptive_enforcement:
enabled: true
exempt_domains:
- "*.example.com"
kill_switch:
api_listen: ":9090" # dedicated port for kill switch APIThese are appended verbatim to pipelock.yaml. Do not duplicate keys already rendered by the chart.
Requiring Pipelock for external assistants
To enforce that Pipelock is enabled whenever the external AI assistant feature is active:
pipelock:
requireForExternalAssistant: trueThis causes helm template / helm install to fail if rails.externalAssistant.enabled=true and pipelock.enabled=false. Note: this only guards the externalAssistant path. Direct MCP access via MCP_API_TOKEN is configured through env vars and not detectable from Helm values.
Security Notes
- Never commit secrets in
values.yaml. Userails.existingSecretor a tool like Sealed Secrets. - The chart defaults to
runAsNonRoot,fsGroup=1000, and drops all capabilities. - For production, set resource requests/limits and enable HPAs.
Values overview
Tip: For production stability, prefer immutable image tags. Set image.tag to a specific release (e.g., v1.2.3) rather than latest.
See values.yaml for the complete configuration surface, including:
image.*: repository, tag, pullPolicy, imagePullSecretsrails.*: environment, extraEnv, existingSecret or secret.values, settings- Also:
rails.extraEnvVars[](full EnvVar),rails.extraEnvFrom[](EnvFromSource), andrails.encryptionEnv.enabledtoggle
- Also:
cnpg.*: enable operator subchart and a Cluster resource, set instances, storageredis-ha.*: enable dandydev/redis-ha subchart and configure replicas/auth (Sentinel/HA); supportsexistingSecretandexistingSecretPasswordKeyredisOperator.*: optionally install OT redis-operator (redisOperator.enabled) and/or render aRedisSentinelCR (redisOperator.managed.enabled); configurename,replicas,auth.existingSecret/passwordKey,persistence.className/size, scheduling knobs, andoperator.resources(controller) /workloadResources(Redis pods)redisSimple.*: optional singleβpod Redis (nonβHA) whenredis-ha.enabled=falseweb.*,worker.*: replicas, probes, resources, scheduling, strategy (rolling update configuration)migrations.*: strategy job or initContainersimplefin.encryption.*: enable + backfill optionscronjobs.*: custom CronJobspipelock.*: AI agent security proxy (forward proxy, MCP reverse proxy, DLP, injection scanning, request-body scanning, health watchdog, signed receipts, trusted domains, tool redirect profiles, logging, serviceMonitor, ingress, PDB, extraVolumes, extraVolumeMounts, extraConfig)service.*,ingress.*,serviceMonitor.*,hpa.*
Helm tests
After installation, you can run chart tests to verify:
- The web Service responds over HTTP.
- Redis auth works when an in-cluster provider is active.
helm test sure -n sureThe Redis auth test uses redis-cli -u "$REDIS_URL" -a "$REDIS_PASSWORD" PING and passes when PONG is returned.
Alternatively, you can smoke test from a running worker pod:
kubectl exec -n sure deploy/$(kubectl get deploy -n sure -o name | grep worker | cut -d/ -f2) -- \
sh -lc 'redis-cli -u "$REDIS_URL" -a "$REDIS_PASSWORD" PING'Testing locally (k3d/kind)
- Create a cluster (ensure storageclass is available).
- Install chart with defaults (CNPG + Redis included).
- Wait for CNPG Cluster to become Ready, then for Rails web and worker pods to be Ready.
- Port-forward or configure Ingress.
helm template sure charts/sure -n sure --debug > rendered.yaml # dry-run inspection
helm upgrade --install sure charts/sure -n sure --create-namespace --wait
kubectl get pods -n sureUninstall
helm uninstall sure -n sureCleanup & reset (k3s)
For local k3s experimentation it's sometimes useful to completely reset the sure namespace, especially if CR finalizers or PVCs get stuck.
The script below is a last-resort tool for cleaning the namespace. It:
- Uninstalls the Helm release.
- Deletes RedisReplication and CNPG Cluster CRs in the namespace.
- Deletes PVCs.
- Optionally clears finalizers on remaining CRs/PVCs.
- Deletes the namespace.
β οΈ Finalizer patching can leave underlying volumes behind if your storage class uses its own finalizers (e.g. Longhorn snapshots). Use with care in production.
/* Detailed source-code truncated for AI context efficiency. */File: design/tokens/README.md
Sure design tokens
This is where the design system actually lives. Tailwind reads from here, and any external tooling (Figma Tokens Studio, AI design tools, anything that shows up later) is meant to read the same JSON.
Files
design/tokens/sure.tokens.json: every token, hand-edited.bin/tokens.mjs: plain Node script. Compiles the JSON into Tailwind v4 CSS.app/assets/tailwind/sure-design-system/_generated.css: the build output. Generated, do not edit by hand.
Workflow
# Edit a token:
$EDITOR design/tokens/sure.tokens.json
# Regenerate the CSS:
npm run tokens:build
# Commit both files together:
git add design/tokens/sure.tokens.json app/assets/tailwind/sure-design-system/_generated.cssbin/setup runs the build automatically on a fresh checkout.
Versioning
The root $version field follows semver, scoped to the token contract:
- Major (
X.0.0): breaking changes β token removed or renamed, value type changed, dark variant removed, semantic meaning changed. - Minor (
1.X.0): additive changes β new tokens, new$extensions.sure.*keys, new top-level groups. - Patch (
1.0.X): cosmetic / value tweaks that consumers don't need to know about β a hex shifts a few points without changing intent.
Bump it when you commit. External consumers (Tokens Studio, future Figma sync, etc.) read this to decide whether their cached snapshot is stale.
Schema
The file uses the W3C DTCG token format: $value, $type, $description, $extensions. Tokens cross-reference via {path.to.token} placeholders.
{
"color": {
"white": { "$value": "#ffffff", "$type": "color" },
"gray": {
"500": { "$value": "#737373", "$type": "color" }
},
"success": {
"$value": "{color.green.600}",
"$type": "color",
"$extensions": { "sure.dark": "{color.green.500}" }
}
}
}Top-level groups
| Key | Purpose |
|---|---|
font |
font-family stacks |
color |
base colors, semantic aliases (success, warning, destructive, shadow), full-scale ladders, alpha ladders |
budget |
budget-chart fills (need their own dark variants because Stimulus controllers reference them) |
border.radius |
corner radii |
shadow |
drop shadows, both light and dark variants |
animate |
named animations |
utility |
Tailwind @utility blocks: semantic surfaces, foregrounds, borders, button backgrounds, etc. |
Custom `$extensions.sure.*`
| Extension | Where | What it does |
|---|---|---|
sure.dark |
any token | Dark-mode override value. Same template syntax as $value. |
sure.alpha |
reserved | Currently unused; alpha is expressed inline via {ref|N%}. Reserved for structured alpha if it's ever needed. |
sure.utility.prefix |
utility.* only |
The Tailwind utility family (bg, text, border). Tells the build which @apply class to emit. |
sure.utility.raw |
utility.* only |
A CSS property name (background-color, box-shadow, etc.) when the utility emits raw CSS instead of @apply. |
sure.compose |
utility.* only |
Array of class names to @apply. For example, bg-loader is ["bg-surface-inset", "animate-pulse"]. |
Template strings
Anywhere a $value is a string:
{path.to.token}resolves tovar(--path-to-token)in the generated CSS.{path.to.token|N%}resolves to--alpha(var(--path-to-token) / N%)(Tailwind v4 alpha syntax).
The same syntax appears inside composite values like shadow.xs.$value: "0px 1px 2px 0px {color.black|6%}".
Alpha modifiers in views (`/N` syntax)
Tailwind v4's class/N modifier (bg-warning/10, text-link/70, etc.) only resolves on theme colors (anything declared under the top-level color.* group, which becomes --color-* in the generated CSS). It does not resolve on classes from this file's utility.* group, because those compile to static @apply blocks with no modifier-aware definition.
The mismatch is silent β Tailwind drops the unrecognized class and the element renders with no CSS for that property. Recently caught examples on text-inverse/70, border-secondary/30, and bg-surface-inset/40 (all of which produced no class output).
Until the build script teaches custom utilities to be modifier-aware, the convention is:
- For alpha on a custom utility: pair the base class with
opacity-N, e.g.text-inverse opacity-70instead oftext-inverse/70. - For alpha on a theme color: the
/Nmodifier works as expected, e.g.bg-warning/10,border-destructive/30.
The pre-resolved alpha tints (color.gray.tint-5, color.gray.tint-10, color.red.tint-5, etc.) are theme colors, so bg-gray-tint-5 and similar work as straight utilities and accept further /N modifiers.
Adding a new token
- Pick the right top-level group.
- Add the
$value(raw or{ref}) and$type. - If it should change in dark mode, add
$extensions.sure.dark. - If it's a utility, add
$extensions.sure.utility.prefix(orraw, orcompose). - Run
npm run tokens:build. - Look at the diff in
_generated.cssand confirm it's what you expected. - Commit both files.
Edge cases the build script handles
color.gray.DEFAULT: theDEFAULTsegment is dropped in the CSS variable name (--color-gray, not--color-gray-DEFAULT). DTCG convention; matches Tailwind.utility.border-divider: the value is a plain class string (border-tertiary) instead of a{ref}. The build treats values without{}as raw@applyarguments.utility.bg-overlay: usessure.utility.raw: "background-color"because it needs alpha rendering instead of@apply.utility.bg-loader: usessure.composeto apply two utilities together (bg-surface-inset animate-pulse).utility.button-bg-ghost-hover: its dark value is a multi-class string (bg-gray-800 text-inverse), not a single ref. The build accepts both forms.
Consumers
- Rails / Tailwind: via the generated CSS, automatically.
- Lookbook reference page:
/design-system/inspect/design_tokens/*readssure.tokens.jsonat request time. - External tools (Figma Tokens Studio, AI design tools, etc.): point them at this file.
If a consumer wants a different shape, transform the JSON in their tooling rather than editing the source here.
File: docs/onboarding/guide.md
Welcome to Sure!
This guide aims to assist new users through:
- Creating a Sure account
- Adding your first accounts
- Recording transactions
This guide also covers the differences between asset and liability accounts, a key concept for using and understanding balances in Sure!
[!IMPORTANT]
Sure is evolving quickly. If you find something inaccurate while following this guide, please:
1. Creating your Sure Account
Once Sure is installed, open a browser and navigate to localhost:3000.
You will see the login page (pictured below). Since we do not have an account yet, click on Sign Up to begin.
You'll be guided through a short series of screens to set your login details, personal information, and preferences.
When you arrive at the main dashboard, showing No accounts yet, you're all set up!
[!Note]
The next sections of this guide cover how to manually add accounts and transactions in Sure.
If you'd like to use an integration with a data provider instead, see:
- Lunch Flow
- Plaid
- Redbark (Australian banks)
- SimpleFIN
- Enable Banking (beta)
- CoinStats (beta)
Even if you use an integration, we still recommend reading through this guide to understand account types and how they work in Sure.
2. Account Types in Sure
Sure supports several account types, which are grouped into Assets (things you own) and Debts/Liabilities (things you owe):
| Assets | Debts/Liabilities |
|---|---|
| Cash | Credit Card |
| Investment | Loan |
| Crypto | Other Liability |
| Property | |
| Vehicle | |
| Other Asset |
3. How Asset Accounts Work
Cash, checking and savings accounts increase when you add money and decrease when you spend money.
Example:
- Starting balance: $500
- Add an expense of $20 -> balance is now $480
- Add an income of $100 -> balance is now $580
4. How Debt Accounts Work (Liabilities)
Liability accounts track how much money you owe, so the math can feel backwards compared to an asset account.
Key rule:
- Positive Balances = you owe money
- Negative balances = the bank owes you (e.g. overpayment or refund)
Transactions behave like this:
- Expenses (e.g. purchases) => increase your debt (you owe more)
- Payments or refunds => decrease your debt (you owe less)
Credit Card example:
- Balance: $200 owed
- Spend $20 => You now owe $220 (balance goes up in red)
- Pay off $50 => You now owe $170 (balance goes down in green)
Overpayment Example:
- Balance: -$44 (bank owes you $44)
- Spend $1 => Bank now owes you $43 (balance shown as -$43, moving towards zero)
[!TIP]
Why does it work this way? This matches standard accounting and what your credit card provider shows online. Think of a liability balance as "Amount Owed", not "available cash."
5. Quick Reference: Assets vs. Liability Behavior
| Action | Asset Account (e.g. Checking) | Liability Account (e.g. Credit Card) |
|---|---|---|
| Spend $20 | Balance β $20 | Balance β $20 (more debt) |
| Receive $50 | Balance β $50 | Balance β $50 (less debt) |
| Negative Balance | Overdraft | Bank owes you money |
6. Adding Accounts
For this example we'll add a Savings Account.
[!TIP]
If you're adding a credit card, loan, or any other debt, be sure to select a Credit Card or Liability account type instead of Cash. This will ensure balances update correctly and match what your bank shows.
Most bank accounts (checking, savings, money market) are Cash Accounts
- Click + Add Account β Cash β Enter Account Balance
- Fill in details such as:
- Account name
- Current Balance
- Account Subtype (This is where you specify checking, savings, or other)
- Click Create Account when you are ready to proceed.
Once created, you'll return to the Home screen.
You'll now see:
- Your new cash account in the Accounts list (left side)
- An overview of your accounts in the center, under the net worth bar.
To get this bar moving let's add some transactions!
7. Adding Transactions
To add a transaction:
- Go to the Transactions page (left sidebar, under Home, above Budgets)
- Click + New Transaction (top right)
- Choose the transaction type:
- Expense β Spending money
- Income β Receiving money
- Transfer β Move money between accounts
- Enter the details, then click Add transaction
You will now see the transaction you added in your transaction history, as well as the net worth chart updating accordingly.
8. Managing Investment Accounts
If you're tracking investments in Sure, there are additional features to help you manage your portfolio accurately.
Cost Basis Tracking
Cost basis tracking helps you understand the original purchase price of your investments, which is essential for calculating returns and tax reporting.
Cost Basis Sources
Sure tracks cost basis from three sources:
| Source | Description |
|---|---|
| Manual | User-entered values that you set directly |
| Calculated | Computed from your buy trades and transaction history |
| Provider | Imported from your financial institution (Plaid, SimpleFin, etc.) |
Priority Hierarchy
When multiple sources provide cost basis data, Sure uses this priority:
Manual > Calculated > Provider
This means:
- Manual values always take precedence
- Calculated values override provider data
- Provider data is used when no other source is available
Lock Protection
When you manually set a cost basis, Sure automatically locks it to prevent automatic updates from overwriting your value. This ensures your manual entries remain intact during account syncs.
Setting Cost Basis Manually
You can set cost basis in two ways:
From the Holdings List:
- Navigate to your investment account
- Find the holding in your portfolio
- Click the pencil icon next to the average cost
- Enter either:
- Total cost basis: The total amount you paid for all shares
- Per-share cost: The average price per share
- The form automatically converts between total and per-share values
- Click Save
The system will show a confirmation if you're overwriting an existing cost basis.
From the Holding Drawer:
- Click on a holding to open its detail drawer
- In the Overview section, click the pencil icon next to "Average Cost"
- Enter the cost basis (total or per-share)
- Click Save
After saving, you'll see:
- A lock icon indicating the value is protected
- A source label showing "(manual)"
Unlocking Cost Basis
If you want to allow automatic updates to recalculate your cost basis:
- Open the holding drawer
- Scroll to the Settings section
- Find "Cost basis locked"
- Click Unlock
After unlocking:
- The lock icon disappears
- Future syncs can update the cost basis
- Calculated values (from trades) will replace the manual value
Bidirectional Conversion
The cost basis editor provides real-time conversion between total and per-share values:
- Enter total cost β automatically calculates per-share cost
- Enter per-share cost β automatically calculates total cost
This makes it easy to enter cost basis in whichever format you have available.
Investment Activity Labels
Activity labels help you classify and understand investment transactions. They appear as badges in your transaction list and can be used to organize and filter your investment activity.
Available Activity Types
Sure supports these investment activity labels:
| Label | Description |
|---|---|
| Buy | Purchase of securities |
| Sell | Sale of securities |
| Contribution | Money added to the investment account |
| Withdrawal | Money removed from the investment account |
| Dividend | Dividend payments received |
| Interest | Interest earned |
| Reinvestment | Dividends or distributions reinvested |
| Sweep In | Cash swept into the account |
| Sweep Out | Cash swept out of the account |
| Fee | Account or transaction fees |
| Exchange | Currency or security exchanges |
| Transfer | Transfers between accounts |
| Other | Miscellaneous transactions |
Setting Activity Labels
You can set activity labels in two ways:
Manually for Individual Transactions:
- Open a transaction from an investment or crypto account
- Scroll to the Settings section
- Find "Activity type"
- Select a label from the dropdown
- The change saves automatically
Automatically with Rules:
Create rules to automatically label transactions based on patterns:
- Go to Settings > Rules
- Create a new rule
- Set conditions (e.g., "IF transaction name contains 'DIVIDEND'")
- Add action: "Set investment activity label"
- Choose the label (e.g., "Dividend")
- Save the rule
Example rules:
- IF name contains "DIVIDEND" THEN set label to "Dividend"
- IF name contains "INTEREST" THEN set label to "Interest"
- IF name contains "FEE" THEN set label to "Fee"
Rules apply automatically to new transactions and can be run on existing transactions.
Viewing Activity Labels
Activity labels appear as badges in:
- Transaction lists
- Transaction detail drawers
- Account activity views
They help you quickly identify the nature of each investment transaction without reading the full transaction name.
9. Next Steps
Now that you have one account and your first transaction:
- Explore the other account types that Sure offers, adding ones relevant to your finances.
- Categorize and Tag transactions for better searching and reporting.
- Experiment with Budgets to track your spending habits.
- If you have many historical transactions, use Bulk Import to load them in.
More detailed user guides for these features are coming soonβ’.
File: docs/llm-guides/adding-a-securities-provider.md
Adding a New Securities Price Provider
This guide covers every step needed to add a new securities price provider (like Tiingo, EODHD, etc.) to the application.
Architecture Overview
User searches ticker in combobox
β SecuritiesController#index
β Security.search_provider (queries all enabled providers concurrently)
β Provider::YourProvider#search_securities
β Returns results with provider key attached
β User selects one β price_provider stored on Security record
Account sync / price fetch
β Security#price_data_provider (looks up provider by security.price_provider)
β Provider::YourProvider#fetch_security_prices
β Security::Price::Importer gap-fills and upserts into DBKey files:
- Provider class:
app/models/provider/your_provider.rb - Registry:
app/models/provider/registry.rb - Settings:
app/models/setting.rb - Provider resolution:
app/models/security/provided.rb - Price import:
app/models/security/price/importer.rb - Market data sync:
app/models/account/market_data_importer.rb
Step 1: Create the Provider Class
Create app/models/provider/your_provider.rb:
/* Detailed source-code truncated for AI context efficiency. */Data Structures (defined in `SecurityConcept`)
All methods must return these exact types (wrapped in with_provider_response):
SecurityConcept::Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code, :currency)
SecurityConcept::SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic)
SecurityConcept::Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic)Error Handling
All public methods must be wrapped in with_provider_response:
def some_method
with_provider_response do
# Your logic. Raise on errors.
# The block return value becomes response.data
end
endCallers always receive a Provider::Response:
response = provider.fetch_security_price(...)
response.success? # true/false
response.data # the return value from the block
response.error # Provider::Error instance (on failure)Rate Limiting
If your provider has rate limits, include RateLimitable and use cache-based atomic counters:
include RateLimitable
MIN_REQUEST_INTERVAL = 1.5 # seconds between requests
MAX_REQUESTS_PER_DAY = 20
private
def throttle_request
super # enforces MIN_REQUEST_INTERVAL
enforce_daily_limit!
end
def enforce_daily_limit!
cache_key = "your_provider:daily:#{Date.current}"
count = Rails.cache.increment(cache_key, 1, expires_in: 1.day, initial: 0)
raise RateLimitError, "Daily limit reached" if count > MAX_REQUESTS_PER_DAY
endExchange Mapping
Every provider has its own naming for exchanges. You must map them to ISO 10383 MIC codes. Define bidirectional maps:
# Provider exchange name β MIC code (for parsing search results)
PROVIDER_TO_MIC = { "NASDAQ" => "XNAS", ... }.freeze
# MIC code β Provider exchange name (for building API requests)
MIC_TO_PROVIDER = PROVIDER_TO_MIC.invert.freezeSee config/exchanges.yml for the full list of MIC codes and their display names.
Currency Handling
Some providers don't return currency in every response. Common pattern: cache currency from search results and reuse later:
def search_securities(symbol, **opts)
with_provider_response do
results = api_call(...)
results.each do |r|
Rails.cache.write("your_provider:currency:#{r[:ticker].upcase}", r[:currency], expires_in: 24.hours)
end
# ...
end
end
def fetch_security_prices(symbol:, **)
with_provider_response do
# ...
currency = Rails.cache.read("your_provider:currency:#{symbol.upcase}") || fallback_currency(exchange)
# ...
end
endStep 2: Register in the Provider Registry
Edit app/models/provider/registry.rb:
Add to available_providers (around line 144):
def available_providers
case concept
when :exchange_rates
%i[twelve_data yahoo_finance]
when :securities
%i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi your_provider]
# ...
end
endAdd the factory method (private section, around line 85):
def your_provider
api_key = ENV["YOUR_PROVIDER_API_KEY"].presence || Setting.your_provider_api_key # pipelock:ignore
return nil unless api_key.present?
Provider::YourProvider.new(api_key)
endIf your provider needs no API key (like Yahoo Finance or MFAPI):
def your_provider
Provider::YourProvider.new
endStep 3: Add Settings
Edit app/models/setting.rb:
Add the API key field (around line 40):
field :your_provider_api_key, type: :string, default: ENV["YOUR_PROVIDER_API_KEY"]Add to encrypted fields (in EncryptedSettingFields, around line 55):
ENCRYPTED_FIELDS = %i[
twelve_data_api_key
tiingo_api_key
eodhd_api_key
alpha_vantage_api_key
your_provider_api_key # β add here
openai_access_token
external_assistant_token
]Step 4: Add to the Settings UI
Hostings Controller
Edit app/controllers/settings/hostings_controller.rb:
In show β add a flag to control visibility:
@show_your_provider_settings = enabled_securities.include?("your_provider")In update β handle the API key:
update_encrypted_setting(:your_provider_api_key)Hostings View
Edit the settings view to add your provider's checkbox and API key field. Follow the existing pattern for Tiingo/EODHD (checkbox in the provider selection list, API key input shown when enabled).
Step 5: Add Translations
Edit config/locales/views/settings/hostings/en.yml:
Add provider name (under provider_selection.providers):
providers:
twelve_data: "Twelve Data"
yahoo_finance: "Yahoo Finance"
tiingo: "Tiingo"
eodhd: "EODHD"
alpha_vantage: "Alpha Vantage"
mfapi: "MFAPI.in"
your_provider: "Your Provider" # β add hereAdd hint text (under provider_selection):
your_provider_hint: "requires API key, N calls/day limit"Add settings section (for the API key input):
your_provider_settings:
title: "Your Provider"
label: "API Key"
env_configured_message: "The YOUR_PROVIDER_API_KEY environment variable is set."Also add a display name in config/locales/en.yml under securities.providers:
securities:
providers:
your_provider: "Your Provider"This is used in the combobox dropdown to show which provider each search result comes from.
Step 6: Test
Manual Testing
# In rails console:
provider = Provider::YourProvider.new("your_api_key")
# Search
response = provider.search_securities("AAPL")
response.success? # => true
response.data # => [SecurityConcept::Security(...), ...]
# Price
response = provider.fetch_security_price(symbol: "AAPL", exchange_operating_mic: "XNAS", date: Date.current)
response.data.price # => 150.25
response.data.currency # => "USD"
# Historical prices
response = provider.fetch_security_prices(symbol: "AAPL", exchange_operating_mic: "XNAS", start_date: 30.days.ago.to_date, end_date: Date.current)
response.data.size # => ~30Enable and Search
# Enable the provider
Setting.securities_providers = "your_provider"
# Search via the app's multi-provider system
results = Security.search_provider("AAPL")
results.map { |s| [s.ticker, s.price_provider] }
# => [["AAPL", "your_provider"]]
# Create a security with your provider
security = Security::Resolver.new("AAPL", exchange_operating_mic: "XNAS", price_provider: "your_provider").resolve
security.price_provider # => "your_provider"
# Import prices
security.import_provider_prices(start_date: 30.days.ago.to_date, end_date: Date.current)
security.prices.countHow It All Connects
Search Flow
When a user types in the securities combobox:
SecuritiesController#indexcallsSecurity.search_provider(query)(app/models/security/provided.rb)search_providerqueries all enabled providers concurrently usingConcurrent::Promiseswith an 8-second timeout per provider- Results are deduplicated (key:
ticker|exchange|provider) and ranked by relevance - Each result's
ComboboxOption#idis"TICKER|EXCHANGE|PROVIDER"(e.g.,"AAPL|XNAS|your_provider") - When the user selects one,
price_provideris stored on the Security record
Price Fetch Flow
When prices are needed:
Security#price_data_providerlooks up the provider bysecurity.price_provider- If the assigned provider is unavailable (disabled in settings), returns
nilβ the security is skipped, not silently switched - If no provider assigned, falls back to the first enabled provider
Security::Price::Importercallsfetch_security_pricesand gap-fills missing dates using LOCF (last observation carried forward)- Prices are upserted in batches of 200
Provider Resolution Priority
security.price_provider present?
βββ YES β Security.provider_for(price_provider)
β βββ Provider enabled & configured β use it
β βββ Provider unavailable β return nil (skip security)
βββ NO β Security.providers.first (first enabled provider)Checklist
- Provider class at
app/models/provider/your_provider.rb- Inherits from
Provider - Includes
SecurityConcept - Implements
search_securities,fetch_security_info,fetch_security_price,fetch_security_prices - Returns correct
Data.definetypes - All methods wrapped in
with_provider_response - Exchange names mapped to MIC codes
- Currency handling (cached or mapped)
- Rate limiting if applicable
- Inherits from
- Registry entry in
app/models/provider/registry.rb- Added to
available_providersfor:securities - Private factory method with ENV + Setting fallback
- Added to
- Setting field in
app/models/setting.rb(if API key needed)-
field :your_provider_api_key - Added to
ENCRYPTED_FIELDS
-
- Settings UI in hostings controller/view
- Translations in
config/locales/- Provider name in
provider_selection.providers - Provider name in
securities.providers(for combobox display) - Hint text and settings section
- Provider name in
- Tested: search, single price, historical prices, info
File: docs/llm-guides/gating-a-preview-feature.md
Gating a preview feature
Sure ships preview features behind a single per-user toggle. Users opt in via Settings β Preferences. Opted-in users see your feature; everyone else doesn't. This guide is for hooking a new feature into the gate.
The intent is to ship in-progress work without blocking smaller PRs on a "feels finished" bar. You gate the entry points (routes, nav, anything that links into your feature) and iterate behind them. Once stable, you remove the gate in a small follow-up PR.
How the gate works
The state lives on users.preferences["preview_features_enabled"], a key inside the existing JSONB column. It defaults to false. Reading it goes through User#preview_features_enabled?.
ApplicationController includes the PreviewGateable concern, which exposes two methods to every controller:
preview_features_enabled?. Returns a boolean.falsefor logged-out callers.require_preview_features!. Abefore_actionhelper. Redirects users without preview access to/with a flash that points them at Settings β Preferences.
The concern also registers preview_features_enabled? as a helper method, so views can call it directly.
Key files:
app/controllers/concerns/preview_gateable.rb. The concern.app/models/user.rb. Thepreview_features_enabled?predicate.app/views/settings/preferences/show.html.erb. The toggle UI users see.app/components/DS/pill.rb. ThePreview/Canarymarker pill.config/locales/views/preview/en.yml. The redirect flash copy.
Gating a controller
Add require_preview_features! as a before_action. That's it.
class GoalsController < ApplicationController
before_action :require_preview_features!
endRoutes stay defined; the gate runs per-request. Users without preview access hitting /goals get redirected with a flash. Preview users pass through.
If only some actions are gated, scope the before_action:
class TransactionsController < ApplicationController
before_action :require_preview_features!, only: %i[forecast scenarios]
endGating a view
Wrap the relevant fragment in the helper:
<% if preview_features_enabled? %>
<li>
<%= link_to t(".nav.goals"), goals_path %>
</li>
<% end %>Same pattern works for dashboard widgets, scoreboard cards, anything that surfaces preview data alongside non-preview data. The helper resolves on every request and reflects the current user's preference.
Gating the main nav
The desktop sidebar rail and the mobile bottom nav both render from app/views/layouts/shared/_nav_item.html.erb. The partial accepts an optional preview: local β when true, it overlays a violet dot-only pill on the icon so opted-in users can tell at a glance that the rail entry leads to a preview surface.
Use the preview_gated_nav_item helper to wrap the entry. It returns nil for users without preview access (so the entry never enters the nav, once Array#compact runs) and stamps preview: true for opted-in users (so the partial paints the dot). One call, both halves of the gate:
<% mobile_nav_items = [
{ name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
{ name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }),
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
].compact %>You don't need to touch _nav_item.html.erb or set preview: true by hand. Adding a new preview nav entry is one helper call wrapped around the same hash you'd write anyway.
Marking the feature in the UI
When a preview surface renders for an opted-in user, mark it. The pill component lives in the design system:
<%# Next to a page header. The md size pairs with h1 / h2. %>
<%= render DS::Pill.new(label: "Preview", size: :md) %>
<%# Next to a sidebar nav label or section title. sm is the default. %>
<%= render DS::Pill.new(label: "Preview") %>
<%# Same shape, fuchsia tone, for canary / experimental surfaces. %>
<%= render DS::Pill.new(label: "Canary", tone: :fuchsia) %>
<%# Sidebar icon rail has no room for a label. The dot-only mode keeps the tone semantics without the text. %>
<%= render DS::Pill.new(tone: :violet, dot_only: true, title: "Preview") %>Default tone is violet. Tones available: violet, indigo, fuchsia, amber, gray. Styles: soft (default), filled, outline. Sizes: sm (default), md. The Lookbook preview at /design-system (look for PillComponentPreview#default) flips every option, so you can see what your call site renders without a round trip to Rails.
Tests
Gated controllers should test both states. The pattern:
class GoalsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
end
test "redirects users without preview access" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
get goals_url
assert_redirected_to root_path
assert_match(/preview/i, flash[:alert])
end
test "renders for users with preview access" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
get goals_url
assert_response :success
end
endIf you write a system test, flip the preference in setup the same way before the visit.
Removing the gate when the feature ships GA
When a feature moves from preview to general availability, removing the gate is a small mechanical PR:
- Drop the
before_action :require_preview_features!line from the controller. - Unwrap the
if preview_features_enabled?blocks in views. - Drop the
DS::Pillmarkers from headers and section titles, and unwrap thepreview_gated_nav_item(...)call back into a plain nav-item hash. - Delete the controller / view tests that exercise the redirect.
Grep for require_preview_features! and preview_features_enabled? near your feature to confirm nothing's left behind.
Notes
The flag is per-user, not per-family. Two users in the same family can see different versions of the product if one opts in and the other doesn't. That's intentional. Data is family-scoped, but visibility is a personal preference. If you write a feature that creates family-shared data (goals, budgets, etc.), the data persists when a user toggles preview off. The UI just disappears from their view while still showing up for opted-in family members.
The gate does nothing for background jobs on its own β PreviewGateable reads Current.user, which a Sidekiq worker doesn't have. A cron job runs regardless of who has preview enabled. That's usually correct: data should keep flowing, so it's there when someone opts in. SweepExpiredGoalPledgesJob is the typical shape β it only walks pledges that opted-in users created, so it's naturally inert for everyone else and needs no gate.
Gate the job when it does work that isn't free. Two cases: it sends something outward (notifications, emails β gate at the send site), or it manufactures data for every family rather than moving existing data around, burning compute or paid API calls per family. GenerateInsightsJob is the second case: seven generators over the income statement and balance sheet, plus optional LLM narration, for every family nightly.
For a family-scoped job, roll the per-user flag up to the family:
# app/models/family.rb
scope :with_preview_features, -> { where(id: User.with_preview_features.select(:family_id)) }
def preview_features_enabled?
users.with_preview_features.exists?
endFilter the fan-out with the scope (one indexed query β User.with_preview_features is a jsonb containment match against the GIN index on users.preferences, not a load-and-iterate), and re-check with the predicate inside the per-family path, which is reachable directly from controllers and the console. Keep the same predicate name on both models so the GA-removal grep finds every call site. GenerateInsightsJob is the reference implementation.
One opted-in member is enough to generate for the whole family β consistent with the per-user-visibility / family-scoped-data split described above.
Note what the shared name costs: preview_features_enabled? means "I opted in" on User but "somebody in my household opted in" on Family. Only ever gate background work on the family form. Gating UI on it (Current.family.preview_features_enabled? reads naturally, which is the trap) would show the feature to someone who explicitly opted out because a family member opted in. For anything a person sees, use the PreviewGateable helper, which reads Current.user.
The redirect target is /. If you want gated controllers to land somewhere else (a docs page, an opt-in nudge), override require_preview_features! in the controller, or write a thin custom before_action that calls preview_features_enabled? directly.
File: docs/llm-guides/goals.md
Working with the Goals feature
Reference for changes to the savings-goals feature. Covers the data
model, the surfaces that consume it, the load-bearing invariants, and
the gotchas worth knowing before you touch the code.
Architecture overview
GoalsController#index
β @active_goals = Family.goals.includes(:open_pledges, linked_accounts: :account_providers)
β KPI strip + per-goal cards (Goals::CardComponent)
β pending-pledges callout if any goal has an open pledge
GoalsController#show
β @goal.open_pledges.reverse_chronological β pending-pledge banners
β progress ring (Goals::ProgressRingComponent)
β projection chart (data-controller="goal-projection-chart")
β Goals::FundingAccountsBreakdownComponent (linked-account rows)
β Notes section if @goal.notes.present?
GoalPledgesController#create (turbo-frame: modal)
β goal.goal_pledges.new(amount:, account:, kind: kind_for_account(account))
β save! β matches?-loop runs once the next sync arrives
Account::ProviderImportAdapter#import_transaction
β GoalPledge::Reconciler.new(entry).run (transfer-kind path)
Account::ReconciliationManager#reconcile
β GoalPledge::Reconciler.new(prepared_valuation).run (manual_save path)
SweepExpiredGoalPledgesJob (cron, every 15 minutes)
β GoalPledge.open_and_expired_now.find_each(&:expire!)Key files
Model layer:
app/models/goal.rbβ balance, pace, status, projection, color map.app/models/goal_pledge.rbβ pledge, match policy, lifecycle.app/models/goal_pledge/reconciler.rbβ entry-to-pledge resolver, called from the import adapters.app/models/account.rbβ#manual?instance method (mirrors theAccount.manualscope) drives pledge kind detection.app/models/family.rbβ#savings_inflow_velocitypowers the KPI strip.
Controllers / routes:
app/controllers/goals_controller.rbβ index / show / new / create / edit / update / destroy (any state) / pause / resume / complete / archive / unarchive.app/controllers/goal_pledges_controller.rbβ new / create / renew / destroy.config/routes.rbβresources :goals do resources :pledges ... member { patch :renew } end.
Views:
app/views/goals/index.html.erb,show.html.erb,new.html.erb,edit.html.erb.app/views/goals/_form_stepper.html.erb,_form_edit.html.erb,_pending_pledge_banner.html.erb,_empty_state.html.erb,_color_picker.html.erb.app/views/goal_pledges/new.html.erb.
View components:
app/components/goals/card_component.{rb,html.erb}β goal card on the index.app/components/goals/funding_accounts_breakdown_component.{rb,html.erb}β per-account widget on show.app/components/goals/avatar_component.{rb,html.erb}β colored letter/icon avatar.app/components/goals/account_stack_component.{rb,html.erb}β overlapping account avatars on the card.app/components/goals/progress_ring_component.{rb,html.erb}β show-page ring.app/components/goals/status_pill_component.{rb,html.erb}β status chip.
Stimulus controllers:
app/javascript/controllers/goal_stepper_controller.jsβ two-step create modal.app/javascript/controllers/goal_pledge_preview_controller.jsβ live amount-impact preview + helper-text toggle.app/javascript/controllers/goal_projection_chart_controller.jsβ D3 projection chart on show.app/javascript/controllers/goals_filter_controller.jsβ index filter chips + search, with URL state.
Schema / migrations:
db/migrate/20260514120000_create_goal_pledges.rbβ table + enums + partial indexes + amount check.db/migrate/20260514120001_drop_goal_contributions.rbβ old ledger.db/migrate/20260514120002_add_pledge_id_index_to_transactions.rbβ partial unique ontransactions.extra->'goal'->>'pledge_id'.
Tests / fixtures:
test/models/goal_test.rb,goal_pledge_test.rb,goal_pledge/reconciler_test.rb.test/controllers/goals_controller_test.rb,goal_pledges_controller_test.rb.test/jobs/sweep_expired_goal_pledges_job_test.rb.test/fixtures/goals.yml,goal_accounts.yml,goal_pledges.yml.
Locales:
config/locales/views/goals/en.yml,goal_pledges/en.yml.config/locales/models/goal/en.yml,goal_pledge/en.yml.
Data model
A goal records a name, target amount, optional target date, color, optional
icon, optional notes, currency, and an AASM state (active / paused /completed / archived). It links to depository accounts via the join
table goal_accounts.
The goal's progress is the live balance of every linked account. There
is no ledger of contributions. Goal#current_balance readslinked_accounts.sum(:balance) at request time.
A GoalPledge is an intent: amount, account, kind, status, expires_at.
The status enum is open / matched / cancelled / expired. The kind
enum is transfer / manual_save; kind is decided at create time from
the selected account's connection state.
Status semantics
Goal#status is computed at render time:
:reachedwhenprogress_percent >= 100.:no_target_datewhentarget_date.nil?.:on_trackwhen the goal has a deadline andmonthly_target_amount <= pace.:behindotherwise.
The AASM state is independent. Read Goal#display_status (not #status)
to get the right pill label: it returns the AASM state when it's not:active, otherwise falls through to #status.
archived is a "keep it as a record" state, not a prerequisite for
deleting. GoalsController#destroy accepts a goal in any state. The only
affordance is the kebab on the show page β index cards deliberately carry
no actions, so the card stays a single click target. A delete cascades
only to goal_accounts and goal_pledges;GoalPledge#clear_matched_transaction_extra unstampsextra["goal"]["pledge_id"] from any transaction a matched pledge
claimed. No account, balance, entry or transaction is removed. ReuseGoal#deletion_confirm rather than building a second CustomConfirm at
a new call site.
Goal#pace is the rolling 90-day net inflow into the linked accounts,
divided by three. The query joins entries with transactions
(valuations excluded by join shape), drops excluded entries, and drops
pending provider transactions via Transaction.excluding_pending. This
last filter matters: a pending Plaid deposit that later reverses would
otherwise quietly reshape pace.
Goal#monthly_target_amount is (remaining_amount / months_remaining).ceil(2).months_remaining uses day precision: (target_date - Date.current) / 30.0,
clamped at zero. Calendar-month math is wrong here β it produces a cliff
in the last 30 days where the required monthly rate spikes.
Goal#catch_up_delta_money returns max(0, monthly_target - pace - sum_of_open_pledges). The show-page catch-up alert hides when this is
zero; the pledge CTA inside the alert pre-fills with this delta, so
accepting it once funds the gap rather than stacking the full required
rate on top.
Pledge match window
GoalPledge#matches? checks three things:
- The pledge is open.
- The entry is on the pledge's
account_id. - The entry's
datesits in[created_at - 5d, max(created_at + 5d, expires_at)],
and the entry's|amount|is within$0.50or1%of the pledge
amount, whichever is larger.
The upper-bound date widens when extend! pushes expires_at forward.
Without that widening, "Extend 7 days" would push the expiry forward but
the actual match window would stay anchored at creation.
The reconciler picks pledges by (account_id, status: "open", kind: expected_kind, expires_at >= NOW()). expected_kind is "manual_save"
for valuation entries and "transfer" for transactions.
When a pledge resolves on a transaction, the reconciler stampstransaction.extra["goal"]["pledge_id"] = pledge.id and setspledge.matched_transaction_id. Two partial unique indexes enforce
single-claim semantics:
goal_pledges (matched_transaction_id) WHERE matched_transaction_id IS NOT NULLtransactions ((extra -> 'goal' ->> 'pledge_id')) WHERE (extra -> 'goal' ->> 'pledge_id') IS NOT NULL
Goal#last_matched_pledge_at joins through matched_transaction_id to
the entry's date, so the show-page header reads the actual entry date,
not goal_pledges.updated_at. The distinction matters: a sync resync
would otherwise touch updated_at on every matched pledge and reset the
"Last pledge matched N days ago" copy across every goal.
Connected vs manual accounts
Account#manual? returns true when the account has noaccount_providers association rows, no plaid_account_id, and nosimplefin_account_id. This mirrors the Account.manual query scope.
Goal#any_connected_account? returns true when any linked account is
not manual. It drives the modal-title copy: connected accounts get
"I just transferredβ¦", manual-only goals get "I just savedβ¦"
GoalPledgesController#kind_for_account(account) is per-account:
manual β manual_save, connected β transfer. A goal with one manual
and one connected linked account works correctly; the kind reflects the
specific account the user picked, not the goal as a whole.
Color map
Goal#account_color_map returns { account_id => palette_hex } for the
goal's linked accounts, sorted by id and assigned palette colors in
order. Three surfaces consume the map: AccountStackComponent on the
goal card, the distribution bar in the funding widget, and the avatars
in the funding widget rows. A given account renders the same color on
every surface within a goal.
Account avatars outside a goal context (the new-goal account checklist)
still call Goals::AvatarComponent.color_for(account.name). The
mismatch is acceptable because the form is a one-shot picker, not a
recurring view.
Common tasks
Adding a new field to `Goal`
- Migration:
add_column :goals, :your_field, :type. Add a partial
index if the field is queried. - Validation: add to
Goalif presence/range rules apply. - Strong params: update
goal_paramsandgoal_update_paramsinGoalsController. - Form: surface in
app/views/goals/_form_stepper.html.erb(create) and_form_edit.html.erb(edit). - Locales: add labels under
goals.form_stepper.step1.fields.*andactiverecord.attributes.goal.*. - Display: pick the right surface (header on show, secondary line on
the card, etc). - Tests: extend
test/models/goal_test.rbfor validation; controller
tests for the form-param flow.
Adding a new status to `Goal#status`
The enum is implicit in the method body (symbol returns); adding a
state means touching:
Goal#statusto return the new symbol from the right branch.Goal#display_statusif the new status interacts with the AASM
states.Goals::StatusPillComponent::VARIANTSto add the chip styling
(classes + icon).Goals::CardComponent#footer_lineif the footer copy depends.GoalsController#kpi_payloadif the KPI strip counts it.config/locales/views/goals/en.ymlundergoals.status.*for the
pill label, plus chip and subtitle keys if the new status filters
on the index.Goals::StatusPillComponent#status_keyand the goal-filter Stimulus
controller (data-status="..."on chips) if the new status filters.
Adding a new pledge kind
The kind is a Postgres enum (goal_pledge_kind) backing theGoalPledge#kind attribute. Adding a new value:
- Migration:
ALTER TYPE goal_pledge_kind ADD VALUE 'your_kind'.
This is irreversible in Postgres; consider whether you really need a
new kind versus a different match strategy on an existing one. GoalPledge::KINDSconstant.GoalPledgesController#kind_for_accountif the new kind has a
per-account trigger.GoalPledge::Reconciler#expected_kindif the new kind matches a
different entry shape.- Locale + modal helper text in
goal_pledges.new.helper_*.
Touching the reconciler
The reconciler is hot β every imported transaction across every provider
calls it. Things to watch:
- The outer
rescue StandardErroris protective: an unexpected raise
here would break the importer for every account. Keep the rescue, but
forward to Sentry so the underlying bug stays visible. - The inner rescue catches
NotOpenError,RecordInvalid, andRecordNotUnique. These cover the known race conditions (another
worker claimed the pledge first; another pledge claimed the
transaction first). Adding new exception classes here should be a
deliberate decision. - The
find_eachloop returns from the method on first successful
resolve. On a rescued failure it falls through to the next candidate
pledge.
Gotchas
The same depository account can fund two goals. Both will read the
full balance and double-count progress toward their targets. This is a
known limitation; an allocation primitive that splits the balance
proportionally (or by explicit user weights) would be the way out.
Goal#pace includes paychecks, rent, debit-card spend β anything on
the linked account. For a goal linked to primary checking, the metric
matches "net change in balance," not "intentional savings." A user
living paycheck-to-paycheck shows near-zero pace even when they
consciously transfer money in. Isolating intentional savings would need
transfer-pair detection.
Status transitions on a single sub-pace month. The current behaviour is
honest but jarring; a two-month moving condition or a recovery banner
would soften the "great for five months, vacation in June, suddenly
Behind" case.
Light-mode contrast on pale palette entries is weak againstbg-container. The fix lives in the design system, not in the goal
feature. The distribution bar segments and the goal-card ring are the
visible surfaces.
Goal#balance_series_values rescues StandardError and logs to Sentry
when Balance::ChartSeriesBuilder raises. The chart degrades to
target-line-only rather than 500ing. If you're debugging "why is the
projection saved-line empty," check Sentry first.
Demo data
Demo::Generator#generate_goals! seeds nine goals chosen to surface
every state on at least one card:
- Active + computed status:
:reached,:on_track,:behind,:no_target_date, plus a past-due active goal that exercises the
"was due" header copy. - AASM: paused, archived, completed.
- Two open pledges (banner + index callout).
- One matched pledge bound to a real recent inflow transaction
(exercises the "Last pledge matched N days ago" header).
Routing goals to different account pools (primary checking holds
the bulk of the balance; secondary checking holds a tenth) is what
forces certain goals to land below their target instead of overshooting.
If you change the demo's account balances, the goal targets need to
move too.
To regenerate from scratch:
bundle exec rails db:drop db:create db:schema:load
SKIP_CLEAR=1 bundle exec rake demo_data:defaultSKIP_CLEAR=0 clears existing data first; on a freshly-loaded schema
the clear step has known issues with the trades constraint so theSKIP_CLEAR=1 path is the reliable one.
Background processes
SweepExpiredGoalPledgesJob runs every 15 minutes via sidekiq-cron
(config/schedule.yml). It scans GoalPledge.open_and_expired_now and
flips matching rows to expired.
GoalPledge::Reconciler runs synchronously inside the existing import
pipeline; it is not a separate job. Any provider sync (Plaid,
SimpleFIN, Lunchflow, Enable Banking, Brex, IBKR, Kraken, SnapTrade) and
any manual balance reconciliation feeds through Account::ProviderImportAdapter
or Account::ReconciliationManager and trips the reconciler hook.
File: docs/hosting/ai.md
LLM Configuration Guide
This document explains how Sure uses Large Language Models (LLMs) for AI features and how to configure them for your deployment.
Overview
Sure includes an AI assistant that can help users understand their financial data by answering questions about accounts, transactions, income, expenses, net worth, and more. The assistant uses LLMs to process natural language queries and provide insights based on the user's financial data.
[!CAUTION]
Onlygpt-4.1was ever supported prior tov0.6.5-alpha*builds!
π Help us by taking a structured approach to your issue reporting. π
Architecture: Two AI Pipelines
Sure has two separate AI systems that operate independently. Understanding this is important because they have different configuration requirements.
1. Chat Assistant (conversational)
The interactive chat where users ask questions about their finances. Routes through one of two backends:
- Builtin (default): Uses the OpenAI-compatible provider configured via
OPENAI_ACCESS_TOKEN/OPENAI_URI_BASE/OPENAI_MODEL. Calls Sure's function tools directly (get_accounts, get_transactions, etc.). - External: Delegates the entire conversation to a remote AI agent. The agent calls back to Sure via MCP to access financial data. Set
ASSISTANT_TYPE=externalas a global override, or configure each family's assistant type in Settings.
2. Auto-Categorization and Merchant Detection (background)
Background jobs that classify transactions and detect merchants. These always use the OpenAI-compatible provider (OPENAI_ACCESS_TOKEN), regardless of what the chat assistant uses. They rely on structured function calling with JSON schemas, not conversational chat.
What this means in practice
| Setting | Chat assistant | Auto-categorization |
|---|---|---|
ASSISTANT_TYPE=builtin (default) |
Uses OpenAI provider | Uses OpenAI provider |
ASSISTANT_TYPE=external |
Uses external agent | Still uses OpenAI provider |
If you use an external agent for chat, you still need OPENAI_ACCESS_TOKEN set for auto-categorization and merchant detection to work. The two systems are fully independent.
Quickstart: OpenAI Token
The easiest way to get started with AI features in Sure is to use OpenAI:
- Get an API key from OpenAI
- Set the environment variable:bash
OPENAI_ACCESS_TOKEN=sk-proj-...your-key-here... - (Re-)Start Sure (both
webandworkerservices!) and the AI assistant will be available to use after you agree/allow via UI option
That's it! Sure will use OpenAI's with a default model (currently gpt-4.1) for all AI operations.
Local vs. Cloud Inference
Cloud Inference (Recommended for Most Users)
What it means: The LLM runs on remote servers (like OpenAI's infrastructure), and your app sends requests over the internet.
| Pros | Cons |
|---|---|
| Zero setup - works immediately | Requires internet connection |
| Always uses the latest models | Data leaves your infrastructure (though transmitted securely) |
| No hardware requirements | Per-request costs |
| Scales automatically | Dependent on provider availability |
| Regular updates and improvements |
When to use:
- You're new to LLMs
- You want the best performance without setup
- You don't have powerful hardware (GPU with large VRAM)
- You're okay with cloud-based processing
- You're running a managed instance
Local Inference (Self-Hosted)
What it means: The LLM runs on your own hardware using tools like Ollama, LM Studio, or LocalAI.
| Pros | Cons |
|---|---|
| Complete data privacy - nothing leaves your network | Requires significant hardware (see below) |
| No per-request costs after initial setup | Setup and maintenance overhead |
| Works offline | Models may be less capable than latest cloud offerings |
| Full control over models and updates | You manage updates and improvements |
| Can be more cost-effective at scale | Performance depends on your hardware |
Hardware Requirements:
The amount of VRAM (GPU memory) you need depends on the model size:
Minimum (8GB VRAM): Can run 7B parameter models like
llama3.2:7borgemma2:7b- Works for basic chat functionality
- May struggle with complex financial analysis
Recommended (16GB+ VRAM): Can run 13B-14B parameter models like
llama3.1:13borqwen2.5:14b- Good balance of performance and hardware requirements
- Handles most financial queries well
Ideal (24GB+ VRAM): Can run 30B+ parameter models or run smaller models with higher precision
- Best quality responses
- Complex reasoning about financial data
CPU-only inference: Possible but extremely slow (10-100x slower). Not recommended for production use.
When to use:
- Privacy is critical (regulated industries, sensitive financial data)
- You have the required hardware
- You're comfortable with technical setup
- You want to minimize ongoing costs
- You need offline functionality
Cloud Providers
Sure supports any OpenAI-compatible API endpoint. Here are tested providers:
OpenAI (Primary Support)
OPENAI_ACCESS_TOKEN=sk-proj-...
# No other configuration needed
# Optional: Request timeout in seconds (default: 60)
# OPENAI_REQUEST_TIMEOUT=60Recommended models:
gpt-4.1- Default, best balance of speed and qualitygpt-5- Latest model, highest quality (more expensive)gpt-4o-mini- Cheaper, good quality
Pricing: See OpenAI Pricing
Google Gemini (via OpenRouter)
OpenRouter provides access to many models including Gemini:
OPENAI_ACCESS_TOKEN=your-openrouter-api-key
OPENAI_URI_BASE=https://openrouter.ai/api/v1
OPENAI_MODEL=google/gemini-2.0-flash-expWhy OpenRouter?
- Single API for multiple providers
- Competitive pricing
- Automatic fallbacks
- Usage tracking
Recommended Gemini models via OpenRouter:
google/gemini-2.5-flash- Fast and capablegoogle/gemini-2.5-pro- High quality, good for complex queries
Anthropic Claude (via OpenRouter)
OPENAI_ACCESS_TOKEN=your-openrouter-api-key
OPENAI_URI_BASE=https://openrouter.ai/api/v1
OPENAI_MODEL=anthropic/claude-3.5-sonnetRecommended Claude models:
anthropic/claude-sonnet-4.5- Excellent reasoning, good with financial dataanthropic/claude-haiku-4.5- Fast and cost-effective
Other Providers
Any service offering an OpenAI-compatible API should work:
- Groq - Fast inference, free tier available
- Together AI - Various open models
- Anyscale - Llama models
- Replicate - Various models
Local LLM Setup (Ollama)
Ollama is the recommended tool for running LLMs locally.
Installation
Install Ollama:
bash# macOS brew install ollama # Linux curl -fsSL https://ollama.com/install.sh | sh # Windows # Download from https://ollama.com/downloadStart Ollama:
bashollama servePull a model:
bash# Smaller, faster (requires 8GB VRAM) ollama pull gemma2:7b # Balanced (requires 16GB VRAM) ollama pull llama3.1:13b # Larger, more capable (requires 24GB+ VRAM) ollama pull qwen2.5:32b
Configuration
Configure Sure to use Ollama:
# Dummy token (Ollama doesn't need authentication)
OPENAI_ACCESS_TOKEN=ollama-local
# Ollama API endpoint
OPENAI_URI_BASE=http://localhost:11434/v1
# Model you pulled
OPENAI_MODEL=llama3.1:13b
# Raise this for large-context local models so auto-categorize and merchant detection
# have enough prompt budget for categories + schemas before transaction rows are added.
LLM_CONTEXT_WINDOW=8192
# Slow local models often need a longer HTTP timeout once the prompt budget issue is fixed.
OPENAI_REQUEST_TIMEOUT=180
# Chained tool calls per turn. Each iteration is another call to the model, so
# lowering this is the cheapest way to keep a turn inside the timeout below.
ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2
# Whole-turn budget before the chat gives up and shows a "no response" error.
# Responses from custom providers are not streamed and tool-call rounds display
# nothing, so this covers every call the turn makes, not just the first token.
# Size it as a sum:
# (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) * OPENAI_REQUEST_TIMEOUT
# + tool execution + queue wait
# Here (1 + 2) * 180 = 540s of model time, plus headroom, so 720.
AI_RESPONSE_TIMEOUT=720
# Optional: enable debug logging in the AI chat
AI_DEBUG_MODE=trueImportant: When using Ollama or any custom provider:
- You must set
OPENAI_MODEL- the system cannot default togpt-4.1as that model won't exist in Ollama - The
OPENAI_ACCESS_TOKENcan be any non-empty value (Ollama ignores it) - If you don't set a model, chats will fail with a validation error
- Auto-categorization uses a conservative default
LLM_CONTEXT_WINDOW=2048, so large category lists or schemas can exhaust the prompt budget before any transactions are sent - If requests start timing out after raising
LLM_CONTEXT_WINDOW, increaseOPENAI_REQUEST_TIMEOUTtoo; these are separate limits - Responses from custom providers are not streamed β the chat shows "Thinkingβ¦" until the entire reply is generated, and a turn that chains tool calls stays there through every round, since tool-call responses have no text to display. If the chat errors while your model is clearly still working, raise
AI_RESPONSE_TIMEOUTor lowerASSISTANT_MAX_TOOL_CALL_ITERATIONS;OPENAI_REQUEST_TIMEOUTalone will not help.AI_RESPONSE_TIMEOUThas to cover the whole turn, so size it as(1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) Γ OPENAI_REQUEST_TIMEOUTplus tool execution and queue wait β a sum, not simply a larger number than the per-call limit
Docker Compose Example
services:
sure:
environment:
- OPENAI_ACCESS_TOKEN=ollama-local
- OPENAI_URI_BASE=http://ollama:11434/v1
- OPENAI_MODEL=llama3.1:13b
- LLM_CONTEXT_WINDOW=8192
- OPENAI_REQUEST_TIMEOUT=180
- AI_DEBUG_MODE=true # Optional: enable debug logging in the AI chat
depends_on:
- ollama
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
# Uncomment if you have an NVIDIA GPU
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
volumes:
ollama_data:Model Recommendations
[!CAUTION]
REMINDER: Onlygpt-4.1was ever supported prior tov0.6.5-alpha*builds!
π Help us by taking a structured approach to your testing of the models mentioned below. π
For Chat Assistant
The AI assistant needs to understand financial context and perform function/tool calling:
Cloud:
- Best:
gpt-4.1orgpt-5- Most reliable, best function calling - Good:
anthropic/claude-4.5-sonnet- Excellent reasoning - Budget:
google/gemini-2.5-flash- Fast and affordable
Local:
- Best:
qwen3-30b- Strong function calling and reasoning (24GB+ VRAM, 14GB at 3bit quantised ) - Good:
openai/gpt-oss-20b- Solid performance (12GB VRAM) - Budget:
qwen3-8b,llama3.1-8b- Minimal hardware (8GB VRAM), still supports tool calling
For Auto-Categorization
Transaction categorization doesn't require function calling:
Cloud:
- Best: Same as chat -
gpt-4.1orgpt-5 - Budget:
gpt-4o-mini- Much cheaper, still very accurate
Local:
- Any model that works for chat will work for categorization
- This is less demanding than chat, so smaller models may suffice
- Some models don't support structured outputs, please validate when using.
For Merchant Detection
Similar requirements to categorization:
Cloud:
- Same recommendations as auto-categorization
Local:
- Same recommendations as auto-categorization
Configuration via Settings UI
For self-hosted deployments, you can configure AI settings through the web interface:
- Go to Settings β Self-Hosting
- Scroll to the AI Provider section
- Configure the provider:
- Access Token - Your API key
- API Base URL - Custom endpoint (leave blank for OpenAI)
- Model - Model name (required for custom endpoints)
- JSON Mode - Structured-output format;
Autosuits most models
- Optionally tune Token Budget β Context Window, Max Response Tokens and Max Items Per Batch. The defaults are conservative so small-context local models work out of the box; raise them for cloud or large-context models.
- Optionally set Chat Response Timeout β how long the chat waits for a whole turn before showing a "no response" error (default 90s). Raise it for slow local models; see Chat Errors While the Model Is Still Generating.
Note: Environment variables take precedence over UI settings. When an env var is set, the corresponding UI field is disabled.
External AI Assistant
Instead of using the built-in LLM (which calls OpenAI or a local model directly), you can delegate chat to an external AI agent. The agent receives the conversation, can call back to Sure's financial data via MCP, and streams a response.
This is useful when:
- You have a custom AI agent with domain knowledge, memory, or personality
- You want to use a non-OpenAI-compatible model (the agent translates)
- You want to keep LLM credentials and logic outside Sure entirely
[!IMPORTANT]
SetASSISTANT_TYPE=externalto route all users to the external agent. Without it, routing falls back to each family'sassistant_typeDB column (configurable per-family in the Settings UI), then defaults to"builtin". If you want a global override that applies to every family regardless of their UI setting, set the env var. If you only want specific families to use the external agent, skip the env var and configure it per-family in Settings.
[!NOTE]
The external assistant handles chat only. Auto-categorization and merchant detection still use the OpenAI-compatible provider (OPENAI_ACCESS_TOKEN). See Architecture: Two AI Pipelines for details.
How It Works
- User sends a message in the Sure chat UI
- Sure sends the conversation to your agent's API endpoint (OpenAI chat completions format)
- Your agent processes it using whatever LLM, tools, or context it needs
- Your agent can call Sure's
/mcpendpoint for financial data and actions (accounts, transactions, balance sheet, budgets, file search, statement import, goals) - Your agent streams the response back to Sure via Server-Sent Events (SSE)
The agent's API must be OpenAI chat completions compatible: accept POST with a messages array, return SSE with delta.content chunks.
Configuration
Configure via the UI or environment variables:
Settings UI:
- Go to Settings -> Self-Hosting
- Set Assistant type to "External (remote agent)"
- Enter the Endpoint URL and API Token from your agent provider
- Optionally set an Agent ID if the provider hosts multiple agents
Environment variables:
ASSISTANT_TYPE=external # Global override (or set per-family in UI)
EXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions
EXTERNAL_ASSISTANT_TOKEN=your-api-token
EXTERNAL_ASSISTANT_AGENT_ID=main # Optional, defaults to "main"
EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main # Optional, for session persistence
[email protected] # Optional, comma-separated allowlistWhen environment variables are set, the corresponding UI fields are disabled (env takes precedence).
MCP Callback Endpoint
Sure exposes a Model Context Protocol (MCP) endpoint at /mcp so your external agent can call back and query financial data. This is how the agent accesses accounts, transactions, balance sheets, and other user data.
Protocol: JSON-RPC 2.0 over HTTP POST
Authentication: Bearer token via Authorization header
Sure supports both:
- OAuth bearer tokens issued through its discovery and registration endpoints (
/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server,POST /register) - Static bearer tokens configured with
MCP_API_TOKENandMCP_USER_EMAIL
Static-token environment variables:
MCP_API_TOKEN=your-secret-token # Bearer token the agent sends to authenticate
[email protected] # Email of the Sure user the agent acts asThe agent must send requests to https://your-sure-instance/mcp with:
Authorization: Bearer <access-token>
Content-Type: application/jsonFor OAuth clients, <access-token> is the issued Doorkeeper bearer token. For static-token clients, it is the configured MCP_API_TOKEN.
Supported methods:
| Method | Description |
|---|---|
initialize |
Handshake, returns server info and capabilities |
tools/list |
Lists available tools with names, descriptions, and input schemas |
tools/call |
Calls a specific tool by name with arguments |
Available tools (exposed via tools/list): the /mcp endpoint serves the
same registry as the builtin assistant. See the canonical tool tables in
mcp.md, which also cover the preview tools.
Example: list tools
curl -X POST https://your-sure-instance/mcp \
-H "Authorization: Bearer $MCP_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Example: call a tool
curl -X POST https://your-sure-instance/mcp \
-H "Authorization: Bearer $MCP_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_accounts","arguments":{}}}'OpenClaw Gateway Example
OpenClaw is an AI agent gateway that exposes agents as OpenAI-compatible endpoints. If your agent runs behind OpenClaw, configure it like this:
ASSISTANT_TYPE=external
EXTERNAL_ASSISTANT_URL=http://your-openclaw-host:18789/v1/chat/completions
EXTERNAL_ASSISTANT_TOKEN=your-gateway-token
EXTERNAL_ASSISTANT_AGENT_ID=your-agent-nameOpenClaw setup requirements:
- The gateway must have
chatCompletions.enabled: truein its config - The agent's MCP config must point to Sure's
/mcpendpoint with the correctMCP_API_TOKEN - The URL format is always
/v1/chat/completions(OpenAI-compatible)
Kubernetes in-cluster example (agent in a different namespace):
# URL uses Kubernetes DNS: <service>.<namespace>.svc.cluster.local:
EXTERNAL_ASSISTANT_URL=http://my-agent.my-namespace.svc.cluster.local:18789/v1/chat/completionsGiving the agent a long-term memory of its own
An external agent can do more than answer questions about the current balance:
with its own repository behind it, it can maintain a document-backed history of
a family's wealth, where every figure traces back to the statement it came from.
That is a two-install shape β Sure as the system of record, the agent harness as
the model and the compiler β rather than a feature inside Sure. Sure exposes a
set of preview MCP tools for it (the Statement Vault, coverage gaps, and
valuations that require a source citation).
See Wealth history with an external agent harness
for which side owns what, and the blueprint
it implements.
Security with Pipelock
When Pipelock is enabled (pipelock.enabled=true in Helm, or the pipelock service in Docker Compose), all traffic between Sure and the external agent is scanned:
- Outbound (Sure -> agent): routed through Pipelock's forward proxy via
HTTPS_PROXY - Inbound (agent -> Sure /mcp): routed through Pipelock's MCP reverse proxy (port 8889)
Pipelock scans for prompt injection, DLP violations, and tool poisoning. The external agent does not need Pipelock installed. Sure's Pipelock handles both directions.
If you need audit evidence, configure Pipelock's flight recorder as described in Pipelock signed action receipts. pipelock.enabled=true gives scanning; receipts require mounted evidence storage plus a receipt-signing key.
NO_PROXY behavior (Helm/Kubernetes only): The Helm chart's env template sets NO_PROXY to include .svc.cluster.local and other internal domains. This means in-cluster agent URLs (like http://agent.namespace.svc.cluster.local:18789) bypass the forward proxy and go directly. If your agent is in-cluster, its traffic won't be forward-proxy scanned (but MCP callbacks from the agent are still scanned by the reverse proxy). Docker Compose deployments use a different NO_PROXY set; check your compose file for the exact values.
mcpToolPolicy note: The Helm chart's pipelock.mcpToolPolicy.enabled defaults to false. Pipelock rejects an enabled tool policy with no rules, so the chart ships it off by default. To turn it on, define at least one rule and set enabled: true:
# Helm values
pipelock:
mcpToolPolicy:
enabled: true
action: warn
rules:
- name: example
toolPattern: "^shell$"
action: blockSee the Pipelock documentation for tool policy configuration details.
Network Policies (Kubernetes)
If you use Kubernetes NetworkPolicies (and you should), both Sure and the agent's namespace need rules to allow traffic in both directions.
[!WARNING]
Port number gotcha: Kubernetes network policies evaluate after kube-proxy DNAT. This means egress rules must use the pod'stargetPort, not the service port. If your agent's Service maps port 18789 to targetPort 18790, the network policy must allow port 18790.
Sure namespace egress (Sure calling the agent):
# Allow Sure -> agent namespace
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: agent-namespace
ports:
- protocol: TCP
port: 18790 # targetPort, not service port!Sure namespace ingress (agent calling Sure's pipelock MCP reverse proxy):
# Allow agent -> Sure pipelock MCP reverse proxy
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: agent-namespace
ports:
- protocol: TCP
port: 8889Agent namespace needs the reverse: egress to Sure on port 8889, ingress from Sure on its listening port.
Access Control
Use EXTERNAL_ASSISTANT_ALLOWED_EMAILS to restrict which users can use the external assistant. When set, only users whose email matches the comma-separated list will see the AI chat. When blank, all users can access it.
Docker Compose Example
x-rails-env: &rails_env
ASSISTANT_TYPE: external
EXTERNAL_ASSISTANT_URL: https://your-agent/v1/chat/completions
EXTERNAL_ASSISTANT_TOKEN: your-api-token
MCP_API_TOKEN: your-mcp-token # For agent callback
MCP_USER_EMAIL: [email protected] # User the agent acts asOr configure the assistant via the Settings UI after startup (MCP env vars are still required for callback).
Assistant Architecture
Sure's AI assistant system uses a modular architecture that allows different assistant implementations to be plugged in based on configuration. This section explains the architecture for contributors who want to understand or extend the system.
Overview
The assistant system evolved from a monolithic class to a module-based architecture with a registry pattern. This allows Sure to support multiple assistant types (builtin, external) and makes it easy to add new implementations.
Key benefits:
- Extensible: Add new assistant types without modifying existing code
- Configurable: Choose assistant type per family or globally
- Isolated: Each implementation has its own logic and dependencies
- Testable: Implementations are independent and can be tested separately
Component Hierarchy
`Assistant` Module
The main entry point for all assistant operations. Located in app/models/assistant.rb.
Key methods:
| Method | Description |
|---|---|
.for_chat(chat) |
Returns the appropriate assistant instance for a chat |
.config_for(chat) |
Returns configuration for builtin assistants |
.available_types |
Lists all registered assistant types |
.function_classes |
Returns all available function/tool classes |
Example usage:
# Get an assistant for a chat
assistant = Assistant.for_chat(chat)
# Respond to a message
assistant.respond_to(message)`Assistant::Base`
Abstract base class that all assistant implementations inherit from. Located in app/models/assistant/base.rb.
Contract:
- Must implement
respond_to(message)instance method - Includes
Assistant::Broadcastablefor real-time updates - Receives the
chatobject in the initializer
Example implementation:
class Assistant::MyCustom < Assistant::Base
def respond_to(message)
# Your custom logic here
assistant_message = AssistantMessage.new(chat: chat, content: "Response")
assistant_message.save!
end
end`Assistant::Builtin`
The default implementation that uses the configured OpenAI-compatible LLM provider. Located in app/models/assistant/builtin.rb.
Features:
- Uses
Assistant::Providedfor LLM provider selection - Uses
Assistant::Configurablefor system prompts and function configuration - Supports function calling via
Assistant::FunctionToolCaller - Streams responses in real-time
Key methods:
| Method | Description |
|---|---|
.for_chat(chat) |
Creates a new builtin assistant with config |
#respond_to(message) |
Processes a message using the LLM |
`Assistant::External`
Implementation for delegating chat to a remote AI agent. Located in app/models/assistant/external.rb.
Features:
- Sends conversation to external agent via OpenAI-compatible API
- Agent calls back to Sure's
/mcpendpoint for financial data - Supports access control via email allowlist
- Streams responses from the agent
Configuration:
config = Assistant::External.config
# => #<struct url="...", token="...", agent_id="...", session_key="...">Registry Pattern
The Assistant module uses a registry to map type names to implementation classes:
REGISTRY = {
"builtin" => Assistant::Builtin,
"external" => Assistant::External
}.freezeType selection logic:
- Check
ENV["ASSISTANT_TYPE"](global override) - Check
chat.user.family.assistant_type(per-family setting) - Default to
"builtin"
Example:
# Global override
ENV["ASSISTANT_TYPE"] = "external"
Assistant.for_chat(chat) # => Assistant::External instance
# Per-family setting
family.update(assistant_type: "external")
Assistant.for_chat(chat) # => Assistant::External instance
# Default
Assistant.for_chat(chat) # => Assistant::Builtin instanceFunction Registry
Assistant.function_classes(user = nil) centralizes all available financial
tools. The full list lives in app/models/assistant.rb (not repeated here;
it drifts). Passing a user matters: preview tools
(PREVIEW_FUNCTION_CLASSES) are appended only when that user has preview
features enabled.
These functions are:
- Used by builtin assistants for LLM function calling
- Exposed via the MCP endpoint for external agents
- Defined in
app/models/assistant/function/
Responder loop
Assistant::Responder drives the tool loop. Facts that matter when adding a
function:
- The iteration cap counts rounds (model round-trips), not individual
calls; parallel calls in one round count once. Default 8, override withASSISTANT_MAX_TOOL_CALL_ITERATIONS. - On the final permitted round the follow-up request offers no tools, so
the model must answer in text with whatever it gathered (a grace turn
instead of a dead chat).ToolCallLimitErrorremains as a backstop. - Tool failures do not raise out of the loop.
FunctionToolCallerreturns{error:, hint:}results, and the system prompt tells the model to follow
the hint and retry exactly once. Functions should return{ error: "...", message: "..." }-shaped soft failures for expected
problems (unknown ids, invalid dates) rather than raising.
System prompt structure
The prompt is Assistant::Configurable::STATIC_INSTRUCTIONS (a frozen,
byte-stable constant, which providers can cache as a repeated prefix)
followed by a volatile ## Session context block: date, formats, currency,
an account roster and category names. The roster collapses to counts beyond
25 accounts, categories beyond 60 names, and both collapse whenever the
configured context window is below 4096 tokens.
Adding a New Assistant Type
To add a custom assistant implementation:
1. Create the implementation class
# app/models/assistant/my_custom.rb
class Assistant::MyCustom < Assistant::Base
class << self
def for_chat(chat)
new(chat)
end
end
def respond_to(message)
# Your implementation here
# Must create and save an AssistantMessage
assistant_message = AssistantMessage.new(
chat: chat,
content: "My custom response"
)
assistant_message.save!
end
end2. Register the implementation
# app/models/assistant.rb
REGISTRY = {
"builtin" => Assistant::Builtin,
"external" => Assistant::External,
"my_custom" => Assistant::MyCustom
}.freeze3. Add validation
# app/models/family.rb
ASSISTANT_TYPES = %w[builtin external my_custom].freeze4. Use the new type
# Global override
ASSISTANT_TYPE=my_custom
# Or set per-family in the database
family.update(assistant_type: "my_custom")Integration Points
Pipelock Integration
For external assistants, Pipelock can scan traffic:
- Outbound: Sure -> agent (via
HTTPS_PROXY) - Inbound: Agent -> Sure /mcp (via MCP reverse proxy on port 8889)
See the External AI Assistant and Pipelock documentation for configuration.
OpenClaw/WebSocket Support
The Assistant::External implementation currently uses HTTP streaming. Future implementations could use WebSocket connections via OpenClaw or other gateways.
Example future implementation:
class Assistant::WebSocket < Assistant::Base
def respond_to(message)
# Connect via WebSocket
# Stream bidirectional communication
# Handle tool calls via MCP
end
endRegister it in the REGISTRY and add to Family::ASSISTANT_TYPES to activate.
AI Cache Management
Sure caches AI-generated results (like auto-categorization and merchant detection) to avoid redundant API calls and costs. However, there are situations where you may want to clear this cache.
What is the AI Cache?
When AI rules process transactions, Sure stores:
- Enrichment records: Which attributes were set by AI (category, merchant, etc.)
- Attribute locks: Prevents rules from re-processing already-handled transactions
This caching means:
- Transactions won't be sent to the LLM repeatedly
- Your API costs are minimized
- Processing is faster on subsequent rule runs
When to Reset the AI Cache
You might want to reset the cache when:
- Switching LLM models: Different models may produce better categorizations
- Improving prompts: After system updates with better prompts
- Fixing miscategorizations: When AI made systematic errors
- Testing: During development or evaluation of AI features
[!CAUTION]
Resetting the AI cache will cause all transactions to be re-processed by AI rules on the next run. This will incur API costs if using a cloud provider.
How to Reset the AI Cache
Via UI (Recommended):
- Go to Settings β Rules
- Click the menu button (three dots)
- Select Reset AI cache
- Confirm the action
The cache is cleared asynchronously in the background. You'll see a confirmation message when the process starts.
Automatic Reset:
The AI cache is automatically cleared for all users when the OpenAI model setting is changed. This ensures that the new model processes transactions fresh.
What Happens When Cache is Reset
- AI-locked attributes are unlocked: Transactions can be re-enriched
- AI enrichment records are deleted: The history of AI changes is cleared
- User edits are preserved: If you manually changed a category after AI set it, your change is kept
Cost Implications
Before resetting the cache, consider:
| Scenario | Approximate Cost |
|---|---|
| 100 transactions | $0.05-0.20 |
| 1,000 transactions | $0.50-2.00 |
| 10,000 transactions | $5.00-20.00 |
Costs vary by model. Use gpt-4o-mini for lower costs.
Tips to minimize costs:
- Use narrow rule filters before running AI actions
- Reset cache only when necessary
- Consider using local LLMs for bulk re-processing
Observability with Langfuse
Sure includes built-in support for Langfuse, an open-source LLM observability platform.
What is Langfuse?
Langfuse helps you:
- Track all LLM requests and responses
- Monitor costs per request
- Measure response latency
- Debug failed requests
- Analyze usage patterns
- Optimize prompts based on real data
Setup
Create a free account at Langfuse Cloud or self-host Langfuse
Get your API keys from the Langfuse dashboard
Configure Sure:
bashLANGFUSE_PUBLIC_KEY=pk-lf-... LANGFUSE_SECRET_KEY=sk-lf-... LANGFUSE_HOST=https://cloud.langfuse.com # or your self-hosted URLRestart Sure
All LLM operations will now be logged to Langfuse, including:
- Chat messages and responses
- Auto-categorization requests
- Merchant detection
- Token usage and costs
- Response times
Langfuse Features in Sure
- Automatic tracing: Every LLM call is automatically traced
- Session tracking: Chat sessions are tracked with a unique session ID
- User anonymization: User IDs are hashed before sending to Langfuse
- Cost tracking: Token usage is logged for cost analysis
- Error tracking: Failed requests are logged with error details
Viewing Traces
- Go to your Langfuse dashboard
- Navigate to Traces
- You'll see traces for:
openai.chat_response- Chat assistant interactionsopenai.auto_categorize- Transaction categorizationopenai.auto_detect_merchants- Merchant detection
Privacy Considerations
What's sent to Langfuse:
- Prompts and responses
- Model names
- Token counts
- Timestamps
- Session IDs
- Hashed user IDs (not actual user data)
What's NOT sent:
- User email addresses
- User names
- Unhashed user IDs
- Account credentials
For maximum privacy: Self-host Langfuse on your own infrastructure.
Testing and Evaluation
Manual Testing
Test your AI configuration:
Go to the Chat interface in Sure
Try these test prompts:
- "Show me my total spending this month"
- "What are my top 5 spending categories?"
- "How much do I have in savings?"
Verify:
- Responses are relevant
- Function calls work (you should see "Analyzing your data..." briefly)
- Numbers match your actual data
Automated Evaluation
Sure doesn't currently include automated evals, but you can build them using Langfuse:
- Collect baseline responses: Run test prompts and save responses
- Create evaluation dataset: Use Langfuse datasets feature
- Run evaluations: Test new models/prompts against the dataset
- Compare results: Use Langfuse's comparison tools
Benchmarking Models
To compare models for your use case:
Speed Test:
- Send the same prompt to different models
- Measure time to first token (TTFT)
- Measure overall response time
Quality Test:
- Create a set of 10-20 realistic financial questions
- Get responses from each model
- Manually rate accuracy and helpfulness
Cost Test:
- Calculate cost per interaction based on token usage
- Factor in your expected usage volume
- Consider speed vs. cost tradeoffs
Example Evaluation Queries
Good test queries that exercise different capabilities:
- Simple retrieval: "What's my checking account balance?"
- Aggregation: "Total spending on restaurants last month?"
- Comparison: "Am I spending more or less than last year?"
- Analysis: "What are my biggest expenses this quarter?"
- Forecasting: "Based on my spending, when will I reach $10k savings?"
Cost Considerations
Cloud Costs
Typical costs for OpenAI (as of early 2025):
- gpt-4.1: ~$5-15 per 1M input tokens, ~$15-60 per 1M output tokens
- gpt-5: ~2-3x more expensive than gpt-4.1
- gpt-4o-mini: ~$0.15 per 1M input tokens (very cheap)
Typical usage:
- Chat message: 500-2000 tokens (input) + 100-500 tokens (output)
- Auto-categorization: 1000-3000 tokens per 25 transactions
- Cost per chat message: $0.01-0.05 for gpt-4.1
Optimization tips:
- Use
gpt-4o-minifor categorization - Use Langfuse to identify expensive prompts
- Cache results when possible
- Consider local LLMs for high-volume operations
Local Costs
One-time costs:
- GPU hardware: $500-2000+ depending on VRAM needs
- Setup time: 2-8 hours
Ongoing costs:
- Electricity: ~$0.10-0.50 per hour of GPU usage
- Maintenance: Occasional updates and monitoring
Break-even analysis:
If you process 10,000 messages/month:
- Cloud (gpt-4.1): ~$200-500/month
- Local (amortized): ~$50-100/month after hardware cost
- Break-even: 6-12 months depending on hardware cost
Recommendation: Start with cloud, switch to local if costs exceed $100-200/month.
Hybrid Approach
You can mix providers:
# Example: Use local for categorization, cloud for chat
# Categorization (high volume, lower complexity)
CATEGORIZATION_PROVIDER=ollama
CATEGORIZATION_MODEL=gemma2:7b
# Chat (lower volume, higher complexity)
CHAT_PROVIDER=openai
CHAT_MODEL=gpt-4.1Note: Sure currently uses a single provider for all operations, but this could be customized.
Troubleshooting
"Messages is invalid" Error
Symptom: Cannot start a chat, see validation error
Cause: Using a custom provider (like Ollama) without setting OPENAI_MODEL
Fix:
# Make sure all three are set for custom providers
OPENAI_ACCESS_TOKEN=ollama-local # Any non-empty value
OPENAI_URI_BASE=http://localhost:11434/v1
OPENAI_MODEL=your-model-name # REQUIRED!Model Not Found
Symptom: Error about model not being available
Cloud: Check that you're using a valid model name for your provider
Local: Make sure you've pulled the model:
ollama list # See what's installed
ollama pull model-name # Install a model"Fixed prompt tokens exceed context budget"
Symptom: Auto-categorization or merchant detection fails immediately with an error like:
Fixed prompt tokens (2108) exceed context budget (1280)Cause: Sure computes the usable prompt budget as:
context_window - max_response_tokens - system_prompt_reserveThe defaults are conservative:
LLM_CONTEXT_WINDOW=2048LLM_MAX_RESPONSE_TOKENS=512LLM_SYSTEM_PROMPT_RESERVE=256
That leaves 1280 input tokens. On local or custom models, the fixed prompt can already exceed that budget once you include Sure's instructions, category taxonomy, and schema payloads.
Fix:
LLM_CONTEXT_WINDOW=8192Then restart both web and worker so the new env var is loaded. If you are using Docker Compose, make sure your compose file forwards LLM_CONTEXT_WINDOW into the containers.
Slow Responses
Symptom: Long wait times for AI responses
Cloud:
- Switch to a faster model (e.g.,
gpt-4o-miniorgemini-2.0-flash-exp) - Check your internet connection
- Verify provider status page
Local:
- Check GPU utilization (should be near 100% during inference)
- Try a smaller model
- Ensure you're using GPU, not CPU
- Check for thermal throttling
- If you see
Net::ReadTimeoutafter fixing the context budget, raiseOPENAI_REQUEST_TIMEOUT(for example180)
Chat Errors While the Model Is Still Generating
Symptom: The chat shows "Thinkingβ¦" for a while, then an error saying the assistant is not available β but the model does produce a reply and LLM Usage shows tokens were generated.
Cause: Three settings interact here, measured over different spans:
OPENAI_REQUEST_TIMEOUT(default60) β applies to each HTTP call to the model, on its own.ASSISTANT_MAX_TOOL_CALL_ITERATIONS(default8) β how many chained tool-call rounds one turn may make. A turn costs up to1 + thismodel calls. On the final permitted round the model is offered no tools, so it answers in text instead of erroring.AI_RESPONSE_TIMEOUT(default90) β covers the whole turn, and its clock starts when the message is queued, so Sidekiq queue time counts against it.
Responses from custom OpenAI-compatible providers are not streamed, so nothing appears in the chat until the entire reply is generated. Worse, the assistant only shows text once a response actually contains some β a tool-call-only response produces nothing to display β so a turn that chains several tool calls sits on "Thinkingβ¦" through all of them. At the defaults the worst case is nine sequential model calls plus eight tool-round executions.
Fix: size AI_RESPONSE_TIMEOUT as a sum, not simply as a number larger than the per-call limit:
AI_RESPONSE_TIMEOUT β₯ (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) Γ OPENAI_REQUEST_TIMEOUT
+ tool execution + queue waitYou have two levers, and the cheaper one is usually the tool-call cap, because it divides the first term. With ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2 a turn costs at most three model calls instead of nine, cutting the timeout you need by two-thirds. The trade-off is that genuinely long tool chains fail earlier, with a clear "exceeded the tool-call limit" error rather than a timeout.
OPENAI_REQUEST_TIMEOUT=300
ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2
AI_RESPONSE_TIMEOUT=1200 # (1 + 2) Γ 300 = 900, plus 300 headroomKeeping the full eight iterations at 300s per call would instead need 9 Γ 300 = 2700 plus headroom β which is why lowering the cap is usually the better trade.
If AI_RESPONSE_TIMEOUT ends up below what the turn actually takes, you get a generic "no response" instead of the specific timeout error, and the job keeps running and burning tokens after the chat has given up.
AI_RESPONSE_TIMEOUT can also be set at Settings β Self-Hosting β OpenAI β Chat Response Timeout, which takes effect without a restart. The environment variable wins if both are set. The minimum accepted value is 30.
Restart web and worker after changing the environment variables, and make sure your Docker Compose file forwards them into the containers.
No Provider Available
Symptom: "Provider not found" or similar error
Fix:
- Check
OPENAI_ACCESS_TOKENis set - For custom providers, verify
OPENAI_URI_BASEandOPENAI_MODEL - Restart Sure after changing environment variables
- Check logs for specific error messages
"Failed to generate response" with External Assistant
Symptom: Chat shows "Failed to generate response" when expecting the external assistant
Check in order:
Is external routing active? Sure uses external mode when
ASSISTANT_TYPE=externalis set as an env var, OR when the family'sassistant_typeis set to "external" in Settings. Check what the pod sees:bashkubectl exec deploy/sure-web -c rails -- env | grep ASSISTANT_TYPE kubectl exec deploy/sure-worker -c sidekiq -- env | grep ASSISTANT_TYPEIf the env var is unset, check the family setting in the database or Settings UI.
Can Sure reach the agent? Test from inside the worker pod (use
sh -cso the env var expands inside the pod, not locally):bashkubectl exec deploy/sure-worker -c sidekiq -- \ sh -c 'curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer $EXTERNAL_ASSISTANT_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"model\":\"test\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \ $EXTERNAL_ASSISTANT_URL'- Exit code 7 (connection refused): Network policy is blocking. Check egress rules, and remember to use the
targetPort, not the service port. - HTTP 401/403: Token mismatch between Sure's
EXTERNAL_ASSISTANT_TOKENand the agent's expected token. - HTTP 404: Wrong URL path. Must be
/v1/chat/completions.
- Exit code 7 (connection refused): Network policy is blocking. Check egress rules, and remember to use the
Check worker logs for the actual error:
bashkubectl logs deploy/sure-worker -c sidekiq --tail=50 | grep -i "external\|assistant\|error"If using Pipelock: Check the Pipelock deployment logs. A crashed Pipelock proxy can block outbound requests:
bashkubectl logs deploy/sure-pipelock --tail=20
High Costs
Symptom: Unexpected bills from cloud provider
Analysis:
- Check Langfuse for usage patterns
- Look for unusually long conversations
- Check if you're using an expensive model
Optimization:
- Switch to cheaper model for categorization
- Consider local LLM for high-volume tasks
- Implement rate limiting if needed
- Review and optimize system prompts
Advanced Topics
Custom System Prompts
The builtin AI assistant uses a system prompt that defines its behavior. The prompt is defined in app/models/assistant/configurable.rb. This does not apply to external assistants, which manage their own prompts.
The prompt has two halves: STATIC_INSTRUCTIONS, a frozen constant that is
byte-identical on every request (providers cache and discount an
exactly-repeated prefix), and a trailing ## Session context block holding
everything volatile (date, currency, account roster, categories).
To customize:
- Fork the repository
- Edit the
STATIC_INSTRUCTIONSconstant (keep customizations there so the
prompt stays cacheable; only put genuinely per-request data in the session
context builders) - Rebuild and deploy
What you can customize:
- Tone and personality
- Response format
- Rules and constraints
- Domain expertise
Function Calling
The assistant uses OpenAI's function calling (tool use) to access user data:
Available functions:
Read and analysis:
get_transactions- Search transactions with filters, sorting and paginationget_recurring_transactions- Detected and manual recurring transactions (subscriptions, bills) with totalsget_accounts- Accounts with ids and current balances; opt-in balance history seriesget_holdings- Investment holdingsget_balance_sheet- Net worth, assets and liabilities with a configurable history period and intervalget_income_statement- Income and expenses for a period, with monthly series, prior-period comparison and account filteringget_budget- Budget summary for a monthget_merchants- Merchants with the ids update_transaction accepts and the exact names get_transactions filters onget_tags/get_categories- Tag and category listings with pagination
Write:
update_transaction,update_budget,create_goalcreate_tag/update_tag,create_category/update_category
Documents:
import_bank_statement- Import bank statement datasearch_family_files- Search uploaded documents (vector store)
These are defined in app/models/assistant/function/. Preview tools
(Statement Vault, get_valuations, get_insights) are listed in
mcp.md.
Vector Store (Document Search)
Sure's AI assistant can search documents that have been uploaded to a family's vault. Under the hood, documents are indexed in a vector store so the assistant can retrieve relevant passages when answering questions (Retrieval-Augmented Generation).
How It Works
- When a user uploads a document to their family vault, it is automatically pushed to the configured vector store.
- When the assistant needs financial context from uploaded files, it calls the
search_family_filesfunction. - The vector store returns the most relevant passages, which the assistant uses to answer the question.
Supported Backends
| Backend | Status | Best For | Requirements |
|---|---|---|---|
| OpenAI (default) | ready | Cloud deployments, zero setup | OPENAI_ACCESS_TOKEN |
| Pgvector | ready | Self-hosted, full data privacy | PostgreSQL with pgvector extension + embedding model |
| Qdrant | scaffolded | Self-hosted, dedicated vector DB | Running Qdrant instance |
Configuration
OpenAI (Default)
No extra configuration is needed. If you already have OPENAI_ACCESS_TOKEN set for the AI assistant, document search works automatically. OpenAI manages chunking, embedding, and retrieval.
# Already set for AI chat - document search uses the same token
OPENAI_ACCESS_TOKEN=sk-proj-...Pgvector (Self-Hosted)
Use PostgreSQL's pgvector extension for fully local document search. All data stays on your infrastructure.
Requirements:
- Use the
pgvector/pgvector:pg16Docker image instead ofpostgres:16(drop-in replacement) - An embedding model served via an OpenAI-compatible
/v1/embeddingsendpoint (e.g. Ollama withnomic-embed-text) - Run the migration with
VECTOR_STORE_PROVIDER=pgvectorto create thevector_store_chunkstable
# Required
VECTOR_STORE_PROVIDER=pgvector
# Embedding model configuration
EMBEDDING_MODEL=nomic-embed-text # Default: nomic-embed-text
EMBEDDING_DIMENSIONS=1024 # Default: 1024 (must match your model)
EMBEDDING_URI_BASE=http://ollama:11434/v1 # Falls back to OPENAI_URI_BASE if not set
EMBEDDING_ACCESS_TOKEN= # Falls back to OPENAI_ACCESS_TOKEN if not setIf you are using Ollama (as in compose.example.ai.yml), pull the embedding model:
docker compose exec ollama ollama pull nomic-embed-textQdrant (Self-Hosted)
[!CAUTION]
OnlyOpenAIhas been implemented!
Use a dedicated Qdrant vector database:
VECTOR_STORE_PROVIDER=qdrant
QDRANT_URL=http://localhost:6333 # Default if not set
QDRANT_API_KEY=your-api-key # Optional, for authenticated instancesDocker Compose example:
services:
sure:
environment:
- VECTOR_STORE_PROVIDER=qdrant
- QDRANT_URL=http://qdrant:6333
depends_on:
- qdrant
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333"
volumes:
- qdrant_data:/qdrant/storage
volumes:
qdrant_data:Note: The Qdrant adapter is currently a skeleton. A future release will add full support including collection management and embedding configuration.
Verifying the Configuration
You can check whether a vector store is properly configured from the Rails console:
VectorStore.configured? # => true / false
VectorStore.adapter # => #<VectorStore::Openai:...>
VectorStore.adapter.class.name # => "VectorStore::Openai"Supported File Types
The following file extensions are supported for document upload and search:
.pdf, .txt, .md, .csv, .json, .xml, .html, .css, .js, .rb, .py, .docx, .pptx, .xlsx, .yaml, .yml, .log, .sh
Privacy Notes
- OpenAI backend: Document content is sent to OpenAI's API for indexing and search. The same privacy considerations as the AI chat apply.
- Pgvector / Qdrant backends: All data stays on your infrastructure. No external API calls are made for document search.
Multi-Model Setup
Currently not supported out of the box, but you could:
- Create multiple provider instances
- Add routing logic to select provider based on task
- Update controllers to specify which provider to use
Rate Limiting
To prevent abuse or runaway costs:
- Use Rack::Attack (already included)
- Configure in
config/initializers/rack_attack.rb - Limit requests per user or globally
Example:
# Limit chat creation to 10 per minute per user
throttle('chats/create', limit: 10, period: 1.minute) do |req|
req.session[:user_id] if req.path == '/chats' && req.post?
endResources
- OpenAI Documentation
- Ollama Documentation
- OpenRouter Documentation
- Langfuse Documentation
- Sure GitHub Repository
Support
For issues with AI features:
- Check this documentation first
- Search existing GitHub issues
- Open a new issue with:
- Your configuration (redact API keys!)
- Error messages
- Steps to reproduce
- Expected vs. actual behavior
Last Updated: March 2026
File: docs/hosting/docker.md
Self Hosting Sure with Docker
This guide will help you setup, update, and maintain your self-hosted Sure application with Docker Compose. Docker Compose is the most popular and recommended way to self-host the Sure app.
Setup Guide
Follow the guide below to get your app running.
Step 1: Install Docker
Complete the following steps:
- Install Docker Engine by following the official guide
- Start the Docker service on your machine
- Verify that Docker is installed correctly and is running by opening up a terminal and running the following command:
# If Docker is setup correctly, this command will succeed
docker run hello-worldStep 2: Configure your Docker Compose file and environment
Create a directory for your app to run
Open your terminal and create a directory where your app will run. Below is an example command with a recommended directory:
# Create a directory on your computer for Docker files (name it whatever you like)
mkdir -p ~/docker-apps/sure
# Once created, navigate your current working directory to the new folder
cd ~/docker-apps/sureCopy our sample Docker Compose file
Make sure you are in the directory you just created and run the following command:
# Download the sample compose.yml file from the GitHub repository
curl -o compose.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ymlThis command will do the following:
- Fetch the sample docker compose file from our public Github repository
- Creates a file in your current directory called
compose.ymlwith the contents of the example file
At this point, the only file in your current working directory should be compose.yml.
Step 3 (optional): Configure your environment
By default, our compose.example.yml file runs without any configuration.
That said, if you would like extra security (important if you're running outside of a local network), you can follow the steps below to set things up.
If you're running the app locally and don't care much about security, you can skip this step.
Create your environment file
In order to configure the app, you will need to create a file called .env, which is where Docker will read environment variables from.
To do this, you should get our .env.example as a starting point:
curl -o .env https://raw.githubusercontent.com/we-promise/sure/main/.env.exampleGenerate the app secret key
The app requires an environment variable called SECRET_KEY_BASE to run.
We will first need to generate this in the terminal. If you have openssl installed on your computer, you can generate it with the following command:
openssl rand -hex 64Alternatively, you can generate a key without openssl or any external dependencies by pasting the following bash command in your terminal and running it:
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' && echoOnce you have generated a key, save it and move on to the next step.
Fill in your environment file
Open the file named .env that we created in a prior step using your favorite text editor.
Fill in this file with the following variables:
SECRET_KEY_BASE="replacemewiththegeneratedstringfromthepriorstep"
POSTGRES_PASSWORD="replacemewithyourdesireddatabasepassword"Using HTTPS
Assuming you want to access your instance from the internet, you should have secured your URL address with an SSL certificate.
The Docker instance runs in plain HTTP and you need to tell it that you are redirecting your HTTPS stream to the HTTP one.
To do this, edit the compose.yml file and find the line stating:
RAILS_ASSUME_SSL: "false"and change it to true
RAILS_ASSUME_SSL: "true"WebAuthn MFA (passkeys and security keys)
If you enable passkeys, Touch ID, Windows Hello, or hardware security keys as MFA credentials, pin the WebAuthn relying party settings in your .env file:
WEBAUTHN_RP_ID="example.com"
WEBAUTHN_ALLOWED_ORIGINS="https://sure.example.com"WEBAUTHN_RP_ID should usually be your registrable domain, not a full URL. See WebAuthn MFA Configuration before changing hostnames or reverse proxy settings for an instance with registered passkeys.
Binding to IPv6 (optional)
By default Sure listens on 0.0.0.0:3000 (IPv4 wildcard) inside the container and Docker publishes the port on the host's IPv4 interface only. If you want the app reachable over IPv6 as well, two things need to change:
- Tell the app to bind to
[::]by settingBINDING=::in the container environment.BINDINGis Rails' native env var for the server bind address. On any kernel withnet.ipv6.bindv6only=0(the default on Linux and macOS) a single[::]bind is dual-stack: it accepts both IPv6 and IPv4 clients from the same socket. You do not need two binds and you do not need two ports. - Tell Docker to publish the host port on IPv6 by adding a bracketed-host
ports:entry alongside the existing IPv4 one.
In compose.yml:
services:
web:
ports:
- ${PORT:-3000}:3000
- "[::]:${PORT:-3000}:3000"
environment:
<<: *rails_env
BINDING: "::"With both changes in place, http://127.0.0.1:3000/ and http://[::1]:3000/ both work against the same container.
Note: Docker's default userland proxy already bridges host-side IPv6 publishes to the container's internal IPv4 address, so in many setups just adding the [::]: port entry is enough. Setting BINDING=:: inside the container only becomes load-bearing when the Docker daemon has "ipv6": true + "ip6tables": true configured (uncommon for self-hosters) and forwards raw IPv6 packets into the container via netfilter instead of the proxy. Setting both is harmless and future-proof.
If you are running behind a reverse proxy that terminates TLS, nothing else changes β proxy_pass http://[::1]:3000 and proxy_pass http://127.0.0.1:3000 both work because the [::] bind is dual-stack.
Local development bind
For bin/dev on your own machine, the server now defaults to Rails' native localhost bind (127.0.0.1 + [::1]) β only reachable from the same machine. If you need external access (phone on the same WiFi, devcontainer port forwarding, LAN testing), set the Rails-native env var:
BINDING=0.0.0.0 bin/dev # reachable from LAN
BINDING=:: bin/dev # IPv6 dual-stackThe bundled devcontainer at .devcontainer/docker-compose.yml already pins BINDING: "0.0.0.0" so Docker port forwarding reaches the app β no manual override needed when using the devcontainer.
Step 4: Run the app
You are now ready to run the app. Start with the following command to make sure everything is working:
docker compose upThis will pull our official Docker image and start the app. You will see logs in your terminal.
Open your browser, and navigate to http://localhost:3000.
If everything is working, you will see the Sure login screen.
Step 5: Create your account
The first time you run the app, you will need to register a new account by hitting "create your account" on the login page.
- Enter your email
- Enter a password
Step 5a: Restrict future signups (optional)
After creating your initial admin account, you can control how other people join your self-hosted instance from Settings > Self-Hosting > Onboarding.
- Open: Anyone can create an account from the registration page.
- Invite-only: New account creation stays enabled. Signups require a valid invite code unless you configure a default family for invite-only onboarding.
- Closed: The registration page is disabled for new signups.
If you do not want additional self-service registrations, switch the instance to Closed after the initial setup.
Step 6: Run the app in the background
Most self-hosting users will want the Sure app to run in the background on their computer so they can access it at all times. To do this, hit Ctrl+C to stop the running process, and then run the following command:
docker compose up -dThe -d flag will run Docker Compose in "detached" mode. To verify it is running, you can run the following command:
docker compose lsStep 7: Enjoy!
Your app is now set up. You can visit it at http://localhost:3000 in your browser.
If you find bugs or have a feature request, be sure to read through our contributing guide here.
AI features, external assistant, and Pipelock
Sure ships with a separate compose file for AI-related features: compose.example.ai.yml. It adds:
- Pipelock (always on): AI agent security proxy that scans outbound LLM calls and inbound MCP traffic
- Ollama + Open WebUI (optional
--profile ai): local LLM inference
Using the AI compose file
# Download both compose files
curl -o compose.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.yml
curl -o compose.ai.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ai.yml
curl -o pipelock.example.yaml https://raw.githubusercontent.com/we-promise/sure/main/pipelock.example.yaml
# Run with Pipelock (no local LLM)
docker compose -f compose.ai.yml up -d
# Run with Pipelock + Ollama
docker compose -f compose.ai.yml --profile ai up -dSetting up the external AI assistant
The external assistant delegates chat to a remote AI agent instead of calling LLMs directly. The agent calls back to Sure's /mcp endpoint for financial data (accounts, transactions, balance sheet).
Set the MCP endpoint credentials in your
.env:bashMCP_API_TOKEN=generate-a-random-token-here [email protected] # must match an existing Sure userSet the external assistant connection:
bashEXTERNAL_ASSISTANT_URL=https://your-agent/v1/chat/completions EXTERNAL_ASSISTANT_TOKEN=your-agent-api-tokenChoose how to activate:
- Per-family (UI): Go to Settings > Self-Hosting > AI Assistant, select "External"
- Global (env): Set
ASSISTANT_TYPE=externalto force all families to use external
See docs/hosting/ai.md for full configuration details including agent ID, session keys, and email allowlisting.
Pipelock security proxy
Pipelock sits between Sure and external services, scanning AI traffic for:
- Secret exfiltration (DLP): catches API keys, tokens, or personal data leaking in prompts
- Prompt injection: detects attempts to override system instructions
- Tool poisoning: validates MCP tool calls against known-safe patterns
- Signed receipts: optional hash-chained evidence of mediated decisions when
flight_recorder.dirandsigning_key_pathare configured
When using compose.example.ai.yml, Pipelock is always running. External AI agents should connect to port 8889 (MCP reverse proxy) instead of directly to Sure's /mcp on port 3000.
For full Pipelock configuration, see docs/hosting/pipelock.md.
How to update your app
The mechanism that updates your self-hosted Sure app is the GHCR (Github Container Registry) Docker image that you see in the compose.yml file:
image: ghcr.io/we-promise/sure:latestWe recommend using one of the following images, but you can pin your app to whatever version you'd like (see packages):
ghcr.io/we-promise/sure:latest(latestalpha)ghcr.io/we-promise/sure:stable(latest release)
By default, your app will NOT automatically update. To update your self-hosted app, run the following commands in your terminal:
cd ~/docker-apps/sure # Navigate to whatever directory you configured the app in
docker compose pull # This pulls the "latest" published image from GHCR
docker compose build # This rebuilds the app with updates
docker compose up --no-deps -d web worker # This restarts the app using the newest versionHow to change which updates your app receives
If you'd like to pin the app to a specific version or tag, all you need to do is edit the compose.yml file:
image: ghcr.io/we-promise/sure:stableAfter doing this, make sure and restart the app:
docker compose pull # This pulls the "latest" published image from GHCR
docker compose build # This rebuilds the app with updates
docker compose up --no-deps -d web worker # This restarts the app using the newest versionTroubleshooting
ActiveRecord::DatabaseConnectionError
If you are trying to get Sure started for the first time and run into database connection issues, it is likely because Docker has already initialized the Postgres database with a different default role (usually from a previous attempt to start the app).
If you run into this issue, you can optionally reset the database.
PLEASE NOTE: this will delete any existing data that you have in your Sure database, so proceed with caution. For first-time users of the app just trying to get started, you're generally safe to run the commands below.
By running the commands below, you will delete your existing Sure database and "reset" it.
docker compose down
docker volume rm sure_postgres-data # this is the name of the volume the DB is mounted to
docker compose up
docker compose exec db psql -U sure_user -d sure_development -c "SELECT 1;" # This will verify that the issue is fixedSlow `.csv` import (processing rows taking longer than expected)
Importing comma-separated-value file(s) requires the sure-worker container to communicate with Redis. Check your worker logs for any unexpected errors, such as connection timeouts or Redis communication failures.
Inspecting background jobs (`/sidekiq`)
Sure ships the Sidekiq Web dashboard at /sidekiq. The route only exists for a signed-in super admin β the first user created on your instance. Anyone else (including logged-out visitors) gets a 404, so there is nothing to configure to keep it safe. If you want a second layer of protection anyway, set both SIDEKIQ_WEB_USERNAME and SIDEKIQ_WEB_PASSWORD in your environment file to additionally require basic-auth credentials; there are no default credentials.
For day-to-day triage of stuck syncs, imports, and exports, prefer Settings β Background jobs β it maps queue state onto the actual records and offers safe recovery actions. The Sidekiq dashboard is a break-glass tool; two warnings when using it directly:
- Never manually retry
SimplefinConnectionUpdateJobβ it consumes a single-use setup token, and a retry permanently breaks that connection attempt. - Deleting or retrying jobs does not update the corresponding Sure record (a deleted
ImportJobleaves its import stuck inimporting) β use Settings β Background jobs for record-level recovery.
File: docs/hosting/hetzner.md
Deploying Sure on Hetzner Cloud
This guide will help you deploy Sure on a Hetzner Cloud server with Docker Compose, including SSL certificates, security hardening, and automated backups.
Prerequisites
Before starting, ensure you have:
- A Hetzner Cloud server (recommended: 4GB RAM, 2 CPU cores minimum)
- A domain name pointing to your server's IP address
- SSH access to your server
- Basic familiarity with Linux command line
Step 1: Server Setup and Security
Connect to your Hetzner server and set up the basic environment:
# Connect to your server (replace with your server's IP)
ssh root@YOUR_SERVER_IP
# Update the system
apt update && apt upgrade -y
# Install essential packages
apt install -y curl wget git ufw fail2ban
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
# Install Docker Compose
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
# Add your user to docker group (replace 'your_username' with your actual username)
# If you are using root, you can skip this step
if [ "$(whoami)" != "root" ]; then
usermod -aG docker "$(whoami)"
fi
# Configure firewall
ufw allow ssh
ufw allow 80
ufw allow 443
ufw --force enable
# Configure fail2ban for SSH protection
systemctl enable fail2ban
systemctl start fail2banStep 2: Create Application Directory
# Create directory for the application
mkdir -p /opt/sure
cd /opt/sure
# Download the Docker Compose configuration
curl -o compose.yml https://raw.githubusercontent.com/we-promise/sure/main/compose.example.ymlStep 3: Configure Environment Variables
Create a secure environment configuration:
# Create environment file
nano .envAdd the following content to the .env file (replace the values with your own):
# Generate a secure secret key
SECRET_KEY_BASE="$(openssl rand -hex 64)"
# Database configuration
POSTGRES_USER="sure_user"
POSTGRES_PASSWORD="$(openssl rand -base64 32)"
POSTGRES_DB="sure_production"
# Optional: OpenAI integration (add your API key if you want AI features)
# OPENAI_ACCESS_TOKEN="your_openai_api_key_here"Important Security Notes:
- Never use the default values from the example file in production
- Keep your
.envfile secure and never commit it to version control - The
SECRET_KEY_BASEis critical for Rails security - keep it secret
Step 4: Set Up Reverse Proxy with SSL
We'll use Nginx as a reverse proxy with Let's Encrypt SSL certificates:
# Install Nginx and Certbot
apt install -y nginx certbot python3-certbot-nginx
# Create Nginx configuration for your domain
nano /etc/nginx/sites-available/sureAdd this Nginx configuration (replace yourdomain.com with your actual domain):
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
}# Enable the site
ln -s /etc/nginx/sites-available/sure /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default
# Test Nginx configuration
nginx -t
# Start Nginx
systemctl enable nginx
systemctl start nginx
# Get SSL certificate
certbot --nginx -d yourdomain.com -d www.yourdomain.comStep 5: Deploy the Application
Now let's deploy the Sure application:
# Navigate to the application directory
cd /opt/sure
# Pull the latest Docker images
docker compose pull
# Start the application
docker compose up -d
# Check if everything is running
docker compose ps
# View logs to ensure everything started correctly
docker compose logs -fStep 6: Test the Deployment
Verify your deployment is working:
# Check if the application is accessible
curl -I https://yourdomain.com
# Check Docker container health
docker compose psNow you can:
- Visit your application: Go to
https://yourdomain.comin your browser - Create your admin account: Click "Create your account" on the login page
- Set up your first family: Follow the onboarding process
Optional: Disable self-service registration
Once your initial admin account is ready, open Settings > Self-Hosting > Onboarding to choose how signups should work:
- Open: Anyone can register.
- Invite-only: New signups require a valid invite code unless you configure a default family for invite-only onboarding.
- Closed: New signups from the registration page are blocked.
For single-admin or tightly controlled deployments, set the onboarding mode to Closed after the initial setup.
Step 7: Set Up Automated Backups
Create a backup script to protect your data:
# Create backup script
nano /opt/sure/backup.shAdd this backup script:
#!/bin/bash
BACKUP_DIR="/opt/sure/backups"
DATE=$(date +%Y%m%d_%H%M%S)
# Create backup directory
mkdir -p $BACKUP_DIR
# Backup database
docker compose exec -T db pg_dump -U sure_user sure_production > $BACKUP_DIR/db_backup_$DATE.sql
# Backup application data
docker compose exec -T web tar -czf - /rails/storage > $BACKUP_DIR/storage_backup_$DATE.tar.gz
# Keep only last 7 days of backups
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete
echo "Backup completed: $DATE"# Make backup script executable
chmod +x /opt/sure/backup.sh
# Add to crontab for daily backups at 2 AM
crontab -eAdd this line to crontab:
0 2 * * * /opt/sure/backup.sh >> /var/log/sure-backup.log 2>&1Step 8: Set Up Basic Monitoring
Create a health check script to monitor your application:
# Install htop for system monitoring
apt install -y htop
# Create a simple health check script
nano /opt/sure/health-check.shAdd this health check script:
#!/bin/bash
# Check if the application is responding
if curl -f -s https://yourdomain.com > /dev/null; then
echo "$(date): Application is healthy"
else
echo "$(date): Application is down - restarting"
cd /opt/sure
docker compose restart web worker
fi# Make health check executable
chmod +x /opt/sure/health-check.sh
# Add to crontab to run every 5 minutes
crontab -eAdd this line:
*/5 * * * * /opt/sure/health-check.sh >> /var/log/sure-health.log 2>&1Maintenance Commands
Here are the essential commands for maintaining your deployment:
Update the application:
cd /opt/sure
docker compose pull
docker compose up --no-deps -d web workerView logs:
# All services
docker compose logs -f
# Specific service
docker compose logs -f web
docker compose logs -f worker
docker compose logs -f dbRestart services:
# Restart all services
docker compose restart
# Restart specific service
docker compose restart webCheck system resources:
# Docker resource usage
docker stats
# System resources
htop
df -hRestore from backup:
# Restore database
docker compose exec -T db psql -U sure_user sure_production < /opt/sure/backups/db_backup_YYYYMMDD_HHMMSS.sql
# Restore application data
docker compose exec -T web tar -xzf /opt/sure/backups/storage_backup_YYYYMMDD_HHMMSS.tar.gz -C /Security Features
Your deployment includes several security measures:
- Firewall: Only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) are open
- Fail2ban: Protects against brute force attacks on SSH
- SSL/TLS: Automatic HTTPS with Let's Encrypt certificates
- Environment variables: Sensitive data stored securely in
.envfile - Non-root containers: Application runs as non-root user
- Regular updates: Keep your system and Docker images updated
Troubleshooting
Common Issues and Solutions
Application won't start:
# Check logs for errors
docker compose logs -f
# Check if ports are available
netstat -tulpn | grep :3000Database connection issues:
# Check database container
docker compose logs db
# Test database connection
docker compose exec db psql -U sure_user -d sure_production -c "SELECT 1;"SSL certificate issues:
# Renew certificates
certbot renew --dry-run
# Check certificate status
certbot certificatesOut of disk space:
# Check disk usage
df -h
# Clean up Docker images
docker system prune -a
# Clean up old backups
find /opt/sure/backups -name "*.sql" -mtime +7 -deleteApplication is slow:
# Check system resources
htop
docker stats
# Check if containers are healthy
docker compose psPerformance Optimization
For better performance on Hetzner Cloud:
- Use SSD storage: Hetzner Cloud provides NVMe SSD storage by default
- Choose appropriate server size:
- Minimum: CX21 (2 vCPU, 4GB RAM)
- Recommended: CX31 (2 vCPU, 8GB RAM) for multiple users
- Enable swap (if needed):bash
fallocate -l 2G /swapfile chmod 600 /swapfile mkswap /swapfile swapon /swapfile echo '/swapfile none swap sw 0 0' >> /etc/fstab
Backup Strategy
Your backup strategy includes:
- Daily automated backups of database and application data
- 7-day retention of backup files
- Separate backup directory at
/opt/sure/backups - Logging of backup operations
Consider additional backup options:
- Off-site backups: Copy backups to external storage (AWS S3, Google Cloud, etc.)
- Database replication: Set up PostgreSQL streaming replication
- Snapshot backups: Use Hetzner Cloud snapshots for full system backups
Next Steps
After successful deployment:
- Create your admin account at
https://yourdomain.com - Set up your first family in the application
- Configure bank connections (if using Plaid integration)
- Set up additional users as needed
- Monitor your deployment using the health check logs
Support
If you encounter issues:
- Check the troubleshooting section above
- Review the application logs:
docker compose logs -f - Check system resources:
htopanddf -h - Open a discussion in our GitHub repository
Security Reminders
- Keep your server updated:
apt update && apt upgrade - Monitor your logs regularly:
/var/log/sure-backup.logand/var/log/sure-health.log - Use strong passwords for all accounts
- Consider setting up SSH key authentication instead of password authentication
- Regularly review your firewall rules:
ufw status - Monitor your SSL certificate expiration:
certbot certificates
2. Official Technical Reference & Guides (we-promise/docs)
File: README.md
Sure Docs
Still #needsupdate!
This is the starter kit to get your docs deployed and ready to customize.
Click the green Use this template button at the top of this repo to copy the Mintlify starter kit. The starter kit contains examples with
- Guide pages
- Navigation
- Customizations
- API reference pages
- Use of popular components
Follow the full quickstart guide
Development
Install the Mintlify CLI to preview your documentation changes locally. To install, use the following command:
npm i mintRun the following command at the root of your documentation, where your docs.json is located:
mint devView your local preview at http://localhost:3000.
Publishing changes
Install our GitHub app from your dashboard to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch.
Need help?
Troubleshooting
- If your dev environment isn't running: Run
mint updateto ensure you have the most recent version of the CLI. - If a page loads as a 404: Make sure you are running in a folder with a valid
docs.json.
Resources
File: guides/knowledge-base/faq.mdx
title: "FAQ"
Transfers and Currencies
- The outgoing transaction uses the source account's currency and amount
- The incoming transaction is converted to the destination account's currency using the latest available rate
- If no exchange rate is found, it falls back to a 1:1 rate, which may result in incorrect amounts until rates are synced
- Foreign exchange metadata is also recorded for auditing purposes
For best results, make sure your exchange rates are up to date to avoid conversion errors.
**Sources:**
- [Transfer Creator Implementation](https://github.com/we-promise/sure/blob/8e36c8e7368cef008133970404bcaabd6cde3df9/app/models/transfer/creator.rb#L1-L103)
- [Issue #124](https://github.com/we-promise/sure/issues/124)File: guides/knowledge-base/troubleshooting.mdx
title: "Troubleshooting"
When Rules Are Triggered
Rules run automatically during family syncs or can be triggered manually. Understanding when rules execute helps troubleshoot categorization issues.
- Clicking "Re-apply" on an individual rule in `/settings/rules`
- Clicking "Apply All" in `/settings/rules`
- Running the rake task: `bin/rails rules:apply_all[family_id]`**Scheduled syncs:**
- `SyncAllJob` - runs daily at 2:22 AM for all families
- `SyncHourlyJob` - runs every hour (for items that opt-in to hourly syncing)
**Triggered syncs:**
- Provider webhooks (Plaid, etc.) - triggers sync which eventually propagates to family
- Manual sync button on accounts page
- After CSV imports complete (`Import` model calls `family.sync_later`)- **Manual transaction creation** - Only triggers an Account sync (not Family sync)
- **Manual transaction editing** - Same as above, only Account sync runs
- **Inactive rules** - Rules with `active: false` never run automatically, only via manual "Re-apply"Category Rule Popup Behavior
When a user selects a category for a transaction, a popup may appear to create an automatic categorization rule. This popup is intentionally gated by several conditions:
1. User has not disabled rule prompts (`rule_prompts_disabled` is false)
2. User hasn't dismissed the popup in the last **24 hours**
3. The transaction category actually changed
4. No existing rule already sets this category for similar transactions
5. The transaction has a category assigned- **Recently dismissed**: Wait 24 hours after dismissing the popup
- **Rule already exists**: A matching rule may already be in place
- **Prompts disabled**: Check if `rule_prompts_disabled` is enabled for the userSure Doesnβt Work Over HTTPS
If Sure behaves incorrectly or fails when accessed over HTTPS, itβs usually due to missing SSL-related configuration between Nginx and Rails.
Sure relies on correct protocol headers to determine whether a request is secure.
When HTTPS is terminated at Nginx but not properly forwarded to the app, Rails may treat requests as HTTP.Apply the following configuration changes:
1. **Nginx**
Ensure HTTPS is forwarded to the upstream app:
```nginx
proxy_set_header X-Forwarded-Proto https;
```
2. **docker-compose.yml**
In the `x-rails-env: &rails_env` section, set:
```yaml
RAILS_ASSUME_SSL: "true"
```
This tells Rails to treat all requests as HTTPS.Some pages break over HTTPS
Sure uses WebSockets for certain pages. If some pages fail to render correctly or break entirely, this is often caused by missing SSL or upgrade headers in your reverse proxy configuration.
You may see pages partially load, fail completely, or errors similar to:
```text
Failed to upgrade to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: close, HTTP_UPGRADE: )
```
This indicates that the WebSocket upgrade request is not being forwarded correctly.Ensure your Nginx configuration includes the required WebSocket headers:
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
These headers allow Nginx to properly handle WebSocket upgrade requests over HTTPS.Budget totals do not update after deleting transactions
If a budget still shows spending from a transaction that was already deleted, the most likely cause is a stale aggregate cache. Sure hard-deletes the transaction entry, so the deleted transaction is gone; the stale value can come from cached budget or income totals that were not invalidated by deleting an older entry.
Budget totals are derived from entry aggregation queries. Older versions of Sure used the most recent entry update timestamp as part of the cache key, which meant deleting an older transaction could leave the cache key unchanged.The entry aggregate cache version now includes both:
- The current entry count
- The newest entry `updated_at` timestamp
Because the count changes when an entry is deleted, budget and income aggregate caches are invalidated even when the deleted transaction was not the most recently updated entry.Confirm the installed version includes the cache invalidation fix for `Family#entries_cache_version`, then re-check the affected budget after the next request or cache refresh. The regression test is:
```bash
bin/rails test test/models/family_test.rb
```Chat fails immediately on a new account with a strict OpenAI-compatible provider
Some strict OpenAI-compatible providers (not OpenAI itself) reject chat requests with a schema validation error when a family has no tags, merchants, or categories yet. This happens because the assistant's tool schemas include enum fields built from family data β an empty family produces enum: [], which is invalid JSON Schema.
Sure builds tool schemas dynamically from your family's data (account names, categories, merchants, tags, tickers). When none of that data exists yet, the schema contains `enum: []`, which is invalid JSON Schema. OpenAI tolerates it, but strict OpenAI-compatible providers reject the entire request before the model runs.Empty `enum` arrays are now pruned from tool schemas before they are sent. An empty enum falls back to a plain `string` type, which is valid. This fix applies to both chat tool definitions and the `/mcp` endpoint's `tools/list` response.
If you are on an affected version, upgrade to the latest release. No configuration change is needed.Chat shows "assistant not available" before the model finishes
Self-hosted users running a local model (Ollama, LM Studio, etc.) may see the chat fail with "assistant not available" even though the model is still generating a reply and tokens are being billed.
Sure uses a whole-turn watchdog (`AI_RESPONSE_TIMEOUT`, default 90 seconds) that starts when the message is queued. Custom OpenAI-compatible providers use a synchronous code path β nothing renders until the full reply is generated β so the watchdog fires before the model finishes.
Tool-using turns make this worse: each tool call adds another full model round. The total time the watchdog must cover is:
```text
(1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) Γ OPENAI_REQUEST_TIMEOUT
+ tool execution time + queue wait
```
With the defaults (`ASSISTANT_MAX_TOOL_CALL_ITERATIONS=5`, `OPENAI_REQUEST_TIMEOUT=60`), a worst-case turn can take up to 360 seconds of model time β far beyond the 90-second default.The most effective approach is to lower `ASSISTANT_MAX_TOOL_CALL_ITERATIONS` (reducing the worst-case bound) and then size `AI_RESPONSE_TIMEOUT` using the formula above.
For a local Ollama setup:
```bash
OPENAI_REQUEST_TIMEOUT=300
ASSISTANT_MAX_TOOL_CALL_ITERATIONS=2
AI_RESPONSE_TIMEOUT=1200
```
You can also set **Chat Response Timeout** from **Settings β Self-Hosting β AI Provider** in the UI. The environment variable takes precedence.
See [Chat response timeout](/llm-support#chat-response-timeout) for full sizing guidance.Why is Sure not running auto-categorization and merchant detection on the same transactions again?
Sure caches AI-generated results to avoid redundant API calls and costs. Once a transaction has been processed by AI rules, it won't be re-processed unless you explicitly reset the AI cache.
When AI rules process transactions, Sure stores:
- **Enrichment records** - Which attributes were set by AI (category, merchant, etc.)
- **Attribute locks** - Prevents rules from re-processing already-handled transactions
This caching ensures:
- Transactions aren't sent to the LLM repeatedly
- API costs are minimized
- Processing is faster on subsequent rule runsTo have AI rules re-process transactions, you need to reset the AI cache:
1. Go to **Settings** β **Rules**
2. Click the menu button (three dots)
3. Select **Reset AI cache**
4. Confirm the action
After resetting, the next time rules run (either manually or during a sync), AI will re-process all transactions.
**Note:** This will incur API costs if using a cloud provider.Common scenarios for resetting the cache:
- **Switching LLM models** - Different models may produce better categorizations
- **After system updates** - New versions may have improved prompts
- **Fixing miscategorizations** - When AI made systematic errors
- **Testing** - During development or evaluation of AI features
The AI cache is automatically cleared when you change the OpenAI model setting.File: guides/key-concepts/account-types.mdx
title: All Account Types Sure Offers
All Account Types Sure Offers
Sure supports a comprehensive range of account types to track your complete financial picture. This page provides a complete reference of all available account types and their subtypes.
Overview: Assets vs. Liabilities
| Classification | Types | Description |
|---|---|---|
| Assets | Cash, Investment, Crypto, Property, Vehicle, Other Asset | Things you own |
| Liabilities | Credit Card, Loan, Other Liability | Things you owe |
Asset Accounts
Cash (Depository)
Liquid funds in bank accounts.
| Subtype | Short Name | Description |
|---|---|---|
| checking | Checking | Everyday spending account |
| savings | Savings | Interest-bearing savings |
| hsa | HSA | Health Savings Account |
| cd | CD | Certificate of Deposit |
| money_market | MM | Money Market account |
Investment
Investment accounts hold securities and have tax treatment based on account type.
United States
| Subtype | Short Name | Full Name | Tax Treatment |
|---|---|---|---|
| brokerage | Brokerage | Brokerage | Taxable |
| 401k | 401(k) | 401(k) | Tax-Deferred |
| roth_401k | Roth 401(k) | Roth 401(k) | Tax-Exempt |
| 403b | 403(b) | 403(b) | Tax-Deferred |
| 457b | 457(b) | 457(b) | Tax-Deferred |
| tsp | TSP | Thrift Savings Plan | Tax-Deferred |
| ira | IRA | Traditional IRA | Tax-Deferred |
| roth_ira | Roth IRA | Roth IRA | Tax-Exempt |
| sep_ira | SEP IRA | SEP IRA | Tax-Deferred |
| simple_ira | SIMPLE IRA | SIMPLE IRA | Tax-Deferred |
| 529_plan | 529 Plan | 529 Education Savings Plan | Tax-Advantaged |
| hsa | HSA | Health Savings Account | Tax-Advantaged |
| ugma | UGMA | UGMA Custodial Account | Taxable |
| utma | UTMA | UTMA Custodial Account | Taxable |
United Kingdom
| Subtype | Short Name | Full Name | Tax Treatment |
|---|---|---|---|
| isa | ISA | Individual Savings Account | Tax-Exempt |
| lisa | LISA | Lifetime ISA | Tax-Exempt |
| sipp | SIPP | Self-Invested Personal Pension | Tax-Deferred |
| workplace_pension_uk | Pension | Workplace Pension | Tax-Deferred |
Canada
| Subtype | Short Name | Full Name | Tax Treatment |
|---|---|---|---|
| rrsp | RRSP | Registered Retirement Savings Plan | Tax-Deferred |
| tfsa | TFSA | Tax-Free Savings Account | Tax-Exempt |
| resp | RESP | Registered Education Savings Plan | Tax-Advantaged |
| lira | LIRA | Locked-In Retirement Account | Tax-Deferred |
| rrif | RRIF | Registered Retirement Income Fund | Tax-Deferred |
Australia
| Subtype | Short Name | Full Name | Tax Treatment |
|---|---|---|---|
| super | Super | Superannuation | Tax-Deferred |
| smsf | SMSF | Self-Managed Super Fund | Tax-Deferred |
Europe
| Subtype | Short Name | Full Name | Tax Treatment |
|---|---|---|---|
| pea | PEA | Plan d'Epargne en Actions | Tax-Advantaged |
| pillar_3a | Pillar 3a | Private Pension (Pillar 3a) | Tax-Deferred |
| riester | Riester | Riester-Rente | Tax-Deferred |
Generic (Available Everywhere)
| Subtype | Short Name | Full Name | Tax Treatment |
|---|---|---|---|
| pension | Pension | Pension | Tax-Deferred |
| retirement | Retirement | Retirement Account | Tax-Deferred |
| mutual_fund | Mutual Fund | Mutual Fund | Taxable |
| angel | Angel | Angel Investment | Taxable |
| trust | Trust | Trust | Taxable |
| other | Other | Other Investment | Taxable |
Crypto
Cryptocurrency holdings.
| Subtype | Short Name | Full Name | Manual Trades |
|---|---|---|---|
| wallet | Wallet | Crypto Wallet | No |
| exchange | Exchange | Crypto Exchange | Yes |
[!NOTE]
Only exchange accounts support manual trade entry. Wallet accounts are sync-only.
Property
Real estate assets.
| Subtype | Short Name | Full Name |
|---|---|---|
| single_family_home | Single Family Home | Single Family Home |
| multi_family_home | Multi-Family Home | Multi-Family Home |
| condominium | Condo | Condominium |
| townhouse | Townhouse | Townhouse |
| investment_property | Investment Property | Investment Property |
| second_home | Second Home | Second Home |
Additional fields:
- Address
- Area (square feet or square meters)
- Year built
Vehicle
Cars, trucks, and other vehicles.
Additional fields:
- Make
- Model
- Year
- Mileage (miles or kilometers)
Other Asset
Anything of value that doesn't fit other categories:
- Collectibles (art, antiques, coins)
- Jewelry and watches
- Equipment
- Other valuables
Liability Accounts
Credit Card
Track credit card balances and spending.
Additional fields:
| Field | Description |
|---|---|
| APR | Annual Percentage Rate |
| Available Credit | Remaining credit limit |
| Minimum Payment | Minimum amount due |
| Annual Fee | Yearly card fee |
Loan
Track mortgages, auto loans, student loans, and other installment debt.
| Subtype | Short Name | Full Name |
|---|---|---|
| mortgage | Mortgage | Mortgage |
| student | Student | Student Loan |
| auto | Auto | Auto Loan |
| other | Other | Other Loan |
Additional fields:
| Field | Description |
|---|---|
| Interest Rate | Annual interest rate |
| Rate Type | Fixed or variable |
| Term (months) | Loan duration |
| Monthly Payment | Calculated from loan details (fixed-rate only) |
[!TIP]
For fixed-rate loans, Sure automatically calculates the monthly payment based on your original balance, interest rate, and term.
Other Liability
Debts that don't fit the above categories:
- Personal loans from friends/family
- Medical bills
- IOUs
- Tax obligations
Tax Treatment Explained
Investment accounts have different tax implications:
| Treatment | Description | When Taxes Apply |
|---|---|---|
| Taxable | Normal taxation | Gains taxed when realized |
| Tax-Deferred | Taxes postponed | Taxes paid on withdrawal |
| Tax-Exempt | No taxes on gains | Qualified withdrawals are tax-free |
| Tax-Advantaged | Special benefits | Varies by account type and use |
[!IMPORTANT]
Tax treatment in Sure is for informational and organizational purposes. Consult a tax professional for tax advice specific to your situation.
Choosing the Right Account Type
| Your Situation | Recommended Account Type |
|---|---|
| Bank checking/savings | Cash (Depository) |
| Brokerage account | Investment |
| 401(k), IRA, pension | Investment (select appropriate subtype) |
| Bitcoin, Ethereum holdings | Crypto |
| Your home | Property |
| Your car | Vehicle |
| Credit card | Credit Card |
| Mortgage | Loan (mortgage) |
| Student loans | Loan (student) |
| Car loan | Loan (auto) |
| Money you owe someone | Other Liability |
| Collectibles, jewelry | Other Asset |
Account Sharing and Permissions
Sure supports sharing accounts with other family members, allowing collaborative financial management while maintaining appropriate access controls.
How Account Sharing Works
Accounts can be shared with other members of your family with different permission levels:
Permission Levels:
| Permission | Can View | Can Edit | Can Delete |
|---|---|---|---|
| View Only | β Yes | β No | β No |
| Edit | β Yes | β Yes | β No |
| Full Access | β Yes | β Yes | β Yes |
Sharing an Account
To share an account with family members:
- Navigate to the account you want to share
- Open the account settings or details page
- Find the "Sharing" or "Permissions" section
- Click Share Account or Manage Access
- Select the family member you want to share with
- Choose their permission level
- Click Save or Share
Managing Shared Accounts
As an account owner:
- You maintain full control over accounts you create
- You can modify or revoke sharing permissions at any time
- You can see who has access to each account
As a shared user:
- Shared accounts appear in your account list
- Your available actions depend on your permission level
- You can see who owns the account
Account Scoping
Account sharing affects how data appears throughout Sure:
Dashboard and reports:
- Your dashboard shows all accounts you own plus accounts shared with you
- Net worth calculations include all accessible accounts
- Reports and budgets can be filtered by account ownership
Transactions and activity:
- You can view transactions for any account you have access to
- Edit permissions allow you to categorize, tag, and modify transactions
- Full access allows you to delete transactions and manage account settings
Provider connections:
- Only account owners and users with full access can manage provider connections
- Synced data is visible to all users with access to the account
Family-Level Features
Some features operate at the family level and are accessible to all family members:
- Recurring transactions: Scoped to accounts you have access to
- Merchants: Shared across the family for consistent categorization
- Categories and tags: Available to all family members
- Rules: Can be created by any family member and apply to their accessible accounts
[!IMPORTANT]
Account sharing is designed for trusted family members. Users with edit or full access permissions can modify financial data, so only share accounts with people you trust.
Privacy and Security
What's shared:
- Account balances and transaction history
- Holdings and investment details (for investment accounts)
- Account metadata (name, type, institution)
What's not shared:
- Your login credentials
- Provider API keys (unless explicitly configured)
- Personal settings and preferences
Admin controls:
- Family admins can manage provider connections for the entire family
- Only admins can configure third-party provider integrations
- Account owners always retain control over their own accounts
Related Pages
- How Asset Accounts Work
- How Debt Accounts Work
- Managing Investment Accounts
- Transfers Between Accounts
File: guides/key-concepts/debt-accounts.mdx
title: How Debt Accounts Work
How Debt Accounts Work
Debt accounts (also called liability accounts) track money you owe to others. This includes credit cards, loans, and any other obligations.
Why Debt Math Seems "Backwards"
If you're used to thinking about asset accounts, debt accounts can feel counterintuitive at first. Here's the key difference:
| Balance Type | What It Means |
|---|---|
| Positive balance | You owe money |
| Negative balance | The lender owes you money (overpayment, refund) |
And transactions work in the opposite direction from assets:
| Action | Asset Account | Debt Account |
|---|---|---|
| Spend money | Balance decreases | Balance increases (more debt) |
| Receive money | Balance increases | Balance decreases (less debt) |
[!TIP]
Think of a debt balance as "Amount Owed" rather than "money I have." A higher number means you owe more, not that you have more.
Credit Card Example
Let's walk through a typical credit card scenario:
| Action | Balance Change | New Balance | What It Means |
|---|---|---|---|
| Starting balance | β | $500 | You owe $500 |
| Buy groceries (+$80) | +$80 | $580 | You now owe $580 |
| Gas purchase (+$45) | +$45 | $625 | You now owe $625 |
| Make payment (-$300) | -$300 | $325 | You now owe $325 |
Notice how spending increases the balance (you owe more) and payments decrease it (you owe less).
Overpayment Scenario
What happens if you pay more than you owe?
| Action | Balance Change | New Balance | What It Means |
|---|---|---|---|
| Starting balance | β | $100 | You owe $100 |
| Make payment (-$150) | -$150 | -$50 | Card company owes you $50 |
| Buy coffee (+$5) | +$5 | -$45 | Card company owes you $45 |
A negative balance on a credit card means you've overpaidβthe credit card company owes you money. This might happen from:
- Overpayment
- Refunds for returned purchases
- Statement credits or rewards
Types of Debt Accounts
Sure supports three types of liability accounts:
Credit Card
Track your credit card balances and spending. Credit card accounts can include:
| Field | Description |
|---|---|
| APR | Annual percentage rate (interest rate) |
| Available Credit | Remaining credit you can use |
| Minimum Payment | Minimum amount due |
| Annual Fee | Yearly card fee |
Loan
Track mortgages, auto loans, student loans, and other installment debt.
| Subtype | Description |
|---|---|
| Mortgage | Home loan |
| Student | Student loan debt |
| Auto | Car loan |
| Other | Personal loans, etc. |
Loan accounts support additional fields:
| Field | Description |
|---|---|
| Interest Rate | Annual interest rate |
| Rate Type | Fixed or variable rate |
| Term | Loan duration (months) |
| Monthly Payment | Calculated based on loan details |
[!NOTE]
For fixed-rate loans, Sure can calculate your monthly payment automatically based on the original balance, interest rate, and term.
Other Liability
For debts that don't fit the above categories:
- Personal loans from friends/family
- Medical bills
- IOUs
- Tax obligations
How Debt Affects Net Worth
Liabilities reduce your net worth:
Net Worth = Total Assets - Total LiabilitiesWhen you pay down debt, your net worth increases (even though your cash decreases). This is because the debt reduction outweighs the cash spentβyou're converting a liquid asset (cash) into reduced liability (less debt).
Example: Paying Off a Credit Card
| Account | Before Payment | After $500 Payment |
|---|---|---|
| Checking (Asset) | $2,000 | $1,500 |
| Credit Card (Liability) | $500 | $0 |
| Net Worth | $1,500 | $1,500 |
Your net worth stays the same because you traded $500 of cash for $500 of debt reduction. But now you have no credit card debt!
Adding Debt Accounts
To add a debt account:
- Click + Add Account on your dashboard
- Select the liability type (Credit Card, Loan, or Other Liability)
- Enter your account details:
- Account name
- Current balance (the amount you owe)
- For loans: interest rate, term, and rate type
- Click Create Account
[!IMPORTANT]
Make sure to select a liability account type (Credit Card, Loan, or Other Liability) when adding debt. If you accidentally create a Cash account for your credit card, the balance calculations will be incorrect.
Understanding Loan Payments
When you make a loan payment, the payment typically covers:
- Interest - Cost of borrowing
- Principal - Reducing the amount you owe
Sure tracks your loan balance, which represents the remaining principal. As you make payments, the balance decreases until the loan is paid off.
[!TIP]
To track loan payments accurately, create a transfer from your checking account to your loan account. This properly reflects the payment in both accounts.
File: guides/key-concepts/investment-accounts.mdx
title: Managing Investment Accounts
Managing Investment Accounts
Investment accounts in Sure go beyond simple balance tracking. They help you understand your portfolio through holdings, trades, cost basis tracking, and investment activity labels.
Understanding Holdings
A holding represents your position in a specific security. For example, if you own 50 shares of Apple stock, that's one holding.
Each holding tracks:
- Security - What you own (stock, ETF, mutual fund, crypto)
- Quantity - How many shares or units you hold
- Price - Current market price per share
- Amount - Total current value (quantity x price)
- Cost Basis - What you originally paid
Recording Trades
Investment accounts support manual trade entry to track your buy and sell activity.
Trade Types
When recording a trade, you can specify:
- Buy: Purchase of securities
- Sell: Sale of securities
- Dividend: Dividend income (recorded as a trade with qty: 0 and price: 0)
- Interest: Interest income (recorded as a trade with qty: 0 and price: 0)
Recording Dividends and Interest
Dividends and interest are recorded as trades to maintain data integrity:
Dividends:
- Require a security selection (the security that paid the dividend)
- Recorded with qty: 0 and price: 0
- Amount represents the dividend payment received
Interest:
- Security selection is optional
- If no security is selected, Sure creates a synthetic cash security for the account
- Recorded with qty: 0 and price: 0
- Amount represents the interest earned
This approach ensures all investment income is properly tracked while maintaining the requirement that trades reference a security.
Transaction Fees
Trades support an optional fee field to track transaction costs:
- Fees are included in the total amount calculation:
qty Γ price + fee - Fees default to 0 if not specified
- Useful for tracking brokerage commissions, exchange fees, or other trading costs
For example:
- Buy 10 shares at $100/share with a $5 fee = $1,005 total
- Sell 10 shares at $110/share with a $5 fee = $1,095 total (proceeds after fee)
To add a fee to a trade:
- Open the trade form (create or edit)
- Enter the fee amount in the fee field
- The total amount updates automatically to include the fee
Fees are automatically factored into your cost basis calculations and realized gains/losses.
Securities Types
Sure supports various security types:
| Type | Examples |
|---|---|
| Stocks | Individual company shares (AAPL, GOOGL) |
| ETFs | Exchange-traded funds (SPY, VTI) |
| Mutual Funds | Pooled investment funds |
| Crypto | Bitcoin, Ethereum, other cryptocurrencies |
| Bonds | Fixed-income securities |
Cost Basis Tracking
Cost basis is the original purchase price of your investments. It's essential for:
- Calculating gains and losses
- Understanding investment performance
- Tax reporting
Cost Basis Sources
Sure tracks cost basis from three sources, in order of priority:
| Priority | Source | Description |
|---|---|---|
| 1 (Highest) | Manual | Values you enter directly |
| 2 | Calculated | Computed from your buy trades |
| 3 (Lowest) | Provider | Imported from your financial institution |
This hierarchy ensures your manual entries always take precedence, calculated values override provider data, and provider data is used when nothing else is available.
Setting Cost Basis Manually
You can set cost basis in two ways:
From the Holdings List:
- Navigate to your investment account
- Find the holding in your portfolio
- Click the pencil icon next to the average cost
- Enter either:
- Total cost basis: The total amount you paid for all shares
- Per-share cost: The average price per share
- Click Save
From the Holding Drawer:
- Click on a holding to open its detail drawer
- In the Overview section, click the pencil icon next to "Average Cost"
- Enter the cost basis (total or per-share)
- Click Save
[!TIP]
The cost basis editor provides real-time conversion between total and per-share values. Enter whichever format you have available, and Sure calculates the other automatically.
Lock Protection
When you manually set a cost basis, Sure automatically locks it to prevent automatic updates from overwriting your value. You'll see a lock icon indicating the value is protected.
This ensures your manual entries remain intact during account syncs.
Unlocking Cost Basis
If you want Sure to recalculate cost basis from your trades:
- Open the holding drawer
- Scroll to the Settings section
- Find "Cost basis locked"
- Click Unlock
After unlocking:
- The lock icon disappears
- Future syncs can update the cost basis
- Calculated values (from trades) will replace the manual value
Recording Trades
Investment accounts support recording various types of trades to track your portfolio activity.
Trade Types
| Type | Description | Security Required |
|---|---|---|
| Buy | Purchase of securities | Yes |
| Sell | Sale of securities | Yes |
| Dividend | Dividend payments received | Yes |
| Interest | Interest earned on cash or securities | No (optional) |
Recording Dividends and Interest
Dividends and interest are recorded as trades with zero quantity and zero price, allowing them to be associated with specific securities while maintaining data integrity.
Dividends:
- Require a security selection (the stock/fund paying the dividend)
- Recorded as a trade with qty: 0 and price: 0
- Amount represents the dividend payment received
Interest:
- Security selection is optional
- When no security is selected, Sure creates a synthetic cash security for the account
- Recorded as a trade with qty: 0 and price: 0
- Amount represents the interest earned
To record a dividend or interest payment:
- Navigate to your investment account
- Click + New Trade
- Select Dividend or Interest from the type dropdown
- For dividends: Select the security that paid the dividend
- For interest: Optionally select a security, or leave blank for cash interest
- Enter the amount received
- Set the date
- Click Save
[!NOTE]
Dividends require a security selection, while interest can be recorded without one. When no security is selected for interest, Sure automatically creates a per-account synthetic cash security (kind:cash, offline:true) to maintain data integrity.
Trade Fees
All trades support an optional fee field to track transaction costs:
- Fees are included in the total amount calculation:
total = (qty Γ price) + fee - Fees default to 0 if not specified
- Fees are tracked separately for accurate cost basis calculations
Investment Activity Labels
Activity labels help you classify investment transactions. They appear as badges in your transaction list and help you understand your investment activity at a glance.
Recording Dividends and Interest
Dividends and interest are recorded as trades with zero quantity and zero price. This approach maintains data integrity while properly tracking investment income:
- Dividends require a security to be selected (the stock or fund paying the dividend)
- Interest can optionally specify a security, or will use a synthetic cash security for the account
- Both appear in your holdings and trade history
- Both are properly labeled with their respective activity types
- The zero-quantity, zero-price approach preserves the trade model's required security association
When creating a dividend or interest transaction:
- Select Dividend or Interest from the trade type dropdown
- For dividends: Choose the security paying the dividend (required)
- For interest: Optionally select a security, or leave blank to use the account's cash position
- Enter the amount received
- The trade is created with qty: 0 and price: 0, maintaining the security association
Trade Fees
All trades support an optional fee field to track transaction costs like brokerage commissions, exchange fees, or other trading expenses.
How Fees Work
- Fees are included in the total transaction amount:
Total = (Quantity Γ Price) + Fee - Fees default to 0 if not specified
- Fees can be added when creating or editing trades
- The fee field auto-submits like other trade fields
Adding Fees to Trades
When creating or editing a trade:
- Enter the quantity and price as usual
- Add the fee amount in the Fee field
- The total amount updates automatically to include the fee
- Save the trade
Example: Buying 10 shares at $50 with a $5 commission:
- Quantity: 10
- Price: $50
- Fee: $5
- Total: $505 (10 Γ $50 + $5)
Fees are particularly important for:
- Accurate cost basis calculations
- Understanding true investment returns
- Tax reporting and record keeping
Available Activity Labels
| Label | Description |
|---|---|
| Buy | Purchase of securities |
| Sell | Sale of securities |
| Contribution | Money added to the investment account |
| Withdrawal | Money removed from the investment account |
| Dividend | Dividend payments received (recorded as trades with qty: 0) |
| Interest | Interest earned (recorded as trades with qty: 0) |
| Reinvestment | Dividends or distributions reinvested |
| Sweep In | Cash swept into the account |
| Sweep Out | Cash swept out of the account |
| Fee | Account or transaction fees |
| Exchange | Currency or security exchanges |
| Transfer | Internal transfers |
| Other | Miscellaneous transactions |
[!NOTE]
Dividends and interest are recorded as trades with zero quantity and zero price. Dividends require a security selection, while interest can optionally use a synthetic cash security if no specific security is selected.
Setting Labels Manually
- Open a transaction from an investment or crypto account
- Scroll to the Settings section
- Find "Activity type"
- Select a label from the dropdown
- The change saves automatically
Setting Labels with Rules
Create rules to automatically label transactions based on patterns:
- Go to Settings > Rules
- Create a new rule
- Set conditions (e.g., "IF transaction name contains 'DIVIDEND'")
- Add action: "Set investment activity label"
- Choose the label (e.g., "Dividend")
- Save the rule
Example rules:
- IF name contains "DIVIDEND" THEN set label to "Dividend"
- IF name contains "INTEREST" THEN set label to "Interest"
- IF name contains "FEE" THEN set label to "Fee"
[!NOTE]
Internal movement labels (Transfer, Sweep In, Sweep Out, Exchange) are automatically excluded from budget calculations since they represent cash management rather than income or expenses.
Tax Treatment Categories
Investment accounts have different tax implications based on their type:
| Category | Description | Examples |
|---|---|---|
| Taxable | Gains taxed when realized | Brokerage, UGMA/UTMA |
| Tax-Deferred | Taxes deferred until withdrawal | 401(k), Traditional IRA, 403(b) |
| Tax-Exempt | Qualified gains are tax-free | Roth IRA, Roth 401(k), ISA |
| Tax-Advantaged | Special tax benefits with conditions | 529 Plan, HSA |
Sure automatically assigns tax treatment based on account subtype, but you can adjust it manually if needed.
Understanding Gains and Losses
Sure helps you track two types of gains:
Unrealized Gains/Losses
Paper gains or losses on holdings you still own:
Unrealized Gain = Current Value - Cost BasisIf you bought 10 shares at $100 ($1,000 total) and they're now worth $1,200, you have an unrealized gain of $200.
Realized Gains/Losses
Actual gains or losses from securities you've sold. These are what matter for taxes.
The Trend indicator on each holding shows your unrealized gain or loss, calculated from your cost basis. If cost basis is unknown (no trades and no manual entry), the trend won't display.
Crypto-Specific Features
Crypto accounts work similarly to investment accounts but have unique characteristics:
| Subtype | Description | Manual Trades |
|---|---|---|
| Wallet | Self-custody or provider-synced | No |
| Exchange | Centralized exchanges (Binance, Coinbase, etc.) | Yes |
- Exchange accounts support manual trade entry, letting you record buys and sells
- Wallet accounts are sync-only, holdings are updated through provider connections
Crypto is taxable by default, though it can be held in tax-advantaged accounts (for example, a self-directed IRA).
Adding Investment Accounts
- Click + Add Account on your dashboard
- Select Investment or Crypto
- Choose the appropriate subtype (see Account Types for all options)
- Enter your account details:
- Account name
- Current balance
- Tax treatment (if different from default)
- Click Create Account
After creating the account, you can add holdings manually or connect a provider to sync them automatically.
File: guides/key-concepts/transfers.mdx
title: Transfers Between Accounts
Transfers Between Accounts
A transfer moves money between your own accounts. Unlike income or expenses, transfers don't change your net worthβthey simply move funds from one place to another.
How Transfers Work
Every transfer consists of two linked transactions:
- Outflow - Money leaving one account (negative amount)
- Inflow - Money entering another account (positive amount)
These two transactions are linked together as a single transfer.
Example: Moving Money to Savings
When you transfer $500 from checking to savings:
| Account | Transaction | Amount |
|---|---|---|
| Checking | Outflow | -$500 |
| Savings | Inflow | +$500 |
| Net Effect | $0 |
Your total net worth stays the sameβthe money simply moved from one account to another.
Transfer Types
Sure recognizes different types of transfers based on the destination account:
| Transfer Type | When It's Used | Budget Impact |
|---|---|---|
| Funds Movement | Between asset accounts | Excluded |
| CC Payment | Payment to a credit card | Excluded |
| Loan Payment | Payment to a loan account | Treated as expense |
| Investment Contribution | To investment or crypto accounts | Excluded |
Funds Movement
General transfers between asset accounts:
- Checking to savings
- Savings to money market
- Between investment accounts
These are excluded from budgets because they don't represent spending or income.
Credit card payment
When you pay your credit card bill, Sure creates a cc_payment transfer:
- Outflow from your checking account
- Inflow to your credit card (reducing your debt)
This is excluded from budgets because the spending already happened when you used the credit card.
When syncing transactions from a provider, Sure automatically classifies transactions to credit card accounts as credit card payment transfers.
Loan payment
Payments to loan accounts (mortgage, auto, student loans) are treated as loan_payment transfers.
[!NOTE]
Loan payments can be treated as expenses in budgets since they represent an ongoing financial obligation, even though they also reduce your debt.
When syncing transactions from a provider, Sure automatically classifies transactions to loan accounts as loan payment transfers.
Investment Contribution
Transfers to investment or crypto accounts:
- Contributing to your 401(k)
- Adding funds to a brokerage account
- Moving money to a crypto exchange
These are excluded from budgets because you're saving/investing, not spending.
Creating Transfers Manually
To create a transfer:
- Go to the Transactions page
- Click + New Transaction
- Select Transfer
- Choose:
- From account - Where money is coming from
- To account - Where money is going
- Amount - How much to transfer
- Date - When the transfer occurred
- Click Add transaction
Sure automatically creates both the outflow and inflow transactions and links them together.
Automatic Transfer Matching
When you sync accounts with a provider (Plaid, SimpleFIN, etc.), Sure attempts to automatically match transfers between accounts.
How Matching Works
Sure looks for transaction pairs that:
- Are from different accounts in the same family
- Have opposite amounts (one positive, one negative)
- Occurred within a date window
Date Windows
| Transaction Status | Window |
|---|---|
| Pending transactions | 4 days |
| Confirmed transactions | 30 days |
Suggested Matches
When Sure finds a potential transfer match, it creates a pending transfer suggestion. You can:
- Confirm - Accept the match and link the transactions
- Reject - Dismiss the match (transactions remain separate)
To review suggested transfers:
- Open a transaction that may be part of a transfer
- Look for transfer match suggestions
- Confirm or reject as appropriate
Cross-Currency Transfers
When transferring between accounts with different currencies:
- Sure validates that amounts have opposite signs
- The exact amounts don't need to match (due to exchange rates)
- Both transactions are linked as a transfer
Example: Transferring from a USD checking account to a EUR savings account:
| Account | Currency | Amount |
|---|---|---|
| US Checking | USD | -$1,000 |
| Euro Savings | EUR | +β¬920 |
The transfer is valid because one amount is negative and one is positive, even though the numbers differ due to the exchange rate.
Transfer Summary Totals
When viewing your transaction list filtered by transfers, Sure displays Inflow and Outflow totals instead of the standard Income and Expenses labels.
How It Works
Transfer-only filter:
- When filtering exclusively by "Transfer" type, the summary bar shows:
- Inflow: Total positive transfer amounts (money coming in)
- Outflow: Total negative transfer amounts (money going out)
Mixed filters:
- When filtering by transfers along with other transaction types (income, expenses), the summary bar shows standard Income/Expenses labels
- Transfer amounts are excluded from these totals since they don't represent actual income or spending
This helps you understand your cash movement patterns when reviewing transfers in isolation.
[!NOTE]
When filtering by both transfers and other transaction types, transfer amounts are excluded from income and expense totals to avoid confusion.
How Transfers Affect Budgets
Transfers are generally excluded from budget calculations because they don't represent actual spending or income:
| Transfer Type | Budget Treatment |
|---|---|
| Funds Movement | Excluded |
| CC Payment | Excluded |
| Investment Contribution | Excluded |
| Loan Payment | Can be included as expense |
[!TIP]
If you want to budget for loan payments, you may want to track them as expenses. Sure treats loan payments specially since they represent ongoing obligations.
Understanding Net Worth Impact
Transfers are zero-sum for net worth:
- Moving $500 from checking to savings: Net worth unchanged
- Paying $500 to credit card: Net worth unchanged (reduced cash = reduced debt)
- Contributing $500 to 401(k): Net worth unchanged (reduced cash = increased investments)
The only time a transfer affects net worth is if there are fees involved (which would be a separate expense transaction).
Common Transfer Scenarios
Paying Off a Credit Card
- Money leaves your checking account (-$500)
- Credit card balance decreases (-$500 debt)
- Net worth: unchanged
Making a Mortgage Payment
- Money leaves your checking account (-$2,000)
- Loan balance decreases (-$1,700 principal, $300 goes to interest)
- Note: The interest portion is technically an expense
[!IMPORTANT]
Mortgage payments include both principal (reducing debt) and interest (an expense). Sure tracks the full payment as a loan transfer, but the interest portion represents a true cost.
Contributing to Retirement
- Money leaves your checking account (-$500)
- Investment account balance increases (+$500)
- Net worth: unchanged (cash converted to investments)
Transferring Between Banks
- Money leaves Bank A checking (-$1,000)
- Money arrives at Bank B checking (+$1,000)
- Net worth: unchanged
Troubleshooting Transfers
Duplicate Transactions
If you see both a transfer and separate expense/income transactions:
- Check if the transactions should be linked as a transfer
- Confirm or reject the transfer suggestion
- Delete duplicate transactions if needed
Unmatched Transfers
If Sure didn't automatically match a transfer:
- The amounts may not match exactly (due to fees or timing)
- The dates may be too far apart
- You can manually create a transfer to link them
Wrong Transfer Type
If a transfer is categorized incorrectly:
- Check that both accounts are the correct type
- The transfer type is determined by the destination account
File: guides/key-concepts/untitled-page.mdx
title: How Asset Accounts Work
How Asset Accounts Work
Assets are things you own that have monetary value. In Sure, asset accounts track your wealthβfrom the cash in your checking account to the value of your home.
Understanding Asset Balances
Asset account balances work intuitively:
- Income increases your balance (money coming in)
- Expenses decrease your balance (money going out)
Example: Checking Account
Let's walk through a simple example:
| Action | Balance Change | New Balance |
|---|---|---|
| Starting balance | β | $1,000 |
| Paycheck deposit (+$2,500) | +$2,500 | $3,500 |
| Rent payment (-$1,200) | -$1,200 | $2,300 |
| Grocery shopping (-$150) | -$150 | $2,150 |
This matches what you see in your bank accountβstraightforward and predictable.
Types of Asset Accounts
Sure supports six types of asset accounts:
Cash (Depository)
Liquid funds you can access immediately. Includes:
| Subtype | Description |
|---|---|
| Checking | Everyday spending account |
| Savings | Interest-bearing savings |
| HSA | Health Savings Account |
| CD | Certificate of Deposit |
| Money Market | Money market account |
Investment
Accounts that hold securities like stocks, bonds, and mutual funds. Investment accounts have additional features for tracking holdings, trades, and cost basis. See Managing Investment Accounts for details.
Crypto
Cryptocurrency holdings, either in:
| Subtype | Description |
|---|---|
| Wallet | Self-custody or provider-synced wallets |
| Exchange | Centralized exchanges with trade history |
[!NOTE]
Only exchange accounts support manual trade entry. Wallet accounts are sync-only.
Property
Real estate you own:
| Subtype | Description |
|---|---|
| Single Family Home | Primary residence |
| Multi-Family Home | Duplex, triplex, etc. |
| Condominium | Condo unit |
| Townhouse | Townhouse property |
| Investment Property | Rental or investment real estate |
| Second Home | Vacation home |
Property accounts can track additional details like address, area (square footage), and year built.
Vehicle
Cars, trucks, motorcycles, and other vehicles you own. Vehicle accounts can track make, model, year, and mileage.
Other Asset
Anything else of value that doesn't fit the categories above:
- Collectibles (art, antiques, coins)
- Jewelry and watches
- Equipment
- Other valuables
Liquid vs. Illiquid Assets
Sure distinguishes between liquid and illiquid assets:
Liquid assets can be quickly converted to cash without significant loss of value:
- Cash accounts (checking, savings)
- Most investments (stocks, bonds, ETFs)
Illiquid assets take time to sell or may lose value if sold quickly:
- Property
- Vehicles
- Some investments (private equity, real estate funds)
- Other assets (collectibles, jewelry)
This distinction helps you understand not just your total net worth, but how much of it you could access quickly if needed.
How Assets Affect Net Worth
Your net worth is calculated as:
Net Worth = Total Assets - Total LiabilitiesEvery asset account contributes positively to your net worth. When asset values increase (your investments grow, your home appreciates), your net worth increases. When you spend from asset accounts, that balance decreasesβbut the money likely went toward something (an expense, a purchase, or a debt payment).
[!TIP]
For a complete picture of your finances, make sure to add both your assets and your debt accounts. Sure calculates your true net worth by subtracting what you owe from what you own.
Adding Asset Accounts
To add an asset account:
- Click + Add Account on your dashboard
- Select the asset type (Cash, Investment, Crypto, Property, Vehicle, or Other Asset)
- Enter your account details:
- Account name
- Current balance
- Subtype (if applicable)
- Click Create Account
Your new asset account will appear in your accounts list and contribute to your net worth calculation immediately.
File: guides/app-features/assistant-sidebar.mdx
title: "AI assistant sidebar"
Sure includes an AI assistant sidebar on desktop. If you do not want to use it yet, you can hide it and bring it back later.
Hide or show the AI sidebar on desktop
On desktop, use the panel-right button in the top bar to toggle the right-hand AI sidebar.
- Click it once to collapse the AI sidebar
- Click it again to reopen the AI sidebar
- Sure saves this preference to your account
This is useful if you want more room for the main app content or simply do not want to use the assistant yet.
When the sidebar is hidden by default
Sure can also hide the AI sidebar by default for some people:
- New
memberandguestusers start with the AI sidebar hidden when AI is not available adminusers still see it by default so they can finish AI setup or troubleshooting
If AI becomes available later, you can reopen the sidebar at any time with the same desktop toggle.
Related settings
- For AI provider setup, see LLM support
- For general app settings, see Settings
File: guides/app-features/budgets.mdx
title: "Budgets"
Budgets
Budgets help you set spending limits for different categories and track how well you're sticking to them throughout the month.
How Budgets Work
When you create a budget, you assign a spending limit to a category. As you spend money in that category, Sure tracks your progress and shows you:
- Amount spent so far this month
- Amount remaining in your budget
- Status indicators showing if you're on track, near your limit, or over budget
Planning ahead
You can create budgets for future months, up to 2 years in advance. This lets you plan your spending ahead of time and set category limits before the month begins. Navigate forward using the month selector to set up upcoming budgets.
Setting Up Category Budgets
Each category can have its own budget. For example, you might set:
- Food & Dining: $500/month
- Entertainment: $200/month
- Transportation: $300/month
Sure shows helpful daily suggestions like "$29.91 suggested per day for 11 remaining days" to help you pace your spending.
When editing a subcategory's budget, you'll see "Shared" as the placeholder text. Leave the field empty or set it to $0 to share the parent category's budget. Hover over the field to see the tooltip: "Leave empty to share parent's budget".
Subcategories and Shared Budgets
You can organize your spending further with subcategories. For example, "Food & Dining" might include:
- Coffee & Takeout
- Groceries
- Restaurants
Individual budgets
Give each subcategory its own limit. This works well when you want strict control over specific types of spending.
Shared budgets
Leave a subcategory's budget empty to share the parent category's budget. This is great when you want flexibility across related expenses.
Example: Set "Food & Dining" to $500, but leave the subcategories without individual limits. Now all your food spending draws from the same $500 pool, whether you're buying groceries or eating out.
When a subcategory shares its parent's budget, you'll see a "(shared)" label next to the budget amount, making it clear which subcategories are drawing from the shared pool.
Mixed approach
Combine both methods. Set a fixed budget for some subcategories while others share the remaining amount.
Example:
- Food & Dining: $500 total
- Restaurants: $100 (fixed limit)
- Groceries and Coffee share the remaining $400
When you set an individual budget for a subcategory, that amount is "ring-fenced" from the parent budget. Only that subcategory can use it, and it won't be available to other subcategories sharing the parent's budget.
How it works:
- Restaurants has $100 remaining ($100 budget - $0 spent)
- Groceries and Coffee share $350 remaining ($400 shared pool - $50 spent between them)
- The parent Food & Dining shows the total remaining across all subcategories
Parent auto-aggregation
When you edit a subcategory's budget, the parent category's budget automatically updates to reflect the change. You don't need to manually adjust the parent β Sure keeps it in sync for you.
How it works:
- When you increase a subcategory's budget, the parent's budget increases by the same amount.
- When you decrease a subcategory's budget, the parent's budget decreases accordingly.
- If the parent had extra budget beyond the sum of its subcategories (a "reserve"), that reserve is preserved during the update.
- The parent's budget never drops below the combined total of its subcategories.
Example:
- Food & Dining starts at $500 with Restaurants at $100 and Groceries at $150 ($250 reserve in the shared pool)
- You increase Groceries from $150 to $200
- Food & Dining automatically updates to $550, preserving the $250 reserve
Status Indicators
Sure uses color-coded badges to show your budget status at a glance:
- On Track (green) - You're within budget and pacing well
- Near Limit (yellow) - Getting close to your limit
- Over Budget (red) - You've exceeded your budget
Tax-advantaged accounts and budgets
Sure automatically excludes activity from tax-advantaged investment accounts when calculating budget spending. This ensures your budgets accurately reflect your day-to-day spending without being skewed by investment contributions or withdrawals.
Excluded account types:
- 401(k) accounts
- IRA accounts (Traditional and Roth)
- 403(b) accounts
- Other tax-advantaged retirement accounts
Transactions from these accounts won't count toward your budget limits, keeping your budget focused on regular income and expenses.
Tips for Effective Budgeting
- Start simple - Begin with just a few major categories before adding detail
- Review monthly - Check your budgets at the end of each month and adjust as needed
- Use shared budgets for categories where you want flexibility between subcategories
- Use individual limits when you need strict control over specific spending