BettingLab

Build a GPT-4o Betting Agent with Tool Calls and the MoneyLine API

Marcus Hale
Marcus Hale

Building a GPT-4o betting agent with tool calls is one of the more useful things you can do with an LLM right now — not because the model knows anything about line value, but because it doesn't need to. You handle the math. The model handles the reasoning loop: deciding which API endpoints to hit, interpreting what comes back, and presenting conclusions a human can act on.

This post walks through a complete implementation: system prompt design, the OpenAI tool-call schema wired to the MoneyLine API, the Python execution loop, and what the agent actually says when it finds a +EV play. No hand-waving. Runnable code throughout.


Why Tool Calls Instead of RAG or Fine-Tuning

Most LLM betting projects I've seen either try to fine-tune a model on historical results (expensive, degrades fast) or stuff a context window with odds data and hope the model spots something (messy, hallucination-prone). Tool calls are cleaner for this problem because:

  1. The model doesn't need to store odds knowledge. It calls out to get fresh data at inference time.
  2. You keep the math in Python. EV calculation, no-vig conversion, Kelly sizing — all deterministic code, not LLM guesswork.
  3. The reasoning trace is auditable. You can see exactly which tools the agent called and why.

The pattern here is: agent receives a user request → decides which MoneyLine API endpoint to call → gets structured JSON back → reasons over it → either calls another tool or answers. Classic ReAct loop, implemented with OpenAI's native tool-call API (no LangChain, no abstraction layers you don't control).


System Prompt Design

The system prompt does three jobs: constrains the domain, tells the model what tools it has, and establishes a decision heuristic it should apply when evaluating edges.

SYSTEM_PROMPT = """
You are a sharp sports betting analyst. You have access to real-time odds and 
edge data via the MoneyLine API. Your job is to help the user find positive 
expected value (+EV) bets.

Rules:
1. Never recommend a bet without first fetching live edge data from the API.
2. When you fetch edges, filter for ev_pct > 3.0 and sort by ev_pct descending.
3. Report the market implied probability, the fair probability (no-vig), and 
   the EV percentage for every play you surface.
4. Be skeptical of edges under 2% — they're within the noise band for most 
   retail bettors.
5. Always mention which book is offering the line and whether that book is 
   known for limiting sharp action.
6. Do not discuss historical win rates of teams or players — that data is 
   not in your tools and you should not hallucinate it.
7. If the user asks about arbitrage, call the /v1/edge endpoint with 
   type=arbitrage.

You are concise. You do not hype plays. You surface data and let the user decide.
"""

Key design decisions:


Tool Schema Definition

We're defining two tools: one to fetch live edges from /v1/edge and one to fetch the current odds markets from /v1/odds for a specific event. That's enough to cover the main user flows.

import openai
import requests
import json

API_BASE = "https://mlapi.bet"
MONEYLINE_API_KEY = "your_api_key_here"

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_edges",
            "description": (
                "Fetch current positive EV and arbitrage edges from the MoneyLine API. "
                "Returns a list of edges with ev_pct, fair_prob, book, and market details."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "sport": {
                        "type": "string",
                        "description": "Sport slug: mlb, nfl, nba, soccer, etc. Omit for all sports."
                    },
                    "type": {
                        "type": "string",
                        "enum": ["ev", "arbitrage", "all"],
                        "description": "Type of edge to fetch. Defaults to 'ev'."
                    },
                    "min_ev": {
                        "type": "number",
                        "description": "Minimum EV percentage to return (e.g. 3.0 for 3%)."
                    },
                    "limit": {
                        "type": "integer",
                        "description": "Max number of edges to return. Defaults to 10."
                    }
                },
                "required": []
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_event_odds",
            "description": (
                "Fetch the full odds market for a specific event by event_id. "
                "Returns all books, all markets, and the no-vig fair line."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "event_id": {
                        "type": "string",
                        "description": "The MoneyLine API event ID."
                    },
                    "market": {
                        "type": "string",
                        "description": "Market type: moneyline, spread, total, props. Defaults to moneyline."
                    }
                },
                "required": ["event_id"]
            }
        }
    }
]

The Execution Loop

This is the core of the agent. It's a standard while loop that keeps processing tool calls until the model either finishes or hits a max-iterations guard.

def call_moneyline_api(tool_name: str, args: dict) -> dict:
    headers = {
        "Authorization": f"Bearer {MONEYLINE_API_KEY}",
        "Content-Type": "application/json"
    }

    if tool_name == "get_edges":
        params = {
            "type": args.get("type", "ev"),
            "limit": args.get("limit", 10),
        }
        if "sport" in args:
            params["sport"] = args["sport"]
        if "min_ev" in args:
            params["min_ev"] = args["min_ev"]

        resp = requests.get(
            f"{API_BASE}/v1/edge",
            headers=headers,
            params=params,
            timeout=10
        )
        return resp.json()

    elif tool_name == "get_event_odds":
        resp = requests.get(
            f"{API_BASE}/v1/odds",
            headers=headers,
            params={
                "event_id": args["event_id"],
                "market": args.get("market", "moneyline")
            },
            timeout=10
        )
        return resp.json()

    return {"error": f"Unknown tool: {tool_name}"}


