Web interface¶
SvelteKit frontend with FastAPI backend, authenticated against Nextcloud's built-in OAuth2 provider.
The web UI is per-user: each authenticated user sees only the features they have configured (feeds, money, location, etc.). Any user with a Nextcloud account and an entry in istota's config.users (or a row in the user_profiles table) can log in.
Prerequisites¶
- A Nextcloud instance (the same one istota connects to for Talk and files)
- An nginx reverse proxy (or equivalent) fronting the istota web service
- Node.js 20+ for building the SvelteKit frontend
No extra Nextcloud apps are required — istota uses NC's built-in OAuth 2.0 provider.
Nextcloud OAuth2 setup¶
1. Register an OAuth 2.0 client¶
In Nextcloud, go to Settings > Administration > Security > OAuth 2.0 clients and add a new client:
| Field | Value |
|---|---|
| Name | istota-web (or any label you prefer) |
| Redirect URI | https://{your-hostname}/istota/callback |
Nextcloud generates a Client ID and Client Secret. Copy both.
The redirect URI must exactly match the callback route. If you're running behind a reverse proxy at a subpath or different hostname, adjust accordingly.
2. Configure istota¶
In your config.toml (or via Ansible vars):
[web]
enabled = true
port = 8766
oauth2_provider = "https://cloud.example.com"
oauth2_client_id = "your-client-id-from-step-1"
oauth2_client_secret = "" # or set ISTOTA_WEB_OAUTH2_CLIENT_SECRET env var
session_secret_key = "" # or set ISTOTA_WEB_SESSION_SECRET_KEY env var
| Setting | Description |
|---|---|
oauth2_provider |
Your Nextcloud URL (no trailing slash) — what the browser hits to authorize. |
oauth2_client_id |
The client ID from the OAuth 2.0 registration. |
oauth2_client_secret |
The client secret. Prefer the ISTOTA_WEB_OAUTH2_CLIENT_SECRET env var. |
session_secret_key |
Random string for signing session cookies. Generate with python3 -c "import secrets; print(secrets.token_hex(32))". Use the ISTOTA_WEB_SESSION_SECRET_KEY env var in production. |
Optional overrides (defaults derive from oauth2_provider):
| Setting | Description |
|---|---|
oauth2_token_endpoint |
Server-to-server token URL. In Docker this often points at the internal NC service URL while oauth2_provider points at the host-mapped URL. |
oauth2_userinfo_endpoint |
Server-to-server userinfo URL. Same Docker pattern. |
oauth2_redirect_uri |
Explicit redirect URI override; otherwise derived from request host + scheme. |
When using the Ansible role, set these in your vars:
istota_web_enabled: true
istota_web_oauth2_provider: "https://cloud.example.com"
istota_web_oauth2_client_id: "your-client-id"
istota_web_oauth2_client_secret: "{{ vault_istota_oauth2_secret }}"
istota_web_secret_key: "{{ vault_istota_web_secret }}"
Secrets stored in secrets.env (via istota_use_environment_file: true) are injected as env vars by systemd, keeping them out of the config file.
3. Build the frontend¶
The Ansible role handles this automatically when istota_web_enabled is set and istota_nodejs_enabled is true.
4. Reverse proxy¶
The web app listens on 127.0.0.1:{port} and should not be exposed directly. Put it behind nginx (or your preferred reverse proxy).
The Ansible role generates an nginx config automatically. The relevant block:
location /istota/ {
proxy_pass http://127.0.0.1:8766/istota/;
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;
}
TLS is required — session cookies are set with secure=true and the registered redirect URI must use HTTPS. Use Let's Encrypt or your preferred certificate provider.
5. Run¶
The Ansible role installs this as the istota-web systemd service:
How authentication works¶
- User visits
https://{hostname}/istota/and is redirected to/istota/login - Istota redirects to Nextcloud's OAuth 2.0 authorization endpoint (
{oauth2_provider}/index.php/apps/oauth2/authorize) - User authenticates with their Nextcloud credentials (or is already logged in)
- Nextcloud redirects back to
/istota/callbackwith an authorization code - Istota exchanges the code for an access token; NC inlines
user_idin the token response, so identity is known without a second round-trip - The access token is dropped immediately — only the username + display_name are kept in the session
- If the username exists in
config.users(or auto-seeds auser_profilesrow), a signed session cookie is set (7-day expiry) - Subsequent requests use the session cookie — no re-authentication until expiry or logout
If the token response doesn't include user_id (older NC versions or custom auth backends), istota falls back to fetching identity from the OCS userinfo endpoint with the bearer token before discarding it.
Users not in the config are rejected with a 403 even if they have a valid Nextcloud account.
A callback that fails does not 500. A state mismatch or a declined authorization renders a login-shaped error card with a 400, and an unreachable provider a 502; the card never echoes provider- or exception-derived text back to the browser. Logging out is confirmation-gated in the UI — the logout and menu icons sit side by side and are small on a phone, so a mistap used to end the session and send you back through the login screen.
A light/dark theme toggle in the shell header switches the whole UI between themes; the choice persists per browser.
Installing to a home screen¶
The UI ships a favicon, an Apple touch icon and a web app manifest, so adding it to a phone's home screen gives the Istota mark rather than a screenshot of the page, and the browser chrome takes the colour of the theme you picked in the app rather than the system's. There is no service worker: hashed assets are cached for a year and everything else revalidates, so a deployment can never leave an installed app pinned to a shell whose chunks the server has since deleted. When a new build lands, a toast offers a Reload rather than reloading under you — it also re-checks when you return to the app, since a suspended one stops polling.
The manifest asks for a portrait orientation. The layout is a single column with a docked composer, and landscape leaves too little height once the keyboard is up. That request only binds an Android home-screen install: iOS ignores the manifest key (the native shell locks iPhone to portrait itself, and leaves iPad free to rotate), and an ordinary browser tab cannot be locked at all.
Pages¶
Chat: an always-on, full-page in-app chat console — the first nav tab, before Feeds. Discord/Slack-style rooms in a sidebar, live SSE streaming of tool use and intermediate text, !commands and the !model prefix, confirmation cards, attachments (drag, paste, the + button, or a voice message), clickable attachment chips, and per-message copy / star / delete. See Web chat for the full surface.
Dashboard: shows available features for the authenticated user. When Google Workspace is enabled, the dashboard also shows a connect/disconnect card for linking a Google account.
Feeds: RSS feed reader with masonry card grid, image/text filter, sort-by dropdown (published/added), grid/list view, navigable image lightbox, and a click-to-expand reader overlay that shows a card's full un-clipped content with ←/→ navigation between posts and an "Open original" link. The sidebar scopes the view to all, unread, an individual feed, or a whole category (click a category name to filter to it). Per-entry starring (f keyboard shortcut) and scope-aware bulk mark-as-read (Shift-A / toolbar button) honor the active feed or category scope. Viewport-based read tracking marks entries as read after 1.5s visible. Repeat images are suppressed as a reblogged photo travels through the blogs you follow: a duplicate inside one post is dropped, and across posts an image a newer entry already showed is hidden on the older ones (the post still appears, with a note counting the hidden repeats). Suppression is bounded to a recent look-back window and to the view you are in, so an image resurfacing much later still shows and browsing one blog never hides a tile because of another. Video embedded in a post plays inline with normal controls; nothing autoplays, and the image/text filter hides inline video along with pictures. Sprocket-icon settings page for managing subscriptions, categories, OPML import/export, and the repeat-image look-back window (switchable off). Served by the in-tree istota.feeds module against per-user SQLite. See Feeds. Requires the feeds module to be enabled (on by default).
Briefings: reader landing page for generated briefings with an archive sidebar (per-result kebab → delete) and a name filter in the header, plus a settings page (cog) for editing a briefing's content blocks and their sources, its schedule, and its delivery target. Source paths use a searching file picker with an advisory existence check. Admins additionally get a "Shared blocks" card for the module-owned blocks every user can read. Requires the briefings module to be enabled (on by default).
Money: accounting dashboard with ledger queries, transaction management, and reports. The Business section is Work | Invoices | Clients: Work is a full CRUD surface over the file-based work-entry store (entries addressed by stable id, with per-entry etags so a concurrent agent edit conflicts rather than being silently reverted), and Clients plus the money settings page are the CRUD surface over the invoicing config — clients, entities and services — so nothing about invoicing needs the CLI. Backed by the in-process money module (no external service); also covers quarterly tax estimates and portfolio tracking. See Money. Requires the money module to be enabled (on by default).
Admin: read-only system health (task counts by source, worker pool, per-module DB stats, models pane showing the active brain and its resolved role tiers). A banner surfaces a degraded primary brain — when the availability breaker is open, automatic work is being skipped or routed to the fallback. Gated by the /etc/istota/admins allowlist, which fails closed when empty.
Health: body stats grid with sparklines, bloodwork matrix (dates × markers with flag-colored cells, CSV import/export), panel detail with inline edit and source preview, per-marker trend charts with out-of-range zones and LLM explainer, medical history timeline with encounters and diagnoses, immunization tracking with coverage status strip, vaccine drill-down pages with clinical explainers. Garmin Connect (daily-summary sync) is on the general Settings → Connected services page, shared with Location. Requires the health module to be enabled (on by default).
Location: today view (current position, day summary, trips), history (date picker, activity filter, heatmap), places (discover clusters, create/edit/delete, visit stats). Requires GPS tracking to be enabled.
Settings: per-user preferences plus the Connected services page (Google Workspace, Garmin, Karakeep, ntfy, Monarch) and the per-module settings pages. This is where credentials are entered — write-only, bullet-masked fields backed by the encrypted secrets store.
API routes¶
| Route | Purpose |
|---|---|
/istota/login |
OAuth2 redirect |
/istota/callback |
Token exchange + identity resolution |
/istota/logout |
Session clear |
/istota/api/me |
User info + features |
/istota/google/connect |
Google OAuth initiation (separate, for the gws skill) |
/istota/google/callback |
Google OAuth callback |
/istota/api/google/status |
Google connection status |
/istota/api/google/disconnect |
Remove Google tokens |
/istota/api/feeds |
Native feeds module (per-user SQLite) |
/istota/api/money/* |
Money module (ledger, transactions, invoicing, work entries, invoicing config) |
/istota/api/briefings/* |
Briefings module (reader, archive, blocks/sources editor, shared blocks) |
/istota/api/location/* |
Places CRUD, pings, trips |
/istota/api/health/* |
Stats, panels, biomarkers, encounters, diagnoses, immunizations, Garmin sync, settings |
/istota/api/garmin/* |
Garmin connected-service auth (status, connect, MFA, disconnect) + GPS track import; shared by Health and Location |
/istota/api/chat/config |
Chat limits + streaming intervals |
/istota/api/settings/* |
Per-user preferences, connected services, per-module settings |
/istota/api/admin/* |
Admin dashboard aggregates (stats, logs, config view) — allowlist-gated |
/istota/api/chat/rooms |
Room CRUD (list/create); PATCH /chat/rooms/{id} renames; DELETE hard-deletes |
/istota/api/chat/rooms/{id}/promote |
Create a Talk conversation for a web-origin room and bind them |
/istota/api/chat/rooms/{id}/read · /chat/rooms/read-all |
Mark read cursors |
/istota/api/chat/rooms/{id}/messages |
Message history + send |
/istota/api/chat/messages |
Cross-room message query |
/istota/api/chat/messages/{id}/star · DELETE /chat/messages/{id} |
Star / delete a message |
/istota/api/chat/stream · /chat/events |
Room-level event stream and snapshot |
/istota/api/chat/commands |
The !command catalogue for the composer |
/istota/api/chat/files |
Files shared into chat |
/istota/api/chat/tasks/{id}/stream |
SSE stream of a task's events (tool use, text deltas) |
/istota/api/chat/tasks/{id}/events |
Snapshot of a task's events |
/istota/api/chat/tasks/{id}/confirm · /cancel |
Confirm / cancel a chat task |
/istota/api/chat/attachments |
Attachment upload (multipart, one file per request) |
The SvelteKit build is served as static files for all other /istota/* paths.
Deployment¶
The Ansible role handles the Node.js build when istota_web_enabled is set. The web app runs as a separate systemd service alongside the scheduler.