For AI agents

Let an AI agent build it.

If an AI agent builds your team’s website, give it this one file. The file tells the agent how to show your team’s record and the videos of your games, and how to keep your key and your players’ names safe.

Four steps

1

Get the file

Open the file and save it, or copy the text at the bottom of this page.

2

Put it in your website's project

For Claude Code, save it as .claude/skills/travelball-team-site/SKILL.md. For any other agent, paste the text into the file where that agent keeps its instructions, such as AGENTS.md.

3

Give your website the key and the team id

Copy both from the team’s API page in the app. Put them in your website’s secret settings as TRAVELBALL_API_KEY and TRAVELBALL_TEAM_ID. Do not paste the key into a chat with the agent. Read Your key first.

4

Ask the agent

Tell the agent what you want on the page. It reads the file and does the rest.

Use the travelball-team-site file. Show our team's record and our game videos on the home page.

What the file tells the agent

It tells the agent to keep the key on your server, and never in a page a visitor can read. It tells the agent to show no player’s name unless you ask, and then a first name and a last initial only.

It names the three reads the agent needs: your record, your team, and your games with their videos. For anything else, it sends the agent to the Reference.

An agent can make mistakes. Before you publish the site, do the six checks at the end of the file, or ask the agent to do them and show you the results.

The file

---
name: travelball-team-site
description: Use when a website must show a youth baseball or fastpitch team's won-lost record, game results, schedule or game videos from the travelball.gg Data API. Covers the key, the team id, the reads to use, the YouTube player, caching, refusals, and the rules for children's names.
---

# Show a travelball.gg team's record and videos on a team website

The travelball.gg Data API is a read-only JSON API. One key reads one team.
This file links a website to ONE team and shows two things: the team's
record and the videos of its games.

The reference is the authority for every field: https://travelball.gg/docs/reference
Other pages: https://travelball.gg/docs (start), https://travelball.gg/docs/key, https://travelball.gg/docs/limits, https://travelball.gg/docs/changes.
Read the reference when you need a field this file does not name.

## 1. What you need from the site owner

Ask for these two values if the environment does not have them. The
account owner copies both from the team's API page in the travelball.gg app.

| Variable | Shape | Meaning |
|---|---|---|
| `TRAVELBALL_API_KEY` | `tb_live_` and 32 letters and digits | The team's key. Secret. |
| `TRAVELBALL_TEAM_ID` | `team_…` | The team's public id. Not secret. |

Put them in the server's environment: a local env file and the host's
secret settings. Make sure git ignores the env file. Never write the key
into source code, a commit, a log line or a message.

## 2. Hard rules

1. **The key stays on the server.** Never send it to a browser. Do not give
   its variable a prefix that exposes it to the client (`NEXT_PUBLIC_`,
   `VITE_`, `PUBLIC_`). Do not call the API from client-side code, although
   the API permits a call from a browser. The key returns children's full
   names, and a visitor who copies it can read them.
2. **The key goes in the header only**: `Authorization: Bearer <key>`. A key
   in the address is refused.
3. **These are children.** The record and the videos need no player name.
   Do not show the roster, a box score or a player page unless the owner
   asks. If the owner asks, show a first name and a last initial only, and
   no photo. The API gives full names to this key, so shorten each name on
   the server before it reaches a page.
4. **Read only.** No request writes. Do not look for one that does.
5. **A site with no server needs a build step or one server function.** For
   plain HTML on a file host, read the API at build time, or add one
   serverless function that holds the key and returns only the data in
   section 6. Never put the key in the page as a shortcut.
6. **Accept new fields.** The shapes only gain fields. Never fail on a field
   you do not know, and do not check an answer with a schema that refuses
   extra fields.

## 3. The reads

Base address: `https://api.travelball.gg/v1`

### The record: `GET /teams/{teamId}/season`

Use `record`. This is a recorded answer:

```json
{
  "countingGames": 1,
  "excludedGames": 0,
  "losses": 0,
  "ties": 0,
  "wins": 0
}
```

- `wins`: Games won. Only games that are final are counted.
- `losses`: Games lost.
- `ties`: Games that finished level.
- `countingGames`: Games in these totals: every game that has started and that the team has not switched off. A game in progress is one of these and is not yet a win, a loss or a tie, so this can be greater than `wins` + `losses` + `ties`.
- `excludedGames`: Games played that the team switched off, so they are in no total here. A game still on the schedule is not one of these.

- Show wins and losses. Add ties only when `ties` is more than 0.
- Do not work out the record from the list of games. A team can switch a
  game out of its totals, and only this read knows which.
- The same answer has `teamName` and `season`.
- Do not use `batting` or `pitching` unless the owner asks (rule 3).

### The team: `GET /teams/{teamId}`

For the name, the city, the age group and the logo.

- `name`: The team's full name, as the coach typed it.
- `logoUrl`: A link to the team's logo: a square PNG, 512 pixels on each side, with a see-through background. Null when the team has no logo. The link can change, so read it again rather than keeping it.

### The games and the videos: `GET /teams/{teamId}/games`

Query: `status` (`scheduled`, `live` or `final`; optional), `limit`
(1 to 50), `cursor`. The answer has `games` and `nextCursor`.

- `games`: The games on this page, newest first. A game with no start time sorts last.
- `nextCursor`: Pass this back as `cursor` for the next page. Null when this was the last page.

Each game is this shape. This is a recorded answer:

```json
{
  "away": {
    "abbrev": "NTX",
    "id": "team_1",
    "name": "NTX Drillers"
  },
  "broadcast": null,
  "home": {
    "abbrev": "DAL",
    "id": "team_2",
    "name": "Dallas Tigers"
  },
  "id": "game_1",
  "lineScore": {
    "away": [
      1
    ],
    "home": [
      1
    ],
    "totals": {
      "awayErrors": 0,
      "awayHits": 2,
      "awayRuns": 1,
      "homeErrors": 1,
      "homeHits": 2,
      "homeRuns": 1
    }
  },
  "scheduledAt": 1750000000000,
  "status": "live",
  "version": "v1"
}
```

- `id`: The game's public id, as `game_…`.
- `status`: Where the game is. `scheduled` has not started, `live` is being played now, `final` is over.
- `scheduledAt`: When the game is due to start, in milliseconds since 1 January 1970. Absent when no time was set.
- `home`: One side of a game. Null when that side is not settled yet: a game can be on the schedule before an opponent is agreed.
- `away`: One side of a game. Null when that side is not settled yet: a game can be on the schedule before an opponent is agreed.
- `lineScore`: Runs by inning for both sides, with the totals line.
- `broadcast`: The YouTube video of this game, live or saved. Null when the game was never broadcast.

When a game has a video, `broadcast` is an object with two fields:

- `youtubeVideoId`: The YouTube video id, for building your own player.
- `watchUrl`: The address a viewer can open to watch.

- The list of videos is each game where `broadcast` is not null.
- `home` or `away` can be null. Show "TBD".
- `scheduledAt` can be absent. Show the time in the team's own time zone,
  not the server's.
- Our side is the side whose `id` equals `TRAVELBALL_TEAM_ID`. The other side
  is the opponent. Our runs are `homeRuns` or `awayRuns` in
  `lineScore.totals`, to match our side.
- A game with `status` `live` and a `broadcast` is on air now. Put it first
  and label it "Live".
- Print each team's name as the answer gives it.

### The player

Build the player from the id. Keep the watch link beside it.

```html
<iframe
  src="https://www.youtube-nocookie.com/embed/{youtubeVideoId}"
  title="{away.name} at {home.name}, {date}"
  loading="lazy"
  allow="accelerometer; encrypted-media; picture-in-picture; fullscreen"
  allowfullscreen></iframe>
<a href="{watchUrl}">Watch on YouTube</a>
```

- For a list of many videos, show a button for each one and load its
  player only on a press. Many players on one page are slow.
- The API does not say whether a video is public, unlisted or private on
  YouTube. A private video shows YouTube's own message. Always keep the
  "Watch on YouTube" link. Do not try to detect which it is.

## 4. Caching and limits

- One key may make 120 requests each minute. Never call the API one time
  for each visitor.
- Keep each answer on the server. The record and the finished games: 5
  minutes or longer. While a game is `live`: 30 to 60 seconds. Use the
  framework's own cache.
- A coach can correct a finished game, or resume it. Never keep a final
  game for ever, and never tell a visitor that a result cannot change.

## 5. Refusals

A refusal is always `{ "error": { "code": "…", "message": "…" } }`.
Branch on `code`.

| Status | `code` | Do this |
|---|---|---|
| 401 | `not-authenticated` | The key is missing, or the owner replaced it. Ask the owner for the current key. A key also stops when the owner turns the team's Data API off. |
| 400 | `key-in-url` | A fault in this site. Move the key to the header. |
| 402 | `api-lapsed` | The owner's plan is not active. Tell the owner. Do not retry. |
| 403 | `no-api-seat` | The team's Data API is off. Tell the owner. |
| 403 | `key-scope` | The key and the team id are from two teams. Ask the owner for the pair from one team. |
| 404 | `not-found` | The id is wrong, or the API did not give that `cursor`. Start again with no cursor. |
| 429 | `rate-limited` | Wait the seconds in `Retry-After`. Then keep each answer longer. |
| 500 | `internal` | Our fault. Try again one time, later. |

After a refusal or a network failure the page must still load. Show the
last good data if you have it. If you have none, hide the section. Log the
`code` on the server. Never show `message`, the key or a stack trace to a
visitor.

## 6. What the page receives

Put every call to the API in one server-only module. It returns only this,
so nothing else can reach a page:

```ts
type TeamSiteData = {
  team: { name: string; season: string; logoUrl: string | null };
  record: { wins: number; losses: number; ties: number };
  games: Array<{
    id: string;
    status: "scheduled" | "live" | "final";
    startsAt: number | null;
    opponent: string;
    isHome: boolean;
    ourRuns: number;
    theirRuns: number;
    video: { youtubeVideoId: string; watchUrl: string } | null;
  }>;
};
```

Use the project's own language, framework and patterns for the fetch, the
components and the styles.

## 7. Check it before you say it works

1. With the real values in the environment, run this. Do not print the key.

   ```sh
   curl -s -H "Authorization: Bearer $TRAVELBALL_API_KEY" \
     https://api.travelball.gg/v1/teams/$TRAVELBALL_TEAM_ID/season
   ```

2. Load the page. The record on it must equal `record` in that answer.
3. Each video must open. A game with no `broadcast` must show no player.
4. Search the built client files for `tb_live_`. There must be no match.
5. Search the repository for `tb_live_[A-Za-z0-9]{32}`. No tracked file may match.
6. No child's full name may be in the page's HTML or in any JSON the
   browser receives.

If you have no real key, say so. Build from the recorded answers on the
reference page, and say plainly that the site was not checked against the
real API.