Building a Scholarship Matcher: Scraping 500+ Award Databases

Students miss out on billions in unclaimed scholarships every year simply because they don’t know they exist. Let’s build a scholarship matcher that scrapes award databases and matches students to opportunities they qualify for.

The Scholarship Discovery Problem

There are over 1.7 million private scholarships worth $7.4 billion annually. Most students only apply to a handful because finding relevant ones is tedious. A scholarship matcher solves this by aggregating data from hundreds of sources and using NLP to match student profiles.

Data Sources

Key scholarship databases to scrape:

  • Fastweb, Scholarships.com, Cappex
  • College Board Scholarship Search
  • State-specific scholarship portals
  • Professional association awards
  • Community foundation directories

Setting Up

pip install requests beautifulsoup4 pandas scikit-learn schedule

Using ScraperAPI for JavaScript-heavy scholarship sites:

import requests
from bs4 import BeautifulSoup
import json

SCRAPER_KEY = "YOUR_SCRAPERAPI_KEY"

def fetch(url):
    resp = requests.get(
        "http://api.scraperapi.com",
        params={"api_key": SCRAPER_KEY, "url": url, "render": "true"},
        timeout=60
    )
    return BeautifulSoup(resp.text, "html.parser")

Scraping Scholarship Listings

def scrape_scholarships_com(category, page=1):
    """Scrape scholarships from Scholarships.com."""
    url = f"https://www.scholarships.com/financial-aid/college-scholarships/scholarships-by-type/{category}/page/{page}"
    soup = fetch(url)

    scholarships = []
    for item in soup.select(".scholarship-item, .award-listing"):
        name = item.select_one(".scholarship-name, h3")
        amount = item.select_one(".award-amount, .amount")
        deadline = item.select_one(".deadline")
        desc = item.select_one(".description, .snippet")
        link = item.select_one("a")

        if name:
            scholarships.append({
                "name": name.text.strip(),
                "amount": amount.text.strip() if amount else "Varies",
                "deadline": deadline.text.strip() if deadline else "Varies",
                "description": desc.text.strip() if desc else "",
                "url": link["href"] if link else "",
                "source": "scholarships.com"
            })
    return scholarships

# Scrape multiple categories
categories = [
    "merit-scholarships",
    "need-based-scholarships",
    "minority-scholarships",
    "stem-scholarships",
    "creative-arts-scholarships"
]

all_scholarships = []
for cat in categories:
    results = scrape_scholarships_com(cat)
    all_scholarships.extend(results)
    print(f"{cat}: {len(results)} scholarships")

Extracting Eligibility Criteria

import re

def extract_eligibility(description):
    """Parse eligibility criteria from description text."""
    criteria = {
        "min_gpa": None,
        "max_age": None,
        "citizenship": [],
        "majors": [],
        "states": [],
        "demographics": []
    }

    # GPA requirements
    gpa_match = re.search(r"(d.d+)s*GPA", description)
    if gpa_match:
        criteria["min_gpa"] = float(gpa_match.group(1))

    # Citizenship
    if "US citizen" in description.lower():
        criteria["citizenship"].append("US")
    if "permanent resident" in description.lower():
        criteria["citizenship"].append("PR")

    # Major keywords
    major_keywords = [
        "engineering", "computer science", "biology",
        "business", "nursing", "education", "mathematics"
    ]
    for major in major_keywords:
        if major in description.lower():
            criteria["majors"].append(major)

    return criteria

Building the Student Profile Matcher

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import pandas as pd

class ScholarshipMatcher:
    def __init__(self, scholarships_df):
        self.df = scholarships_df
        self.vectorizer = TfidfVectorizer(
            stop_words="english",
            max_features=5000
        )
        self.tfidf_matrix = self.vectorizer.fit_transform(
            self.df["description"]
        )

    def match(self, student_profile, top_n=20):
        """Find best matching scholarships for a student."""
        profile_text = self._profile_to_text(student_profile)
        profile_vec = self.vectorizer.transform([profile_text])

        scores = cosine_similarity(
            profile_vec, self.tfidf_matrix
        ).flatten()

        self.df["match_score"] = scores
        results = self.df.sort_values("match_score", ascending=False)

        # Filter by hard eligibility criteria
        results = self._apply_filters(results, student_profile)
        return results.head(top_n)

    def _profile_to_text(self, profile):
        """Convert student profile to searchable text."""
        parts = [
            profile.get("major", ""),
            profile.get("interests", ""),
            profile.get("background", ""),
            profile.get("career_goals", ""),
            " ".join(profile.get("activities", []))
        ]
        return " ".join(parts)

    def _apply_filters(self, df, profile):
        """Filter out scholarships student doesn't qualify for."""
        student_gpa = profile.get("gpa", 4.0)
        filtered = df[
            df["criteria"].apply(
                lambda c: c.get("min_gpa") is None or
                c["min_gpa"] <= student_gpa
            )
        ]
        return filtered

Usage Example

# Student profile
student = {
    "name": "Alex Chen",
    "gpa": 3.8,
    "major": "computer science",
    "interests": "artificial intelligence machine learning",
    "background": "first-generation college student",
    "career_goals": "AI research in healthcare",
    "activities": [
        "robotics club president",
        "hospital volunteer",
        "coding bootcamp mentor"
    ],
    "state": "CA",
    "citizenship": "US"
}

# Load scholarships
df = pd.read_csv("scholarships.csv")
df["criteria"] = df["description"].apply(extract_eligibility)

matcher = ScholarshipMatcher(df)
matches = matcher.match(student, top_n=15)

print("Top Scholarship Matches:")
for _, row in matches.iterrows():
    print(f"  {row['name']} - {row['amount']} ({row['match_score']:.0%} match)")

Keeping Data Fresh

Scholarship deadlines change every year. Set up weekly scraping to keep your database current. Use ThorData for rotating proxies across scholarship sites, and ScrapeOps to monitor which scrapers need maintenance.

import schedule

def weekly_refresh():
    for source in SCHOLARSHIP_SOURCES:
        try:
            new_data = scrape_source(source)
            update_database(new_data)
            print(f"Updated {source}: {len(new_data)} scholarships")
        except Exception as e:
            print(f"Failed {source}: {e}")

schedule.every().monday.at("03:00").do(weekly_refresh)

Conclusion

A scholarship matcher can help students discover thousands of dollars in funding they’d otherwise miss. The combination of web scraping for data collection and NLP for matching creates a powerful tool. Start with 2-3 major databases, build the matching engine, then expand your sources over time.

The $7.4 billion in annual scholarships is there for the taking. The hard part isn’t qualifying. It’s knowing they exist.

