# GunSpec API: AI agent brief

> Drop-in context that teaches an AI coding agent to consume the GunSpec.io REST
> API correctly. GunSpec is a firearms specification database: 9,000+ firearms with
> dimensions, ballistics, materials, and game-balance stats, plus ammunition,
> calibers, manufacturers, countries, conflicts, and aggregate statistics.

This file is format-agnostic. For a ready-to-install version in your agent's own
format, see the sibling folders in this directory:

- Claude Code:    `claude-code/skills/gunspec-api/SKILL.md`
- GitHub Copilot: `github-copilot/instructions/gunspec-api.instructions.md`
- OpenAI Codex:   `codex/AGENTS.md`
- Cursor:         `cursor/rules/gunspec-api.mdc`

## Discovery (source of truth)

- Base URL: `https://api.gunspec.io/v1`
- OpenAPI 3 spec: `https://api.gunspec.io/openapi.json` (generate a typed client or tool definitions from this)
- Interactive explorer: `https://api.gunspec.io/docs`
- Human docs: `https://docs.gunspec.io`
- Machine index: `https://docs.gunspec.io/llms.txt`

Treat the OpenAPI spec as authoritative. If an endpoint or field is not in this
brief, fetch the spec and read it. Do not invent routes or fields.

## Reference docs (link these for detail)

- Field reference / data dictionary: `https://docs.gunspec.io/en?section=field-reference`
- Error handling: `https://docs.gunspec.io/en?section=errors`
- Rate limits: `https://docs.gunspec.io/en?section=rate-limits`
- Caching and refreshing: `https://docs.gunspec.io/en?section=caching`
- Plans and pricing: `https://docs.gunspec.io/en?section=rapidapi-plans`
- Endpoint access by tier: `https://docs.gunspec.io/en?section=endpoint-access`
- Auth and quick start: `https://docs.gunspec.io/en?section=getting-started`
- Pagination (rules, per-plan depth, every paged endpoint): `https://docs.gunspec.io/en?section=pagination`
- Versioning (what changes without a bump, how breaking changes ship): `https://docs.gunspec.io/en?section=versioning`
- OpenAPI, Postman, Swagger: `https://docs.gunspec.io/en?section=tools`
- AI and LLMs: `https://docs.gunspec.io/en?section=ai`

## Authentication

- Send the key in the `X-API-Key` request header, or as `Authorization: Bearer <key>`. Both are accepted on every endpoint and neither is being retired; `X-API-Key` wins if you send both.
- Read the key from an environment variable (for example `GUNSPEC_API_KEY`). Never hardcode it, never commit it, never log it.
- A few read-only endpoints (blog, changelog, shared collections) need no key.
- Get a key at `https://gunspec.io/en/pricing`.

## Response envelope

- Success: `{ "success": true, "data": ... }`
- List endpoints also include `"pagination": { "page", "limit", "per_page" }`, plus `"total"` and `"totalPages"` on Builder and above
- Error: `{ "success": false, "error": { "code", "message", "request_id" } }`
- Every response carries an `X-Request-Id` header. Log it when reporting a problem.
- Not every success is JSON. `GET /ammunition/{id}/bullet.svg`, `GET /firearms/{id}/media/{selector}` and `GET /firearms/{id}/images/{imageId}` with `format=raw` (the default) and `GET /firearms/{id}/model` return the file's bytes, and `GET /out/{clickId}` answers a 302 redirect. Check `Content-Type` before parsing. The media page lists them: `https://docs.gunspec.io/en?section=media`

Always check `success` before reading `data`.

## Error codes

`VALIDATION_ERROR` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403),
`SUBSCRIPTION_REQUIRED` (403), `NOT_FOUND` (404), `RATE_LIMITED` (429),
`INTERNAL_ERROR` (500), `SERVICE_UNAVAILABLE` (503).

`SUBSCRIPTION_REQUIRED` is a 403 carrying `details.requiredTier`: the caller is
authenticated but their plan does not include the endpoint. It is the response
the compatibility and Studio endpoints give a lower tier. Treat it as "upgrade",
never as "retry".

**401 and 403 mean different things and want different handling.** 401 says the
credential is missing, malformed or expired, so presenting a valid key can work.
403 says the key is valid and the caller is not permitted: the plan is below
the endpoint's tier, the account is suspended, or the key is not attached to the
thing being acted on (the `me` routes need a key that belongs to an account, the `vendor` routes need one a shop has named).
Reissuing a key never clears a 403; read `error.message` and surface it.

