> ## Documentation Index
> Fetch the complete documentation index at: https://docs.forwardmodel.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# For coding agents

> Everything a coding agent needs to use the Forward Model scores API, on one page.

Everything a coding agent needs to use the Forward Model scores API, on one page. A person makes the key; the agent does the rest.

## The contract

| | |
| - | - |
| Base URL | `https://api.forwardmodel.ai` |
| Endpoint | `GET /v1/scores` |
| Auth | Header `Authorization: Bearer <key>`. Keys start with `fm_live_`. |
| Where the key lives | An environment variable, by convention `FORWARD_MODEL_API_KEY`. Never print, log or commit it. |
| Required parameter | `symbols`: up to 50 US stock tickers, comma-separated, such as `COST,MSFT,BRK-B`. Share classes use a hyphen: `BRK-B`, `BF-B`. Duplicates are dropped and letters upper-cased. |
| Optional parameters | `date` (`YYYY-MM-DD`, a trading day within the last 21 trading days), `detail` (`full` or `short`) |
| Format | JSON, UTF-8. Times are ISO 8601 to the minute with a US Eastern offset, such as `2026-11-16T07:14-05:00` (no seconds). |
| Machine-readable spec | OpenAPI 3.1 at `https://docs.forwardmodel.ai/openapi.json` |

## How the data behaves

* **One set of scores per trading day.** They're published before the market opens, usually by 9:00 a.m. Eastern. Until the new day is out (early in the morning, on a market holiday, or on the rare late day), a call returns the latest published day. If the new day isn't out by 9:30 a.m. Eastern, use the latest day and check again later.
* **Check `date`.** The top-level `date` tells you which trading day you got. Compare it with today's date in US Eastern time.
* **A day's scores never change.** Cache each answer by `(symbol, date)` and don't ask again that day. A repeat call returns the same values.
* **Batch.** Ask for up to 50 symbols in one call instead of one call per symbol. When you split a long list into several calls, pass the first answer's `date` to the rest, so every batch is the same day.
* **Some symbols aren't scored.** Funds, ETFs, unknown tickers and other non-equities come back under `not_scored` with a `reason`, not as an error, and don't count. A symbol that can't be a ticker (anything but letters, digits, `.` and `-`, up to 10 characters, starting with a letter) fails the whole call with `invalid_input`.
* **Compare within a day.** A score is a position among that day's scored stocks. Don't compare one day's score with another day's.
* **Show the notice.** When you show a score to a person, show this line with it: "Published score, the same for everyone. Not personal investment advice." Full entries carry it as `notice`.

## How the plan counts

* **A stock counts once per day, Pacific time** (midnight to midnight in Los Angeles), whichever day's score you get. Asking for it again that day, including after the new scores come out, is free. So checking early in the morning wastes nothing.
* **The allowance resets when the plan renews each month.** `scores_left_words` in each answer says, in words, how much is left until then: "plenty left", "running low" or "none left until your plan renews". Slow down when it says running low.
* **There's also a daily limit.** It resets at midnight Pacific time.
* **At the limit, the whole call is refused** with `limit_reached` and nothing in it counts. `resets_at` says when: the next midnight Pacific for the daily limit, or the plan's renewal for the monthly one (null if not known). Stocks you've already seen that day still come back if you ask for only those.

## Response schema

Top level:

| Field | Type | Notes |
| - | - | - |
| `date` | string, `YYYY-MM-DD` | The trading day of these scores. |
| `of` | integer | How many stocks were scored that day. |
| `entries` | array of entry | One per requested symbol that was scored, in request order. |
| `not_scored` | array of `{symbol: string, reason: string}` | Requested symbols with no score that day. |
| `scores_left_words` | string | How much of the plan's allowance is left until it renews, in words: "plenty left", "running low" or "none left until your plan renews". |

Each entry (`detail=full`):