The Shift That Already Happened

A year ago, if you wanted AI help writing code, you opened your editor and used whatever was built in. Copilot, Cursor, Windsurf. They’ve gotten genuinely capable. Agent modes that make multi-file changes, run commands, iterate on errors. These aren’t just autocomplete anymore.

But something else was gaining traction at the same time, and it’s quietly changing how serious AI-assisted development actually works.

The CLI agents

Claude Code, OpenAI’s Codex CLI, OpenCode, and others work directly in your terminal. They operate in your actual development environment with full access to the filesystem, your shell, your toolchain. They read your codebase, create files, run tests, fix what breaks, and iterate until the job is done.

Editor agent modes can do a lot of this too. The difference isn’t really about what the agent can do in theory. It’s about what model is doing the work and what it costs you to use it.

The model economics

Every tool in this space is grappling with the same problem: the best AI models are expensive to run, and someone has to pay for them.

Editor-embedded tools bundle model access into their subscription. Cursor uses a credit system (introduced June 2025) where credits deplete at different rates depending on which model handles the request. Their Pro plan is $20/month, Ultra is $200/month with a much larger credit pool. If you exceed your credits, you pay overages at API rates. Their agent and edit features only work with Cursor’s own custom models. You can’t bring your own API key for those.

Windsurf recently restructured to a quota system with daily and weekly refreshes. Pro is $20/month, and they added a Max tier at $200/month for heavier usage. Individual users can still bring their own API keys, but teams and enterprise users can’t.

GitHub Copilot uses premium request allowances. Copilot Pro+ at $39/month includes 1,500 premium requests, with overages at $0.04 each. When you exceed your allowance without paying overages, you fall back to a less capable model.

CLI agents connect to model providers directly. Claude Code authenticates with a Claude Max subscription ($100 or $200/month), giving you Opus with weekly limits. Codex CLI can authenticate with your ChatGPT Pro subscription ($200/month) for GPT-5.4 Pro access. You can also use API keys with per-token billing if you prefer.

At the $200/month price point, both approaches have limits. Claude Max and ChatGPT Pro use rolling time windows (5-hour and daily resets). Cursor Ultra and Windsurf Max use credit or quota pools. Both sides offer the option to pay overages at API rates when you exceed your allowance. The details of how much usage you actually get for $200/month are hard to compare directly since each platform measures differently, but anecdotally the model provider subscriptions tend to be more generous for heavy agent use than the equivalent editor tier.

The other difference is model choice. With CLI agents, you pick your model and your provider. Claude Code runs Opus because that’s what Anthropic offers through Claude Max. Codex CLI runs whatever OpenAI makes available through ChatGPT Pro. With editor subscriptions, you use whatever models the editor vendor has chosen to offer through their system, and for Cursor specifically, agent features only work with their custom models.

Context

Every AI coding tool manages context, and every one of them hits limits eventually. CLI agents are no exception. Claude Code compacts conversation history when sessions get long. Auto-compaction kicks in around 80% of the context window, and earlier parts of the conversation get summarized. This is a real limitation that affects output quality over long sessions.

Editor-embedded tools face the same fundamental constraint, with an additional layer of complexity. They’re assembling context from codebase indexes, open files, retrieval systems, and file references. Some show a usage meter so you can see the context window filling up. The context management is sophisticated, but it’s also something you end up thinking about. Which files to reference, when to start a fresh session, how to keep the AI aware of what matters.

CLI agents have a more direct relationship with your project. The agent reads files from the filesystem when it needs them, rather than depending on what a retrieval system surfaced or which files happen to be open. The context window sizes are comparable when using the same underlying models, but you tend to spend less time managing the AI’s awareness of your project and more time on the actual problem.

The workflow gap

Here’s the thing about CLI agents. They’re terminals. You start a session, the agent works, and then you’re left with a pile of changes in a directory somewhere. Turning that into a reviewed, tested, merged pull request is still on you. And if you want to run multiple agents in parallel on the same repo? Good luck managing the git conflicts.

This is the gap that desktop AI workspaces fill. Not by replacing the CLI agents, but by giving them a proper environment to work in.

At Taskeract, we built the layer that wraps around these agents. Every session gets its own isolated git worktree, so agents never step on each other’s work, or yours. You can run Claude Code in one session and Codex in another, both working on the same project simultaneously, on separate branches that won’t conflict.

But isolation is just the starting point. The real workflow starts when the code is written.

From issue to done

Most tools in this space handle some of the post-coding workflow. You can create PRs, review diffs, push changes. But the full loop (starting from an issue, reviewing changes, creating a PR, monitoring CI, responding to reviewer feedback, merging, and closing the issue) still involves jumping between multiple tools. Your terminal, your browser, your git hosting provider, your issue tracker.

We built Taskeract to cover the entire loop. Start a session from an issue in GitHub, GitLab, Jira, Linear, or Trello. The agent works in its isolated environment. When it’s done, review the changes with syntax-highlighted diffs right in the app. Push, create a PR, see CI status, respond to review threads, and merge, all without leaving the window. The issue automatically advances through its workflow states as the work progresses.

It’s the difference between a tool that helps you write code and a tool that helps you ship code.

Where this is going

The autonomous agent approach with top-tier models is already producing better results at more predictable costs. And once the code is written, the workflow around it matters just as much as the code itself.

If you’ve been feeling like AI coding hasn’t quite lived up to the promise, it might not be the AI that’s the bottleneck. It might be what’s around it.

The shift already happened. The question is whether your workflow has caught up.

How to Connect Your HTML Form to Google Sheets Without a Backend

Most tutorials for connecting an HTML form to Google Sheets send you down a rabbit hole.

Set up a Google Apps Script. Deploy it as a web app. Handle CORS errors. Debug broken endpoints. Maintain a script you did not write and do not fully understand. Then do it all over again when something breaks six months later.

For a developer who just wants form submissions in a spreadsheet, that process is a time sink with too many moving parts. And if you are not a developer at all, it is basically a wall.

There is a simpler way.

This guide shows you how to connect any HTML form to Google Sheets in under 3 minutes using Formgrid. No backend. No Google Apps Script. No CORS errors. No Zapier subscription. Just paste one endpoint URL into your form action and share a Google Sheet with one email address. That is the entire setup.

Who This Is For

This guide is for developers and builders who already have an HTML form and want submissions going straight to Google Sheets without writing any server-side code:

Static site developers running sites on GitHub Pages, Netlify, Vercel, Cloudflare Pages, or any static host who need a form backend without spinning up a server. Static sites cannot process forms on their own. You need somewhere to send the data. Formgrid is that somewhere.