On 429, read the `Retry-After` header (seconds) and back off before retrying.
That is the only rate-limit header the API sends: the edge limiter reports
whether a request was allowed, not how much budget is left, so there is no
`X-RateLimit-Remaining` to read. Track your own usage, or poll `GET /me/usage`.

## Pagination

Use `page` (1-based) and `per_page`. `limit` is echoed back alongside `per_page`
with the same value, so read either.

**Do not drive the loop from `totalPages`.** On `/v1/firearms` the total is
suppressed for Anonymous and Explorer callers, so `pagination.totalPages` is
`undefined` there and a loop that tests it stops after one page. Page until a
short page comes back, and treat the total as an optional extra:

```js
let page = 1, per_page = 100, all = []
for (;;) {
  const { data, pagination } = await get(`/v1/firearms?page=${page}&per_page=${per_page}`)
  all.push(...data)
  if (data.length < per_page) break          // works on every tier
  if (pagination.totalPages && page >= pagination.totalPages) break
  page++
}
```

`per_page` maxes out at 100. The SDKs expose auto-paging helpers
(`listAutoPaging` in TypeScript, `list_auto_paging` in Python) that already do this.
Full rules, the per-plan page depth, the sequential-page burst limit and every
paged endpoint: `https://docs.gunspec.io/en?section=pagination`.

## Rate limits and tiers

Per minute: Explorer 10, Builder 60, Studio 120, Enterprise 300. Tiers also gate
which endpoints a key may call (Explorer < Builder < Studio < Enterprise).

## Caching and refreshing

Caching is encouraged, not merely tolerated. The catalog is large and mostly
static, and an agent that re-downloads a record it already holds is slower,
more expensive and closer to its rate limit for no new information.

**Do not hardcode a lifetime from this file.** Every response carries its own
`Cache-Control`; read it off the response. The per-resource table, with the
reasoning behind each number, is at
`https://docs.gunspec.io/en?section=caching`. Broadly: single records are the
shortest lived, list and search results shorter still because they depend on
what was added today, and reference vocabularies (calibers, categories,
manufacturers) the longest.

Three signals tell you whether a copy is current, and they answer different
questions:

- `updatedAt` on a record: when the record last changed. It is maintained by
  the database on a content comparison, so re-importing an identical record
  does not move it. A timestamp that moved means something a reader can see is
  different.
- `version` on a record: a short hash of the record's own fields. Equal
  versions mean equal data. It does not vary by plan or by response shape, so
  this is the value to store beside a mirrored record.
- `ETag` on the response: a hash of the exact bytes you were sent, for your
  plan. Send it back as `If-None-Match`.

The refresh loop:

```ts
const res = await fetch(`${BASE}/firearms/${record.id}`, {
  headers: {
    'X-API-Key': key,
    ...(record.etag ? { 'If-None-Match': record.etag } : {}),
  },
})

// 304: your copy is current. No body was sent, and it did not count
// against your daily allowance.
if (res.status === 304) return record

const body = await res.json()
return { ...body.data, etag: res.headers.get('ETag') }
```

A `304` counts toward the per-minute rate limit but **not** toward the daily
request allowance, so checking often is deliberately cheap. Conditional
requests are answered on the catalog reads (firearms, manufacturers,
calibers, ammunition, categories, attachments, interfaces, platforms) and on
`/stats/summary`, which is cached for an hour and is the one aggregate worth
revalidating rather than recomputing. Not on the other statistics, seller
offers or anything under `/user`. `GET /firearms/random` is excluded for the
obvious reason.

Two things to get right when mirroring:

- **Store the `ETag` per plan, not per record.** Two keys on different plans
  receive different fields for the same firearm, so they see different tags.
  Compare `version` across plans; compare `ETag` only against a response you
  received on the same key.
- **`updatedAt` is a change signal, not a freshness signal.** A record whose
  timestamp is a year old is not stale; it is a record nobody has needed to
  correct.

## Being told, instead of asking

On Studio and Enterprise, register an endpoint at `POST /me/webhooks` and the
change comes to you. A mirror kept this way needs no polling loop at all: the
delivery carries the record in the same shape `GET /firearms/{id}` returns it
for your plan, `version` included, so the row can be written straight into your
own store without a second call. A deletion carries the id alone, which is the
one change a poller cannot see, since a record it holds simply stops appearing, and
that is indistinguishable from a filter it got wrong.

