Docs

Operating manuals

The full set of guides for self-hosting Winnow, integrating it with your stack, and running the hosted SaaS. Sourced directly from the repo's docs/ folder — what you see here is what ships in the latest release.

LLM Product Intelligence Knowledge Base (PIKB)

A pattern for building a persistent, evolving Product Intelligence system using LLMs.


The Core Idea

This document defines a Product‑focused adaptation of the LLM Wiki pattern. Instead of transient retrieval or summarisation, an LLM incrementally builds and maintains a Product Intelligence Knowledge Base (PIKB) that compounds insight over time and supports evidence‑based product strategy and ideation.

The PIKB is designed to:

  • Accumulate insights from diverse sources (customers, sales, market, competitors)
  • Maintain an evolving, synthesised understanding of the product space
  • Anchor interpretation and ideation to explicit strategy and constraints
  • Surface opportunities and product ideas grounded in evidence

The LLM maintains the system. Humans provide judgement, strategy, and direction.


Architecture Overview

The PIKB consists of three layers:

1. Raw Sources (Immutable)

A curated collection of unmodified source data:

  • Customer interviews and feedback
  • Win/loss analysis
  • Usage and behavioural data
  • Market research and benchmarks
  • Competitor intelligence
  • Internal strategy and roadmap documents

Raw sources are never edited or summarised in place.


2. Product Intelligence Wiki (LLM‑Maintained)

A directory of Markdown files generated and continuously maintained by the LLM. This layer contains:

  • Synthesised insights
  • Customer and market understanding
  • Problem statements and opportunity areas
  • Competitive positioning
  • Product ideas and enhancement candidates

The wiki represents the current best understanding of the product reality and evolves as new evidence is ingested.


3. Schema (LLM Operating Manual)

A configuration document (e.g. AGENTS.md or CLAUDE.md) that defines:

  • Wiki structure and conventions
  • Ingestion workflows per insight type
  • Rules for synthesis and cross‑referencing
  • Guardrails for opportunity detection and idea generation

This schema transforms a general LLM into a disciplined Product Intelligence system.


Strategy as a First‑Class Constraint

Before ingesting insight data, the wiki must be seeded with baseline product reality. These foundations anchor interpretation and prevent unconstrained ideation.

Required Baseline Files

  • product_proposition.md
  • vision_and_strategy.md
  • lifecycle_model.md
  • target_markets.md
  • constraints_and_assumptions.md

These documents are treated as authoritative context and must be explicitly referenced when evaluating insights, opportunities, and ideas.


Example Wiki Structure

wiki/
  index.md
  log.md

  foundations/
    product_proposition.md
    vision_and_strategy.md
    lifecycle_model.md
    target_markets.md
    constraints_and_assumptions.md

  customers/
    personas/
    segments/
    jobs_to_be_done/
    unmet_needs/

  problems/
    problem_statements/
    pain_points/

  insights/
    customer_insights.md
    sales_insights.md
    usage_insights.md
    market_insights.md
    competitor_insights.md

  competitors/
    competitor_profiles/
    comparison_tables/

  opportunities/
    opportunity_backlog.md
    validated_opportunities/
    discarded_opportunities/

  experiments/
    hypotheses.md
    tests.md
    outcomes.md

  recommendations/
    product_ideas.md
    enhancement_candidates.md

Core Operations

Ingest

When new raw sources are added, the LLM:

  1. Reads and interprets the source
  2. Extracts key signals and patterns
  3. Updates relevant wiki pages
  4. Strengthens or challenges existing beliefs
  5. Records changes in the log

Insights are never isolated; they are always integrated into the broader product understanding.


Query

Users query the wiki rather than raw data. Example questions:

  • Which customer problems recur most often?
  • Where does evidence conflict with our assumptions?
  • Which opportunities are best supported by insight?

High‑value analyses generated during queries are written back into the wiki so that learning compounds.


Lint (Health Checks)

Periodic maintenance passes identify:

  • Contradictory claims
  • Stale or superseded insights
  • Repeated pain points without opportunity framing
  • Opportunities lacking recent evidence
  • Orphaned or weakly connected pages

This keeps the PIKB current and decision‑ready.


Opportunity‑First Product Discovery

Ideas are not generated directly from insights. Instead, the LLM is trained to identify opportunities by detecting tension between:

  • Customer evidence
  • Strategic intent
  • Market dynamics
  • Current solution coverage

Opportunities are structured artifacts, each linked to supporting evidence and strategy.


Disciplined Idea Generation

Product ideas and enhancements are proposed only when:

  • A validated opportunity exists
  • Multiple independent evidence sources support it
  • The idea aligns with strategy and lifecycle stage
  • Constraints and risks are explicitly documented

Each idea includes:

  • Linked opportunity
  • Problem addressed
  • Solution hypothesis
  • Expected impact
  • Risks and unknowns
  • Evidence strength

Low‑confidence ideas are flagged rather than obscured.


Indexing and Traceability

index.md

A structured catalogue of all wiki content with short summaries. Used by the LLM for navigation and synthesis.

log.md

An append‑only chronological record of ingestions, analyses, belief changes, and lint passes. This provides historical context for decision‑making.


Why This Works

Product knowledge systems fail when maintenance effort exceeds value. In the PIKB model:

  • Bookkeeping is automated
  • Cross‑references are maintained continuously
  • Synthesis improves over time
  • Strategic memory is preserved

The LLM handles maintenance and synthesis. Humans focus on judgement, prioritisation, and leadership.


Final Note

This is a pattern, not a product.

When implemented with clear schema rules and strong strategic anchors, a Product Intelligence Knowledge Base becomes:

  • A living product memory
  • A disciplined opportunity engine
  • A guard‑railed product idea generator

The LLM maintains the system. Product leaders do the thinking.

Winnow CLI — User guide

A plain-English walkthrough of using Winnow from the command line. This guide assumes you've already installed it (see the README's Install section) and are looking for the "now what?" answer.

If you'd rather click around in a browser than type commands, jump to the Web UI guide. Both surfaces talk to the same project — switch freely between them.

If Python isn't your thing, the Docker walkthrough gets you running with a single docker run — same CLI, same Web UI, no Python install on the host. The container also dispatches CLI commands directly: docker run --rm winnow:latest winnow status works out of the box.


What Winnow is, in one paragraph

Winnow is a Product Intelligence Knowledge Base. You feed it raw evidence (sales call notes, support tickets, research transcripts, competitor intel), and it maintains a curated wiki of insights, problems, opportunities, and recommendations on top. The wiki is edited by the LLM, never by hand. You stay in the loop by reviewing proposed changes (--dry-run), inspecting the diff, and committing the ones you want to keep.

The whole project lives in a single directory:

  • raw/ — every piece of evidence you've ever fed in. Append-only.
  • wiki/ — the LLM-maintained synthesis. Insights, customers, opportunities, recommendations — and a small set of foundations that anchor everything (your strategy, target markets, lifecycle, product proposition, constraints).
  • .winnow/ — bookkeeping (state, log, proposals). Mostly leave this alone.

Starting a project from scratch

mkdir my-product-intel && cd my-product-intel
winnow init

This creates the raw/, wiki/, .winnow/ directories and writes five foundation stub files under wiki/foundations/. These are the most important files in your project. They define what your product is, who it's for, what it isn't, and what constraints shape every decision.

Open each foundation file and replace the TODO content with the real thing. There are five:

  • product_proposition.md — what you sell, to whom, for what outcome
  • vision_and_strategy.md — where you're heading and how
  • target_markets.md — who counts as a customer; who doesn't
  • lifecycle_model.md — the stages a customer moves through
  • constraints_and_assumptions.md — the things you're treating as fixed

Don't overthink these. A first draft is enough — the LLM will flag things that don't fit, and you'll refine them via the reconcile flow (more on that below) as decisions and delivery play out.

Configure Winnow:

winnow setup

The walkthrough prompts for your Anthropic / OpenRouter API key, synthesis + selector models, and offers to generate a bearer token (used by the webhook ingestion API, the MCP HTTP transport, and winnow gateway). Pressing Enter accepts the default shown in [brackets]. You can re-run winnow setup any time to rotate or update values.

You're ready.

The non-interactive path. Already have keys in ANTHROPIC_API_KEY / OPENROUTER_API_KEY env vars? Skip the walkthrough — winnow setup --non-interactive reads them directly into the SQLite config store at .winnow/config.db. This is also what the Docker image's entry-point runs on first boot; see docs/docker-deployment.md.