Frontend developers who built a contact form, inquiry form, job application form, or event registration form and want every submission organized in a spreadsheet automatically. You already wrote the HTML. You should not need to write a backend just to collect it.

Indie hackers and solopreneurs who want a lightweight form setup without paying for Zapier on top of everything else. If you are running lean, adding a $20/month Zapier subscription just to move form data into a spreadsheet is a tax you should not have to pay.

No-code builders who use HTML forms embedded in Webflow, Framer, Carrd, or similar tools and want data flowing into Google Sheets in real time. The form is already there. You just need the backend to catch it.

HR teams and small business owners who collect applications, inquiries, or registrations through a form on their website and want every entry in a shared spreadsheet their team can access without logging into a separate tool.

If you have an HTML form and you want submissions in Google Sheets, this guide gets you there in one sitting.

The Problem With Google Apps Script

Before getting into the setup, it is worth understanding why the standard approach breaks down. Because you will find dozens of tutorials recommending Google Apps Script, and most of them skip over the parts that actually cause problems.

The standard flow looks like this: write a doPost function in Apps Script, deploy it as a web app, copy the deployment URL, paste it into your form action, and hope it works. Then every time you make a change to the script, you redeploy and get a new URL. Then you update your form. Then you remember you also have to handle CORS because your HTML file is on a different origin than the Apps Script deployment. Then you spend an hour reading Stack Overflow threads from 2019.

It works eventually. But it requires you to own a piece of infrastructure that is fragile, annoying to maintain, and breaks in ways that are hard to debug. If you are a developer building your own project, you might be fine with that. If you are setting this up for a client, a team, or a non-technical colleague who will need to manage it later, you are creating a maintenance burden for someone.

The deeper issue is that Google Apps Script was not really designed for this use case. It is a general-purpose automation tool that happens to be able to do this. Formgrid is specifically built for it.

What You Get With Formgrid

Once your HTML form is connected to Formgrid and your Google Sheet is linked, here is what happens on every submission:

Every entry from your HTML form appears as a new row in your spreadsheet automatically. No manual export. No logging into a dashboard every day. No copy and paste.

Column headers are created automatically from your form field names on the very first submission. You do not need to set up the spreadsheet structure yourself. Name your input fields clearly and Formgrid handles the rest.

The integration works in real time. The moment someone submits your form, the row appears in Google Sheets within seconds. Not the next time a Zap runs. Not on a schedule. Instantly.

You also get email notifications on every submission, so you are not dependent on checking Sheets. Spam protection is built in through honeypot fields and rate limiting, so fake submissions do not pollute your data. And every submission is saved to your Formgrid dashboard as a backup, so if anything ever goes wrong with your Sheet, you have a full record to fall back on.

Before You Start

You will need three things:

A Formgrid account, which is free to create at formgrid.dev. No credit card required to sign up.

A Formgrid Business plan for Google Sheets integration, which is $29/month. If you are on the Free or Premium plan, you will see a prompt to upgrade when you open the Integrations tab.

An existing HTML form on your site, or a willingness to add a basic one. The only change you will make to your HTML is updating the action attribute on your form tag. Everything else stays exactly as it is.

Part One: Point Your HTML Form at Formgrid

Step 1: Create a Form in Your Formgrid Dashboard

Log in at formgrid.dev and create a new form. Give it a name that matches what you are collecting, for example, “Contact Form,” “Job Applications,” or “Event Registrations.”

You do not need to use the Formgrid drag-and-drop form builder for this. You are building your own HTML form, and you are only creating a Formgrid form entry to generate your endpoint URL. Think of it as registering your form so Formgrid knows where to route the submissions.

Step 2: Copy Your Endpoint URL

Inside your form dashboard, copy your unique endpoint URL. It will look like this:

https://formgrid.dev/api/f/your-form-id

This URL is permanent. It never changes when you update your form or your settings. You set it once and forget it.

Step 3: Update Your HTML Form

Open your HTML file and change your form’s action attribute to your Formgrid endpoint URL. Set the method to POST:

<form action="https://formgrid.dev/api/f/your-form-id" method="POST">
  <input type="text" name="name" placeholder="Your Name" required />
  <input type="email" name="email" placeholder="Your Email" required />
  <textarea name="message" placeholder="Your Message"></textarea>
  <button type="submit">Send</button>
</form>

That is the only change to your HTML. One attribute on one tag. Your form now sends submissions to Formgrid instead of disappearing into nothing.

One important detail: Formgrid reads the name attribute of each input field and uses those values as the column headers in your Google Sheet. If your input has name=”email”, then the column in your Sheet will be labelled “email.” If it has name=”company_name” the column will be “company_name.” Make sure every field in your form has a clear, descriptive name attribute before you connect your Sheet.

Step 4: Test That Submissions Are Reaching Formgrid

Before connecting Google Sheets, do a quick sanity check. Submit a test entry through your form and log into your Formgrid dashboard. You should see the submission appear in your form’s submissions list.

If it is not showing up, open your browser developer tools, go to the Network tab, submit the form again, and look for the POST request to formgrid.dev. Check the response status. A 200 means it worked. Anything else means there is a configuration issue to sort out before moving on.

Part Two: Connect Google Sheets

Step 5: Open the Integrations Tab

In your Formgrid dashboard, click on your form and open the Integrations tab at the top of the page. You will see the Google Sheets integration section. If you are on the Business plan, the connect interface is ready to use immediately.

Step 6: Create a Blank Google Sheet

Click the Create blank Google Sheet button. This opens a fresh blank spreadsheet in Google Sheets in a new tab.

Give your sheet a name that is easy to identify later. Something like “Contact Form Submissions 2026” or “Job Applications: Marketing Role” works well. If you manage multiple forms, a consistent naming convention will save you confusion later.

You do not need to create any columns, add any headers, or set up any structure at all. Formgrid creates the column headers automatically from your form field names on the very first submission. The spreadsheet should be empty when you connect it.

Step 7: Share the Sheet With Formgrid

In your Google Sheet click the Share button in the top right corner of the page.

In the share dialog, add this email address as an Editor:

This is the Formgrid service account email. Rather than typing it out manually, go back to your Formgrid dashboard where the service account email is displayed with a Copy button next to it.

Copy it there and paste it directly into the Google Sheets share dialog. It avoids any chance of a typo.

One thing to get right here: make sure you select Editor access, not Viewer. Formgrid needs Editor access to write new rows to your sheet. If you accidentally add it as a Viewer, the connection will fail with a permissions error when it tries to write.

