AI-powered desktop workstation for generating and publishing WordPress content at scale — multi-provider LLMs, local-first storage, and direct site sync.
- The Problem
- Overview
- Architecture
- Domain Modules
- Key Features
- Tech Stack
- Showcases
- What I Learned
- Status
Content teams running multiple WordPress sites face three pain points that SaaS products do not solve. Privacy — their briefs, drafts, prompts, and API keys live on someone else's server, next to someone else's data. Flexibility — they are locked into whichever model the SaaS chose and pay a markup on every token, with no real control over model, mode, or cost. Security — a SaaS endpoint is a public attack surface, and for users in restricted regions or sensitive verticals the existence of any outbound signal is itself a risk.
Postulator is a local-first desktop workstation that puts the user in control of the whole loop: their own API keys, their own database, their own model choice, and an application-level proxy layer that can route every external call through Tor, SOCKS, or HTTP proxy — making the workstation effectively invisible on the network. The product ships as a single native Windows binary; no server, no cloud, no telemetry.
Postulator is a Wails v2 desktop application built for professionals who produce content at scale for one or more WordPress sites. The user imports a site's sitemap (CSV, JSON, or Excel), defines topics and categories, builds a prompt library, picks an AI provider (OpenAI, Anthropic, or Google Gemini), and generates articles in bulk — with internal SEO linking, per-provider token and cost tracking, and direct publication to the target WordPress site over the REST API. Everything is stored locally in a SQLite database under the platform's XDG data directory.
The product was built and delivered under contract to a specific client as a turnkey workstation. It is this portfolio's first end-to-end polished desktop product and the first one where UI/UX was the product — the core value is a rich, multi-screen flow for building complex contextual inputs per site, which is the hardest thing the app does.
Technically it is interesting for three reasons: a unified abstraction over three different AI vendor SDKs, a pure-Go SQLite stack that packages trivially into a single Windows binary with no CGO or bundled native libraries, and a first-class application-level proxy layer that treats network invisibility as a core feature rather than an afterthought.
Postulator is built as a modular Go backend hosted inside a Wails v2 window, with a Next.js 15 / React 19 static-exported frontend embedded directly into the Go binary via embed.FS. Every handler exposed to the frontend is an auto-generated Wails binding — the React side calls articles.Generate(...) as if it were a local function, and the Wails runtime marshals the call across the JS ↔ Go boundary.
Inside the Go process, the same modular-monolith discipline I use on the server side applies here: domains are isolated packages under internal/domain, infrastructure adapters sit behind interfaces under internal/infra, and everything is wired through Uber Fx with lifecycle hooks tied directly into Wails' OnStartup and OnShutdown events. The DI graph lights up when the window opens and tears down cleanly when the user quits from the system tray.
The architecture is explicitly local-first. All persistent state — articles, prompts, site configurations, AI usage history, migrations, settings — lives in a single SQLite file under the platform's XDG data directory, accessed through a pure-Go SQLite driver (ncruces/go-sqlite3, compiled from SQLite's WASM build and run on tetratelabs/wazero). There is no CGO, which means the whole application cross-compiles and packages into one Windows executable with no external DLLs or native dependencies. Schema evolution is handled by pressly/goose migrations bundled into the binary and run on startup.
Every outbound network call — AI provider APIs, WordPress REST calls, update checks — flows through a single application-level proxy layer (internal/infra/proxy) that supports direct, HTTP proxy, SOCKS5, and Tor. The proxy is configured per environment, and the rest of the code does not know or care: adapters call the proxy-aware HTTP client and the right transport is chosen at runtime. API keys for the three AI providers are never written to the SQLite database — they live in the operating system's secure secret storage via internal/infra/secret.
A zoomed-out view of the whole product in one frame: the user, the Wails window with the embedded Next.js frontend, the Go backend running in the same process, the application-level proxy transport, the local-first storage, and the only two kinds of external calls the app ever makes. The proxy sits deliberately on the path to every external dependency — there is no second exit from the process.
%%{init: {'theme':'dark', 'themeVariables': {'fontSize':'14px', 'fontFamily':'Inter, system-ui, sans-serif'}}}%%
flowchart LR
User([User])
subgraph Window["Desktop Window — Wails v2"]
direction TB
UI[Next.js 15 · React 19<br/>embedded static export]
Tray[System Tray<br/>Show · Hide · Quit]
end
subgraph Backend["Go Backend · single process"]
direction TB
Core[Handlers · Domain · Infrastructure<br/>wired via Uber Fx]
end
Proxy[[Proxy Transport<br/>direct · HTTP · SOCKS5 · Tor]]
subgraph Local["Local-First Storage"]
direction TB
DB[(SQLite<br/>pure-Go · wazero<br/>goose migrations)]
Keys[(OS Keychain<br/>API keys)]
end
subgraph Ext["External Services"]
direction TB
AI[AI Providers<br/>OpenAI · Anthropic · Gemini]
WP[Client WordPress Sites<br/>REST API]
end
User ==> UI
User <-.-> Tray
UI <==> Core
Core --> DB
Core --> Keys
Core --> Proxy
Proxy --> AI
Proxy --> WP
classDef user fill:#0f172a,stroke:#64748b,color:#e2e8f0,stroke-width:2px
classDef window fill:#1e3a8a,stroke:#60a5fa,color:#dbeafe,stroke-width:2px
classDef backend fill:#14532d,stroke:#4ade80,color:#dcfce7,stroke-width:2px
classDef proxy fill:#713f12,stroke:#facc15,color:#fef9c3,stroke-width:3px
classDef storage fill:#7f1d1d,stroke:#f87171,color:#fee2e2,stroke-width:2px
classDef external fill:#4a044e,stroke:#d946ef,color:#fae8ff,stroke-width:2px
class User user
class UI,Tray window
class Core backend
class Proxy proxy
class DB,Keys storage
class AI,WP external
Zooming into the Go process. Every call from the frontend follows the same top-down path: Wails binding → handler → domain service → infrastructure adapter, and from there out to SQLite, the OS keychain, or — through the proxy transport — to an AI vendor or a WordPress site. Uber Fx wires the whole graph at startup and tears it down cleanly on window close; dashed arrows mark the DI boundary.
%%{init: {'theme':'dark', 'themeVariables': {'fontSize':'14px', 'fontFamily':'Inter, system-ui, sans-serif'}}}%%
flowchart TB
Bridge[[Wails JS ↔ Go Bindings<br/>auto-generated · typed contracts]]
subgraph Handlers["Handlers — internal/handlers"]
direction LR
H1[articles · prompts<br/>categories · topics]
H2[sites · sitemaps<br/>linking · healthcheck]
H3[providers · ai_usage<br/>stats · jobs]
H4[importer · media · dialogs<br/>settings · proxy]
end
subgraph Domain["Domain Services — internal/domain"]
direction LR
D1[articles · prompts<br/>categories · topics]
D2[sites · sitemap<br/>linking · healthcheck]
D3[providers · aiusage<br/>stats · jobs]
D4[settings · proxy<br/>deletion · entities]
end
subgraph Infra["Infrastructure — internal/infra"]
direction LR
I1[ai<br/>provider registry]
I2[database<br/>SQLite + dbx]
I3[wp<br/>WordPress REST]
I4[importer · events<br/>notification · window]
I5[secret<br/>OS keychain]
end
Proxy[[Application Proxy Transport<br/>direct · HTTP · SOCKS5 · Tor]]
subgraph Storage["Local-First Storage"]
direction LR
DB[(SQLite · wazero<br/>goose migrations)]
Keys[(OS Keychain · API keys)]
end
subgraph Ext["External Services"]
direction LR
AI[AI Providers<br/>OpenAI · Anthropic · Gemini]
WP[Client WordPress Sites<br/>REST API]
end
DI[[Uber Fx · Dependency Injection<br/>lifecycle · OnStartup / OnShutdown]]
Bridge --> Handlers
Handlers --> Domain
Domain --> Infra
Infra --> Storage
Infra --> Proxy
Proxy --> Ext
DI -. provides .-> Handlers
DI -. provides .-> Domain
DI -. provides .-> Infra
classDef bridge fill:#581c87,stroke:#c084fc,color:#f3e8ff,stroke-width:3px
classDef handler fill:#134e4a,stroke:#2dd4bf,color:#ccfbf1,stroke-width:2px
classDef domain fill:#14532d,stroke:#4ade80,color:#dcfce7,stroke-width:2px
classDef infra fill:#7c2d12,stroke:#fb923c,color:#ffedd5,stroke-width:2px
classDef proxy fill:#713f12,stroke:#facc15,color:#fef9c3,stroke-width:3px
classDef storage fill:#7f1d1d,stroke:#f87171,color:#fee2e2,stroke-width:2px
classDef external fill:#4a044e,stroke:#d946ef,color:#fae8ff,stroke-width:2px
classDef di fill:#581c87,stroke:#c084fc,color:#f3e8ff,stroke-width:3px
class Bridge bridge
class H1,H2,H3,H4 handler
class D1,D2,D3,D4 domain
class I1,I2,I3,I4,I5 infra
class Proxy proxy
class DB,Keys storage
class AI,WP external
class DI di
A concrete trace of what happens when the user clicks "Generate Article" on a topic. Every horizontal column is one architectural layer; every arrow is a real call across the boundary, with pseudocode-style labels showing what is actually being invoked.
%%{init: {'theme':'dark', 'themeVariables': {'fontSize':'14px', 'fontFamily':'Inter, system-ui, sans-serif'}}}%%
sequenceDiagram
participant U as User
participant UI as React / TipTap
participant W as Wails Binding
participant H as articles.Handler
participant S as articles.Service
participant P as ai.ProviderRegistry
participant PX as proxy Transport
participant AI as OpenAI / Anthropic / Gemini
participant D as SQLite (dbx)
U->>UI: click "Generate Article"<br/>topic, site, prompt, provider
UI->>W: articles.Generate(dto)
W->>H: Handler.Generate(ctx, dto)
H->>S: Service.GenerateArticle(cmd)
S->>D: load prompt template + site context
D-->>S: PromptTemplate · SiteContext
S->>S: build unified completion request
S->>P: registry.ProviderFor(cmd.Provider)
P-->>S: Provider impl (OpenAI · Anthropic · Gemini)
S->>PX: Complete(msgs, opts)
PX->>AI: HTTPS via direct / SOCKS / Tor
AI-->>PX: streamed completion + usage
PX-->>S: UnifiedCompletion{text, tokens, cost}
S->>D: save Article draft
S->>D: record AIUsage event
D-->>S: Article{ID, ...}
S-->>H: ArticleDTO
H-->>W: ArticleDTO
W-->>UI: typed Article object
UI->>U: render draft in TipTap editor<br/>toast "article generated"
The arrows going right are commands, the arrows going back are responses, and the round trip touches every layer exactly once. The signal never skips the proxy transport — even a health check against a WordPress site goes through the same path.
The Go backend is organized into sixteen domain modules under internal/domain, each exposed to the Next.js frontend through a paired handler in internal/handlers:
| Group | Modules |
|---|---|
| Content Creation | articles, prompts, categories, topics |
| Site Integration | sites, sitemap, linking, healthcheck |
| AI Layer | providers, aiusage |
| Operations | jobs, stats, deletion |
| Platform | settings, proxy, entities |
Each module follows the same internal shape — domain types and interfaces in internal/domain/<name>, HTTP-equivalent handlers in internal/handlers/<name>.go, and infrastructure adapters in internal/infra/<area>. Everything is resolved through a single Uber Fx container with startup and shutdown hooks wired directly into the Wails window lifecycle.
- Multi-provider AI abstraction — OpenAI, Anthropic, and Google Gemini sit behind a single provider registry with a unified request / response contract. The user picks their provider per run, per topic, or as a default, and swapping vendors never touches domain code.
- Local-first storage — every byte of state (articles, prompts, sites, sitemap data, usage history, settings) lives in a single SQLite file under the platform's XDG data directory. No server, no cloud, no telemetry. The app works fully offline aside from the AI and WordPress calls the user explicitly makes.
- Pure-Go SQLite with no CGO — SQLite is run through
ncruces/go-sqlite3on top oftetratelabs/wazero, meaning the Windows build has zero native dependencies. The whole application cross-compiles and ships as one binary with no DLLs, no installer surgery, no CGO toolchain. - Goose migrations bundled in the binary — schema evolution for the local SQLite is handled by
pressly/goosemigrations embedded at build time and applied on startup, so an updated Postulator build safely upgrades an existing client database. - Application-level proxy layer — every outbound call (AI providers, WordPress REST) flows through a single proxy transport that supports direct, HTTP, SOCKS5, and Tor. The workstation can be made effectively invisible on the network, which is a first-class product requirement, not an afterthought.
- OS-level secret storage for API keys — AI provider credentials never touch the SQLite database. They are stored in the operating system's secure secret store via
internal/infra/secret, so a leaked database file reveals no keys. - Direct WordPress REST integration — Postulator connects directly to the client's WordPress sites over the REST API, performs health checks, pulls site structure, and publishes generated articles straight into the CMS without intermediate servers.
- Bulk sitemap import — sitemap data can be imported from CSV, JSON, or Excel, drag-and-dropped into the window; the importer streams and normalizes the file and builds the per-site context that later drives topic, category, and linking decisions.
- Internal linking engine with graph visualization — the
linkingdomain builds SEO-aware internal links between articles, and the frontend renders the result as an interactive graph usingxyflow+dagre, so the user can see and edit the structure visually. - Prompt library with a Monaco code editor — reusable prompt templates are authored in a Monaco editor (the same engine VS Code uses) with full syntax affordances, so the user can iterate on prompts as first-class artifacts rather than one-off strings.
- Per-provider AI usage and cost tracking — every completion records token counts and cost against the chosen provider, and the stats domain aggregates usage over time so the user sees exactly what each article and each run actually cost.
- Desktop-native integration — system-tray presence with Show / Hide / Quit, single-instance lock so a second launch refocuses the existing window, drag-and-drop file handling, native OS notifications via
beeep, and full window lifecycle wired to Uber FxOnStartup/OnShutdownhooks. - Rich multi-surface UI — TipTap for article editing, Monaco for prompt authoring, xyflow for the linking graph, TanStack Table for bulk operations, and a cohesive shadcn-ui + Radix design system across every screen — all statically exported from Next.js 15 and embedded in the Go binary.
| Layer | Technology |
|---|---|
| Backend Language | Go 1.25 |
| Desktop Framework | Wails v2 (Go ↔ web bindings, embedded frontend, system-tray) |
| Dependency Injection | Uber Fx — with lifecycle hooks tied into Wails OnStartup / OnShutdown |
| Database | SQLite via ncruces/go-sqlite3 (pure-Go, tetratelabs/wazero runtime, no CGO) |
| Query Builder | Masterminds Squirrel |
| Migrations | Pressly Goose v3 (embedded in the binary) |
| Logging | Zerolog |
| AI Providers | OpenAI v3, Anthropic Go SDK, Google Generative AI Go SDK |
| Networking | go-resty/resty/v2, custom proxy transport (direct · HTTP · SOCKS5 · Tor) |
| System Integration | getlantern/systray, gen2brain/beeep (notifications), adrg/xdg (paths), OS keychain for secrets |
| Excel / Import | xuri/excelize, streaming CSV / JSON parsers |
| JSON Schema | invopop/jsonschema — for LLM structured outputs |
| Frontend Framework | Next.js 15 (static export) + Turbopack, React 19, TypeScript |
| Styling / Components | Tailwind v4, shadcn-ui + Radix UI, Framer Motion, Sonner |
| Rich Editing | TipTap (article editor) + Monaco (prompt editor) |
| Graph Visualization | @xyflow/react + dagre (internal linking graph) |
| Data UI | TanStack Table, Recharts |
| Packaging | Wails build → single native Windows executable, zero CGO, embedded migrations and frontend |
Screens and feature walkthroughs, ordered roughly along the user journey — from first opening the app, through configuring providers and sites, to generating and publishing articles.
In the screenshots below, blurred regions contain the client's personal or operational data (API keys, site URLs, article titles, internal categories). The blur is deliberate and only affects those regions — the surrounding UI is live.
Register any supported AI provider — OpenAI, Anthropic, or Google Gemini — choose a model from the set Postulator supports, and paste your own API key. The key never touches the SQLite database: it goes straight into the operating system's secure keychain, so a copied data file reveals nothing about the user's credentials. The first shot shows the providers list; the second is the same flow from inside the provider-creation modal.
The prompt library is where reusable instructions for the AI live. On first launch the user already finds a curated set of Built-in templates shipped with the app — worked examples for every use case (articles, linking, sitemap generation, and more). They can be studied, duplicated, and used as starting points for the user's own prompts.
Users don't write raw system and user messages. They write instructions describing who the AI is and what it does, and the prompt's category (article, linking, sitemap, etc.) decides which context fields appear below — tone, language, target word count, and so on. Defaults are set once on the prompt and can be overridden per run. Because the shape and count of the context fields is driven by the chosen category, every prompt lands exactly in the pipeline stage it was built for and can never be misused.
The user's pool of connected WordPress sites and everything that touches a single site — credentials, health, per-site dashboard, and the site-level health history.
Sites pool — the main surface. The user's full pool of connected WordPress sites in one place: add, remove, edit credentials, run health probes, and drill into any single site in one click. Every column is live data pulled from the site's REST API.
Add-site modal. Paste a WordPress Application Password, the publishing user the articles should be posted under, the site URL, and a friendly display name for the local pool. No OAuth dance, no plugin install — Postulator authenticates against the stock WordPress REST API.
Per-site dashboard. Everything about one site in one place: status, aggregated stats, last activity, and quick navigation into that site's articles, jobs, categories, topics, and sitemap. This is the default landing surface once the user picks which site they want to work on.
Site health history. Every health probe feeds a timeline chart that the user can scope to any period; the table underneath lists response time, status code, payload, error text, and timestamp for every probe, with full-text search for drilling into a specific incident.
The sitemap module is the heart of the app. A Postulator sitemap is a full graph of a WordPress site where every node is a page (not an article) — title, slug, description, keywords, status, parent, and children. Everything downstream (linking, content generation, article jobs) is driven by the sitemap tree.
Four ways to create a sitemap. The entry modal. Manual starts an empty tree with just the root home node and leaves everything else to the user. Import loads an existing sitemap from CSV, JSON, or Excel. AI Generate picks a prompt from the library and lets the AI draft a complete page architecture from scratch. Scan crawls the user's own connected site and mirrors its live structure — hierarchy, parent/child links, and per-page status — into a fresh sitemap.
Import mode in action. The user has chosen a local JSON file. The modal ships with inline format documentation for each supported format, so the user can see exactly how Postulator expects a CSV / JSON / Excel file to be structured before building the import.
The sitemap editor canvas — the heart of the app. Built on React Flow. Every sitemap interaction lives here: panning, zooming, node creation, reshaping, bulk selection, a persistent quick-action toolbar, a status-color legend for page states, and contextual tooltips on every affordance.
Command palette. Every editor action and every shortcut in one searchable surface, so the entire canvas is navigable from the keyboard without touching the mouse.
Node editor. Set the page title, URL slug, an optional description, and the list of target keywords (addable and removable inline). From the same modal the user can delete the node — cascading to its children — or spawn a new child node with one click. Every field is auto-saved.
Bulk node creation. One textarea, one row per node — slug Title — and a hotkey to materialize the whole batch at once. A fast path for hand-building a subtree from a prepared outline.
AI-powered node generation. Pick a provider and a library prompt, feed in raw material (titles, keywords), optionally pass in the existing sitemap hierarchy for context, and optionally cap the max depth of the generated tree (0 = no limit). The AI returns a proposed architecture — pages, slugs, parent/child layout — ready to drop into the canvas.
Linking mode — the editor's second hat. The graph is the same but the toolset is different: instead of building pages, the user is wiring internal links between them. Inspect every incoming and outgoing link for a page, author new ones by hand, or hand the job to the AI — give it a prompt and the per-page context, and it proposes a link graph that matches topical relevance and hierarchy, with configurable caps on incoming and outgoing counts per page.
AI content generation. The step that actually fills the pages with articles. Pick a provider, pick a library prompt, and the AI walks the hierarchy — all eligible nodes, or just the current selection — and generates content for each. Linking can be applied in one of two strategies: pre-built uses the links authored in Linking mode and drops them verbatim as text is written; post-pass runs content generation first, then a second AI pass over the finished corpus — slower, but the links land with full awareness of what was actually written instead of only what the sitemap planned.
The second half of the generation modal — parameters that didn't fit in one viewport. Treat this and the previous shot as a single screen split across two captures.
The hero flow — AI generation running live against a sitemap. The job descends the tree in carefully batched passes instead of generating every page in one request, for two reasons: to stay inside each provider's context window, and to guarantee that parent pages exist before their children do — a child page cannot reference a parent that has not been generated yet. Progress and per-node status stream back into the canvas in real time. (For the recording I ran this against example.com because I didn't have a live client site on hand, so every node ends with a red error badge — generation itself worked, there was simply nowhere for the finished pages to be published.)
The article layer sits on top of the sitemap. In Postulator a piece of content is an Article (deliberately not a WordPress "post") — a first-class entity with its own lifecycle, stats, publication status, and edit history. Articles can be authored manually, generated one at a time from a library prompt, or produced in bulk by a background job.
All articles for the current site. The full article table: inline editing, per-article stats, publication-status toggling, and bulk actions across the whole table. This is where the user sees everything that has been generated or authored for a site, in one place.
Manual article editor. A full authoring surface for when the user wants to skip AI generation and write an article by hand. Rich text or raw HTML, live Preview, media upload, categories and tags, publication-status control, and a basic SEO probe that shows how the article will look in a search-result listing.
The automation surface — background article jobs. This is where bulk generation is orchestrated. By the time the user reaches this modal, the upstream rules are already in place: the prompt library for tone, language, and category-aware context fields; the topic list as first-class SEO-optimized titles; and the site as the destination. Here the user picks a configured provider, chooses how the job should consume topics, and sets the schedule.
Two topic strategies. Unique guarantees that once a topic has been used, the job remembers it and never reuses it — so a job that runs forever still produces strictly unique titles. Reuse with Variation feeds the same topic back in round-robin but asks the AI to generate variations on the theme rather than the literal title, letting a single topic seed dozens of non-overlapping articles.
Four run modes. Run once manually, run once at a scheduled future time, run on a recurring interval, or run on specific days of the week at specific times. The two recurring modes accept an optional jitter that spreads publication timestamps so a batch of generated articles doesn't all land on the wall clock at once.
Validation toggle. When enabled, generated articles land in WordPress in Draft status instead of being published immediately, so a human reviews before anything goes live.
Application settings. Dashboard auto-refresh interval (or fully off), and global site health-checking as an app-wide feature: enable it, set the interval, and wire up OS-native notifications. Once enabled, any site that has health-check turned on participates — and the moment a probe catches a site that is down or returning errors, the user gets alerted at the desktop level, not just inside the app.
Proxy settings — where network invisibility is switched on. Toggle the proxy layer on or off, register one or many SOCKS5 endpoints, and pick a routing mode: Single funnels every outbound call through whichever registered endpoint is currently healthy, Multi distributes requests across the whole registered pool. If the user has the Tor Browser running, Postulator auto-discovers its local proxy and routes traffic through the Tor network transparently — no manual address configuration. The anonymity check button runs a live probe and reports which exits the traffic is currently leaving through, how long the circuit has been up, and whether the chain is actually working end-to-end.
- Shipping a polished desktop product end-to-end — this was the first desktop project I took all the way from an empty directory to a turnkey delivery. Packaging a Wails app forces you to care about things server work never makes you think about: the window lifecycle, system-tray UX, single-instance locking, OS-level notifications, secret storage via the keychain, XDG paths, and cross-compile constraints. None of that work is flashy, but skipping any of it breaks the user's trust in the product the first time they close and reopen it.
- A rich multi-surface UI was the product — this was my first project where the interaction surface was not a thin admin screen on top of server logic, but the actual value proposition. Building a coherent context-authoring flow across TipTap, Monaco, xyflow, TanStack Table, and dozens of shadcn screens taught me where state belongs, when to lift it, and how much state shape matters when the user is composing complex inputs (site context, prompt templates, linking rules) across many screens in one session.
- A unified contract over three AI vendor SDKs — OpenAI, Anthropic, and Google Gemini each have their own message schema, streaming format, structured-output mechanism, and error model. Designing a single provider interface that is thin enough to stay honest but rich enough to be useful meant sitting between the three SDKs for a while and carefully deciding what to normalize, what to pass through, and where to let the caller see the native shape anyway.
Completed contract project, delivered to the client and running in their content workflow. The application is packaged as a single native Windows executable with embedded migrations and frontend; schema upgrades are handled transparently on the next launch, and no external hosting or infrastructure is required on the client's side.
Built by David Movsesian
























