I started building Grantly in June 2024. Nonprofits spend dozens of hours a week hunting for grants. They search government databases, read foundation websites, and copy descriptions into spreadsheets. I wanted to build something that did that for them.

Nineteen months and 153 commits later, the system crawls the web for grant pages, classifies them with a trained ML model at 80%+ accuracy, stores them in a pgvector database, and ranks grants against an organization’s profile with a RAG pipeline. Getting there took two complete rewrites, a Docker debugging marathon that ate a week, and enough auth system failures to give me strong opinions about when to reach for a library.

Here is how each part works, and how I got it there.

Version 1: Prompt Everything (June – August 2024)

The first 111 commits came in one summer. I ran Cloudflare Workers for the backend, Cloudflare D1 (SQLite) for the database, and a matching system that embedded grant descriptions and compared them to organization profiles with cosine similarity. One LLM prompt did everything: classify relevance, extract grant details, rank results.

The early mistakes taught me a lot. I stored session IDs in plaintext until I added bcrypt. I then picked argon2 for password hashing, until I found out Cloudflare Workers caps CPU time and argon2 timed out on every login. I dropped session-based auth and moved to OTP email links. Simpler, no crypto overhead.

The matching quality was the real problem. Prompting is local optimization. A rule I added to fix one organization’s results would break another’s. By August I was iterating on prompt upgrades, get location in collector with no theory for why any change helped. The system worked, but it had a ceiling, and I had no way to measure whether a change improved anything.

Version 2: A Decomposed Pipeline (November 2025)

Commit 201dff1, “Welcome to Grantly, version 2,” deleted more code than it added. The monolithic Workers app became a monorepo: grantly-web, grantly-database, grantly-crawler, grantly-cron. D1 was out; Postgres with pgvector was in.

One LLM prompt doing everything meant nothing was measurable or replaceable on its own. V2 split the pipeline into stages with single jobs: crawl, classify, embed, retrieve, rank. I could now evaluate each stage on its own terms.

Here is how each package works.

The Database Layer

A Postgres database managed with Drizzle ORM sits at the core, built up through eight incremental migrations.

The grants table holds the metadata you would expect: title, description, eligibility criteria, award range, close date, source URL. The column that makes matching possible is embedding: vector(768). Every grant carries a 768-dimensional vector of its description and tags, stored through the pgvector extension with an HNSW cosine similarity index. That index keeps semantic search fast at query time.

Grants also carry an isDirty boolean. When a grant’s content changes, isDirty flips to true and the cron pipeline re-embeds it on the next run. This skips redundant embedding calls for grants that have not changed. Re-embedding everything on every cycle would waste compute, and the embedding model is not free.

Every row uses a 16-character Base62 random string instead of a UUID. Shorter, URL-safe, and easy to tell apart from the numeric IDs in source data from places like grants.gov.

The matchedGrants table records which grants matched which organization. It also stores matchQuality (a good/bad enum from user feedback), isHidden (for grants the user dismissed), and an LLM-generated explanation, a plain-English sentence about why the grant fits. A match without a reason is a name and a description. The explanation is what an organization reads to decide whether to apply.

The Crawler

The crawler is the package I spent the most time on. It discovers grant pages, decides whether each page is a grant, and pulls structured data from the ones that are.

Content extraction. Turning an arbitrary webpage into something an ML model can read is the first problem. The WebClient class fetches the page, then runs it through a Cheerio DOM pipeline that strips navigation, scripts, styles, footers, iframes, and forms. Everything that is chrome rather than content goes. It finds the main content block by looking for <main>, <article>, role="main", and class or ID patterns like content, article, primary, then converts that block to Markdown through Turndown with the GFM plugin. For PDFs, pdf-parse handles the text and a regex pass pulls out links.

Classification. Each page goes through a DistilBERT model I fine-tuned to classify grant pages before any expensive work happens. I trained the model on 174 positive examples and 395 negative ones. The negatives are pages that look like grants, such as foundation websites, RFP pages, and nonprofit reports, but are not. Training used DistilBERT-uncased as the base, 5 epochs, a max sequence length of 512, and int8 quantization through Hugging Face Optimum for runtime speed. The ONNX model lives on Hugging Face Hub and downloads at startup through @xenova/transformers. Pages scoring below 0.5 get dropped before they reach the LLM.

The two-stage approach, a cheap classifier first and an expensive LLM second, is what makes the crawler affordable to run. In V1 the LLM touched every page. With the classifier in front, most crawled pages never reach a generation call.

Extraction. Pages that clear the classifier go to an Ollama generation call. The prompt validates three conditions before it accepts anything: the page describes money organizations can apply for, it gives specific grant details, and it states eligibility requirements. The response gets parsed as JSON and validated against a Zod schema before an upsert to Postgres. The dedup key is (title, source_url).

Queue design. A SQLite CrawlDatabase holds two queues: discovery (URLs never crawled) and revisit (URLs where a grant turned up before). Every crawled URL records its host, crawl depth, a SHA256 content hash, and whether a grant was found. The content hash gives a cheap dedup check. If the hash matches the last crawl, the page has not changed and the crawler skips it.