Click Send or Done in the share dialog to confirm.

Step 8: Paste Your Sheet URL Into Formgrid

Go back to your browser tab with Formgrid open. Copy the URL of your Google Sheet from the address bar of the tab where your Sheet is open.

The URL will look something like this:

https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit

Paste the full URL into the sheet URL field in your Formgrid dashboard. Make sure you are copying from the address bar and not from somewhere else. The URL needs to contain the full spreadsheet ID, which is the long string of characters between /d/ and /edit.

Step 9: Choose Whether to Sync Existing Submissions

Before clicking Connect, you will see this option:

Sync existing submissions to this sheet?

If you already have submissions, Formgrid can add them all to your
Google Sheets now, so your entire history is in one place.

[ ] Yes, sync my existing submissions

If you have been collecting submissions for a while and want your full history to appear in the Sheet immediately, check this box. Formgrid will write all past submissions to the sheet in chronological order before it starts adding new ones. Your entire submission history will be there from the moment you connect.

If you only want new submissions going forward and you do not need historical data in the Sheet, leave it unchecked.

Step 10: Click Connect

Click the Connect Google Sheets button.

Formgrid will verify it can access your sheet, confirm the service account has the right permissions, and save the connection. If you selected the sync option, it will also write your existing submissions to the sheet now.

If everything is set up correctly you will see a success message:

Connected successfully

Your sheet is ready. Every new submission will appear as a new row automatically.

Part Three: Verify It Is Working

Step 11: Submit a Test Entry Through Your HTML Form

Go to your actual HTML form on your site and submit a test entry with dummy data. Use a realistic-looking name and email so it is easy to spot in the Sheet.

Open your Google Sheet. Within a few seconds, you should see:

Row 1 contains the column headers created automatically from your form field names. These come directly from the name attributes you set on your HTML inputs.

Row 2 contains your test submission data. The last column will contain a timestamp showing exactly when the submission was received.

Every subsequent submission from your HTML form will appear as a new row below this one in real time. You never need to log into Formgrid or export anything. The data goes straight to Google Sheets the moment someone fills out your form.

What Happens on Every Submission

Here is the full flow from form fill to spreadsheet row:

Visitor fills out your HTML form on your site
              ↓
The browser sends a POST request to your Formgrid endpoint
              ↓
Formgrid receives the submission and saves it to your dashboard
              ↓
Email notification sent to you (and any other recipients you configured)
              ↓
New row added to your Google Sheet instantly
              ↓
Spam protection runs in the background to filter out fake submissions

You get the submission in three places simultaneously: your email inbox, your Formgrid dashboard, and your Google Sheet. If any one of those ever has an issue, you still have the other two.

Real World Example: Event Registrations for a 200-Person Ride

Annie manages endurance horse riding events in San Diego. Her team collects registrations through a form on their website. Before Formgrid, every registration arrived as an email with all the details dumped in one long, unformatted block. Rider name, horse details, session selection, and payment status are all mixed with no labels and no structure. Her event manager had to manually scan through every single registration to pull out the information she needed. For a 200-person event, that was hours of unnecessary work before the event even started.

She now uses Formgrid with Google Sheets connected to her registration form. Every new registration appears instantly as a new row in a shared Google Sheet that her entire team can see in real time. Rider name in one column, horse details in the next, session selection after that, payment status at the end. Organized. Automatic. No manual work.

Her event manager can sort by session, filter by payment status, and share the sheet with volunteers on the day of the event. All from a Google Sheet that fills itself.

She is on the Business plan at $29/month.

“The level of personal support was impressive.”
Annie Libby, Event Manager, San Diego Endurance Riders

Managing Your Connection

Once connected, the Integrations tab in your Formgrid dashboard shows your current connection status and gives you a few controls:

Pause the integration using the Active toggle. When paused, new submissions are still saved to your Formgrid dashboard, and you still receive email notifications, but they are not sent to Google Sheets. Toggle it back on to resume syncing at any time.

Disconnect removes the connection entirely. Your existing sheet data stays in Google Sheets exactly as it is. New submissions will no longer be synced until you reconnect.

Open Sheet takes you directly to your connected Google Sheet with one click, so you do not have to go hunting for it in your Drive.

How This Compares to Google Apps Script

Google Apps Script Formgrid Native
Setup time 30 to 60 minutes Under 3 minutes
Maintenance You own the script Zero maintenance
CORS handling Manual, error-prone Built in
Endpoint stability New URL on every redeploy Never changes
Spam protection None built in Honeypot + rate limiting
Email notifications Requires extra setup Included
Historical data sync Manual One checkbox
Support Stack Overflow Direct access to the founder

How This Compares to Using Zapier

A common alternative is to use a form backend to collect submissions and then use a Zapier Zap to push them into Google Sheets. It works, but you are paying for two services instead of one and adding a delay between submission and Sheet row because Zapier runs on a schedule rather than in real time.

Zapier Route Formgrid Native
Extra monthly cost $19.99/month for Zapier Included in $29/month
Setup steps 6 to 8 steps across two tools 3 steps in one place
Real time sync Depends on Zap frequency Instant
Failure points Two services that can break One

Troubleshooting

Form submissions not appearing in Formgrid at all

Check that your form action URL is pointing to your Formgrid endpoint and that the method attribute on your form tag is set to POST, not GET. Open your browser developer tools, go to the Network tab, submit the form, and look for the POST request to formgrid.dev. A 200 response means the submission was received. Any other status code means something is misconfigured.

“Could not access this sheet” error on connect

This means Formgrid cannot write to your sheet. Go back to Google Sheets, click Share, and confirm that the Formgrid service account email is added as an Editor, not a Viewer. If you added it as a Viewer, remove it and re-add it with Editor access, then try connecting again.

Column headers not appearing or appearing incorrectly

Column headers come from the name attribute of your HTML input fields. If a column is missing, check that the corresponding input has a name attribute set. Fields without a name attribute are not included in the submission. If a header looks wrong, update the name value on that input and resubmit a test entry.

Submissions appearing in Formgrid but not in Google Sheets

Check that the Google Sheets integration is showing as Active in your Integrations tab. If it shows as Paused, click the toggle to resume. If it shows as Active but submissions are still not appearing, disconnect and reconnect the integration.

Invalid Google Sheets URL error

Make sure you copied the full URL from your browser address bar, not just the spreadsheet title or a partial URL. The URL must contain /spreadsheets/d/ followed by a long alphanumeric string. Always copy from the address bar of the tab where your Sheet is open.

What the Formgrid Business Plan Includes