def run_betting_agent(user_message: str) -> str:
    client = openai.OpenAI()  # uses OPENAI_API_KEY env var

    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_message}
    ]

    max_iterations = 5
    iteration = 0

    while iteration < max_iterations:
        iteration += 1

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
            temperature=0.1  # low temp for analytical tasks
        )

        choice = response.choices[0]
        messages.append(choice.message)

        # If no tool calls, we're done — return the final answer
        if choice.finish_reason == "stop" or not choice.message.tool_calls:
            return choice.message.content

        # Process all tool calls in this response
        for tool_call in choice.message.tool_calls:
            fn_name = tool_call.function.name
            fn_args = json.loads(tool_call.function.arguments)

            print(f"[agent] Calling tool: {fn_name} with args: {fn_args}")

            api_result = call_moneyline_api(fn_name, fn_args)

            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": json.dumps(api_result)
            })

    return "Agent hit max iterations without a final answer."


# Example usage
if __name__ == "__main__":
    result = run_betting_agent(
        "Find me the best MLB +EV plays right now, minimum 3% edge."
    )
    print("\n=== AGENT RESPONSE ===")
    print(result)

A few implementation notes worth flagging:


What the Agent Actually Outputs

With a prompt like "Find me the best MLB +EV plays right now, minimum 3% edge.", the agent will call get_edges with sport=mlb, min_ev=3.0, receive the JSON, and produce something like:

Here are the top MLB +EV plays as of right now:

1. **Mariners ML vs. Astros** — DraftKings @ +165
   - Fair line (no-vig): +148
   - Market implied prob: 37.7%
   - Fair prob: 40.3%
   - EV: +6.9%

2. **Cubs Team Total Over 4.5** — FanDuel @ -108
   - Fair line: -120
   - Market implied prob: 51.9%
   - Fair prob: 54.5%
   - EV: +4.2%

3. **Phillies -1.5** — Bet365 @ +195
   - Fair line: +178
   - Market implied prob: 33.9%
   - Fair prob: 36.0%
   - EV: +3.8%

Note: Bet365 has a history of limiting sharp accounts. If you're already on their radar, 
the Mariners ML on DraftKings is the cleaner play for account longevity. The Cubs 
team total is a lower-profile market that tends to stay open longer.

The last paragraph is the model applying Rule 5 from the system prompt — it surfaced the book-level context unprompted because we told it to. That's the value of a tight system prompt over a generic one.

If you want the agent to drill into the Mariners game specifically, you'd ask it to call get_event_odds with that event's ID, and it'll return the full cross-book spread so you can see where the market has drifted.


Extending the Agent

Once this loop is running, a few natural extensions:

Discord bot wrapper — Wrap run_betting_agent() in a discord.py command handler. Users post !ev mlb in a channel, the agent runs, posts the table back. Takes about 40 lines of glue code.

Scheduled polling — Run the agent on a cron job every 15 minutes during game windows. Log agent outputs to a database, alert when edge exceeds a threshold. Combine with the EV betting guide methodology for bankroll-sizing context.

Multi-agent arbitrage scanner — One agent watches /v1/edge?type=arbitrage via get_edges. A second agent verifies the play by calling get_event_odds on the flagged event and checking whether the arb legs are still live. See also the arbitrage betting reference for the staking math your verification agent should apply.

Embedding search over historical edges — Pull your edge history from the API, embed each edge as a vector (event, market, book, ev_pct, outcome), store in pgvector or Chroma, then let the agent retrieve similar historical edges as context before making a recommendation. This is where LLMs genuinely add something over a pure rules engine: pattern-matching over history that's hard to express as a SQL query.

Compare the MoneyLine API's edge data against other providers at /build/compare before you commit to an integration — the depth of no-vig line data is what makes the tool-call pattern tractable.


Frequently Asked Questions

Does GPT-4o have current sports knowledge that could conflict with the API data?

Yes, and that's exactly the problem Rule 6 in the system prompt addresses. GPT-4o's training data has a cutoff; it knows things about teams, players, and historical betting patterns. Without an explicit prohibition, it will blend that stale knowledge with live API data. Keep the model in its lane: it reasons over data you provide, nothing else.

How many API credits does a typical agent session consume?

Each tool call to /v1/edge or /v1/odds costs credits against your MoneyLine API quota. A single-turn agent conversation typically makes 1-3 tool calls. On the free tier (1k credits/month), that's roughly 300-1000 conversations depending on how chatty your prompts are. For production use cases with scheduled polling, you'll want a paid plan.

Can I use Claude instead of GPT-4o?

Yes. Claude 3.5 Sonnet (Anthropic) supports the same tool-use pattern via their API. The tool schema format differs slightly — Anthropic uses input_schema instead of OpenAI's parameters — but the logic is identical. The system prompt design carries over unchanged.

Why not use LangChain or LlamaIndex for this?

I've shipped both. For this specific use case — a handful of well-defined tools, a tight system prompt, and full visibility into the message loop — raw OpenAI SDK is less friction. LangChain adds value when you're orchestrating many tools across many agents. Here it would just add a dependency and obscure what's happening in the loop.

How do I prevent the agent from recommending a play when the edge has already moved?

The agent fetches live data at call time, but there's still latency between the API call and when a user actually places a bet. Add a fetched_at timestamp to every tool response and have the system prompt instruct the agent to surface that timestamp in its output. Users should treat anything older than 2-3 minutes as potentially stale for fast-moving lines.

Build with the same data we use.

MoneyLine API powers BettingLab's edge calculations. Free tier, 1k credits/month.