Subscribe to what you actually mirror:

- `firearm.created`, `firearm.updated`, `firearm.deleted`, and the same three
  for `manufacturer.*` and `caliber.*`.
- `firearm.variant.updated` repeats the payload for records that have a parent,
  so a family can be followed without taking the whole catalog's traffic.
- `firearm.source.changed` and `firearm.confidence.changed` fire when a figure's
  sourcing or confidence moved. They arrive alongside `firearm.updated`, and
  they are the ones to watch if you gate on provenance rather than on specs.
- `catalog.resynced` means more records changed at once than can be described
  individually. Re-sync rather than trying to apply it.

Three rules for the receiver:

- **Dedupe on `X-Webhook-Id`.** It identifies the event and is unchanged across
  retries. `X-Webhook-Delivery` is the individual attempt and differs each time.
- **Verify `X-Webhook-Signature` before trusting the body.** It is
  `t=<unix>,v1=<hex>`, where the hex is HMAC-SHA256 over `` `${t}.${rawBody}` ``
  using the signing secret shown once when the endpoint was created. Sign the
  raw bytes, not a re-serialised object.
- **Answer 2xx quickly and do the work afterwards.** A non-2xx is retried three
  times, 1, 5 and 15 minutes apart, and then given up on.

A webhook is a change signal, not a guarantee of delivery. Keep the
`If-None-Match` refresh loop above as the backstop and let webhooks decide when
to run it.

## Key endpoints (paths are relative to the base URL)

Firearms:
- `GET /firearms`: list. Filters: `manufacturer`, `caliber`, `category`, `action_type`, `country_of_origin` (**not** `country`), `status`, `features`, `has_image`, `has_3d_model`, `year_introduced_min`/`_max`, `weight_min`/`_max`, `barrel_length_min`, `created_after`/`_before`. Sort with `sort` + `order`, trim the payload with `fields`. An unknown parameter is ignored, not rejected, so a wrong filter name silently returns the unfiltered list.
- `GET /firearms/resolve?q={name}`: one name as a person writes it ("G19 gen 5 MOS", "H&K MP5") to one id, with an honest `ambiguous` when it is several. Call this first when you start from text rather than an id
- `POST /firearms/resolve`: the same for up to 50 names in one request (Studio)
- `GET /firearms/search?q={query}`: full-text search
- `GET /firearms/compare?ids={a},{b}`: side-by-side comparison
- `GET /firearms/filter-options`: all filter dropdown values in one call
- `GET /firearms/random`: a random firearm
- `GET /firearms/{id}`: full specifications (id is the slug, e.g. `glock-g17`)
- `GET /firearms/{id}/variants` | `/images` | `/silhouette` | `/game-stats` | `/dimensions` | `/similar`
- `GET /popular/firearms`: most-viewed

Ammunition and calibers:
- `GET /ammunition`, `/ammunition/{id}`, `/ammunition/{id}/ballistics`
- `GET /calibers`, `/calibers/{id}`, `/calibers/{id}/ammunition`, `/calibers/ballistics`

Reference:
- `GET /manufacturers`, `/manufacturers/{id}`, `/manufacturers/{id}/firearms`
- `GET /categories`, `/categories/{slug}/firearms`
- `GET /countries`, `/countries/{code}/arsenal`, `/conflicts`

Statistics and game:
- `GET /stats/summary`, `/stats/by-category`, `/stats/calibers/popular`
- `GET /game/tier-list`, `/game/matchups`, `/game/role-roster`, `/game/balance-report`

Account (needs a key):
- `GET/POST/DELETE /me/favorites`, `GET /me/favorites/ids`
- `GET /me/usage`, `/me/reports`, `/me/support`, `/me/webhooks`

Attachment compatibility (what fits a firearm, computed from mount interfaces):
- `GET /attachments`: catalog, open to any key. `requires=<standard>` narrows by interface.
  **`fits=<firearm-id>` runs the compatibility engine and needs Studio**, like the endpoints below.
- `GET /attachments/{id}`, `GET /interfaces`: open
- `GET /firearms/{id}/attachments`, `/firearms/{id}/interfaces`: Studio
- `GET /attachments/{id}/firearms`, `/interfaces/{id}/firearms` (id URL-encoded): Studio
- `GET /platforms`, `/platforms/{id}`: Studio
- `GET /attachments/{id}/offers`, `/firearms/{id}/offers`: sellers stocking a record, open

