Autonomous ESPN Fantasy Baseball manager that optimizes lineups, handles waivers, and proposes trades using live stats.
SaferSkills independently audited bbagent (MCP Server) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
A competitive agent that owns and operates one team in an ESPN H2H points league: lineup + IL optimization, waiver add/drop, and trades (proposing fair offers out, reacting to declines, evaluating incoming). It scouts with live MLB StatsAPI data blended with ESPN's projections. Every move it decides is appended to actions.log, and it can email a readable report each run.
It runs two ways — the analysis is identical, only who drives the loop differs:
| Deterministic / API | Claude Code (MCP) | |
|---|---|---|
| Driver | built-in logic, or an Anthropic-API LLM loop | Claude Code, on your Pro/Max subscription |
| Cost | free (plan) / API credits (run) | your subscription — no API key |
| Entry point | python espn_agent.py … | bbagent_mcp.py over MCP |
pip install -r requirements.txt # requests; mcp[cli] for Claude Code; anthropic for `run`
cp .env.example .env # then edit .env — see belowPut your secrets + league identity in .env (gitignored):
BBAGENT_ESPN_S2=<long cookie>
BBAGENT_SWID={<cookie-with-braces>}
BBAGENT_LEAGUE_ID=<number in your league URL>
# optional: BBAGENT_AGENT_TEAM_ID=<id> (otherwise auto-resolved from SWID)Get ESPN_S2 and SWID from a logged-in browser: DevTools → Application → Cookies on fantasy.espn.com (SWID includes the curly braces).
Non-secret tuning (scouting weight, trade thresholds, slot ids, email host) lives in bbagent.config.json, which is safe to commit. Config resolves highest-priority first:
BBAGENT_<KEY> env var → bbagent.local.json → bbagent.config.json → defaultVerify what loaded (secrets masked):
python espn_agent.py config # prints every resolved value + source
python espn_agent.py selftest # offline, no network — proves the logic
python espn_agent.py discover # lists teams; marks your auto-matched team *python espn_agent.py plan # print-only: IL, lineup, waivers, fair tradesplan is read-only by default — it analyzes, logs every recommendation to actions.log, emails the report if email is configured, and submits nothing. To actually act, add flags (see §4 enabling writes):
python espn_agent.py plan --execute # simulate the moves (dry-run payloads)
python espn_agent.py plan --execute --commit # really submit moves + trade offersUseful one-off flags: --no-scouting, --scouting-weight 0.7, --max-trades 2, --email / --no-email.
The same tools are exposed over MCP; Claude Code is the agent loop. From this directory (it ships a .mcp.json, so the bbagent server auto-registers — approve it once):
pip install "mcp[cli]" # required for the server (the --selftest below skips it)
python bbagent_mcp.py --selftest # offline smoke test of every tool
claude # start Claude Code here — see approval note below
# then ask: "Use bbagent to manage my team to win this week."First run: approve the server. A project-scoped server from.mcp.jsonstays⏸ Pending approvaluntil you approve it once in an interactiveclaudesession (it prompts on launch;/mcpshows status). Until then its tools don't load and Claude will say it can't find them.claude mcp listshould readbbagent: ... ✓ Connectedonce it's good. (--selftestpasses even when themcppackage is missing — it uses fixtures — so a green selftest does not prove the live server will start; thepip installabove is what does.)
Claude Code calls full_plan, check_il, optimize_lineup, scout_trades, review_declined_trades, etc. and proposes a batch. Agent guidance lives in CLAUDE.md. Writes stay dry-run until BBAGENT_COMMIT_WRITES=1 (in .mcp.json env).
It emails you the same report the deterministic version does: the email_report tool renders through the identical plain-text + HTML template, so a recipient can't tell an agent-driven pass from a deterministic one — except for a single Run by: line (Claude Code agent vs. deterministic engine). CLAUDE.md tells Claude to call it at the end of every pass, populated with what it actually decided. (full_plan also auto-emails its dry-run plan when SMTP is set.) SMTP config: host in bbagent.config.json, SMTP_PASS in .env — see §1. Without SMTP configured it no-ops with a clear "not configured" message.
export ANTHROPIC_API_KEY=sk-ant-...
python espn_agent.py run "Manage my team to win this week."Same tools, driven by an API model instead of Claude Code. Costs credits.
Fantasy management is a daily job, so the robust pattern is a scheduled run, not a long-lived daemon. Pick the run method (3.1–3.3) — that decides the command. Then schedule that command with your platform's scheduler (3.4). All of this assumes Setup (§1) is done on the host and, for live writes, that you've completed the write verification (§4).
In the scheduler examples below, RUN_CMD stands for whichever command you chose. Replace /path/to/bbagent (or C:\path\to\bbagent) with the real path, and python with python3 if that's your interpreter name.
Most reliable to automate: one deterministic command, no model in the loop. Logs to actions.log and emails the report each run.
RUN_CMD = python espn_agent.py plan --execute --commitDrop --commit (or --execute) to email yourself recommendations without touching the roster.
Drives the same MCP server on your Pro/Max subscription (no API key). The host must have claude installed and authenticated — either log in interactively once (claude), or set a CLAUDE_CODE_OAUTH_TOKEN from claude setup-token for a headless host (same token the container uses, see §3.6). For it to submit moves, set BBAGENT_COMMIT_WRITES=1 in .mcp.json's env block.
RUN_CMD = claude -p "Use bbagent to run a full management pass: optimize the lineup, work the waivers, and send any fair trade offers. Then summarize what you did." --mcp-config .mcp.json --allowedTools "mcp__bbagent__*"Two things make this work headlessly, and both bite silently if missed:
.mcp.json server is⏸ Pending approval until approved in a real claude session — and headless -p can't show that prompt, so the tools never load and the run does nothing useful. Run claude once on the host and approve bbagent (verify with claude mcp list → ✓ Connected) before scheduling.
--permission-mode acceptEdits onlyauto-accepts file edits, not tool/command calls — a headless run under it stalls waiting for approval. Use --allowedTools "mcp__bbagent__*" (allow just the bbagent tools, as above) or --dangerously-skip-permissions (allow everything).
Prefer this mode when you want model judgment on close calls; prefer 3.1 for pure determinism.
Same tools, driven by an API model; bills credits per run. Needs ANTHROPIC_API_KEY in the environment (put it in .env).
RUN_CMD = python espn_agent.py run "Run a full management pass and act on it."RUN_CMD on your platformEach example runs daily at 9:00 AM. Secrets come from .env on the host (or BBAGENT_* env vars) — never hard-code them in the scheduler entry.
macOS — launchd (per-user, survives reboots). Save as ~/Library/LaunchAgents/sh.laggi.bbagent.plist, then launchctl load ~/Library/LaunchAgents/sh.laggi.bbagent.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>sh.laggi.bbagent</string>
<key>WorkingDirectory</key><string>/path/to/bbagent</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string><string>-lc</string>
<string>python3 espn_agent.py plan --execute --commit</string>
</array>
<key>StartCalendarInterval</key><dict>
<key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer>
</dict>
<key>StandardOutPath</key><string>/path/to/bbagent/cron.log</string>
<key>StandardErrorPath</key><string>/path/to/bbagent/cron.log</string>
</dict></plist>(macOS also has cron; the Linux line below works there too.)
Linux — cron. crontab -e, then add:
0 9 * * * cd /path/to/bbagent && python3 espn_agent.py plan --execute --commit >> cron.log 2>&1For Claude Code / API on Linux, cron drops most env vars — source .env first:
0 9 * * * cd /path/to/bbagent && set -a && . ./.env && set +a && RUN_CMD >> cron.log 2>&1Windows — Task Scheduler. In PowerShell (one line):
schtasks /create /tn bbagent-daily /sc daily /st 09:00 /tr "cmd /c cd /d C:\path\to\bbagent && python espn_agent.py plan --execute --commit >> cron.log 2>&1"Swap the python ... part for your chosen RUN_CMD. Run schtasks /run /tn bbagent-daily once to test it immediately.
Platform-agnostic and the easiest to host: one always-on container that watches the league on a short interval and runs a full pass every tick — no OS scheduler to wire up. A watcher (not a once-a-day batch) is the right model here because lineups, injuries, incoming/declined offers, and waiver windows all move on their own clock; polling every few minutes catches them as they happen. The repo ships a Dockerfile, a .dockerignore, and run-loop.sh (poll → full pass → sleep INTERVAL → repeat). Build and run:
docker build -t bbagent .
docker run -d --name bbagent --restart unless-stopped --env-file .env bbagent--env-file .env injects your secrets at run time; .dockerignore keeps .env out of the image so they're never baked in. --restart unless-stopped is what makes it "always running" across reboots and crashes.
Running a full pass every tick is safe to repeat: plan reads ESPN's pending transactions and won't re-offer players already tied up in a pending trade, declined offers are routed to counter-or-abandon (never blindly re-sent), and waiver claims are bounded by max_waivers (default 1/pass). So a 10-minute poll reacts fast without spamming offers or churning the roster.
Override the command or poll cadence with env vars (defaults: the deterministic plan --execute --commit, every 600s = 10 min):
docker run -d --env-file .env \
-e RUN_CMD="python espn_agent.py plan" \ # watch + recommend only, no writes
-e INTERVAL=300 \ # poll every 5 min
bbagentLogs go to loop.log inside the container (docker logs bbagent also works). All three run modes work in the container — the image bundles the claude CLI, so the Claude Code (3.2) path runs in-container too (see §3.6 for headless auth).
The image ships Node + the claude CLI, so you can drive the Claude Code run mode without a host install. The only catch is auth: there's no interactive browser login in a container, so inject a token at run time instead. Two options:
in, run claude setup-token once and copy the printed token. Pass it as CLAUDE_CODE_OAUTH_TOKEN.
ANTHROPIC_API_KEY (bills credits).Put whichever you use in .env (it's gitignored and injected with --env-file), then point RUN_CMD at claude:
docker run -d --name bbagent --restart unless-stopped --env-file .env \
-e RUN_CMD="claude -p 'Use bbagent to run a full management pass: optimize the lineup, work the waivers, and send any fair trade offers. Then summarize what you did.' --mcp-config .mcp.json --dangerously-skip-permissions" \
bbagentTwo gotchas, both unique to running headless in a container — neither has an interactive escape hatch, so get them right up front:
.mcp.json server normally needs a one-timeinteractive approval, which a container can't show — so --mcp-config .mcp.json alone leaves the tools ⏸ Pending forever and the pass does nothing. --dangerously-skip-permissions bypasses both the trust approval and the per-call tool prompts, which is what makes the server load unattended. (On a host you'd instead approve once with claude and use the narrower --allowedTools "mcp__bbagent__*"; see §3.2.)
requirements.txt, so theimage's pip install covers it — just don't strip it out. Without it the server fails to start even though --selftest (fixture-based) still passes.
Writes still stay dry-run until BBAGENT_COMMIT_WRITES=1 in .mcp.json's env block. The watcher reruns this every INTERVAL like any other RUN_CMD.
Writes are gated behind the COMMIT_WRITES switch (--commit, or BBAGENT_COMMIT_WRITES=1) and use ESPN's v3 transactions endpoint. The payload shapes (lineup ROSTER moves, ADD/DROP, TRADE) follow ESPN's protocol, but the write API is unofficial — verify once before trusting it:
would_post payload every dry-runprints (plan --execute without --commit).
--commit. If ESPN tweaked the shape, adjustEspnClient._post_txn / the item builders to match.
Until then, nothing is ever POSTed — you only see the payloads.
Note: writes succeed only for a team yourSWIDowns or co-manages. PointingAGENT_TEAM_IDat another team is fine for analysis/email, but ESPN will reject write attempts to a roster you don't control.
By default plan blends live MLB StatsAPI rest-of-season projections with ESPN's own (scouting_weight = the external share). Tune or disable via config (use_scouting, scouting_weight) or flags (--no-scouting, --scouting-weight). You can also feed custom projection CSVs:
from espn_agent import EspnClient, plug_projections
import projections as proj
client = EspnClient()
overrides, unmatched = proj.build_overrides(
client, hitters_csv="hitters.csv", pitchers_csv="pitchers.csv")
plug_projections(overrides)espn_agent.py — ESPN client, deterministic core, CLI, API loop, email.bbagent_mcp.py — MCP server exposing the toolset to Claude Code (no API key).scouting.py — MLB-StatsAPI outside source → league-scored ROS values.projections.py — external-CSV → league-scored value pipeline.bbagent.config.json — non-secret tuning knobs (committed)..env — secrets + league identity (gitignored; see .env.example)..mcp.json — Claude Code server config (auto-discovered in this directory).CLAUDE.md — operating instructions Claude Code loads automatically.actions.log — append-only audit of every move (gitignored).Dockerfile — image with Python deps + Node/claude CLI; runs all three runmodes (§3.5–3.6).
run-loop.sh — the container watcher loop (poll → full pass → sleepINTERVAL); the image's default CMD, also runnable standalone.
.dockerignore — keeps secrets (.env), logs, and local cruft out of the image.loop.log — the container watcher's output (gitignored; docker logs too).~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.