Guides/Build a Scanning Agent

    Guides

    Build a Scanning Agent

    An AI agent with scanr.ai can answer questions like "which beaten-down large caps are starting to turn up, and which of them are in strong sectors?" by building a scan, running it and reading the results. This guide covers the two ways to get there, and the design choices that make the agent reliable.

    Option 1: no code, with MCP

    If the agent is Claude Code, Claude Desktop, Cursor, VS Code or Codex, connect the MCP server and install the Agent Skill. The client gets the scan tools, and the skill tells the model how to use them. Then ask:

    Find stocks above their 200-day moving average with RSI under 35 and market cap over $5B. Show me what the scan checks before you run it.

    That covers most interactive use. Build your own agent when you need it inside your product, on a schedule, or with tools the MCP server doesn't have (relative strength, themes, your own data).

    Option 2: your own agent

    The agent below uses the Claude API with four tools over the scanr.ai REST API. The model decides when to call them; the SDK's tool runner handles the loop.

    Design the tools around the workflow

    The tools mirror the reliable scanning workflow (generate → refine → run → enrich), and two design choices matter more than anything else:

    • The model never writes payload JSON. natural_scan and refine_scan produce payloads on the server, and the agent stores them and hands the model a short payload id. The model passes ids between tools instead of re-typing large JSON, which is where agents otherwise introduce errors.
    • Results are trimmed before the model sees them. A broad scan can match a thousand tickers. run_scan returns the count and the first 50 in sort order, which is what the model needs to reason with.

    The code

    python
    """A scanning agent: Claude + scanr.ai. pip install anthropic requests"""import jsonimport osimport time
    import anthropicimport requestsfrom anthropic import beta_tool
    SCANR = "https://scanr.ai/api/v2"SCANR_HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"}PAYLOADS: dict[str, dict] = {}  # payload id -> scan payload, kept out of the model's context
    
    def scanr(path: str, body: dict) -> dict:    response = requests.post(SCANR + path, headers=SCANR_HEADERS, json=body, timeout=120)    if response.status_code >= 400:        # Hand the error to the model: a 400 names the field that is wrong.        return {"error": response.status_code, "message": response.text[:500]}    return response.json()
    
    def remember(payload: dict) -> str:    payload_id = f"p{len(PAYLOADS) + 1}"    PAYLOADS[payload_id] = payload    return payload_id
    
    @beta_tooldef natural_scan(description: str) -> str:    """Turn a plain-English stock screen into a scan payload. Does not run it.
        Returns a payload_id and the reasoning (a summary and one line per filter).    Show the summary to the user before running the scan.
        Args:        description: The screen in plain English, with concrete thresholds where known,            e.g. "market cap over $5B, above the 200-day moving average, RSI(14) under 35".    """    result = scanr("/scan/natural", {"query": description})    if "error" in result or not result.get("conversion_success"):        return json.dumps({"error": result.get("message") or result.get("validation_errors")})    return json.dumps({        "payload_id": remember(result["generated_payload"]),        "reasoning": result.get("reasoning"),    })
    
    @beta_tooldef refine_scan(payload_id: str, change: str) -> str:    """Change an existing scan payload in plain English, keeping its other filters.
        Returns a new payload_id and the updated reasoning.
        Args:        payload_id: The id returned by natural_scan or an earlier refine_scan.        change: What to change, e.g. "only large caps" or "use the weekly chart".    """    if payload_id not in PAYLOADS:        return json.dumps({"error": f"unknown payload_id {payload_id}"})    result = scanr("/scan/refine", {        "existing_payload": PAYLOADS[payload_id],        "refinement_query": change,    })    if "error" in result or not result.get("refinement_success"):        return json.dumps({"error": result.get("message") or result.get("validation_errors")})    return json.dumps({        "payload_id": remember(result["refined_payload"]),        "reasoning": result.get("reasoning"),    })
    
    @beta_tooldef run_scan(payload_id: str) -> str:    """Run a scan payload over the whole U.S. market.
        Returns the match count and up to 50 matching tickers, largest first.
        Args:        payload_id: The id of the payload to run.    """    if payload_id not in PAYLOADS:        return json.dumps({"error": f"unknown payload_id {payload_id}"})    result = scanr("/scan", {"instructions": PAYLOADS[payload_id]})    if "error" in result:        return json.dumps(result)    tickers = result.get("tickers") or []    return json.dumps({"count": result["count"], "tickers": tickers[:50]})
    
    @beta_tooldef relative_strength(tickers: list[str]) -> str:    """Latest relative-strength percentile (1-99, 99 strongest) for up to 50 tickers,    against the market, the sector and the industry. Use it to rank scan results.
        Args:        tickers: Ticker symbols, e.g. ["NVDA", "AMD"].    """    since = int((time.time() - 5 * 86400) * 1000)    result = scanr("/stocks/get-stock-indicators", {"tickers": tickers[:50], "from": since, "limit": 500})    if "error" in result:        return json.dumps(result)    latest: dict[str, dict] = {}    for row in result.get("results") or []:        if row["ticker"] not in latest or row["date"] > latest[row["ticker"]]["date"]:            latest[row["ticker"]] = row    return json.dumps({        t: {k: row.get(k) for k in ("rs_score", "rs_sector_score", "rs_industry_score", "sector")}        for t, row in latest.items()    })
    
    SYSTEM = """You are a stock-screening assistant with access to scanr.ai.
    To answer a screening question:1. Call natural_scan with the user's criteria, stated concretely.2. Tell the user in one or two sentences what the scan checks (from the reasoning).   If it misread the request, fix it with refine_scan rather than starting over.3. Call run_scan. If it matches nothing, loosen the strictest filter with refine_scan   and say what you changed. If it matches hundreds, tighten it.4. When ranking or comparing matches, call relative_strength on the top results.5. Answer with the count, the most relevant tickers and why they qualify.
    Scans read live data: during U.S. market hours, daily indicators include today'sunfinished bar, so say results are provisional. You screen; you don't giveinvestment advice."""
    client = anthropic.Anthropic()
    
    def ask(question: str) -> str:    runner = client.beta.messages.tool_runner(        model="claude-opus-5-5",        max_tokens=16000,        system=SYSTEM,        tools=[natural_scan, refine_scan, run_scan, relative_strength],        output_config={"effort": "medium"},        # If a request is declined by a safety classifier, retry it on the        # recommended fallback model instead of stopping.        betas=["server-side-fallback-2026-07-01"],        fallbacks="default",        messages=[{"role": "user", "content": question}],    )    final = runner.until_done()    if final.stop_reason == "refusal":        return "The request was declined."    return "".join(block.text for block in final.content if block.type == "text")
    
    if __name__ == "__main__":    print(ask(        "Find large caps (over $10B) that are above their 200-day moving average "        "but pulled back to an RSI under 40. Rank the top ones by relative strength."    ))

    Run it with SCANR_API_TOKEN and ANTHROPIC_API_KEY set. A typical run makes one natural_scan call, sometimes a refine_scan, one run_scan and one relative_strength call: a few scanr.ai requests and a few thousand AI credits. The relative_strength tool needs a Power User or Business token; on Starter, leave it out.

    Adapting it

    • Other frameworks. The four functions are plain HTTP calls. Wrap them as tools in any agent framework, or generate them from the OpenAPI spec.
    • More tools. Useful additions are market status (GET /misc/market-status), security search (GET /stocks/search-securities) for resolving company names, theme performance (Business) for "which groups are leading?", and saving a scan (POST /scan/save-filters) so the user can follow it.
    • Payloads from the scanner. Users often have the screen already. Add an import_scan tool that takes JSON the user exported from the scanr.ai scanner (Filters → Export filters), stores it with remember(), and returns a payload id. Refine and run it like a generated one, with no AI credits.
    • Hand-written payloads. If you want the model to write payloads directly (for full control, or to skip AI credits), give it Building Scan Payloads as context and a run_payload tool that accepts JSON, and return the API's 400 messages to it verbatim. It works, but expect more retries than with natural_scan.
    • Scheduled agents. Run ask() from a scheduler after the close for a daily written briefing; see Run an End-of-Day Screen.

    What makes these agents reliable

    • One scan, not a crawl. Agents left alone sometimes loop over tickers calling per-stock endpoints. The system prompt and tool descriptions steer them to scan first; keep it that way.
    • Show the screen before the result. Having the agent say what the scan checks catches misreadings ("small caps" read as under $2B when the user meant under $300M) before they turn into confident wrong answers.
    • Treat errors as instructions. Return scanr.ai's error text to the model. A 400 names the bad field; a 429 means stop and wait.
    • Give it its own token. A dedicated, read-only token makes the agent's usage visible under API Usage and easy to revoke.

    Next