Kontor consolidates personal finance data - transactions, portfolios, and market data - and uses a GenAI layer to surface personalised, actionable insights. Stock and ETF data is sourced from Twelve Data; the AI component uses Retrieval-Augmented Generation (RAG) over the user's own financial data and curated financial news.
| Layer | Technology |
|---|---|
| Client | React 19, TypeScript 6, Vite 8, Tailwind CSS 4, shadcn/ui |
| Server | Java 25, Spring Boot 4, Gradle |
| News Service | Java 25, Spring Boot 4, RabbitMQ |
| AI Service | Python 3.14, FastAPI, uv |
| Linting/Formatting | Biome (client), Spotless + Checkstyle + Error Prone (server), Ruff (AI) |
Running the full stack locally needs only Docker (Docker Desktop or Docker Engine) with Compose v2. The rest of the toolchain (Node, Java 25, uv) is required only for the non-Docker Local Development workflow.
Create your local environment file from the tracked template:
cp .env.example .envThe defaults in .env match the Compose defaults, so the stack runs as-is. Update .env to change the Postgres credentials, Keycloak database credentials, or the shared local Keycloak dev-user password — and to set the API keys described below.
The AI service accepts any hosted OpenAI-compatible provider through
AI_API_KEY, AI_BASE_URL, AI_CHAT_MODEL, and AI_EMBEDDING_MODEL. The local
and Kubernetes defaults point at the TUM course gateway and therefore require
TUM network or eduVPN access; Azure defaults to OpenAI. When AI_API_KEY is
missing, the service automatically falls back to a local LLM (see
Local LLM (offline AI) below). If no local LLM is
configured either (LOCAL_LLM_BASE_URL empty), recommendation calls return
503; if a local LLM is configured but not running, they return 502
("Local LLM is not reachable").
Live stock/ETF market data (quotes, price history, the holdings chart) is sourced from Twelve Data via a TWELVE_DATA_API_KEY in .env. See Market data (Twelve Data) below for how to get a free key and what the free-tier rate limit means for the app.
Hosted news ingest uses the same provider key and base URL as chat, with an
independently configurable AI_EMBEDDING_MODEL.
The Compose Keycloak service is for local development only. It builds the custom Keycloak image from infra/keycloak/theme/ (Keycloak with the Kontor login theme baked in), runs start-dev, imports infra/keycloak/realms/kontor-realm.json, and stores Keycloak state in the keycloak_postgres_data Docker volume. The realm import is skipped once the realm already exists, so delete that volume if you need to re-apply the import from scratch. The first docker compose up --build builds the theme image (Node + Maven), which takes a few minutes; subsequent runs use the cached layer.
Local Keycloak users live in infra/keycloak/realms/kontor-users-0.json. Add another object to the users array to create more local accounts with their own realmRoles and attributes. The sample users share the KEYCLOAK_DEV_USERS_PASSWORD environment placeholder so adding users does not require more Compose variables.
docker compose up --build| Service | URL |
|---|---|
| Client | http://localhost:5173 |
| Server (core) | http://localhost:8080 |
| AI Service | http://localhost:8000 |
| News Service | http://localhost:8082 |
| Keycloak | http://localhost:8081 |
| RabbitMQ management | http://localhost:15672 |
The news aggregator and its RabbitMQ broker are documented in
news/README.md; each service directory has its own README
(client/, core/, ai/).
The client redirects to Keycloak for login. Sign in with a seeded local user — username dev, password dev (the password comes from KEYCLOAK_DEV_USERS_PASSWORD, default dev). Two more seeded users exist: analyst (regular user) and admin-user (also holds the kontor-admin role required to trigger news-aggregation runs). Add or edit users in infra/keycloak/realms/kontor-users-0.json.
The dev user comes pre-loaded with example data (several months of transactions and a multi-asset portfolio), so the dashboard is populated on first login. To try the CSV import flow yourself, upload the sample file at resources/example-data/transaction-csv.example.csv. Note that an import replaces the signed-in user's existing transactions, so it overwrites the seeded data.
Live stock/ETF market data — quotes, price history, and the holdings chart — comes from Twelve Data. You need a free TWELVE_DATA_API_KEY in .env for it to work; the free tier is enough for local development.
How to get an API key:
-
Create a free account at https://twelvedata.com/pricing (the free plan needs no card).
-
After signing in, open your API dashboard — it shows your unique API key.
-
Copy the key into the root
.envfile:TWELVE_DATA_API_KEY=your-key-here
-
(Re)start the stack so
corepicks up the key:docker compose up --build
The holdings chart on the dashboard should now render live prices.
Rate limit. The free tier caps how often you can call the API (currently 8 API credits per minute and 800 requests per day). When you exceed it, Twelve Data responds with 429; core passes that through as a 429, and the holdings chart dialog shows a "Live market data paused" notice with the provider's rate-limit message instead of the chart. Wait for the limit window to reset and reopen the chart — no key change is needed.
No key configured. When TWELVE_DATA_API_KEY is empty, market-data endpoints return 502 with a message explaining the key is missing, and the chart shows that message instead of prices. The rest of the app works normally.
When AI_API_KEY is empty, the AI service falls back to a local
Ollama model so the AI features keep working without
hosted-provider access. The fallback is opt-in: LOCAL_LLM_BASE_URL is unset
by default, so a bare advisor run (no Compose) returns 503 until you point it
at a local model. Compose sets it explicitly; hosted Helm and Azure deployments
require an API key. There are two ways to provide it.
Native Ollama (recommended on macOS). A native Ollama uses the Mac GPU and is much faster than a container:
brew install ollama # or download the app from ollama.com
ollama pull llama3.2 # one-time model download
ollama pull nomic-embed-text # one-time news embedding model download
ollama serve # start this yourself and keep it runningThe default docker compose up reaches it via host.docker.internal:11434. No extra flags needed.
Containerised Ollama (no host setup). Layer the opt-in override file — it runs Ollama in a container, pulls the model once, and points the AI service at it:
docker compose -f docker-compose.yml -f docker-compose.local-llm.yml up --buildNote: containerised Ollama on macOS is CPU-only (Docker Desktop can't pass through Apple's Metal GPU), so it is noticeably slower than the native option above.
Override the model with LOCAL_LLM_MODEL (default llama3.2). Trust boundary: in fallback mode your portfolio data is sent to whatever listens on LOCAL_LLM_BASE_URL. Set LOCAL_LLM_BASE_URL= (empty) in .env to disable the fallback entirely.
cd client
npm install
npm run dev| Task | Command |
|---|---|
| Type check | npm run typecheck |
| Unit tests | npm test |
| Unit tests (watch) | npm run test:watch |
| Unit tests (coverage) | npm run test:coverage |
| E2E tests | npm run e2e |
| Lint | npm run lint |
| Lint (autofix) | npm run lint:fix |
| Format (autofix) | npm run format |
| Build | npm run build |
cd core
./gradlew build| Task | Command |
|---|---|
| Test | ./gradlew test |
| Compile check | ./gradlew compileJava |
| Format check | ./gradlew spotlessCheck |
| Format fix | ./gradlew spotlessApply |
| Lint | ./gradlew checkstyleMain checkstyleTest |
Requires uv.
cd ai
uv sync
uv run uvicorn advisor.main:app --reload| Task | Command |
|---|---|
| Install deps | uv sync |
| Dev server | uv run uvicorn advisor.main:app --reload |
| Test | uv run pytest |
| Type check | uv run ty check |
| Format check | uv run ruff format --check . |
| Format fix | uv run ruff format . |
| Lint | uv run ruff check . |
| Lint (autofix) | uv run ruff check --fix . |
The client's TypeScript types and Zod schemas are generated from each backend's OpenAPI spec — never hand-written. After changing a route or its request/response models, regenerate the spec and the client, then commit the result:
cd core && ./gradlew generateOpenApiDocs # core spec → core/docs/openapi.yml
cd ai && uv run export-openapi # AI spec → ai/docs/openapi.json
cd client && npm run generate:api # both specs → client/src/network/generated*CI's openapi-sync workflow fails if the committed files are out of sync.
The production stack runs on the TUM Kubernetes cluster and is served at:
| Environment | URL |
|---|---|
| App | https://kontor.live |
| Auth (Keycloak) | https://auth.kontor.live |
| Grafana | https://grafana.kontor.live |
Unlike the local stack, prod is not pre-seeded with example data — new
accounts start empty, so import a CSV (e.g. the sample at
resources/example-data/transaction-csv.example.csv)
to populate the dashboard.
The Kontor stack ships as a single Helm chart in
deploy/helm/kontor/ that bundles the client,
core, the news aggregator, the AI service, their Postgres instances (core's
and the AI service's with pgvector), a RabbitMQ broker (subchart), and an
optional Keycloak. The chart README documents environment overlays (values-prod.yaml,
values-pr.template.yaml), required secrets, and the install / upgrade flow.
Deployment is CI-driven: a push to main releases and deploys prod into the
team-3m namespace, and adding the deploy:preview label to a PR stands up an
ephemeral preview in team-3m-pr-<N> (torn down when the label is removed or
the PR closes). Manually triggering the CI/CD workflow redeploys the latest
version without a code change.
There is also a standalone single-VM deployment on Azure (Terraform + Docker
Compose + Traefik), served at https://azure.kontor.live and documented in
deploy/azure/README.md.
For a manual install, copy deploy/helm/kontor/secrets.example.yaml to secrets.yaml
and fill in all REPLACE_ME values. Create the hosted-provider Secret out-of-band so
the API key is not retained in Helm release history. The course gateway URL and model
defaults live in values.yaml (and the deploy workflow's vars.AI_* || fallback
expressions), not in values-prod.yaml; change ai.baseUrl, ai.chatModel,
and ai.embeddingModel to use another OpenAI-compatible provider:
kubectl create secret generic kontor-ai-provider \
-n team-3m \
--from-literal=AI_API_KEY="$AI_API_KEY"
helm upgrade --install kontor ./deploy/helm/kontor \
-n team-3m \
-f deploy/helm/kontor/values-prod.yaml \
-f deploy/helm/kontor/secrets.yaml \
--set-string ai.existingSecret=kontor-ai-providerPR previews run hosted AI too (it is a requirement). The k8s-preview GitHub
environment supplies its own AI_API_KEY; use a separate, budget-capped key
there so pull-request-built code never holds the production credential.
Keycloak ships as a custom image with the Kontor login theme baked in, rather
than stock Keycloak plus a sibling jar. The theme source is vendored under
infra/keycloak/theme/ — a minimal
Keycloakify project whose Docker build assembles the
theme jar and copies it into /opt/keycloak/providers/. CI builds and pushes it
to ghcr.io/aet-devops26/team-3m/kontor-keycloak; the realm selects it via
loginTheme: kontor. See the vendored README for how to edit the theme.
A single shared LGTM stack (Loki, Grafana, Tempo, Prometheus) with a Grafana
Alloy OTLP gateway serves all environments (prod and every PR preview). It has
its own chart (deploy/helm/observability/),
namespace (team-3m-monitoring), and workflow_dispatch workflow
(.github/workflows/observability.yml) — PR deploy/teardown never touches it.
The core, news, and AI services push traces/metrics via OTel, the client ships
browser RUM via Grafana Faro, and pod logs are collected into Loki. Alloy also
scrapes Keycloak, the RabbitMQ broker (rabbitmq_prometheus), and the AI
database's postgres_exporter (pgvector embedding store). Every signal is
tagged with deployment_environment, so one Grafana variable switches between
environments.
The same configuration runs locally as a compose overlay:
docker compose -f docker-compose.yml -f docker-compose.observability.yml up -dGrafana is then available at http://localhost:3001.
This project is configured for AI coding agents (Claude Code, Codex). Rules, skills, and MCP servers ensure agents follow consistent standards across the codebase.
CLAUDE.md is the top-level instruction file for Claude Code. It is automatically loaded at the start of every agent session and provides the agent with project context, commands, and coding rules. AGENTS.md serves the same purpose for Codex. For more information, see the Claude Code docs on CLAUDE.md.
Coding rules live in .claude/rules/ and are automatically loaded based on the files being edited. They enforce consistent style, testing, security, and architectural patterns. For more information, see the Claude Code Docs.
.claude/rules/
├── common/ # Apply to all code
│ ├── coding-style.md # Immutability, KISS/DRY/YAGNI, naming, file organization
│ ├── testing.md # TDD workflow, AAA pattern, 80%+ coverage
│ ├── patterns.md # Repository pattern, API envelope, service classes
│ └── code-review.md # Review checklist, security triggers, severity levels
├── java/ # Apply to *.java, pom.xml, build.gradle*
│ ├── coding-style.md # Records, sealed classes, Optional, modern Java (16+)
│ ├── patterns.md # Constructor injection, Spring conventions, Flyway, jOOQ
│ └── testing.md # JUnit Jupiter 5, AssertJ, Mockito, Testcontainers
├── python/ # Apply to *.py, pyproject.toml
│ ├── coding-style.md # Ruff, type hints, PEP 8 naming, modern Python (3.13+)
│ ├── patterns.md # FastAPI routers, pydantic models, uv workflow
│ └── testing.md # pytest with asyncio, httpx ASGITransport
└── typescript/ # Apply to *.ts, *.tsx, *.js, *.jsx
├── coding-style.md # Biome, kebab-case files, React patterns, Zod, shadcn/ui
├── patterns.md # Custom hooks, data fetching, repository pattern
├── shadcn-components.md # shadcn/ui component usage
└── testing.md # Vitest for unit tests, Playwright for E2E
- Common rules apply to all code. Language-specific rules extend them — they don't replace them.
- When common and language-specific rules conflict, the language-specific rule takes precedence.
MCP (Model Context Protocol) servers provide tool integrations for AI coding agents. They are configured in two places to support both Claude Code and Codex:
| File | Agent |
|---|---|
.mcp.json |
Claude Code (Anthropic) |
.codex/config.toml |
Codex (OpenAI) |
Both files define the same servers — keep them in sync when adding or removing MCP servers.
Skills extend AI coding agents with reusable instructions. They live in .claude/skills/ (Claude Code) and .agents/skills/ (Codex) and are tracked via skills-lock.json.
# Add a skill
npx skills add shadcn/ui
# List installed skills
npx skills list
# Update skills
npx skills update
# Remove a skill
npx skills remove <skill-name>Only skills prefixed with local_ (e.g. .agents/skills/local_conventional-commit/) are tracked in git. Externally synced skills are ignored via .gitignore.
Local skills are project-specific skills that live in the repository. To create one:
-
Create a directory in
.agents/skills/with alocal_prefix (e.g..agents/skills/local_my-skill/). -
Add a
SKILL.mdfile with frontmatter and instructions:--- name: my-skill description: Short description of when and how the skill should be triggered. --- # My Skill Instructions for the agent...
-
Run
./install-skills.shto symlink the skill into.claude/skills/.
The local_ prefix ensures the skill is tracked in git and not overwritten by external skill updates.
To install skills from the lock file and symlink all agent skills (including local ones) to Claude Code:
./install-skills.sh