Skip to content
Docs

Contributing

Architecture

How Startica's Electron shell, local Next.js server, SQLite database and background jobs fit together.

Startica is a desktop app made of two processes: an Electron shell that owns the window, the tray and the OS keychain, and a local Next.js server that owns everything else (pages, server actions, the SQLite database and the background job runner). This page explains how they connect and why it’s built this way. Read it before you change anything in apps/desktop, apps/desktop-web/src/proxy.ts, packages/db/src/client.ts or the job runner.

Monorepo layout

Startica is a Yarn 4 workspace (nodeLinker: node-modules) with these packages:

Path Package What it holds
apps/desktop @startica/desktop Electron main process (main.mjs): window, tray, launch at login, keychain, the posting window (posting/), the accessibility scanner (scanner/), auto-update (updater.mjs), installers
apps/desktop-web @startica/desktop-web Next.js 16.4 app the desktop window shows: pages, server actions, /api/events, /api/activity, startup
packages/core @startica/core Business logic, one folder per area under src/: AI client and evals (ai/), jobs (jobs/), channel adapters (channels/), listening and drafting (distribution/), recording posts (posting/), Content Studio (content/), Intel (intel/), the roadmap, Today’s moves, playbooks and audits (growth/), metrics connectors (metrics/), the product database setup SQL (integrations/), first-run setup state (setup/), the Dashboard (dashboard/), accessibility scans (a11y/), SEO & GEO site health (seo/), Search Console and Bing (search/), AI visibility (visibility/), SEO & GEO opportunities (opportunities/), agent readiness (agents/), off-site presence (offsite/), Security (security/), Ads (ads/), issue trackers (trackers/), private-address checks (net/) and the demo seed (demo/)
packages/db @startica/db SQLite schema (Drizzle), migrations, connection, job queue, secrets, change events
apps/web @startica/web The mystartica.com website (simple-engine)
apps/docs @startica/docs This documentation site (simple-engine)
packages/web-theme @startica/web-theme Shared theme for the two sites
packages/brand @startica/brand The Startica logo, for the sites and the desktop app
scripts/ (root) Install hook: ensure-electron.mjs

packages/core and packages/db export their TypeScript source directly ("./*": "./src/*.ts"), so imports look like @startica/core/jobs/runner and @startica/db/queue. Next.js compiles them through transpilePackages; scripts run them with tsx.

Processes

Electron main process (apps/desktop/main.mjs)
 ├─ window (BrowserWindow, sandboxed) → http://127.0.0.1:58300  (cookie startica_session = launch token)
 ├─ tray / menu-bar icon, launch at login, single-instance lock
 ├─ OS keychain (safeStorage): encrypts and decrypts secrets on request over IPC
 ├─ posting window (posting/window.mjs, on request over IPC): Startica bar + site view (persist:posting)
 ├─ accessibility scanner (scanner/scanner.mjs, on request over IPC): up to 2 hidden scan windows (in-memory "scan" session)
 └─ child process: the same Electron binary with ELECTRON_RUN_AS_NODE=1
      └─ Startica server (apps/desktop-web/scripts/serve.mjs in dev, serve-prod.mjs when packaged)
           ├─ Next.js: pages, server actions, /api/events (SSE), /api/activity
           ├─ proxy.ts: rejects any request without the launch token (no exceptions)
           ├─ instrumentation.ts → lib/startup.ts: open and migrate SQLite, set the cipher,
           │                                     create the workspace, pick the scanner backend,
           │                                     start the job runner
           └─ job runner: one loop per queue + croner schedules (only while the app is open)

Why a child process

Next.js’s dev server starts helper processes using the current executable. Inside Electron’s main process, the current executable is the Electron app, so every helper would start another copy of Startica. Instead, main.mjs forks the server with ELECTRON_RUN_AS_NODE=1, which makes the Electron binary behave as plain Node. The server and all of its helpers run as Node 24 (the version bundled with Electron), in development and in the installed app, so users don’t need a separate Node install.

The child gets STARTICA_ELECTRON=1, STARTICA_DATA_DIR (Electron’s userData folder unless you set it) and an IPC channel. When the server is listening, it sends { type: "ready", url, token } to the parent. If the server exits unexpectedly, the app shows an error and quits.

Warning