The daily flow

The most common loop is short:

  1. Drop new evidence into raw/. Sub-folders are sales/ · customer_success/ · support/ · research/ · market/ · competitors/ · systems/ · decisions/ · delivery/. Supported formats: .md, .txt, .pdf, .docx. Plain text needs no frontmatter or structure — just the evidence. PDFs and Word docs are converted to text in-memory at ingest time (the original binary stays on disk). Four equivalent input paths: drag-and-drop in your editor, the Create evidence button on /raw in the Web UI (WYSIWYG markdown or PDF / DOCX upload), Webhook ingestion (n8n / Zapier / Make, text only), or Cloud-sync ingestion (Dropbox / iCloud / Google Drive / OneDrive). Pick whichever fits the moment — winnow ingest treats them all the same.

  2. See what Winnow thinks.

    winnow ingest --dry-run
    

    This reads the new evidence, decides what wiki pages should change, and prints the plan without writing anything.

  3. Run for real.

    winnow ingest
    

    Same plan, this time applied to wiki/.

  4. Review and commit.

    git diff wiki/
    git add wiki/ && git commit -m "ingest: <what changed>"
    

That's it. Repeat as new evidence comes in.

Two specialised cousins

winnow ingest is the general path. There are two narrower commands for specific kinds of evidence:

  • winnow decide — for decisions. Reads only raw/decisions/. Writes to wiki/insights/decisions/.
  • winnow deliver — for delivery outcomes. Reads only raw/delivery/. Writes to wiki/insights/delivery/.

Use them when you're explicitly logging "we decided X" or "we shipped Y" — the prompts are tuned for those flows. Otherwise, ingest covers the rest.


Discovering opportunities

Once you've ingested enough evidence to have a few insights, run:

winnow discover --dry-run
winnow discover

This scans the wiki for tensions — recurring pains, contradictions, gaps — and proposes opportunities. Each opportunity has cited evidence. You can scope a run with --limit N if you want a slower introduction.

Discover is opportunity-first by design: you don't go from a single piece of evidence straight to "let's build a feature." The opportunity is the unit that earns a recommendation.


Generating recommendations

Once you've validated an opportunity (in your head, in a meeting, or by deciding to invest in it), run:

winnow recommend --opportunity opportunities/enterprise-sso-gap

Or run it without --opportunity to let Winnow pick one for you.

Recommendations carry an explicit confidence, risks, and alignment with strategy. They're proposals, not facts. You can ignore them, refine them, or build them.


Reconciling foundations

The five foundation files are constraints, not suggestions. They shape every interpretation Winnow makes. So they only change for two reasons:

  • A decision has been documented (you logged something to raw/decisions/).
  • Delivery has changed reality (you logged something to raw/delivery/).

Run:

winnow reconcile --dry-run
winnow reconcile             # writes a proposal — does NOT touch foundations yet

Reconcile is deliberately a two-step workflow: it proposes changes to your foundation files and saves them as a sidecar in .winnow/proposals/<id>.{json,md}. It does not modify the wiki.

Then you review:

winnow reconcile --list      # see pending proposals

Open the .md file, read the proposed changes. If they look right:

winnow reconcile --apply <id>

If not:

winnow reconcile --discard <id>

This guardrail exists because foundations propagate. Updating them without explicit human review would let one weird Sunday-afternoon LLM call drift your strategy.


Keeping the wiki healthy

winnow lint

Static checks — no LLM, no API spend. Catches:

  • Foundation files that still have stub content
  • Missing required frontmatter
  • Orphan links (pages referencing other pages that don't exist)
  • Opportunities with no evidence
  • Recommendations not tied to an opportunity
  • Cloud-sync conflict-file litter (e.g. Dropbox (conflicted copy) files lingering in raw/)
  • Corrupt PDFs / DOCX that won't parse, and binaries that extract to no text (likely scanned PDFs that need OCR upstream — Winnow doesn't OCR for you)
  • And so on.

Run it any time. Run it before commits. CI it if you want.

For the smarter version that costs API tokens:

winnow lint --deep

This adds an LLM audit that surfaces things only a human-or-LLM could see — contradictions across pages, stale insights overtaken by newer evidence, weak connections. Useful occasionally, not every commit.


Three more useful commands

winnow status     # snapshot of project state
winnow log -n 20  # last 20 operations and what they touched
winnow reindex    # regenerate wiki/index.md (Winnow does this for you, but you can force it)

Configuring Winnow

Runtime config (LLM API keys, bearer tokens, model picks, rate limits) lives in a SQLite store at .winnow/config.db (Inc 27). Three places mutate it; pick whichever fits:

# Interactive walkthrough — defaults shown in [brackets].
# Re-run any time to rotate keys / regenerate tokens.
winnow setup

# Per-key CRUD from the terminal — JSON-decoded so `4` lands as
# int, `true` as bool, plain strings round-trip. `list` redacts
# anything ending `_api_key` plus `api_tokens`.
winnow config get   selector_model
winnow config set   selector_model anthropic:claude-haiku-4-5
winnow config set   max_concurrency 4
winnow config set   api_tokens '["tok-a","tok-b"]'
winnow config list
winnow config unset selector_model

# Browser surface — the Web UI's `/settings` page (Inc 27d).
# Tabbed into Models / LLM provider keys / Bearer tokens /
# Operational. Same DB; live changes apply without a restart.
winnow ui

The DB is the source of truth at runtime. Env vars (ANTHROPIC_API_KEY, OPENROUTER_API_KEY, WINNOW_API_TOKENS, etc.) are read on the first Config.load() call after a project is created — values get migrated into the DB and the env vars are dormant thereafter. Two exceptions stay live: WINNOW_TRUSTED_PROXY and WINNOW_PROJECT_ROOT are read at every boot because they shape how the backend boots, not what config it loads.


Connecting an AI builder via MCP

winnow mcp        # start the MCP server on stdio (blocks until stdin closes)

winnow mcp exposes 7 read-only tools — list_recommendations, get_recommendation, list_opportunities, get_opportunity, get_wiki_page, get_foundations, list_recent_changes. AI builders (Claude Desktop, Cursor, Claude Code, etc.) spawn this as a subprocess and call the tools when they need wiki context.

You normally don't run winnow mcp by hand — your AI builder runs it for you, configured via a JSON config file. The Web UI's Integrations page (/integrations) generates the config snippet for the major builders with the project root pre-filled, so you can paste verbatim.

For remote use (hosted SaaS, AI builder on a different machine), winnow ui exposes the same tools over HTTP at http://localhost:8000/api/mcp with bearer auth — see the README's MCP server section.


Chatting with Winnow from any OpenAI-shaped client

Open WebUI, Continue.dev, LibreChat, LobeChat — any client that speaks the OpenAI API can chat with Winnow:

winnow gateway
# Starting Winnow gateway at http://127.0.0.1:11434 …

The gateway exposes POST /v1/chat/completions and GET /v1/models at port 11434 (Ollama's default port — Open WebUI auto-discovers it). Each chat turn loads foundations

  • a recent sample of insights / opportunities / recommendations as system context, then generates a Winnow-flavoured response.

Configure your client's OpenAI base URL and API key:

  • Base URL: http://localhost:11434/v1
  • API Key: a bearer token from winnow setup / the Web UI's /settings page (Inc 27)

winnow appears as a model in the picker. Bearer auth gates everything; with no tokens configured in the DB, requests get 503 with a clear "tokens not configured" message. The gateway loads its config once at startup, so restart it after generating a new token.

For the CLI flag reference and the gateway's exposed routes, see the README's winnow gateway section. For a full Open WebUI walkthrough — Docker setup, docker-compose, Continue.dev / LibreChat / LobeChat / Cursor configurations, and troubleshooting — see docs/open-webui-integration.md.


Notifying external systems via outbound webhooks

winnow ingest / discover / recommend / reconcile runs can fire HTTP POSTs to subscribed URLs (n8n / Zapier / Slack / a custom service) on completion. Configure subscribers via the CLI (a dedicated editor on /settings is a deferred follow-on):

winnow config set outbound_subscriptions '[
  {
    "url": "https://n8n.example.com/webhook/winnow",
    "events": ["log.appended", "proposal.created"],
    "secret": "your-shared-secret"
  }
]'