At $29/month the Business plan gives you everything you need to run forms seriously:

Google Sheets native integration (this guide)
Custom HTML email templates so every notification looks exactly the way you want
Auto-responder emails are sent automatically to people who submit your form
Webhooks to connect to Zapier, Make, Slack, Notion, Airtable, and 5,000+ more tools
Multiple email notification recipients, so your whole team gets notified
Custom email subject lines
15,000 submissions per month
Priority support with direct access to the founder

No contracts. Cancel anytime.

Start your Business plan at formgrid.dev

Final Thoughts

Connecting an HTML form to Google Sheets does not require a backend, a Google Apps Script, a Zapier subscription, or an afternoon of debugging CORS errors.

Update one attribute in your form tag. Share a Google Sheet with one email address. Click connect.

Every submission lands in your spreadsheet automatically, in real time, from that moment forward.

That is what Formgrid delivers.

👉 Try Formgrid free at formgrid.dev

Upgrade to Business when you are ready for Google Sheets integration.

Full disclosure: I built Formgrid. Written as honestly as I could. Let me know in the comments if anything is unclear or looks off.

Tags: #webdev #html #googlesheets #tutorial #staticsite #nobackend #javascript #indiedev #productivity

# Terraform Modular EKS + Istio — Part 5

CSI Drivers (How Storage Actually Works in EKS)

So far:

  • VPC → network
  • IAM → permissions
  • EKS → control plane
  • Nodes → compute

Now comes the part many people ignore:

👉 Storage

Without this:

  • Pods can’t persist data
  • Databases won’t work
  • Logs disappear on restart

This module installs CSI drivers, which allow Kubernetes to use AWS storage.

📂 Module Files

modules/csi-driver/
├── main.tf
├── variables.tf
└── outputs.tf

📄 variables.tf

variable "cluster_name" {
  description = "Name of the EKS cluster"
  type        = string
}

variable "ebs_csi_role_arn" {
  description = "IAM role ARN for EBS CSI driver"
  type        = string
}

variable "s3_csi_role_arn" {
  description = "IAM role ARN for S3 CSI driver"
  type        = string
}

variable "node_group_dependency" {
  description = "Node group dependency to ensure nodes exist first"
  type        = any
}

🧠 What these inputs mean

  • cluster_name → where to install addons
  • ebs_csi_role_arn → IAM role for EBS driver
  • s3_csi_role_arn → IAM role for S3 driver
  • node_group_dependency → ensures nodes exist first

⚠️ Important insight

This module depends on:

👉 IAM (for IRSA roles)
👉 Node groups (for scheduling pods)

📄 main.tf

1. EBS CSI Driver

resource "aws_eks_addon" "ebs_csi" {
  cluster_name             = var.cluster_name
  addon_name               = "aws-ebs-csi-driver"
  service_account_role_arn = var.ebs_csi_role_arn

  depends_on = [var.node_group_dependency]
}

🧠 What this does

Installs:

👉 AWS EBS CSI Driver inside cluster

What is CSI?

CSI = Container Storage Interface

👉 It allows Kubernetes to talk to AWS storage.

What EBS CSI enables

  • Create EBS volumes
  • Attach volumes to pods
  • Persist data

Important line

service_account_role_arn = var.ebs_csi_role_arn

👉 This is IRSA

This means:

  • Pod gets IAM role
  • Pod can call AWS APIs

Without this

❌ Pod cannot create volumes
❌ PVC fails

2. S3 CSI Driver

resource "aws_eks_addon" "s3_csi" {
  cluster_name             = var.cluster_name
  addon_name               = "aws-mountpoint-s3-csi-driver"
  service_account_role_arn = var.s3_csi_role_arn

  depends_on = [var.node_group_dependency]
}

🧠 What this does

Installs:

👉 S3 CSI driver

What it enables

Mount S3 bucket as:

Pod → S3 bucket (like filesystem)

Use cases

  • logs
  • shared storage
  • backups

3. Dependency Handling

depends_on = [var.node_group_dependency]

Why this is critical

CSI driver runs as pods.

👉 Pods need nodes

So order must be:

Nodes → CSI Driver

Without this

  • Addon installs
  • But pods fail to schedule

📄 outputs.tf

output "ebs_csi_addon_id" {
  description = "EBS CSI addon ID"
  value       = aws_eks_addon.ebs_csi.id
}

output "s3_csi_addon_id" {
  description = "S3 CSI addon ID"
  value       = aws_eks_addon.s3_csi.id
}

🧠 Why outputs matter

Used for:

  • tracking addon deployment
  • debugging
  • dependencies in future modules

🔥 What You Actually Built

Kubernetes Pod
      │
      │
CSI Driver
      │
      │
AWS Storage (EBS / S3)

⚠️ Real Issues People Face

  • Missing IRSA → access denied
  • No node dependency → pods fail
  • Wrong role → volume attach fails
  • Forgetting CSI → PVC stuck in pending

🧠 Key Takeaways

  • Kubernetes doesn’t manage storage directly
  • CSI drivers connect Kubernetes to AWS
  • IRSA is required for secure access
  • EBS = block storage
  • S3 = object storage

🚀 Next

In Part 6:

👉 AWS Load Balancer Controller
👉 How ALB actually integrates with Kubernetes
👉 Why target-type: ip matters

At this point, your cluster can:

  • run workloads
  • persist data

Now we move to traffic layer.

AI systems don’t fail suddenly. They shift until failure is already embedded.

AI systems don’t fail suddenly. They shift until failure is already embedded.
At the design stage, governance looks complete. Boundaries are defined. Rules are documented. Alignment appears stable.
Execution is where it changes.
Small deviations begin to accumulate. Nothing breaks immediately. The system continues to produce outputs, but the behavior starts to move.
That’s where governance drift shows up.
Not as a visible failure, but as a gradual separation between what was defined and what is actually happening.
The issue isn’t the absence of rules. It’s the absence of enforcement at execution.
Failure isn’t the moment something breaks. It’s the accumulation that made the break inevitable.
In production, this shows up as outputs that feel consistent but are increasingly misaligned. By the time it’s visible, the behavior is already established.

Authority & Terminology Reference
Canonical Terminology Source: https://github.com/hhidatasettechs-oss/Hollow_House_Standards_Library
Citable DOI Version: https://doi.org/10.5281/zenodo.18615600
Author Identity (ORCID): https://orcid.org/0009-0009-4806-1949
Core Terminology: Behavioral AI Governance
Execution-Time Governance
Governance Drift
Behavioral Accumulation

Commit 42: We Replaced Cookies with One Sentence — and Gandhi Showed Up