Electron’s RunAsNode fuse must stay enabled. The server child depends on it.

The launch token and proxy

Startica has no login. Instead:

  1. The server binds to 127.0.0.1 only, so nothing on the network can reach it.
  2. At startup, server.mjs (or serve-prod.mjs) generates a random 32-byte token and stores it in STARTICA_SESSION_TOKEN.
  3. The Electron main process receives the token over IPC and sets it as the startica_session cookie (httpOnly, sameSite: strict) for the window’s session.
  4. apps/desktop-web/src/proxy.ts (Next.js 16’s replacement for middleware) checks every request. It accepts the cookie, or a ?token= query parameter that it swaps for the cookie and strips from the URL. The comparison uses timingSafeEqual. Anything else gets a 401.

This keeps other programs and websites on the same machine out of the server. Without STARTICA_SESSION_TOKEN the proxy answers 503, which is why you always start the server through scripts/serve.mjs and never with next dev directly.

In browser mode (yarn dev:desktop-web) there’s no Electron to set the cookie, so the server prints a one-time http://127.0.0.1:58300/?token=… link instead.

No sign-ins

Startica has no OAuth sign-ins, so there are no callback routes and no path in proxy.ts that skips the token. Every connection uses a credential the user pastes: GA4 and Search Console use service account keys, Stripe a restricted key, Bing and Umami an API key (or username:password for older self-hosted Umami). The product database uses SQL the user runs themselves (packages/core/src/integrations/product-db-setup.ts writes it) and a read-only login that checkReaderConnection() in packages/core/src/metrics/product-db.ts checks before anything is saved. See Product database.

A second launch of the app focuses the existing window instead of starting a second server (Electron’s single-instance lock).

Secrets

Secrets (the Anthropic and OpenAI keys, the X API token, issue tracker tokens, connection credentials such as the GA4 service account key and the product database’s read-only connection URL) are stored encrypted in the secrets table through packages/db/src/secrets.ts. The encryption depends on where the server runs:

  • In the desktop app (STARTICA_ELECTRON=1): ipcCipher() sends each encrypt or decrypt request to the Electron main process, which uses safeStorage (macOS Keychain, Windows DPAPI, Linux libsecret). Ciphertexts start with ss1..
  • Outside Electron (browser mode, tests, scripts): fileKeyCipher() uses AES-256-GCM with a random key in dev-secret.key in the data folder.

The two can’t read each other’s secrets. A key saved in the desktop app can’t be decrypted in browser mode, and the other way round.

SQLite: one connection per process

The database is a single file, startica.db, opened with Node’s built-in node:sqlite and used through Drizzle’s sqlite-proxy driver (packages/db/src/client.ts). There are no native modules, so nothing needs rebuilding per platform or Electron version.

  • One connection per process, shared through globalThis. Next.js bundles modules more than once, so a module-level variable would give each bundle its own connection, and separate connections on one thread can deadlock each other. openDatabase() stores the connection under Symbol.for("startica.db") and every copy of the module finds it there.
  • Pragmas: WAL journal, synchronous = normal, foreign keys on, busy_timeout = 5000.
  • Transactions take a process-wide lock. Statements run synchronously, but code around them is async, so another request could otherwise slip a query into someone else’s open transaction. transaction(fn) waits for the lock, runs begin immediate, and tracks “inside a transaction” with an AsyncLocalStorage (also shared through globalThis). Statements outside the transaction wait for the lock. Nested transaction() calls join the outer one.
  • Keep transactions short and local. No network calls, no AI calls inside transaction(). Everything else in the process waits while it’s open.
  • Migrations live in packages/db/migrations and are applied at startup by migrateDatabase().

Pages and server actions get the database through db() in apps/desktop-web/src/lib/dal.ts. It calls Next’s connection() (marking the read as request-time work for Cache Components) and makes sure startup has run.

Startup

apps/desktop-web/src/instrumentation.ts runs once when the Next.js server starts (never during next build, so a build can’t open anyone’s database). It calls start() in src/lib/startup.ts, which runs once per process:

  1. Open the database at <data folder>/startica.db and apply migrations (from STARTICA_MIGRATIONS_DIR).
  2. Set the secret cipher: ipcCipher() in Electron, fileKeyCipher() otherwise.
  3. Create the workspace and its default AI model routing on first launch (ensureWorkspace()).
  4. Start the job runner, unless STARTICA_DISABLE_JOBS=1.