Four event types: log.appended (every successful pipeline run), proposal.created (reconcile-propose lands a sidecar), proposal.applied (proposal applied via the Web UI's /proposals/[id] page), proposal.discarded (proposal discarded). Delivery is fire-and-forget — the pipeline doesn't wait. See the README's Outbound webhooks section for the envelope shape, HMAC verification snippet, and retry semantics. Diagnose delivery problems on the Integrations page (/integrations) in the Web UI.


Tips and pitfalls

  • Always start with --dry-run. It's free (well, it costs the same in tokens, but nothing is written). The diff is your quality gate.

  • Commit small. One ingest, one diff, one commit. If a run produces fifty changes you wouldn't want as one commit, split the raw evidence and run separately.

  • Foundations are sacred. Don't edit them directly to "fix" a reconcile proposal — instead, discard the proposal, fix the underlying decision/delivery doc in raw/, and re-run.

  • The log is append-only. Every operation writes to wiki/log.md — that's how you reconstruct what changed and why. Don't manually edit log.md; Winnow uses it for state.

  • Cost control. Each ingest, discover, recommend, reconcile, and lint --deep makes LLM calls. --dry-run and the deep-lint runs cost the same as the real thing. Static lint, log, status, and reindex cost nothing. Add a pricing map (winnow config set pricing '...') to see per-run USD estimates in the Web UI (see "Cost estimation" in the README's Configuration section); the CLI doesn't render dollars itself, but token totals show up in the operation result panels and on the dashboard's "Last operation" card.

  • Provider override. Pass --model anthropic:claude-sonnet-4-6 (or any other prefixed model) on any LLM-driven command if you want a different / cheaper / faster model than your stored default.

  • Conflict files from cloud sync. If you're feeding raw/ through Dropbox / iCloud / Google Drive / OneDrive, sync conflicts produce extra files (winloss (conflicted copy …).md). winnow ingest auto-skips Dropbox's distinctive pattern with a warning; winnow lint flags it (and Google Drive's (n) pattern) so you remember to clean them up. iCloud's bare 2 / 3 and OneDrive's device-suffix style are too generic to filter automatically — handle by hand.


What's next

Winnow Web UI — User guide

A plain-English walkthrough of using Winnow in the browser. The web UI sits on top of the same project as the CLI — you can use either or both, switch freely, and they always see the same data.

If you'd rather work from the terminal, see the CLI guide. The two share a mental model; the web UI just makes browsing and reviewing nicer.


When the web UI shines

Use the browser when you want to:

  • Browse the wiki — clicking around insights, opportunities, recommendations, and the back-references between them is faster than navigating files.
  • Review what a discover or reconcile run actually produced — proposals especially benefit from a side-by-side review pane with apply/discard buttons.
  • Watch long operations stream their progress in real time, rather than staring at a terminal scrollback.
  • Eyeball what's queued for ingestion, what's recently been ingested, and what's still pending.

The CLI is still the right call when you're scripting, doing many things back-to-back, or want a clean git diff before committing.


Starting the UI

winnow ui

That's it. One command boots the backend, the frontend, waits for both to come online, and opens your browser.

Useful flags:

  • --dev — use Next.js's hot-reload dev server instead of the production build. Slower first paint, faster subsequent edits.
  • --no-frontend — start only the backend (useful if you're running npm run dev in another terminal yourself).
  • --no-browser — start everything but don't open the browser (useful if you're sharing your screen and don't want the tab to pop).

Ctrl-C in the terminal stops both processes cleanly.

If winnow ui reports errors about missing node_modules or .next/, follow the install steps in the README's Web UI section — you need to run npm install && npm run build once before the production launcher will work.


The pages, in order

Dashboard (/)

A snapshot of the project: which directory you're in (top of the header), how many wiki pages exist by category, the raw worklist state (how many sources are new, revised, or unchanged since you last ingested), and the latest log entry.

Useful as a homepage; not where you actually do work.

Wiki (/wiki)

Browse every wiki page grouped by category — foundations, customers, problems, insights, opportunities, recommendations, etc. Clicking a page opens a detail view with the rendered markdown, the frontmatter sidebar (so you can see citations and links), and a back-references panel showing every other page that links to this one (via related:, evidence:, or opportunity: fields).

The wiki is read-only in the UI — pages get edited by Winnow's operations, never by hand.

Exporting a recommendation to an AI builder

When you open a recommendation page (e.g. /wiki/recommendations/ship-saml-sso), an Export to prompt button appears next to the type badge. Clicking it opens a side panel that assembles a paste-ready brief from the recommendation, its parent opportunity, the evidence the opportunity cites, and the five foundation pages — labelled as non-negotiable constraints so an AI builder treats them as guardrails.

The panel shows a metadata header (char count, included page count, any broken links) and a scrollable preview. Copy to clipboard drops the markdown into your buffer; paste it into Claude Code, Cursor, Lovable, or any other AI builder that takes markdown context. No LLM tokens are spent on the assembly — it's pure templating, instant and deterministic.

If a referenced page is missing (broken evidence link, missing foundation file), the panel surfaces it as a "missing" badge in the metadata header. The brief still copies cleanly with whatever context is available, so partial graphs don't block the export.

Raw (/raw)

Every piece of evidence under raw/, grouped by source type. Each row carries a status badge:

  • new — Winnow hasn't seen this file yet. Will be processed on the next ingest.
  • revision — file existed before but the contents have changed since the last ingest.
  • unchanged — already ingested, no edits since.
Creating evidence from the UI

A Create evidence button in the toolbar opens a dialog with two tabs:

  • Text — WYSIWYG markdown editor for typed-up evidence (call notes, interviews, decision docs). Output is canonical markdown. Source defaults to manual-ui.
  • Upload — file picker for .pdf and .docx (max 25 MB). The binary is preserved in raw/ untouched; the LLM reads extracted text at ingest time via pypdf / python-docx. Source defaults to manual-upload. Scanned PDFs need OCR before upload — Winnow won't OCR for you (lint flags zero-extraction binaries so you spot them).

Both tabs share the rest of the form: subdir, optional filename, optional source / external id overrides, and an Auto-ingest after creating checkbox that runs the synthesis pipeline once the debouncer's quiet window settles — useful when you want the LLM to chew on what you just dropped in without switching to the Operations page.

This is the fourth way to add evidence — alongside dropping a file in your editor, the webhook API, and cloud-sync ingestion. All four converge on "a file lands in raw/<subdir>/" — pick whichever fits the moment.

The receipts card

Whenever any receipts have landed via any input path, a small Recent receipts card appears at the top of /raw. Each row shows the subdir, source label (manual-ui, hubspot, n8n, etc. — makes the input path obvious at a glance), filename, external id if any, and timestamp. If the receipt asked Winnow to auto-ingest, the row also carries a status badge — scheduled (debouncer hasn't fired yet), pending / running (job in flight), done, or failed. Receipts that didn't request auto-ingest render no badge.

Cloud-sync providers can leave conflict files when the same file is edited on two devices. The lint page flags those as conflict-file-litter so you can clean them up.

Click any row to read the source. Sources are immutable — you can read but not edit. The Create form only writes new files; it never modifies existing ones.

Activity log (/log)

Every operation Winnow has ever run, append-only, newest-first. Filter by mode (ingest / discover / reconcile / etc.) and expand a row to see exactly which sources fed in, which pages were created/updated, and any belief changes that were noted.

This is your audit trail. It's also append-only on disk — wiki/log.md is the canonical version; /log just renders it.

Operations (/operations)

Where you run the LLM-driven commands. Six tabs across the top, one per operation:

  • Ingest — process new evidence
  • Decide — process decisions (raw/decisions/ only)
  • Deliver — process delivery outcomes (raw/delivery/ only)
  • Discover — scan the wiki and propose new opportunities
  • Recommend — generate recommendations from existing opportunities
  • Reconcile — propose updates to foundation files (always produces a proposal — never touches the wiki directly)

Each tab has its own form. Common pattern:

  1. Optionally pick a specific source path (Ingest / Decide / Deliver), opportunity (Recommend), or model override.
  2. Click Dry run to preview the plan, or Run for real to apply.
  3. Watch the live log stream as the LLM works. Long synthesis calls emit a "still working (Ns elapsed)…" heartbeat every ten seconds so you know the call hasn't stalled.
  4. When the operation finishes, the result panel shows what changed: pages created, pages updated, selector reasoning, belief changes.
  5. A Token usage card at the bottom of the result panel breaks down input / output / cache-write / cache-read tokens per phase (selector / synthesis / deep_lint), with per-phase and total rows. If you've configured a pricing map in the config DB (winnow config set pricing '...'), the card title gains "& cost", per-phase rows show the model + USD cost, the total row shows the run total, and the dashboard's "Last operation" card surfaces the same USD figure for the most recent run. Without it, the cost columns simply don't render — see the README's "Cost estimation" section to set rates up.

The page mirrors the active operation in the URL (?job=<id>&mode=…), so you can refresh during a long run and pick up where you left off, or share the link with a colleague who's also running this Winnow instance.

For Reconcile specifically, the result panel ends with a "Review proposal" button that takes you straight to the proposal review page (next section).

Proposals (/proposals and /proposals/[id])

When reconcile produces a proposal, it lands here as a pending sidecar. The list view shows each one with its summary, edit count, and which decision/delivery sources triggered it. Clicking through opens the review page:

  • The full proposal markdown (what's changing and why)
  • Per-edit breakdown with the operation type, the affected foundation file, the rationale, and the triggering sources
  • An Apply proposal button (writes the changes, appends a log entry, deletes the proposal)
  • A Discard button (deletes the proposal sidecar, no other changes)

Both buttons open a confirmation dialog before doing anything. The apply dialog reminds you to check git diff wiki/ after — Winnow doesn't auto-commit the changes.

This is the only place foundations get edited. Direct edits in your text editor still work, but they bypass the audit trail.

Lint (/lint)

The static health checks load on page open: foundation completeness, frontmatter shape, broken links, opportunities without evidence, etc. Pass/fail banner at the top, findings grouped by severity and code below.

Below that is a Run deep lint button. This costs API tokens — it asks the LLM to read every non-foundation page and look for contradictions, stale insights, unframed pains, and so on. Findings are advisory — they don't change the wiki, just surface things worth thinking about. The button stays available so you can re-run without leaving the page.

Integrations (/integrations)

The Integrations page is where you wire AI builders (Claude Desktop, Cursor, Claude Code, Lovable, anything else that speaks MCP) to Winnow's wiki, plus the OpenAI-compatible gateway for Open WebUI / Continue.dev / etc., plus outbound webhooks. The MCP-server card has four sections:

  • Status — Enabled / Disabled badge, full HTTP endpoint URL, tool count, recent-call count. Shows a "configure tokens" CTA when bearer tokens aren't set (the HTTP transport is fail-closed). The stdio transport works regardless of tokens.
  • Available tools — the 7 read-only tools the MCP server exposes (list_recommendations, get_recommendation, etc.) with one-line descriptions. The list is driven by the live server so it never drifts from what your AI builder will see.
  • Connect from your AI builder — three builder tabs (Claude Desktop / Cursor / Claude Code) with stdio + HTTP sub-tabs. The stdio snippet auto-fills the project's absolute path so you can paste it verbatim into your builder's MCP config. The HTTP snippet uses a <your-bearer-token> placeholder — copy the token from /settings → Bearer tokens → Generate token (the plaintext is shown once; copy it then). Helper text under each tab tells you which file to paste the snippet into.
  • Recent activity — newest-first list of the last 20 MCP tool calls (timestamp, tool name, elapsed ms, ok/error badge). Polls every 5 seconds while the page is open. Empty until you connect a builder. The buffer lives in memory; restarting winnow ui wipes it.

The MCP server is read-only by design — AI builders can pull wiki context but can't add evidence or edit pages. To add evidence from an AI builder session, paste the relevant content into the Create-evidence form on /raw.

OpenAI-compatible gateway

A separate panel on the Integrations page describes the winnow gateway command — an OpenAI-compatible chat-completions service that lets Open WebUI / Continue.dev / LibreChat / Cursor "chat with Winnow" via the OpenAI API. The card shows the default base URL (http://127.0.0.1:11434/v1 — port matches Ollama for auto-discovery), the start command (winnow gateway), and a pointer at the full integration walkthrough (docs/open-webui-integration.md). The gateway reads its bearer tokens from the same config DB the rest of Winnow uses — no env var needed.

The gateway runs as a separate process from winnow ui, so the panel is informational only — there's no live status query because the gateway lives at a different port and may or may not be running. Walkthrough steps for connecting Open WebUI in particular (Docker setup, model picker config, troubleshooting) live in the dedicated doc.

Outbound webhooks

A second card on the same page covers outbound delivery — Winnow notifying external systems (n8n / Zapier / Slack / a custom service) when a pipeline event fires. The card shows:

  • Subscribers — every URL configured in the outbound_subscriptions config row (winnow config set outbound_subscriptions '[…]' from the CLI; a dedicated editor on /settings is a deferred follow-on), with the events it's subscribed to and a signed / unsigned badge (signed means a HMAC-SHA256 secret is configured; the secret value never reaches the UI).
  • Recent deliveries — newest-first list of the last 50 delivery attempts. Per row: success / error badge with the HTTP status code, the event name, the destination URL, and the timestamp. Multi-attempt rows show the retry count. Polls every 5 seconds; the buffer lives in memory and wipes on restart.

When no subscribers are configured, the card surfaces a brief explainer pointing at the four event types and the secret opt-in. Useful for diagnosing "why isn't my n8n flow getting events?" without tailing stderr — fire a fresh ingest run, watch the row land in the feed (or fail loudly enough to spot the problem).

The dashboard (/) carries a small status card linking to this page so you can tell at a glance whether the integration surface is enabled.


A typical session

A first-time week might look like this:

  1. Monday morning: dropped a few sales call notes into raw/sales/ over the weekend. Open /operations, hit the Ingest tab → Dry run, watch the plan. Hit Run for real, watch the live log, see the new insights land. Pop to /wiki to skim them.

  2. Wednesday: there's enough material to find patterns. Open /operationsDiscoverRun for real. Let it propose five opportunities. Open /wiki/opportunities/ to read them.

  3. Friday: a leadership decision came in — "we're going upmarket, mid-market is no longer a focus." Drop a markdown note into raw/decisions/upmarket-pivot.md. Open /operationsDecide → run. Then Reconcile → run (this proposes baseline changes, not changes the wiki). Open /proposals, click the new proposal, review the proposed updates to target_markets.md, hit Apply.

You'd run lint occasionally, browse /log if you wanted to see what an op did last month, and check /raw whenever you wanted to know what was queued for ingestion.


A few tips

  • Dry-run first is always cheap insurance. The diff is your quality gate before anything writes.

  • The header shows your project root, truncated. Hover for the full path. Winnow finds the project from your current directory when you ran winnow ui, not from the URL — make sure you're in the right place when you start.

  • One backend = one project. If you want to look at two Winnow projects in two browser tabs, run two winnow ui invocations on different ports (winnow ui --port 8001, winnow ui --port 8002). Each opens its own browser tab.

  • You can't edit pages in the UI. That's intentional — edits-by-hand bypass the LLM's reasoning and the audit trail. Drop new evidence into raw/ instead, or accept/discard proposals.

  • The web UI is local-only by default. It binds to 127.0.0.1. Nobody else on your network can see it unless you explicitly run winnow ui --host 0.0.0.0 (and even then, you should set up bearer tokens if you do — see the README).

  • All commits are still your responsibility. Winnow writes files; git is yours. Run git diff wiki/ after each operation and commit when you're satisfied.


What's next

Webhook ingestion recipes

Worked examples for pushing raw evidence into Winnow from the major workflow platforms. Quick reference for the API contract is in the README's Webhook ingestion section.

One-off entry? If you just want to type or paste a single piece of evidence by hand, the Create evidence button on /raw in the Web UI is faster than wiring a webhook. The recipes below are for automation — recurring pushes from external systems. See the web user guide for the UI form.

All recipes assume:

  • The Winnow backend is reachable at http://127.0.0.1:8000. If you're running Winnow on a different host, swap the URL and bear in mind it's still bound to 127.0.0.1 by default — flip --host 0.0.0.0 on winnow ui (or run uvicorn directly with --host 0.0.0.0) to expose it.
  • You've configured at least one bearer token. The fastest path is winnow setup (interactive) or the Web UI's /settings page → Bearer tokens → Generate token (Inc 27). The Docker image auto-generates one on first boot and prints it via docker logs.
  • Your workflow tool can hit the host. For local dev with a hosted workflow tool (n8n cloud, Zapier, Make), run a tunnel — ngrok and Tailscale Funnel both work.

Common request shape

Every recipe boils down to a POST /api/raw/{subdir} with this JSON body:

{
  "content": "<markdown body — your formatted evidence>",
  "filename": "<optional — auto-generated if absent>",
  "source": "<your tool / system name>",
  "external_id": "<stable upstream id, e.g. HubSpot deal id>",
  "frontmatter": {
    "<extra-key>": "<extra-value>"
  }
}

Headers:

Authorization: Bearer <your-token>
Content-Type: application/json

Pick a subdir from the canonical taxonomy (sales, customer_success, support, research, market, competitors, systems, decisions, delivery).

Optional query params:

  • ?auto_ingest=true — debounce-and-fire winnow ingest after the configured quiet window (default 30s). Bursts collapse to one ingest run. The response gains auto_ingest_scheduled: true. See the Auto-ingest section in the README for the full contract.

n8n

n8n's HTTP Request node is the only node you need.

  1. Trigger — whatever fires your workflow (HubSpot deal stage change, RSS feed, schedule, manual webhook, etc.).
  2. Set / Function node — shape the data into a markdown blob. For a HubSpot "deal lost" workflow this typically reads from the incoming HubSpot payload and assembles a content string.
  3. HTTP Request node with these settings:
    • Method: POST
    • URL: http://winnow-host:8000/api/raw/sales (append ?auto_ingest=true to have Winnow run ingest automatically after the burst settles).
    • Authentication: Generic Credential Type → Header Auth. Create a credential with name Authorization and value Bearer your-token-here.
    • Body Content Type: JSON
    • Body (use n8n expressions to splice your data in):
      {
        "content": "{{ $json.markdown_body }}",
        "filename": "winloss-{{ $json.deal_id }}.md",
        "source": "hubspot",
        "external_id": "{{ $json.deal_id }}"
      }
      
  4. On retry — n8n retries on 5xx by default. Winnow's webhook handles retries safely: identical content returns 409 with the existing path. If you want n8n to treat that as success, add an On Error branch that ignores 409.

Tips

  • Templating in n8n. Build the content string in a Function node so you have full JS access to the upstream payload. Use template literals to assemble the markdown:
    const deal = $input.first().json;
    return [{
      json: {
        markdown_body: `# Lost ${deal.dealname}\n\n` +
          `**Stage:** ${deal.dealstage}\n` +
          `**Reason:** ${deal.closed_lost_reason}\n\n` +
          `${deal.notes_last_updated}\n`,
      }
    }];
    
  • Multiple sources from one workflow. Splice subdir into the URL via an n8n expression: /api/raw/{{ $json.subdir }}.
  • Skip dupes silently. Set the HTTP Request node's "Continue On Fail" to true and inspect the response code in a downstream branch. 201 = new, 409 = dupe, anything else = real error.

Zapier

Use the Webhooks by Zapier — POST action.

  1. Trigger — any Zapier-supported source.
  2. Code by Zapier (JavaScript) step — assemble the markdown content from the trigger payload. Output a single object with markdown_body, external_id, etc.
  3. Webhooks by Zapier — Custom Request step:
    • Method: POST
    • URL: http://winnow-host:8000/api/raw/sales
    • Data Pass-Through: false
    • Headers:
      • Authorization: Bearer your-token-here
      • Content-Type: application/json
    • Data (raw JSON, with Zapier merge fields):
      {
        "content": "{{markdown_body}}",
        "filename": "winloss-{{deal_id}}.md",
        "source": "hubspot",
        "external_id": "{{deal_id}}"
      }
      

Tips

  • Zapier doesn't natively format markdown. Build the markdown in the Code step and pass it through as a single markdown_body field. Newlines survive Zapier's templating.
  • Retry behaviour. Zapier retries 5xx but not 4xx by default. A 409 is treated as a hard failure unless you add a Filter step that branches on status code.
  • Auth. Zapier doesn't have first-class bearer-token support in the Webhooks action — you wire it through the Headers field. If you're worried about leaking the token in Zapier's UI history, use a Storage by Zapier or a service like Doppler / Bitwarden Secrets Manager.

Make (formerly Integromat)

Use the HTTP — Make a Request module.

  1. Trigger — any Make module.
  2. Set Multiple Variables module — assemble the markdown body from upstream data.
  3. HTTP — Make a Request module:
    • URL: http://winnow-host:8000/api/raw/sales
    • Method: POST
    • Headers:
      • Name Authorization, Value Bearer your-token-here
      • Name Content-Type, Value application/json
    • Body type: Raw
    • Content type: JSON (application/json)
    • Request content (with Make expressions):
      {
        "content": "{{1.markdown_body}}",
        "filename": "winloss-{{1.deal_id}}.md",
        "source": "hubspot",
        "external_id": "{{1.deal_id}}"
      }
      
    • Parse response: Yes (so 4xx / 5xx pop into the Make error handler).

Tips

  • Make's iterator gotcha. If you're sending one webhook per row in an upstream collection, drop an Iterator before the HTTP module. Otherwise Make tries to send the whole array as a single body and Winnow rejects it (content must be a string).
  • Routing on response. Add a Router after the HTTP module with filters on bundle.statusCode to branch on 201 (new), 409 (dupe), or other (error).

Custom scrapers

For ad-hoc scripts (e.g. a cron job that pulls competitor release notes, scrapes pricing pages, mirrors a Discourse forum):

import datetime as dt
import requests

WINNOW_URL = "http://127.0.0.1:8000"
TOKEN = "your-shared-secret-here"

def push_raw(subdir: str, content: str, *, source: str, external_id: str,
             filename: str | None = None) -> requests.Response:
    payload = {
        "content": content,
        "source": source,
        "external_id": external_id,
    }
    if filename:
        payload["filename"] = filename
    resp = requests.post(
        f"{WINNOW_URL}/api/raw/{subdir}",
        json=payload,
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    if resp.status_code == 409:
        # Idempotent retry — content already on disk. Treat as success.
        return resp
    resp.raise_for_status()
    return resp


# Example: mirror a competitor's release notes RSS into raw/competitors/
for entry in fetch_release_notes("https://example.com/releases.xml"):
    push_raw(
        "competitors",
        content=f"# {entry.title}\n\n{entry.body}\n",
        source="competitor-rss",
        external_id=entry.id,
    )

The 409-as-success handling lets the cron job re-run end-to-end without producing duplicates — every entry hashes the same on each poll, and the second poll onwards is a no-op for unchanged items.


Troubleshooting

SymptomLikely cause
503 Service Unavailable with "no tokens configured"No bearer tokens stored in .winnow/config.db. Run winnow setup (interactive) or hit the Web UI's /settings page → Bearer tokens → Generate token. Live changes apply without restart (Inc 27).
401 UnauthorizedHeader missing, malformed (Authorization: Bearer <token>), or token doesn't match any configured value. Check for trailing whitespace in the token.
404 Not Found on POST /api/raw/<subdir>Subdir isn't in the taxonomy. See the canonical list above.
400 Bad Request with "filename must match…"Caller-supplied filename failed validation (slashes, leading dot, bad extension, special chars). Drop the filename field to let Winnow auto-generate one.
413 Payload Too LargeBody exceeds 1 MB. Trim or split.
409 Conflict with path in the responseIdempotent retry — same body hash already on disk. Treat as success.
409 Conflict with "different content"Different content with the same filename. Pick a different name or omit filename to auto-generate.
Webhook lands but winnow ingest doesn't see itCheck .winnow/state.jsoningest only re-processes new / revised sources. The webhook write is treated as a new source on the next ingest run.

For the deferred items (HMAC verification, token-management UI, outbound webhooks, provider-specific templates) see the backlog at docs/implementation/to-do.md.

Cloud-sync ingestion

A second integration path alongside the webhook API: point a cloud-sync folder (Dropbox, iCloud, Google Drive, OneDrive) at Winnow's raw/ directory, and the existing winnow ingest flow Just Works.

No API tokens, no HTTP, no auth surface. Drop a file on any device, the sync provider gets it onto the box where Winnow runs, and the next ingest picks it up.

When to reach for this vs. the webhook API:

  • Cloud sync — humans dropping files, occasional manual scrapers, mobile / iPad workflows where curl is awkward.
  • Webhook API — anything programmatic / high-volume / from a workflow tool that already speaks HTTP (n8n, Zapier, Make).

Use both if you like. They write to the same raw/ tree.


The recommended pattern

Symlink raw/ to a folder inside your sync provider.

cd my-product-intel
mv raw raw.local                              # if raw/ already exists
ln -s ~/Dropbox/winnow-raw raw                # adjust the path for your provider
mv raw.local/* raw/ 2>/dev/null && rmdir raw.local

Now every file under raw/ is synced; wiki/ and .winnow/ (including the config.db SQLite store with your LLM keys and bearer tokens) stay local to this machine.

Why this shape:

  • .winnow/state.json is updated on every op (it tracks which raw files have been ingested at which hash). Keeping it out of the sync means no cross-device merge conflicts on the bookkeeping.
  • wiki/ is LLM-maintained. Multi-device editing of the wiki is a semantic problem — let one machine be the "ingest box" and use git or a separate process to share the wiki if you need to.
  • raw/ is append-only. Multiple devices writing to it is fine; the conflicts cloud sync produces are limited to "two people edited the same file at once," which is rare for raw evidence (you don't usually edit it after dropping it in).

Alternative: sync only one source type

If you'd rather have a strict separation between "stuff I drop in manually on this machine" and "stuff that comes from the cloud":

mkdir -p raw/inbox
ln -snf ~/Dropbox/winnow-inbox raw/inbox

raw/inbox/ becomes the synced bucket. The downside is mental overhead — you have two places to look. The recommended pattern above is simpler unless you have a specific reason to split.


Per-provider recipes

The mechanics are nearly identical across providers. The differences are mostly:

  • Where the sync folder lives by default
  • What the conflict-file naming convention looks like
  • Whether the provider uses placeholder ("on-demand download") files

Dropbox

ln -s ~/Dropbox/winnow-raw raw

Default sync location: ~/Dropbox/. Conflict files look like <name> (Paul's conflicted copy 2026-04-29).md.

If you have Smart Sync / online-only files turned on, mark the winnow-raw folder as "Make Available Offline" — Winnow needs to read the actual file content, not the placeholder.

iCloud Drive

ln -s ~/Library/Mobile\ Documents/com~apple~CloudDocs/winnow-raw raw

Or, if you've enabled Desktop & Documents in iCloud, drop the folder anywhere under ~/Documents/.

iCloud's conflict-file naming is not distinctive — it appends 2, 3 to the filename (e.g. winloss 2.md). That's too generic to filter automatically; if you see them, rename or delete by hand.

iCloud aggressively offloads files; mark the folder for "Always Keep on This Mac" via Finder so Winnow doesn't trip on placeholder files.

Google Drive

ln -s "/Users/$USER/Library/CloudStorage/GoogleDrive-<your-email>/My Drive/winnow-raw" raw

(Adjust the path for your Google Drive setup — locations vary between the official client and "Drive for Desktop.")

Google Drive uses (1), (2) for some auto-renamed conflicts (e.g. winloss (1).md). Mostly rare.

Google Drive also creates .DS_Store and the like; Winnow ignores anything that isn't .md or .txt, so they're harmless, just visual noise.

OneDrive

ln -s ~/OneDrive/winnow-raw raw

OneDrive uses device-suffix conflict naming (winloss-paul-mbp.md). Distinctive enough to spot at a glance but not always automatic-filter-friendly.

OneDrive's "Files On-Demand" feature uses placeholder files. As with the others, mark the winnow-raw folder for offline access so Winnow can read it.


Don't sync the whole project root

It's tempting to drop the entire my-product-intel/ directory into your sync provider. Don't.

  • .winnow/state.json is rewritten on every operation. With cloud sync, every device gets its own version, and conflict resolution is undefined.
  • wiki/log.md is append-only — but two devices appending in parallel produces a merge conflict the cloud provider can't resolve cleanly.
  • wiki/index.md is regenerated on every ingest. Same pattern.
  • wiki/'s contents are semantic, not file-level mergeable. Two devices both running winnow ingest in parallel could write contradictory updates to the same insight page.

If you want multi-device workflows on the wiki, use git. Let one machine be the "ingest box," push wiki changes to a remote, pull on the other devices.


Conflict-file handling

When two devices edit the same file at once, your sync provider produces a conflict file. Winnow handles them in two layers:

  • winnow ingest — silently skips the distinctive Dropbox pattern ((conflicted copy)) so the LLM doesn't double-count the same evidence. With --verbose (or via the live log on the web UI's /operations page) the skip is logged as a warning so you can see what was filtered.
  • winnow lint — flags Dropbox conflict files and Google Drive's (n) pattern (e.g. winloss (1).md) as conflict-file-litter warnings. Lint surfaces both because the litter shouldn't sit around in raw/ even if ingest can route around the Dropbox ones.

The patterns Winnow does not auto-detect:

  • iCloud's bare 2 / 3 suffix (e.g. winloss 2.md) — too generic to filter without false positives. iCloud users: spot them in Finder and rename or delete by hand.
  • OneDrive's device-suffix style (e.g. winloss-paul-mbp.md) — also too generic. Same story: handle by hand.
  • Google Drive's (n) pattern is matched by lint but not auto-skipped on ingest, because the same shape can appear in intentional human filenames (notes (1).md for v1, etc.). Lint warns; ingest still picks the file up.

The right resolution is almost always:

  1. Compare the conflict file with the original.
  2. If they're substantively the same, delete the conflict file.
  3. If they differ, merge by hand (or pick one) and delete the loser.

Troubleshooting

SymptomLikely cause
winnow ingest reports zero new sources, but I just dropped a fileSync isn't complete on this machine yet. Wait for the cloud-provider's status icon to go green.
Permission errors when reading filesSync provider is using on-demand / placeholder files. Mark the folder for offline access.
Files in raw/ look duplicated (winloss.md, winloss 2.md)Either iCloud's auto-rename pattern or a manual copy. Resolve by hand — Winnow doesn't auto-filter the bare-number suffix.
Two devices both ran winnow ingest and now wiki/ is a messDon't sync wiki/ — see the warning above. Recover via git: pick one device's wiki state and git checkout -- wiki/ on the other.
winnow status shows the same source as "new" after every ingestThe file is being touched by sync (re-downloaded, re-permission'd) which changes its hash. Check whether your sync client is mangling line endings or metadata; consider moving the file outside the sync, ingesting, then moving it back.

What's deferred

The post-v1 backlog at docs/implementation/to-do.md tracks related follow-ons that are explicitly excluded:

  • A winnow watch command that auto-detects newly-synced files and runs winnow ingest for you (cloud providers already raise desktop notifications on sync; this would mostly be a small ergonomics win).
  • Auto-ingest after the cloud-sync watcher fires (would share plumbing with the deferred webhook auto-ingest from Increment 10).
  • Two-way sync of wiki/ itself (semantic conflicts; out of file- level resolution's depth).

Open WebUI integration

Winnow's winnow gateway (Inc 25b–c) exposes an OpenAI-compatible chat-completions API at localhost:11434 so any OpenAI-shaped client — Open WebUI, Continue.dev, LibreChat, LobeChat, Cursor's OpenAI-compatible mode — can chat with Winnow as if it were a model. This page walks through the Open WebUI setup end to end; the same pattern works for the others.

What you'll get

A winnow entry in Open WebUI's model picker. Each chat turn:

  • Loads wiki/foundations/* + a recency-capped sample of wiki/insights/, wiki/opportunities/, wiki/recommendations/ as the system prompt (built fresh on every request — no manual context-pasting).
  • Treats the latest user message as a free-text recommendation request.
  • Calls Winnow's configured synthesis_model (Anthropic / OpenRouter — whatever the SQLite config store at .winnow/config.db carries).
  • Streams the response back as OpenAI SSE chunks.

Other modes (winnow/wiki Q&A, winnow/discover opportunity discovery) lift via model-name suffix in a follow-on increment; v1 is the recommendation flow.

Prerequisites

  • Winnow installed (pip install -e . from the repo, or pip install winnow once published).
  • A scaffolded project (winnow init) with at least the foundation files filled in.
  • At least one bearer token in the SQLite config store. Run winnow setup (interactive) or hit the Web UI's /settings page → Bearer tokens → Generate token (Inc 27). The same tokens gate the webhook + MCP HTTP surfaces, so if you've already configured them for those, you're done.
  • Open WebUI running locally — Docker or pip install both work, instructions below.

If no bearer tokens are configured, every gateway request returns 503 — tokens not configured. That's the fail-closed contract; it's not a bug.

Quick start (host-mode)

The simplest setup: gateway running on the host, Open WebUI in its own container talking to host networking.

1. Start the gateway

winnow gateway
# Starting Winnow gateway at http://127.0.0.1:11434 …
# Point Open WebUI / any OpenAI-compatible client at
# http://127.0.0.1:11434/v1 (Bearer token from `winnow setup` /
# the `/settings` page).

Default port 11434 matches Ollama's default — Open WebUI auto-discovers anything serving OpenAI-shaped endpoints there. Pass --port to override if you've already got Ollama running.

2. Start Open WebUI

docker run -d \
  --name open-webui \
  -p 3010:8080 \
  --add-host=host.docker.internal:host-gateway \
  -v open-webui:/app/backend/data \
  ghcr.io/open-webui/open-webui:main

Open http://localhost:3010 and create the first admin account (Open WebUI requires one user at install time).

The --add-host flag makes host.docker.internal resolve to the host's IP on Linux too; macOS/Windows Docker resolves it automatically. winnow gateway runs on the host's 11434, not inside the container.

3. Connect Open WebUI to the gateway

In Open WebUI:

  1. Click your avatar (bottom-left) → SettingsAdmin SettingsConnections.
  2. Under OpenAI API Connections, click + Add Connection.
  3. Fill in:
    • API Base URL: http://host.docker.internal:11434/v1
    • API Key: a bearer token from winnow setup / the /settings page
  4. Click Verify. You should see a green check; if not, see Troubleshooting below.
  5. Save.

4. Chat with Winnow

Back on the main Open WebUI screen:

  1. Click + New Chat.
  2. In the model dropdown, pick winnow.
  3. Ask anything — e.g. "What should we build next?" or "Where are we losing deals?".

You'll see the response stream in. Reload the wiki at any time; the next chat picks up the new context (foundations + recent sample are loaded fresh per request).

Docker compose (both in containers)

Once Winnow ships its own container (Inc 24+), this is the canonical setup. Today, Winnow runs on the host:

# docker-compose.yml
services:
  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    ports:
      - "3010:8080"
    environment:
      # Pre-fill the OpenAI connection so users skip the
      # admin-settings click-through. WEBUI_AUTH=False also
      # disables Open WebUI's own login (you've already
      # got bearer auth on the gateway).
      - OPENAI_API_BASE_URL=http://host.docker.internal:11434/v1
      - OPENAI_API_KEY=${WINNOW_API_TOKEN}
      - WEBUI_AUTH=False
    extra_hosts:
      - "host.docker.internal:host-gateway"
    volumes:
      - open-webui:/app/backend/data
    restart: unless-stopped

volumes:
  open-webui:

Run alongside winnow gateway:

# `your-token` = a bearer pulled from `winnow setup` / `/settings`.
WINNOW_API_TOKEN=your-token docker compose up -d
winnow gateway

Open http://localhost:3010. Open WebUI starts with the gateway already wired; pick winnow from the model picker.

Other OpenAI-compatible clients

The same pattern works for any client that takes an OpenAI base URL + API key:

  • Continue.dev (VS Code extension): in .continue/config.yaml add models: [- name: Winnow, provider: openai, model: winnow, apiBase: http://localhost:11434/v1, apiKey: <your-token>].
  • LibreChat: under Custom Endpoints add an OpenAI-style endpoint pointing at http://localhost:11434/v1 with the bearer token as API key.
  • LobeChat: Settings → Language Model → OpenAI → set the API Endpoint and API Key.
  • Cursor (OpenAI-compatible mode): Settings → Models → + Add Model → set the Base URL and API Key.

winnow shows up wherever the client lists discovered models.

Troubleshooting

HTTP 503 — "tokens not configured" — no bearer tokens stored in .winnow/config.db. Run winnow setup (interactive) or hit the Web UI's /settings page → Bearer tokens → Generate token. The gateway loads Config once at startup (it runs as a separate uvicorn process), so restart it after generating a token: kill the running winnow gateway and start it again.

HTTP 401 — "Invalid bearer token" — the token you pasted into Open WebUI doesn't match any stored token. Tokens are exact-match; check for trailing newlines or whitespace, or rotate via /settings and try the new value.

Open WebUI "Verify" fails with connection refused — the gateway isn't running, or it's bound to a port the container can't reach. From inside the Open WebUI container:

docker exec -it open-webui curl http://host.docker.internal:11434/v1/models

Should return the model list. If it doesn't, check that the gateway is bound to 0.0.0.0 (or pass --host 0.0.0.0) — by default it binds 127.0.0.1 which Docker's host-gateway can't reach on Linux.

"winnow" doesn't appear in the model picker — Open WebUI caches discovered models. Settings → Refresh models under Admin Settings → Connections, or restart the Open WebUI container.

The reply takes a while before anything appears — v1 of the gateway fakes streaming: it gets the full LLM response from Winnow's existing one-shot LLMClient, then chunks the result into SSE events. So the first chunk only arrives after the LLM has finished generating. Native streaming (where the user sees each token as the LLM produces it) is the top Inc 25 follow-on.

Chat seems to ignore prior turns — v1 only uses the latest user message. Conversation continuity (folding prior turns into the assembled context) is a follow-on. For multi-turn flows, keep questions self-contained.

Current limitations (v1)

  • No conversation history — only the latest user message is passed to the LLM. Prior turns in the same chat aren't in the context.
  • Faked streaming — chunks are emitted post-hoc from a one-shot LLM call. Visually similar to real streaming for short responses; longer responses sit silent until the LLM finishes.
  • Single model name (winnow) — mode-routing via suffixes (winnow/wiki, winnow/discover) is a follow-on.
  • No tool calls — the gateway doesn't expose Winnow's MCP tools to the chat surface. For tool-style integration, point at the existing MCP HTTP endpoint instead.
  • No /v1/embeddings — opt-in if Open WebUI's RAG features want it. Not needed for the chat-only flow.

Cross-references

Docker deployment

The fastest way to run Winnow without installing Python locally. The Dockerfile at the repo root produces a single image (~520 MB) that runs both the FastAPI backend (:8000) and the Next.js Web UI (:3000) inside one container, supervised by tini + a small bash entry-point.

The same image powers self-hosted use today and the hosted SaaS when Phase B of the Hosted-SaaS roll-out ships — running the same product code in both contexts is a deliberate locked decision (Inc 24a).

Prerequisites

  • Docker 20.10 or newer (multi-stage build + BuildKit by default).
  • A directory on the host to mount as the project root. Hold on to this — losing it loses your wiki.
  • An LLM provider keyANTHROPIC_API_KEY or OPENROUTER_API_KEY. The container boots without one (/api/status works), but winnow ingest will fail at the first LLM call. You can supply this on first run or later via the Web UI's /settings page once you have the bootstrap bearer token (see below).

Quick start (build + run from source)

The image isn't published to a registry yet. Build it locally from the repo root:

git clone <this-repo> winnow
cd winnow
docker build -t winnow:latest .

That takes 2–5 minutes on a first build (npm install + pip install + frontend compile dominate); subsequent builds reuse the cached layers.

Run it (first-boot — minimal flags, no secrets baked in):

mkdir -p winnow-data
docker run -d \
  --name winnow \
  --restart unless-stopped \
  -p 3000:3000 -p 8000:8000 \
  -v "$(pwd)/winnow-data:/data" \
  winnow:latest

Now grab the bootstrap bearer token from the container's first-boot logs:

docker logs winnow | grep -A 1 WINNOW_BOOTSTRAP_TOKEN

You'll see a block like:

============================================================
WINNOW_BOOTSTRAP_TOKEN
------------------------------------------------------------
3KsVUz1dVgMgL1aFzQUtSf2pj3hFtQvzGl7iMHNxd7Q
------------------------------------------------------------

Copy that value. It's your first-and-only bearer credential — the entry-point printed it once on first boot and the backend never returns it from any GET endpoint thereafter. Save it somewhere durable (password manager, secret store) before the log rotates.

Visit:

  • http://localhost:3000/settings — the Web UI's settings page. Paste your LLM provider key (anthropic_api_key / openrouter_api_key) and any operational tweaks. Use the bootstrap token in Authorization: Bearer <token> for webhook ingestion, the MCP HTTP transport, and winnow gateway.
  • http://localhost:8000/api/status — backend health JSON.

Behind the scenes, first boot:

  1. Scaffolds winnow-data/ (wiki / raw / foundations / etc.) via winnow init.
  2. Initialises winnow-data/.winnow/config.db (SQLite — Inc 27) from any env vars present.
  3. Generates the bootstrap bearer token and prints it to stdout.

Subsequent boots: the entry-point is a no-op for both — the DB stays authoritative across restarts.

Deterministic bootstrap (orchestrator deploys)

For automation that needs a known credential at boot, set WINNOW_BOOTSTRAP_TOKEN on the container — the entry-point uses that exact value instead of generating one. No-op on restarts (the DB already carries a token).

docker run -d \
  --name winnow \
  -v "$(pwd)/winnow-data:/data" \
  -e WINNOW_BOOTSTRAP_TOKEN="$(openssl rand -hex 32)" \
  -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
  -p 3000:3000 -p 8000:8000 \
  winnow:latest

Volume + persistence

The single mount point is /data inside the container. It maps to the Winnow project root: raw/, wiki/, .winnow/ (which holds config.db, state.json, and the inbox + log JSONLs), and the on-disk winnow.toml if you ever had one (frozen after 27b's migration). The container has no other persistent state — losing the volume loses your wiki AND your config (LLM keys, bearer tokens, model picks); keeping it across docker run invocations means a clean restart picks up where you left off.

To back up: snapshot the host directory you mounted (zip it, rsync it, commit it to git). Toggling auto_commit = true (via docker exec winnow winnow config set auto_commit true or the /settings page) plus a configured git remote means the wiki auto-versions each pipeline run — for many setups this is the backup, no other tooling needed.

Environment variables

After Inc 27, the SQLite config store is the source of truth at runtime — env vars are read on first boot only, then the DB takes over. Mutate via the Web UI's /settings page or winnow config set from inside the container.

VarPurposeRequiredRead after first boot?
ANTHROPIC_API_KEY or OPENROUTER_API_KEYLLM provider key. Migrated into the DB on first boot. Required for any pipeline run; pipelines that don't fire LLMs (e.g. winnow lint static-only) work without it.One, eventuallyNo — DB wins
WINNOW_BOOTSTRAP_TOKENDeterministic first-boot bearer token (Inc 27e). When set, the entry-point uses this exact value; otherwise generates a fresh one and prints to stdout. No-op when the DB already has tokens.OptionalNo
WINNOW_API_TOKENSComma-separated bearer tokens. Migrated into the DB on first boot; ignored thereafter. New self-hosted installs should use the bootstrap token + Web UI for rotation instead.OptionalNo — DB wins
WINNOW_TRUSTED_PROXYtrue to enable trusted-proxy auth mode (Inc 24d) — the backend reads X-Authenticated-User from the request instead of (or alongside) the bearer header. Set when running behind Caddy / Nginx / Tailscale Funnel / Cloudflare Access.Optional, default offNo — DB wins
WINNOW_PROJECT_ROOTProject-root path inside the container. Read at every boot (the entry-point needs to know where /data is mounted).Optional, default /dataYes

Pass them via -e VAR=value on docker run, an env file (--env-file .env), or a docker-compose.yml environment: block.

Running CLI commands in the container

The entry-point dispatches: if you pass arguments after the image name, they're exec'd directly inside the running CLI context instead of booting the services. This means the container also doubles as a Winnow CLI runner without installing Python on the host:

# Show project status (auto-uses the mounted volume's wiki)
docker run --rm \
  -v "$(pwd)/winnow-data:/data" \
  -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
  winnow:latest \
  winnow status

# Run an ingest cycle
docker run --rm \
  -v "$(pwd)/winnow-data:/data" \
  -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
  winnow:latest \
  winnow ingest

# Open a shell inside the running container
docker exec -it winnow bash

These short-lived runs share the volume with the long-running service container — drop a new file into winnow-data/raw/sales/ on the host, then either run winnow ingest from the running container's terminal, or wait for auto-ingest if you have it configured.

docker-compose example

For declarative + repeatable setup:

# docker-compose.yml
services:
  winnow:
    build: .                 # or `image: winnow:latest` once built
    container_name: winnow
    restart: unless-stopped
    ports:
      - "3000:3000"          # Web UI
      - "8000:8000"          # Backend API
    volumes:
      - ./winnow-data:/data
    # All env vars below are first-boot-only after Inc 27 — the
    # DB at .winnow/config.db is authoritative once seeded.
    environment:
      # Optional: deterministic bootstrap token. Omit to let the
      # entry-point auto-generate + log one.
      WINNOW_BOOTSTRAP_TOKEN: ${WINNOW_BOOTSTRAP_TOKEN:-}
      # Optional: pre-seed the LLM key. You can also set this via
      # `/settings` after first boot using the bootstrap token.
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
      # Uncomment when running behind a reverse proxy:
      # WINNOW_TRUSTED_PROXY: "true"
# Minimum-friction bootstrap — no env file needed.
docker compose up -d
docker compose logs winnow | grep -A 1 WINNOW_BOOTSTRAP_TOKEN

# Or, with deterministic credentials baked in:
echo "WINNOW_BOOTSTRAP_TOKEN=$(openssl rand -hex 32)" > .env
echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env
docker compose up -d

Reverse proxy in front of the container

For anything beyond local-dev — TLS, custom domain, a single port for the public to hit — put a reverse proxy in front. Caddy, Nginx, Traefik, or any ingress controller works. Exposed-port 3000 (frontend) and 8000 (backend) need to be routed separately because the backend's API lives at /api/* while everything else is the Next.js app.

Caddy (single binary, automatic TLS)

winnow.example.com {
    handle /api/* {
        reverse_proxy localhost:8000
    }
    handle {
        reverse_proxy localhost:3000
    }
}

If Caddy authenticates users itself (e.g. via forward_auth against an SSO provider), set WINNOW_TRUSTED_PROXY=true on the container and have Caddy inject X-Authenticated-User: <username> on the upstream request — the backend will treat that as the authenticated identity (Inc 24d).

Nginx

server {
    server_name winnow.example.com;

    location /api/ {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        # When auth is upstream + WINNOW_TRUSTED_PROXY=true:
        # proxy_set_header X-Authenticated-User $remote_user;
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
    }
}

Healthcheck

The image ships a Docker HEALTHCHECK that polls GET /api/status every 30s with a 15s start grace. Status shows up in docker ps:

STATUS
Up 2 minutes (healthy)

If the backend can't load Config (bad volume mount, missing permissions, corrupted .winnow/config.db, or — on a fresh project — a malformed winnow.toml getting migrated) the healthcheck fails and the container is marked unhealthy. docker logs winnow shows the exception.

Updating

git pull                                  # pull new commits
docker build -t winnow:latest .           # rebuild
docker compose up -d                      # recreate the container
# or:
docker stop winnow && docker rm winnow
docker run -d ... winnow:latest           # same flags as before

The volume survives docker rm. Schema changes between releases are documented in docs/implementation/completed.md's ledger.

Troubleshooting

SymptomLikely cause
Where's my bootstrap token?First-boot logs only — docker logs winnow | grep -A 1 WINNOW_BOOTSTRAP_TOKEN. If you missed it: docker exec -it winnow winnow setup will let you generate a new bearer interactively, or docker exec -it winnow winnow config set api_tokens '["new-token"]' rotates the list.
HTTP 503 from /api/raw/{subdir}, /api/mcp, or /v1/chat/completionsNo bearer tokens configured in the DB. Run docker exec winnow winnow setup and pick the regenerate option, or hit the /settings page's Bearer tokens card.
HTTP 401 on the same routesToken mismatch. The token in Authorization: Bearer <…> doesn't match any stored token. Tokens are exact-match — check for trailing whitespace. Use /settings to rotate or docker exec winnow winnow config get api_tokens to inspect (treat the output as secret).
Container starts then immediately exitsdocker logs winnow will show the cause. Common: missing /data write permissions (the entry-point can't run winnow init / winnow setup), or — on first boot — a malformed winnow.toml being migrated into the config DB.
/api/status healthcheck failingBackend can't load Config. Check docker logs — usually a missing project structure under /data, a corrupted .winnow/config.db, or (first boot only) a malformed winnow.toml.
Web UI loads but every action failsFrontend talks to /api/* on the same origin. If your reverse proxy isn't forwarding /api/* to port 8000, requests fall back to the Next.js dev server and 404. Re-check the proxy config.
winnow ingest errors with LLMErrorLLM key missing or wrong. Both the boot and HTTP serving don't call the LLM, so they succeed without a key — winnow ingest is the first thing that does.

Cross-references

Self-host today. Hosted version on the way.

Winnow is open-source and free to run on your own infrastructure. The hosted version at usewin.now is opening in waves — join the waitlist to get early access.