Fit is computed from mount interfaces, never from names. Every fit carries
`source`, the weakest evidence behind it (`curated`, `universal`,
`inherited:parent`, `inherited:platform` or `inferred`), and a `confidence`
capped by the weakest interface it went through. Treat `inferred` as
unverified and pass `min_confidence` to hide fits below a figure you choose;
convention rows (a thread guessed from cartridge and country) are the weakest
evidence, at 0.6 or below, and are labelled. A caliber, bore or minimum-barrel
mismatch is never bridged by an adapter. A lower tier receives
`403 SUBSCRIPTION_REQUIRED`, not an empty list, so do not read an error as "nothing fits".

Discovery and analysis (tier in brackets):
- `GET /firearms/by-action` [open], `/by-designer` `/by-feature` `/by-material` [Builder], `/by-conflict` [Studio]
- `GET /firearms/timeline` `/top` `/head-to-head` `/power-rating` `/game-meta` [Builder]
- `GET /firearms/{id}/family-tree` [Builder], `/schematics` `/adoption-map` [Studio]
- `GET /calibers/compare`, `/calibers/{id}/family`, `/calibers/{id}/parent-chain` [Builder]
- `GET /manufacturers/{id}/stats`, `/manufacturers/{id}/timeline` [Builder]

Data quality [Enterprise]:
- `GET /data/confidence`: per-record confidence, lowest first
- `GET /data/coverage`: field coverage across the dataset

This list is a curated subset. The spec at `https://api.gunspec.io/openapi.json`
describes every path the API serves; fetch it rather than assuming an endpoint
is absent.

## Resolving a firearm (do this before anything else)

A user names a firearm; the API is addressed by slug. Never guess the slug -
`glock-19-gen5`, `glock-g19-gen5` and `glock-19` are not interchangeable, and a
guessed id returns 404 or, worse, a real record for a different variant.

1. `GET /firearms/resolve?q={what the user said}`. Pass their words through
   unchanged, since the endpoint is built for the way people write ("G19 gen 5 MOS",
   "AK-47", "H&K MP5"), so normalising, expanding or correcting the query
   yourself only makes it harder to match.
2. `status: "resolved"`: use `firearmId`. It is one record and the endpoint is
   saying so.
3. `status: "ambiguous"`: **ask the user which one.** `firearmId` is null and
   `alternatives` holds the tied candidates with their names and makers. Do not
   pick the first, the highest-scoring, or the most popular. "Glock 19" alone
   does not name a generation, and Gen 3, Gen 4, Gen 5 and the MOS variants
   differ in the dimensions and weights people ask about.
4. `status: "not_found"`: say we hold no such record. `alternatives` may carry
   `match: "fuzzy"` suggestions; those are offered rather than resolved and
   always score 0. Never present one as the answer.
5. Read `unresolvedTokens` before you answer. Those are the words the resolver
   could not place, usually a variant the catalog does not hold. Say so rather
   than answering as though the user had not said them.
6. Only then `GET /firearms/{id}` for the full record.
7. `GET /firearms/{id}/variants` lists the rest of the family when the user
   wants to compare generations rather than choose one.

```js
const { data } = await get(`/firearms/resolve?q=${encodeURIComponent(userText)}`)

if (data.status === 'resolved') {
  const firearm = await get(`/firearms/${data.firearmId}`)
  // data.unresolvedTokens may still hold words we could not place: mention them
} else if (data.status === 'ambiguous') {
  askUser(data.alternatives)     // never data.alternatives[0]
} else {
  say('GunSpec holds no record matching that.')
}
```

Resolving many names at once, such as the firearms mentioned in a document, a
thread or a spreadsheet column, is `POST /firearms/resolve` with `{"queries": [...]}`, up
to 50, results returned in the order sent. Studio and above. Every query is
scored exactly as the single form scores it, so the two cannot disagree.

`GET /firearms/search?q=` remains the right call when the user is browsing
rather than naming: it ranks a page of records *about* a query. Resolve answers
which record a query *is*, and reports its own certainty. Do not use search to
pick an id.

`GET /firearms/filter-options` gives the exact manufacturer, caliber, category
and country_of_origin values the list filters accept. Read it instead of
guessing filter strings.