| Field | Type | Notes |
| - | - | - |
| `symbol` | string | The ticker. |
| `name` | string | The company. |
| `date` | string, `YYYY-MM-DD` | The trading day of the score. |
| `score` | number | 1.00 to 10.00, two decimals. |
| `out_of` | number | Always `10.0`. |
| `band` | string | One of `Strong`, `Above average`, `Middle`, `Below average`, `Weak`. |
| `top_pct` | integer | 1 to 100. 35 means the top 35% of the day's scores. |
| `summary` | string or null | One line on the stock. |
| `for` | array of string | Main points for. May be empty. |
| `against` | array of string | Main points against. May be empty. |
| `watch` | array of string | Things to watch. Often empty. |
| `context_through` | string or null, ISO 8601 | The newest information the score could use. |
| `scored_at` | string or null, ISO 8601 | When the stock was scored. |
| `set_at` | string or null, ISO 8601 | When the day's scores were locked. |
| `graded_after_trading_days` | integer | Always `21`. |
| `notice` | string | Show it with any score you display. |

With `detail=short`, an entry has only `symbol`, `name`, `date`, `score`, `band`, `top_pct` and `summary`. `detail` defaults to `full` for 1 to 10 symbols (after duplicates are dropped) and `short` for more.

`date` may be any of the last 21 trading days, the latest included. A day with no published scores, such as today before they're out, a weekend or a holiday, returns `invalid_date`.

## Errors

Every error is `{"error": {"type", "message", "request_id", ...}}`.

| Status | `type` | Do this |
| - | - | - |
| 400 | `invalid_input`, `invalid_date` | Fix the input named in `field`. Don't retry unchanged. |
| 401 | `unauthenticated`, `key_revoked` | Stop. Ask the person for a valid key. |
| 402 | `plan_required` | Stop. The account needs Plus, or the date is older than the plan covers. |
| 429 | `limit_reached` | The whole call was refused and nothing counted. Stop asking for new stocks until `resets_at`; stocks already seen today still come back. |
| 429 | `rate_limited` | Wait `retry_after_seconds`, then retry. |
| 503 | `model_unavailable` | Scores can't be read right now (rare). Wait `retry_after_seconds`, then retry. |
| 500 | `server_error` | Retry once. If it fails again, report the `request_id`. |

Retry only `rate_limited`, `model_unavailable` and `server_error`, and honour the `Retry-After` header. There's no fixed request rate to stay under; calling one batch at a time is plenty. A response that isn't JSON (for example a gateway error) is a temporary error: retry it like `server_error`.

## A complete client

```python theme={null}
import os, time, requests

BASE = "https://api.forwardmodel.ai"
KEY = os.environ["FORWARD_MODEL_API_KEY"]          # never print or log this
RETRY = {"rate_limited", "model_unavailable", "server_error"}


def get_scores(symbols, date=None, detail=None, tries=3):
    """Today's scores (or a given trading day's) for up to 50 tickers."""
    params = {"symbols": ",".join(symbols)}
    if date:
        params["date"] = date
    if detail:
        params["detail"] = detail
    for attempt in range(tries):
        r = requests.get(f"{BASE}/v1/scores", params=params,
                         headers={"Authorization": f"Bearer {KEY}"}, timeout=30)
        if r.ok:
            return r.json()
        try:
            err = r.json().get("error", {})
        except ValueError:                             # not JSON: a gateway error, treat as temporary
            err = {"type": "server_error", "message": r.text[:200]}
        if err.get("type") in RETRY and attempt < tries - 1:
            time.sleep(int(r.headers.get("Retry-After", err.get("retry_after_seconds", 5))))
            continue
        raise RuntimeError(f"{r.status_code} {err.get('type')}: {err.get('message')} ({err.get('request_id')})")


def get_many(symbols):
    """Any number of tickers, 50 at a time, all from the same trading day."""
    out = {"date": None, "entries": [], "not_scored": []}
    for i in range(0, len(symbols), 50):
        part = get_scores(symbols[i:i + 50], date=out["date"])   # the first answer's day for every batch
        out["date"] = part["date"]
        out["entries"] += part["entries"]
        out["not_scored"] += part["not_scored"]
    return out


if __name__ == "__main__":
    day = get_scores(["COST", "MSFT"])
    print("Scores for", day["date"])
    for e in day["entries"]:
        print(f'{e["symbol"]:6} {e["score"]:5.2f}  {e["band"]}')
```

## In an AI assistant instead

If the person wants the scores inside Claude or ChatGPT rather than in code, there's no key to handle: see [Claude and ChatGPT](https://docs.forwardmodel.ai/claude-and-chatgpt.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.