Background jobs

AI calls, network fetches and anything slow run as jobs, never inside a request. The queue is a jobs table (packages/db/src/queue.ts), and the runner (packages/core/src/jobs/runner.ts) lives in the server process:

  • One loop per queue. A 15-minute Reddit scan never holds up drafting. Jobs within one queue run one at a time.
  • Leases. claim() flips the next ready job to running and sets locked_until to now plus the job’s visibilityTimeoutSeconds. If the app quits mid-job, the lease expires and the job can be claimed again on the next launch.
  • Retries. On failure the job is retried after 1, 4, 9… minutes until maxAttempts, then marked failed. A successful job is deleted from jobs.
  • History. Every attempt writes a job_runs row, shown on the System page. Every AI call writes an ai_runs row linked to its job run.
  • Schedules use croner in local time: listening hourly at :07, engagement every 6 hours at :17, metrics daily at 08:15, security monitoring daily at 06:47, due site scans hourly at :37, the roadmap re-plan on Mondays at 07:00, and AI visibility on Mondays at 09:30 (only for products with Run every week on). The list is SCHEDULES in packages/core/src/jobs/runner.ts.

Jobs only run while the app is open. That’s why closing the window keeps Startica running in the tray. See Add a background job and the jobs reference.

Live updates

There’s no polling. Every insert, update or delete that goes through the database layer calls noteWrite() (packages/db/src/events.ts), which extracts the table name and emits one debounced change event per table (50 ms). Writes to secrets and to tables starting with __ are skipped.

/api/events streams those events as server-sent events, with a keep-alive ping every 25 seconds. On the client, the LiveRefresh component (src/components/app/live-refresh.tsx) takes a list of tables, listens on an EventSource, and calls router.refresh() when one of them changes. That’s how a page updates when a background job finishes.

The posting window

Posting with Startica (beta) needs a real browser signed in to the site (X, Hacker News, Reddit, LinkedIn, Bluesky, the user’s Mastodon server or a Discourse forum), so it lives in the Electron process, not the server:

  1. Post… calls a server action that builds a PostingRequest (packages/core/src/posting/requests.ts: site, reply or new post, the page to open, the text; postingContext() in apps/desktop-web/src/lib/posting.ts adds the user’s Mastodon server, the product’s first watched Discourse forum, and whether replies on X are tracked, which is only with an X API token) and sends it to the parent process as { type: "posting-open" } over the child-process IPC channel (apps/desktop-web/src/lib/posting.ts). Outside Electron there’s no channel, so the button is hidden.
  2. apps/desktop/posting/window.mjs opens a BaseWindow with two views: the Startica bar (bar.html, whose only bridge is bar-preload.cjs, exposing fill, copy, save link and close) and the site (a sandboxed WebContentsView on the persistent partition persist:posting, with no preload).
  3. After each page load, the site’s fixed fill-in script from posting/sites.mjs runs with executeJavaScriptInIsolatedWorld. It fills the fields and returns; it never submits. Rich-text editors (LinkedIn, Bluesky, Discourse’s rich mode) ignore scripted values, so for those the script only focuses the editor and returns insert: true, and the window types the text with Electron’s webContents.insertText(). A site’s allowed hosts are a fixed list, or come from the request for sites on any domain (Mastodon, Discourse). SIGN_IN_HOSTS in sites.mjs adds the Google, Apple, GitHub, GitLab, Discord and Microsoft sign-in pages for every site.
  4. The window watches the site view’s network traffic through the Chrome DevTools Protocol for the request the site’s own button sends, and reads the new post’s link from the response (for Hacker News, from the HN API after the redirect). Sites with needsBody (Bluesky) also read the request body to tell a new post from a like. A Discourse post held for moderation is reported as pending, without a link. It reports { type: "posting-result" } to the server.
  5. The server records it with recordPostingResult() (packages/core/src/posting/record.ts), the same code Mark as posted uses (idempotent), and answers posting-recorded so the bar can confirm.

The rules for this code are in Working rules. The full design is in ARCHITECTURE.md at the repo root, section 8.4.

The accessibility scanner