## Reading a record honestly

**Never infer a missing specification.** A field that is `null` is a field
GunSpec does not hold. Do not fill it from your own model knowledge, do not
carry a figure over from a sibling variant, and do not compute it from other
fields. Say the value is not in the database. A null is a fact about the
catalog; a plausible-looking number that nobody published is a fabrication the
user cannot detect.

**Units live in the field names.** `weightEmptyG` is grams, `barrelLengthMm`
and `overallLengthMm` are millimetres, `muzzleVelocityMps` is metres per second.
Read the suffix; never assume a unit, and state the unit when you report a
value. `GET /firearms/{id}/dimensions` returns metric and imperial together if
you need both.

**`dataConfidence` is not accuracy.** It is a 0-1 record-level score for how
much of the record a source stands behind, set from what was actually sourced
when the record was compiled or verified and never raised by hand. It is
**not** a per-field probability that a given number is correct: 0.95 does not
mean the barrel length is 95% likely to be right. 0.5 with `verifiedAt` null is
seed model knowledge; at 0.6 only the item's existence was confirmed; treat
anything below 0.7 as unverified. Use it to rank and triage records. To check
one specific figure, follow `sources`, the URLs the record was compiled from,
or the `provenance` object on a detail record, and say what the source was. The
bands and which source wins when two disagree:
`https://docs.gunspec.io/en?section=field-reference#data-confidence`

**Specifications legitimately differ** by production year, factory, batch,
regional variant and modification. When a record's `notes` or `description`
records disagreement between sources, surface the disagreement rather than
picking a number and presenting it as settled.

## Tool layer (recommended shape)

Do not hand a model the raw API and the docs. Give it a small, named tool per
job and let the workflow above drive the order:

```
search_firearms(query)      -> GET /firearms/search
get_firearm(id)             -> GET /firearms/{id}
list_variants(id)           -> GET /firearms/{id}/variants
compare_firearms(ids)       -> GET /firearms/compare   (max 5 ids)
get_manufacturer(id)        -> GET /manufacturers/{id}
get_caliber(id)             -> GET /calibers/{id}
```

Generate these from `https://api.gunspec.io/openapi.json` rather than writing
them by hand, so the parameters and response types stay in step with the API.

## SDKs (prefer these over raw fetch)

TypeScript / Node (`@buun_group/gunspec-sdk`):

```ts
import { GunSpec } from '@buun_group/gunspec-sdk'

const client = new GunSpec({ apiKey: process.env.GUNSPEC_API_KEY })
const { data, pagination } = await client.firearms.search({ q: 'ak-47' })

for await (const firearm of client.firearms.listAutoPaging({ category: 'rifle' })) {
  // handle each firearm across all pages
}
```

Python (`gunspec`):

```python
import os
from gunspec import GunSpec

client = GunSpec(api_key=os.environ["GUNSPEC_API_KEY"])
result = client.firearms.search({"q": "ak-47"})

for firearm in client.firearms.list_auto_paging({"category": "rifle"}):
    ...  # handle each firearm across all pages
```

## Raw HTTP example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/search?q=ak-47&per_page=5' \
  --header "X-API-Key: $GUNSPEC_API_KEY"
```

## Rules for the agent

1. Read the key from the environment. Never hardcode or print it.
2. Prefer the official SDK. Fall back to fetch with the `X-API-Key` header.
3. Check `success` before reading `data`. On failure, surface `error.code` and `request_id`.
4. Handle 429 with `Retry-After` backoff.
5. When unsure about a field or endpoint, fetch `https://api.gunspec.io/openapi.json`. Do not guess.
6. Resolve a name to an id with `/firearms/search` before fetching a record. Never guess a slug.
7. When search returns several variants, ask the user which one. Do not pick for them.
8. Never invent a missing specification. `null` means GunSpec does not hold it, so say so.
9. Report units from the field-name suffix (`...Mm`, `...G`, `...Mps`). Never assume one.
10. Treat `dataConfidence` as record completeness and provenance, not per-field accuracy. Cite `sources` for a specific figure.
11. Cache what you fetch, and refresh with `If-None-Match`. Re-downloading a record whose `version` has not changed spends quota to learn nothing.
12. If you keep a mirror, subscribe to webhooks rather than polling for changes, and dedupe on `X-Webhook-Id`.