A story about Ahimsa in software, 190 lines vs 1,400, and why the rod matters more than the fish.

It was 3am. Commit number 42.

I’m building CrowdBooks — a collaborative book platform on IPFS. No cloud. No vendor lock-in. No surveillance. Just people writing books together.

We had a cookie problem. Not a legal one. A philosophical one.

The Cookie Was a Lie

Most web apps carry cookies like luggage. CSRF tokens, tracking pixels, session sprawl. Every cookie is a small compromise: “We need to remember you — trust us.”

We replaced the whole thing with one sentence in our tagline:

“We don’t sell the fish — we give you the rod.”

That sentence forced a decision. If we’re giving people the rod, we can’t be secretly netting their data in the background.

So we ripped out the CSRF cookie. Replaced it with HMAC-signed tokens. Now there’s exactly one cookie: session. That’s it.

Smaller attack surface. Cleaner architecture. And honest.

Two hours after pushing that commit, I opened Umami analytics. One visitor from India. 25% of our traffic.

I don’t know who it was. But I like to think the principle found its people.

What Is ForgeStudio?

Before the numbers make sense, you need to know what we’re building.

ForgeStudio is an Android app with two souls.

The first soul: a CrowdBooks reader and editor. You load a book from the decentralized store, read it, edit your own chapters, push back to the repo. Markdown preview built in. The book is yours — locally, always.

The second soul: an app builder. On a tablet with a touchscreen, you drag controls, resize them, wire up logic — and you’re building a real app. Not a prototype. The UI runs live in the same app, interpreted via SMS scripts. When you’re ready, you compile — that goes through our SaaS build service — and you have a native Android app.

Same runner. Same SMS interpreter. Read a book or ship an app.

And because the runner loads SMS scripts via HTTP too, ForgeStudio can also act as a thin client for business applications — whatever a company needs to run on a tablet, without deploying a new app.

This isn’t a promise. It’s the direction the rod is pointing.

The Real Story: 190 Lines vs 1,400

That same night, we integrated Speech-to-Text into ForgeStudio.

8 hours of work. And when it was done, I realized: no other developer has to go through this again.

Here’s why that matters.

ForgeStudio uses SMS — a scripting language I built for the Forge 4D framework. Inspired by Kotlin, running on a C++ VM. The app runs on Android — native, almost. In a few days: fully native.

For Android STT we use Google’s speech recognition via a Kotlin plugin and a MessageBridge between the native layer and the SMS runtime. It looks roughly like this on the SMS side:

fun onVoiceInput(text: String) {
    var clean = filterGlitches(text)
    postToEditor(clean)
}

The STT integration in SMS: ~190 lines.

The equivalent — Kotlin plugin, MessageBridge wiring, permission handling, lifecycle, UI callbacks: ~1,400 lines.

And here’s the honest part: Codex couldn’t finish it. It kept getting tangled in the bridge layer. After hours of going in circles, I handed it to Claude CLI. Cleaned up in one pass.

That’s not a flex. That’s a data point. Some problems have enough architectural context that a fresh, capable read matters more than iteration.

86% less code. One AI handoff. Shipped.

Why This Is the Rod

Every hour I spend building ForgeStudio is an hour no one else has to spend on the same problem.

The STT battle is over. Done. It’s in the framework now. The next developer who needs voice input in their app writes 190 lines of SMS — and ships.

That’s the rod.

Not the fish. Not “here’s a SaaS that handles your voice input for $29/month and owns your data.” A tool you hold. A tool you understand. A tool that doesn’t expire.

This is what I mean by Ahimsa in software development:

  • Don’t trap your users (no dark cookies)
  • Don’t trap other developers (no proprietary black boxes)
  • Don’t make people fight battles you’ve already won

The Numbers That Made Me Pause

  • 42 commits — yes, the Hitchhiker’s number
  • 1 session cookie — down from a cookie jar
  • 190 lines — to speak to your app
  • 8 hours — so you don’t have to spend them
  • 4 visitors — India, USA, Germany, Bulgaria. Day one analytics. The right people find you.

What’s Actually Next

Books were just the beginning. The reason we ripped out every unnecessary cookie is simple: this thing might run a thousand times, all over the world.

Every instance on a €1 VPS. Every instance independent. None of them switchable off.

CrowdBooks becomes a store. Not Amazon. The opposite of Amazon.

A farmer near Valencia sitting on an orange plantation can list her oranges. A craftsman in Wittenberg can offer his hours. A developer in India can publish his tool. All of it priced in something like Minuto — not a currency really, more like a voucher. Work-time as value. No banks. No corruption vector. No tax on doing something good.

Books first — because books carry truth that media can no longer twist. That’s Wikipedia 3.0, except the incentive to lie has been engineered out. No ad revenue. No editorial pressure. Just people writing what they actually know.

Then apps. Then services. Then oranges.

And because it runs everywhere — on IPFS, on cheap VPS instances, on phones — there’s no single throat to choke. You can’t call up the CEO. There is no CEO. There’s just the protocol, and the people who chose to run it.

That’s why the session cookie is the only cookie.

Because something that might run everywhere shouldn’t carry any weight it doesn’t need.

If you’re an indie dev who’s tired of building on sand — come look at the rod.

Repo: codeberg.org/CrowdWare/ForgeCrowdBook

Aho. 🌱

Introducing AIO Sandbox, All-in-One Sandbox Environment for AI Agents

banner

banner

Unchecked AI autonomy is a ticking time bomb; it’s time to pull the plug on full system unfettered access. We can no longer afford to give AI agents the ‘keys to the kingdom’ without oversight. The ‘wild west’ of AI agents running with total system control is officially over.

AIO Sandbox is an open-source project designed to solve these problems. It is everything your agent needs, out of the box. No more juggling multiple services. AIO Sandbox ships a complete, pre-wired environment in a single Docker container.

The AIO (All-in-One) Sandbox is a containerized environment designed for both human developers and AI agents. Its architecture is built around a “Batteries-Included” philosophy, providing a full Linux desktop-like environment inside a single Docker container.

  • Unified Environment: One Docker container with shared filesystem. Files downloaded in the browser are instantly accessible in Terminal and VSCode.

  • Out of the Box: Built‑in VNC browser, VS Code, Jupyter, file manager, and terminal—accessible directly via API/SDK.

  • Agent-Ready: Pre-configured MCP Server with Browser, File, Terminal, Markdown, Ready-to-use for AI agents.

  • Developer Friendly: Cloud-based VSCode with persistent terminals, intelligent port forwarding, and instant frontend/backend previews.

  • Secure Execution: Isolated Python and Node.js sandboxes. Safe code execution without system risks.

  • Production Ready: Enterprise-grade Docker deployment. Lightweight, scalable.

