WORK

Fantasy Baseball AI GM

Independent Build · Agentic AI Pipeline · 2026

Runs my fantasy roster every day of the season, completely unattended, and hasn’t needed a human in the loop since.

This system manages my fantasy baseball roster on its own, every day of the season. It pulls live league and player data, decides what needs to happen today, has an LLM write the recommendation in plain English, then checks that recommendation against the data before sending it. No one has to prompt it. It runs on a schedule and emails the result.

I’m Brian, and I built this to run my own team. The public repo is a sanitized copy of the working system — the decision logic, the prompts, the node graph. I replaced my league’s private identifiers and a couple of opponent-specific details with placeholders. Nothing else has been changed.

Why I built this

I wanted to know what it actually takes to make an AI system reliable enough to run completely unattended — something that has to be right on a random Tuesday morning in July, when nobody’s checking it. That turned out to be a harder problem than writing a good prompt.

A chatbot answers a question you ask it, right then. This system wakes up on a schedule with no prompt from me, decides what needs attention, and acts, or reports that nothing does. Every recommendation also gets logged as structured data, not just prose: add this player, drop that one, activate this bench bat. After the AI writes that recommendation, a separate deterministic step checks it against the real roster and rankings and flags anything that doesn’t match. And it remembers state across days, how many roster moves are left this week, what’s already been activated, what the schedule looked like yesterday versus today.

An early version of the Fantasy GM n8n workflow, showing Yahoo Fantasy Sports API data retrieval, context assembly, and Claude analysis nodes
An early version of the pipeline, mid-build — before scheduling, before the deterministic guards described below.

How it works

The AI never computes a fact. It only writes sentences from facts that something else already decided, and everything it writes gets checked against those same facts before it goes out. That’s the reason this system can run unattended.

n8n runs the whole thing: about 50 nodes, a scheduled trigger, retry and error handling. Every deterministic decision — category math, roster legality, rotation projections, identity checks — is plain JavaScript in n8n’s Code nodes. Claude writes the report and trade verdicts from decided facts, with extended thinking enabled so its reasoning stays in a separate channel instead of leaking into the final text. Claude Code built and debugged the system, in live sessions against the actual running workflow. The data itself comes from the Yahoo Fantasy API, the MLB Stats API, and Baseball Savant.

The system is really four coordinating pieces. The Daily GM Pipeline is the one described above, and the one I’ve documented in the most depth. A Trade Evaluator scores a pending or hypothetical trade against the rest of the season, not just the current week, and checks whether multiple pending offers would collide. A Trade Finder proactively scans the rest of the league for complementary trade partners before an offer even exists. A League Scout profiles every other team’s roster and tendencies and feeds the two trade tools above. The last three are lighter-weight and share the same architecture as the daily pipeline, just applied to a narrower question.

What actually broke, and what that taught me

None of the failures worth mentioning were “the AI is dumb.” They were cases where I’d asked a language model to do something a few lines of deterministic code should have handled instead: check an identity, rank a list, compare two numbers. The model usually got these approximately right, and that turned out to be more dangerous than being obviously wrong, because a wrong answer that looks reasonable is the one nobody catches. The fix, whenever it held, was the same: find what’s actually checkable, check it in code, and let the LLM’s job shrink down to writing good sentences about facts it’s handed.

The clearest example: early in a scoring week, the daily report confidently declared my team “cruising to a likely sweep.” My actual pitching line at that point was one inning pitched, a 27.00 ERA, and a 5.00 WHIP. A single bad outing, read as a commanding lead.

The bug was that the category-comparison logic trusted Yahoo’s own “who’s currently ahead” flag at face value. That flag credits a leader the instant either side posts any value, even a terrible one, if the other side hasn’t posted anything yet. Early in a week, that’s most categories, most days. It wasn’t just a bad headline. That same flag fed directly into the logic deciding whether a category was worth spending a scarce weekly roster move on — so a category that was actually still wide open was getting marked settled, and the system would never flag it as worth acting on. The fix was to stop trusting a “leader” flag when either side hasn’t posted a real value yet. Absent, not zero. A category with no real data on either side reads as unresolved, full stop, no matter what the raw numbers say.

A second failure took three tries before I found the real fix. The model would occasionally leave visible self-correction in a final report — a first recommendation, a “wait, actually,” and a revised one, both left in the email. I tried a single sentence forbidding visible reconsideration. Reduced it, didn’t eliminate it. I tried a more structural instruction: work everything out silently, then write once, straight through. Fixed the specific case that prompted it, then recurred on a different one days later. I widened that instruction to cover the whole report. Still recurred.