Accessibility scans open each page in a real Chromium browser and run axe-core in it. The scan logic lives in the server; only the browser part is swappable.

Core code

packages/core/src/a11y/:

File What it does
urls.ts URL normalization, same-site checks, include and skip patterns (matchesPattern(), DEFAULT_EXCLUDES), private-host and request checks (scanRequestAllowed())
discovery.ts Reads robots.txt and sitemaps (indexes followed, up to 10 sitemaps) with the StarticaAccessibilityScan/0.1 user agent
scanner.ts The ScannerBackend interface (scan(request) returns a ScanResponse), setScanner() and the in-page script (runScript), which returns axe’s results, the page’s links and its SeoFacts
results.ts Maps axe tags to WCAG criteria and levels, fingerprints elements into issue groups, and computes page and site scores
scan.ts The two jobs: runSiteScan() (crawl, check, record, then mark issues new or fixed) and runPageCheck()
sites.ts Adding, editing and removing sites, and starting scans and checks
playwright-backend.ts The Playwright backend for browser development mode and tests

Backends

startScanner() in apps/desktop-web/src/lib/scanner.ts picks a backend once at startup:

Where the server runs Backend
Desktop app (STARTICA_ELECTRON=1 with an IPC channel) Electron: asks the main process to open the page
Browser development mode Playwright’s Chromium, if installed
Demo mode (STARTICA_DEMO=1) None: scans are off

With no backend, the Accessibility screens say scans aren’t available and disable the scan buttons.

The Electron scanner

The Electron backend uses the same child-process IPC channel as the keychain and the posting window:

  1. For each page, the server sends { type: "scan-page", requestId, request }. The request carries the URL, the timeout, the axe-core source and the in-page script that runs axe and collects the page’s links. The server reads axe-core (pinned to exactly 4.14.0) from node_modules at run time; it’s listed in Next’s serverExternalPackages and the standalone tracing includes, so it ships with the packaged app.
  2. apps/desktop/scanner/scanner.mjs loads the page in a hidden BrowserWindow (sandboxed, context isolation, no Node.js) on the in-memory partition scan. It keeps at most 2 windows and replaces them every 50 pages. The session refuses permission requests, cancels downloads, denies popups, keeps navigation on the site, and blocks requests to Startica’s own port and to private-network addresses unless the site itself is local. Its user agent is Chromium’s with the Electron and Startica tokens removed and StarticaAccessibilityScan/0.1 added.
  3. After the page loads, axe-core and the in-page script run with executeJavaScriptInIsolatedWorld in world 1088 (the posting window uses 1077).
  4. The main process answers { type: "scan-result", requestId, response }, or error with a reason (for example a timeout). A hung or broken window is replaced.

Jobs

Job Queue Lease Notes
site_scan its own 4 h A full scan. Pages are recorded in site_pages as they’re found and checked, so a scan interrupted by quitting resumes where it stopped when the lease expires. Max 1 attempt.
site_page_check its own 1 h Check a page and the recheck buttons. Its own queue, so it never waits behind a full scan. Max 1 attempt.

Two pages are checked at a time, with at most one page load a second per site.

Tables

Migration 0003_accessibility adds:

Table Holds
sites One row per site: product, start address, page cap, patterns, options, target score, latest score
site_scans One row per scan: scope (full, page, group), status, counts, score, new and fixed issues, error
site_pages Every page found: address, state (queued, done, skipped, failed), reason, score, title, language
a11y_issue_groups One rule failing on one fingerprinted element, across pages: impact, WCAG criteria, status (open or fixed), counts
a11y_occurrences Each failing (or needs-review) element on each page: selector, HTML snippet, failure summary

See Testing for the stand-in site and tests.

SEO & GEO site health

Site health runs on the accessibility scan: the same jobs, the same page loads, the same sites and site_pages. There’s no second crawl. The full design is in ARCHITECTURE.md at the repo root, section 10.