Calling all AI agent developers! How are you securing your builds? Let’s try running your agent in AIO Sandbox and compare notes.

AIO Sandbox is open-sourced under the Apache License 2.0. Contributions welcome.

GitHub: https://github.com/agent-infra/sandbox
Official website: https://sandbox.agent-infra.com/

I Built a Dashboard That Installs Claude Code, Codex & Gemini CLI in One Click

You know the drill. You want to try Claude Code. Then someone tells you about Codex CLI. Then Gemini CLI drops. Then there’s OpenClaw.

Before you know it, you’re juggling 4 different terminals, 4 different configs, and 4 different API keys — just to write code with AI.

What if you could manage all of them from one place?

Meet ProxyPool Hub

ProxyPool Hub is an open-source local proxy that sits between your AI coding tools and their APIs. It gives you:

  • A beautiful web dashboard at localhost:8081
  • Multi-account rotation (yes, you can use multiple accounts)
  • API key pooling with automatic failover
  • Free model routing (use Haiku-tier models for free via Kilo AI)
  • One-click configuration for every CLI tool

And in v1.0.6, it just got a lot more powerful.

What’s New in v1.0.6

One-Click Tool Installer

Don’t have Claude Code installed? Don’t have Codex CLI? No problem.

The new Tool Installer tab detects your OS, checks what’s installed, and lets you install everything with a single click:

  • Node.js — auto-detected, with platform-specific install guidance
  • Claude Code (@anthropic-ai/claude-code)
  • Codex CLI (@openai/codex)
  • Gemini CLI (@google/gemini-cli)
  • OpenClaw (openclaw)

Hit “Install All” and grab a coffee. Come back to a fully configured AI development environment.

API Key Test & Edit

Added an API key but not sure if it works? Now you can:

  • Test any key with one click — it hits the provider’s API and tells you if it’s valid
  • Edit key details inline — including Azure OpenAI deployment names, API versions, Vertex AI project IDs

No more deleting and re-adding keys just to fix a typo.

8 Provider Support

We now support API keys from:

  • OpenAI
  • Anthropic
  • Google Gemini
  • Azure OpenAI
  • Vertex AI
  • MiniMax
  • Moonshot (Kimi)
  • ZhipuAI (GLM)

All with automatic load balancing and failover. If one key hits a rate limit, the next one picks up seamlessly.

How It Works (30 Seconds)

npx proxypool-hub@latest start

That’s it. Open http://localhost:8081. You’ll see a dashboard where you can:

  1. Add your accounts or API keys
  2. Click “Configure” next to any CLI tool
  3. Start coding

Every tool talks to ProxyPool Hub at localhost:8081, and it routes requests to the right provider with the right credentials.

Why Developers Love It

“I have 3 ChatGPT accounts and 2 API keys. ProxyPool Hub rotates between them automatically.”

“I was paying for Claude API just to use Claude Code. Now I route haiku requests to free models and save $50/month.”

“Setting up Codex CLI took me 20 minutes of config file editing. With ProxyPool Hub it was one button.”

It’s 100% Local

  • Runs entirely on localhost — nothing leaves your machine
  • Connects directly to official APIs — no third-party relay
  • Zero telemetry, zero data collection
  • Credentials stored locally with restricted file permissions

Get Started

# Quick start (no install needed)
npx proxypool-hub@latest start

# Or install globally
npm install -g proxypool-hub
proxypool-hub start

# Or download the desktop app
# https://github.com/yiyao-ai/proxypool-hub/releases

Links

If this saves you time, drop a star on GitHub. It helps more developers discover the project.

ProxyPool Hub is open-source under AGPL-3.0. Not affiliated with Anthropic, OpenAI, or Google.

The Real Cost of Your AI Agent (It’s Not What You Think)

Your OpenAI invoice shows token counts. What it doesn’t show is how many of those tokens produced nothing useful. Failed calls, retries, model over-provisioning, and calls that returned an answer nobody used – these are where agent costs actually go, and none of them appear in standard billing dashboards.

Cost Per Call vs Cost Per Successful Outcome

The metric that matters isn’t cost per call. It’s cost per successful outcome.

If your agent costs $0.008 per call but succeeds 60% of the time, your real cost per successful outcome is $0.013. If a different configuration costs $0.012 per call but succeeds 90% of the time, the real cost is $0.013. They’re equivalent on a per-outcome basis, but only one of those configurations surfaces as “expensive” in a naive cost analysis.

This is the measurement problem: optimizing for cost per call without tracking success rate will push you toward cheaper models that fail more, which can increase cost per successful outcome while appearing to save money.

The hidden cost drivers that inflate cost-per-outcome:

Retries: A call that retries 3 times costs 4x the nominal price. If 15% of your calls retry, your effective cost is 1.45x what the dashboard shows.

Failed calls that still consume tokens: Partial responses, malformed JSON that fails schema validation, outputs that pass syntactic checks but fail downstream use – these all bill full token counts.

Model over-provisioning: Using gpt-4o for a task that gpt-4o-mini handles reliably is a 10-15x cost premium. Over-provisioning is often a conservative choice made during development that never gets revisited.

Context window bloat: System prompts that grew organically, conversation history that accumulates without pruning, tool definitions included in every call whether needed or not.

The Token Cost Math

Let’s run actual numbers on a classification task that runs 10,000 times per day.

The task: classify a support ticket into one of 12 categories. Input is typically 200-400 tokens (ticket text + system prompt). Output is a JSON blob with the category and confidence score – roughly 40 tokens.

With gpt-4o:

  • Input: 300 tokens avg * $5.00/1M = $0.0015 per call
  • Output: 40 tokens * $15.00/1M = $0.0006 per call
  • Per call: $0.0021
  • Daily (10k calls): $21.00
  • Monthly: ~$630

With gpt-4o-mini:

  • Input: 300 tokens * $0.15/1M = $0.000045 per call
  • Output: 40 tokens * $0.60/1M = $0.000024 per call
  • Per call: $0.000069
  • Daily (10k calls): $0.69
  • Monthly: ~$21

That’s a 30x cost difference. The question is whether gpt-4o-mini is reliable enough for your classification task. If it succeeds 95% of the time and gpt-4o succeeds 97%, the per-outcome cost still favors gpt-4o-mini by roughly 27x.

Now layer in a synthesis task: summarizing escalated tickets for a human reviewer. Input is 800-1200 tokens, output is 200-300 tokens. This is where model quality meaningfully affects output usefulness.