Discovery scoring weighs several signals:

score = (domainYield × 20)
      + (urlPhraseBonus × 10)    // "grant", "funding", "opportunity", etc.
      + freshnessBonus
      - (depth × 5)
      + hostPenalty              // penalize recently crawled hosts

Revisit scoring rewards pages with high historical update rates and penalizes pages that have not changed in a while. The budget split between discovery and revisit adjusts every 100 crawls or so, based on which queue produces more grants.

One depth heuristic earns its place: when the crawler finds a grant on a page, it resets crawl depth to 0 for links found on that page. Pages near a grant page tend to be grant pages, so penalizing their depth makes no sense.

The Cron Pipeline

The cron service runs three stages: scrape known sources, re-embed dirty grants, and run matchmaking.

Grants.gov scraping. The federal grants.gov publishes a full XML export of every open opportunity. The scraper fetches the latest export URL by parsing the HTML table on the downloads page, downloads the ZIP, and processes the XML with a SAX streaming parser. I stream it on purpose. The export is large, and loading it into memory at once is slow. Each OpportunitySynopsisDetail_1_0 element maps to a grant row. Eligibility codes arrive as a numeric enum (12 means 501(c)(3) nonprofits) that I expand to human-readable strings. The grants.gov date format is MMDDYYYY, so it needs a custom parser. Everything runs through he.decode() to handle XML entities.

Embedding dirty grants. The processDirtyGrants task queries for grants where isDirty = true and closeTimestamp > NOW(). Embedding an expired grant is wasted work. It concatenates each grant’s description and tags, passes them to Ollama’s embeddinggemma:300m model (768-dimensional output), writes the vector back, and sets isDirty to false. It works in batches of 50.

Matchmaking. For each user with a complete organization profile, an active plan, and a verified email, the pipeline:

  1. Embeds the organization’s biography with the same embedding model.
  2. Queries Postgres for the 10 closest grants by cosine distance:
    SELECT *, 1 - cosine_distance(embedding, $userEmbedding) AS similarity
    FROM grants
    WHERE close_timestamp > NOW()
      AND embedding IS NOT NULL
      AND is_dirty = false
      AND id NOT IN (SELECT grant_id FROM matched_grants WHERE user_id = $userId)
    ORDER BY similarity DESC
    LIMIT 10
  3. Runs a generation call for each candidate with the full organization profile and grant details, returning a judgment plus an explanation sentence.
  4. Inserts matchedGrants records for every candidate, setting isHidden = true for non-matches.

The retrieval step does the heavy lifting. It narrows 10,000+ grants down to 10 plausible candidates. The LLM then has a narrow job: compare two specific things and decide which fits better.

The Frontend

The SvelteKit app deploys to Cloudflare Workers through @sveltejs/adapter-cloudflare. Database access runs through Cloudflare Hyperdrive, a connection pooling proxy, because Workers cannot hold persistent Postgres connections the way a long-running server can. Hyperdrive manages the pool and hands the Worker a connection string per request.

hooks.server.ts builds a fresh Drizzle client from the Hyperdrive connection string on each incoming request, puts it in event.locals.db, and closes the connection after the response. The same hook validates the session.

Dropping better-auth (December 2025)

The auth system went through three iterations: argon2 (CPU limits), session IDs (the plaintext mistake), better-auth, then custom OTP.

better-auth is a solid library. Its assumptions clashed with the Cloudflare Workers runtime. The library expects a Node.js request/response cycle; Workers runs a different async model. Session handling that passed in tests failed in production in ways I could not reproduce, and every edge case sent me debugging through the library’s internals instead of my own code.

The replacement is about 200 lines. It generates a time-limited 6-digit OTP, SHA512-hashes it before storage, verifies the hash on submission, and issues a 64-character Base62 session token (SHA512-hashed in the database, delivered as an HttpOnly cookie). Sessions bind to the IP address and User-Agent they were created on. Three new migrations, and I can audit the whole thing.

better-auth is not bad. A library built for one runtime can hurt you in another, and you only find out when you are debugging production edge cases.

The Docker Week (December 2025)

Containerizing the crawler and cron services took about five days longer than I expected. The failure taught me something. The CI runner was x86_64, the production host was ARM64. Docker built the image, passed its checks, and failed at runtime with no useful error.

The fix was --platform linux/arm64 in the build command plus QEMU emulation in CI for the ARM64 layer. A second problem: the build baked the DistilBERT model into the image and pushed it past 2 GB. Moving the model to Hugging Face Hub and pulling it at container startup fixed the size and split model versioning from service versioning. I can update the model now without rebuilding the image.

Where It Stands

The skill that paid off most was spotting when an architecture has a ceiling. V1’s prompt-based matching worked, but it could not reach 80% accuracy and gave me no way to measure progress. Fixing that meant a trained classifier, which meant decomposing the pipeline, which meant a different database. Each decision cascaded from naming the right constraint first.

The stack I run now is the one I would pick on day one: TypeScript, SvelteKit, Python for ML training, Postgres with pgvector, Cloudflare Workers, Docker, Ollama. I had to build the wrong version to know that.

You can view it at https://github.com/lllincoln/grantly today.