How it hooks into the scan

  1. The in-page script (runScript in a11y/scanner.ts) also returns seo: the meta description, canonical, robots meta tag, h1 texts, Open Graph title and image, the raw text of each JSON-LD block and a word count (SeoFacts).
  2. Both backends add the main document’s X-Robots-Tag header to the ScanResponse as xRobotsTag: playwright-backend.ts from the response headers, and apps/desktop/scanner/scanner.mjs from the scan session’s webRequest.onHeadersReceived, per window.
  3. storePageResult() in a11y/scan.ts calls storePageSeo(), which stores the page’s facts and links and replaces its page-level findings and score, in the page’s own transaction.
  4. finishScan() calls finishSeo() after every scan that checked at least one page (full scan, page check, issue recheck, or a stopped scan). It replaces the site-level findings, fetches robots.txt and llms.txt, and recomputes the page and site scores.
  5. A full scan resets site_pages.in_sitemap and sets it again from the sitemap it reads, so orphan and noindex-in-sitemap checks always use the current sitemap.

Core code

packages/core/src/seo/:

File What it does
rules.ts SEO_RULES: every check’s title, severity (high, medium, low, info), category, why and fix, shown as written in the UI; SEVERITY_WEIGHT (15, 7, 2)
checks.ts Page-level checks (pageFindings()): title and description lengths, h1, noindex from the robots meta tag or X-Robots-Tag, canonical elsewhere; readStructuredData() with the required properties per JSON-LD type; duplicateGroups(); llmsTxtValid()
robots.ts robots.txt as RFC 9309 reads it: parseRobots(), robotsAllows() (the crawler’s own group, else *; longest rule wins; Allow wins ties; * and $)
crawlers.ts AI_CRAWLERS (token, operator, purpose: training, search or user), PURPOSE_LABEL, and AI_CRAWLERS_CHECKED, the date the list was last checked. Update the date when you edit the list.
site.ts storePageSeo(), finishSeo() (duplicates, broken links, broken canonicals, orphans, missing sitemap, AI crawler access, llms.txt, home page structured data and social image), seoPageScore(), and siteSeoFindings() for the screens
seo.test.ts Unit tests for robots.txt, the page checks, JSON-LD and the page score

Each finding has a group_key, so one problem shared by several pages (one duplicate title, one broken address, one JSON-LD type missing properties) is one group. Site-level findings without a page (ai-search-blocked, sitemap-missing, llms-missing and so on) have no page_id.

finishSeo() makes no request for reserved hostnames (.example, .test, .invalid), so the demo’s fieldnote.example never reaches the network.

The screens are in apps/desktop-web/src/app/(app)/seo/: page.tsx (Overview), health/page.tsx (Site health), data.ts (seoRuleGroups(), findings grouped by rule then group) and shared.tsx. They reuse the Accessibility site picker data, scan buttons and add-site form.

Tables

Migrations 0005_seo_site_health and 0006_ai_access add:

Table or column Holds
seo_findings One row per finding: site, page (null for site-wide ones), rule, severity, group_key, detail (JSON for the UI: lengths, the duplicate title, the broken link’s status and where it’s linked from, which JSON-LD block, which crawlers)
site_pages.seo The page’s search facts (PageSeo: description, canonical, robots, xRobotsTag, h1, Open Graph, structured-data types, word count)
site_pages.links The page’s links, normalized, up to 300 (for broken links and orphans)
site_pages.in_sitemap Whether the last full scan’s sitemap listed the page
site_pages.seo_score The page’s site health score
sites.seo_score, site_scans.seo_score The site’s site health score, now and at each scan
sites.ai_access Each AI crawler’s access, the llms.txt status (present, invalid, missing) and when they were checked

To add a check: add the rule to SEO_RULES, produce it in pageFindings() (page-level) or finishSeo() (site-level, and add it to SITE_RULES there so it’s replaced on each finish), add a detail line in health/page.tsx if it has details, and add a planted case to the stand-in site and the expected findings in a11y/scan.test.ts. Document it in Site health.

Issue trackers

A product can connect one GitHub or GitLab repository, where Startica opens an issue per accessibility issue group or security finding (see Issues in GitHub or GitLab). The code is in packages/core/src/trackers/:

File What it does
client.ts The TrackerClient interface (checkAccess(), createIssue(), comment(), setState()) and its two implementations, using fetch with a 20-second timeout. GitHub: REST with Authorization: Bearer <token> and X-GitHub-Api-Version: 2022-11-28; GET /repos/{repo}, POST /repos/{repo}/issues, POST …/issues/{n}/comments, PATCH …/issues/{n} (state_reason: completed when closing). GitLab: PRIVATE-TOKEN header on /api/v4/projects/{url-encoded path}; POST …/issues, POST …/issues/{iid}/notes, PUT …/issues/{iid} with state_event.
trackers.ts Saving and checking a tracker (saveTracker(), trackerBaseUrl()), the issue’s title and Markdown body (a11yIssueText(), securityIssueText()), Create issue (createIssueForGroup(), createIssueForSecurityFinding()) and the tracker_sync job (runTrackerSync(), queued by queueTrackerSync() at the end of finishScan() in a11y/scan.ts and after security_monitor)
standin.mjs Stand-in GitHub and GitLab APIs for tests. See Testing.

Migration 0004_issue_trackers adds two tables:

Table Holds
issue_trackers One row per product: provider, API base address, repository, secret_id of the token, labels, auto_min_impact, auto_min_security (added in Security S2), close_when_fixed, status and last error
tracker_issues One row per repository issue Startica opened: tracker, source_kind (a11y or security) and source_id (the issue group or security finding), number, address and state. Unique on tracker, kind and source, so a finding never gets two issues.

The token is a secret (issue-tracker-<tracker id>) and is only ever sent to the tracker’s base address. The base address must be https, or http for a private host (isPrivateHost() from a11y/urls.ts). tracker_sync runs on its own queue with a 600-second lease and 2 attempts, and opens at most AUTO_CREATE_PER_RUN (10) issues a run, accessibility and security together.

Security

The Security module runs on the accessibility scan too, but only for sites whose ownership is verified. Page checks run on the pages the scan already loads; site checks (HTTPS, certificates, exposed files, security.txt, email) run once per full scan. The code is in packages/core/src/security/: rules.ts, checks.ts, probes.ts, verify.ts, findings.ts, report.ts, exposure monitoring in monitor.ts (the security_monitor job), AI help in ai.ts and the disclosure kit in disclosure.ts. The full design is in ARCHITECTURE.md at the repo root, section 11. User docs: Security.

Ads

Ads lives in the Marketing module: campaign plans and copy (ad_plan, ad_copy), creatives drawn by the image task and laid out by HTML templates, hand-off files, and results from spend you enter plus GA4 or Umami. Startica never creates campaigns or spends money. The code is in packages/core/src/ads/; the creative renderer in the desktop app is apps/desktop/renderer/render.mjs. The full design is in ARCHITECTURE.md at the repo root, section 8.6. User docs: Ads.

Dashboard

The Dashboard (/dashboard, where the app opens) sums up every module for the active product. dashboardSummary() in packages/core/src/dashboard/summary.ts only reads: it never builds Today’s moves or starts work. The screens are listed in ARCHITECTURE.md at the repo root, section 13. User docs: Dashboard.

Packaging

yarn build:desktop runs apps/desktop/scripts/build.mjs:

  1. next build with output: "standalone" into its own build folder, .next-desktop (set through STARTICA_STANDALONE=1 and STARTICA_NEXT_DIST). It uses its own tsconfig so a running dev server can’t break the build.
  2. Assemble apps/desktop/build/server: the standalone server, its static files, serve-prod.mjs and the database migrations.
  3. Run electron-builder: DMG and ZIP on macOS, NSIS on Windows, AppImage and .deb on Linux, into apps/desktop/dist.

electron-builder strips node_modules from extra resources, so an afterPack hook (scripts/after-pack.cjs) copies the assembled server into the app’s resources/server folder. In the installed app, main.mjs starts resources/server/apps/desktop-web/serve-prod.mjs, which sets the same environment that server.mjs sets in development and then loads Next’s standalone server.js.

Auto-update is apps/desktop/updater.mjs, which uses electron-updater against the repository’s GitHub releases. See Releases for building installers and publishing releases.

Data folder

The server’s data folder comes from dataDir() in packages/db/src/paths.ts. It matches Electron’s userData for an app named Startica, so the desktop app, yarn dev:desktop and yarn dev:desktop-web all share one database by default:

OS Default
macOS ~/Library/Application Support/Startica
Windows %APPDATA%\Startica
Linux $XDG_CONFIG_HOME/Startica (usually ~/.config/Startica)

STARTICA_DATA_DIR overrides it. See Data folder.

Search the docs