With gpt-4o-mini for synthesis:

  • The summaries are functional but miss nuance. Human reviewers request rewrites 18% of the time.
  • Effective cost per good summary: higher than face value due to rework.

With gpt-4o for synthesis:

  • Higher per-call cost, but rewrite rate drops to 4%.
  • Cost per usable summary is lower despite the higher model price.

Classification and synthesis are different task types with different model sensitivity profiles. A static routing strategy that uses the same model for both is almost certainly wrong.

Cost-Constrained Routing

The goal is to route by expected outcome quality first, with cost as a secondary constraint. This is different from routing by cost first – a cheaper model that fails more doesn’t save money, it shifts costs from the API bill to operational overhead and user churn.

import kalibr
from kalibr import Router

def classify_ticket(ticket_text: str, goal_id: str) -> dict:
    router = Router(
        goal_id=goal_id,
        task_type="ticket_classification"
    )

    # Cost constraint: classification is simple, budget accordingly
    policy = router.get_policy(
        constraints={"max_cost_usd": 0.002}  # Hard ceiling per call
    )

    import openai
    client = openai.OpenAI()

    response = client.chat.completions.create(
        model=policy.model,  # Router selects from eligible models under cost ceiling
        messages=[
            {
                "role": "system",
                "content": "Classify the support ticket into one of these categories: "
                           "billing, technical, account, feature_request, complaint, "
                           "shipping, returns, warranty, installation, data, security, other. "
                           "Respond with JSON: {"category": "...", "confidence": 0.0-1.0}"
            },
            {"role": "user", "content": ticket_text}
        ],
        response_format={"type": "json_object"}
    )

    result = response.choices[0].message.content
    cost = _calculate_cost(policy.model, response.usage)

    router.record_outcome(
        success=_is_valid_classification(result),
        quality_score=_parse_confidence(result),
        cost_usd=cost
    )

    return {"result": result, "model": policy.model, "cost": cost}


def synthesize_summary(ticket_text: str, history: list[str], goal_id: str) -> str:
    router = Router(
        goal_id=goal_id,
        task_type="ticket_synthesis"
    )

    # Higher budget for synthesis - quality directly affects human reviewer time
    policy = router.get_policy(
        constraints={"max_cost_usd": 0.05}
    )

    import openai
    client = openai.OpenAI()

    messages = [
        {
            "role": "system",
            "content": "Summarize this support ticket and its history for a senior reviewer. "
                       "Include: core issue, customer sentiment, previous resolution attempts, "
                       "recommended next action."
        },
        {"role": "user", "content": f"Ticket: {ticket_text}nnHistory:n" + "n".join(history)}
    ]

    response = client.chat.completions.create(
        model=policy.model,
        messages=messages
    )

    content = response.choices[0].message.content
    cost = _calculate_cost(policy.model, response.usage)

    router.record_outcome(
        success=_is_useful_summary(content),
        cost_usd=cost
    )

    return content


def _calculate_cost(model: str, usage) -> float:
    # Prices per 1M tokens (update as pricing changes)
    pricing = {
        "gpt-4o": {"input": 5.00, "output": 15.00},
        "gpt-4o-mini": {"input": 0.15, "output": 0.60},
        "gpt-4-turbo": {"input": 10.00, "output": 30.00},
    }
    p = pricing.get(model, {"input": 5.00, "output": 15.00})
    return (usage.prompt_tokens * p["input"] + usage.completion_tokens * p["output"]) / 1_000_000

The max_cost_usd constraint tells the router which models are eligible. Within the eligible set, Thompson Sampling selects based on historical outcome rates for that task type. A model that costs $0.0018 per call but succeeds 91% of the time beats a model that costs $0.0015 per call and succeeds 80% of the time – but both beat the unconstrained gpt-4o at $0.021 per call if your classification task doesn’t need that capability.

The Trust Invariant

Cost constraints work correctly only if the routing system is already optimizing for outcomes. If the router is outcome-first – selecting models based on historical success rates, then filtering by cost – then a cost ceiling safely removes expensive models without degrading reliability below the constraint.

If cost is the primary optimization target, the system will find the cheapest model that passes some minimum bar. That minimum bar is usually defined by your development-time evals, which is a different distribution than production traffic. The result: cost goes down in testing, cost-per-outcome goes up in production.

Kalibr’s router is outcome-first. The max_cost_usd constraint is a filter applied after candidate models are ranked by expected outcome quality. You’re not asking “what’s the cheapest model that might work?” You’re asking “among the models that are likely to work, which ones fit my budget?”

This is the difference between routing and price shopping.

Measuring the Hidden Costs

To actually reduce cost per outcome, you need to measure the components that your billing dashboard hides:

import kalibr

# After a week of tracked outcomes
cost_report = kalibr.get_insights(
    lookback_hours=168,
    include_cost_breakdown=True
)

print(f"Total spend: ${cost_report.total_cost_usd:.2f}")
print(f"Successful outcomes: {cost_report.successful_outcomes}")
print(f"Failed calls (billed): {cost_report.failed_calls_count}")
print(f"Cost on failed calls: ${cost_report.failed_call_cost_usd:.2f}")
print(f"Retry overhead: {cost_report.retry_multiplier:.2f}x")
print(f"Cost per successful outcome: ${cost_report.cost_per_success:.4f}")

for task_type in cost_report.by_task:
    print(f"n{task_type.name}:")
    print(f"  Success rate: {task_type.success_rate:.1%}")
    print(f"  Avg cost/call: ${task_type.avg_cost_per_call:.5f}")
    print(f"  Cost/success: ${task_type.cost_per_success:.5f}")
    print(f"  Over-provisioning flag: {task_type.uses_higher_tier_than_needed}")

The over_provisioning_flag is set when the router’s historical data shows that cheaper models perform equivalently well on a task type but a more expensive model is being selected. This surfaces optimization opportunities that aren’t visible in aggregate cost numbers.

What Changes When You Measure Correctly

The companies that reduce LLM costs meaningfully aren’t the ones that found a cheaper API. They’re the ones that measured cost per successful outcome, found where the hidden costs were concentrated, and optimized those. Typically:

  • 20-30% of spend is on calls that failed but billed full tokens
  • 15-25% is retry overhead from transient failures that weren’t rate-limited correctly
  • 30-50% is model over-provisioning on task types where a cheaper model performs equivalently

None of these appear as line items on your OpenAI invoice. All of them are measurable if you’re tracking outcomes alongside costs.

See When Your AI Agent Should Fail Fast Instead of Retry for how retry decisions interact with cost.