Three attempts at solving this through prompt wording had failed. The problem was structural: asking a model to keep its own reasoning invisible inside one continuous stream of text is an unnatural constraint, because there’s no real boundary between “thinking” and “the answer” unless something gives it one. Claude’s extended-thinking mode does exactly that: reasoning happens in a genuinely separate channel that never becomes part of the visible response. Turning it on is what actually stopped the leaking.

It came with its own complication. My first attempt used an adaptive thinking-effort setting at its highest level, and it timed out — a hard platform-level request ceiling, given how much context this prompt carries (every candidate player’s recent stats, projections, and matchup data). A second attempt at a lower effort level hit the exact same wall, at nearly the same elapsed time, which was the tell that the problem wasn’t the effort level at all. “Adaptive” doesn’t bound generation time the way a fixed budget does. Switching to a small, hard-capped thinking budget fixed it: the model still gets a genuine separate reasoning channel, generation finishes reliably in about two minutes, and the leaking hasn’t come back since.

A third bug flipped on the exact day it mattered most. The system’s sense of “how late is it in the scoring week” assumed a Sunday-through-Saturday week; my league’s actual week runs Monday-through-Sunday. That meant Sunday, the real last day of the week, was being read as the first day of a fresh one — and the system would happily suggest spending a move on a category that had already locked in. I fixed the week-boundary assumption. The same symptom came back a few hours later, on the correct day this time. The date math was reading the day of the week in server time instead of my actual US time zone, so near UTC midnight, which falls in the early evening here during baseball season, the calculation would quietly roll over to “tomorrow” before it actually was tomorrow. The fix was to anchor every date calculation to the same already-correct, timezone-aware date string used elsewhere in the system, instead of asking a raw date object what day it thinks it is. A fix that’s correct in isolation can still fail in production because of an entirely separate, adjacent assumption. The fastest way I found the second bug was a real person, at the actual moment it mattered, telling me it was still Sunday evening where they were, and me taking them at their word over what the code confidently calculated.

The last one: a “which available pitcher should I stream” ranking favored volume, two starts beating one, far more strongly than it should have. A pitcher on a real losing streak with two starts that week kept outranking a healthier pitcher with better recent form and only one start. The ranking logic was mathematically fine on quality; it just weighted start-count as an unconditional override instead of a tiebreaker. I re-ordered it to lead with actual recent performance, and let two-start volume only break a near-tie. Even after that fix, the language layer could still pick the lower-ranked option and justify it with a cherry-picked good outing instead of the more reliable larger sample the ranking used — which is exactly what a separate ranking-deviation guard exists to catch: not to stop the model from ever disagreeing with the ranking, but to make sure that disagreement is visible instead of silently trusted.

An n8n AI Agent node mid-build, showing an early fantasy GM briefing output format
Mid-build, testing an earlier report format than what shipped.

The system making a decision

Here’s a real report the live system generated, right after the fix for the false-leading-category bug above, reproduced exactly as it went out:

TODAY’S MOVES:

Activate Trevor Rogers (confirmed start Thursday, last 14 days ERA 0.79) and Framber Valdez (confirmed start Thursday; ERA rough lately, but the rotation projection and rest-of-season value say hold him). Neither move costs a weekly add.

THE THINKING:

Both moves are simple activations of pitchers I already own. The two adds stay in the bank. It’s early in the week, the pitching floor is already projected to be met, and burning an add on a mediocre streamer when two rostered starters are available for free is bad economics.

WHERE WE STAND:

The category picture is almost entirely unformed. We have one inning pitched with a 27.00 ERA and 5.00 WHIP that will look nothing like this once the starters roll this week.

That last line is the corrected version of the exact bug described above. It refuses to call a single bad inning a real lead, and it holds two scarce roster moves in reserve instead of spending one on something with no time pressure. Two things an earlier version of this system got wrong, both fixed in the same pass.

What I'd tell someone building something similar

Three weeks of evenings, about 38 commits, debugged live against the actual running workflow rather than built once and left alone. If I had to compress what I learned into one sentence: if it’s checkable, it’s not the model’s job. Category math, roster legality, rankings, timing — all of it moved into deterministic code, with the model’s actual job shrunk down to writing good sentences about facts it’s handed, and checked again after it writes them.