# Hook Detector: the complete manual

Version 2.4.0. Generated by the API itself from the code that is running, so every
route, tool, price, limit and error code below is the live one. Latest copy:
https://api.hookdetector.com/docs.md (the short version for agents: https://api.hookdetector.com/llms.txt). A person to write to:
contact@hookdetector.com.

## Contents

1. What Hook Detector is
2. Quick start: three calls
3. Your API key
4. Connect your agent or tool
5. Research: start, wait, read
6. Chat, decisions, the kept set, exports and stills
7. Every REST route
8. Every MCP tool
9. Errors, limits and health
10. FAQ

## 1. What Hook Detector is

Give it a topic. It searches TikTok, Instagram and YouTube Shorts (choose with
`platforms`), reads what each clip says and shows,
and returns the hooks that already work there: real clips, their exact opening lines,
the most quotable line in each, and why each one travelled. Everything is reachable two
ways that behave the same: a REST API at https://api.hookdetector.com and an MCP server at https://api.hookdetector.com/mcp, so an
agent (Claude Code, Codex, Cursor and others) can call it as tools.

One research run returns up to 30 hooks. Each hook is an object with these
fields:

- `hook_id`: the id to use with the hook routes and tools
- `rank`: 1 is the strongest hook of the run
- `hook`: the exact opening line, as spoken or shown, word for word; null when there is nothing to quote
- `catchy_line`: the most quotable line anywhere in the clip, word for word
- `main_idea`: one sentence on what the clip argues
- `why_it_travelled`: why the opening stops the scroll; since 2026-10-02 the same text as mechanism, never a view or follower count
- `topic`: three to six words naming the narrow subject
- `onscreen_text`: text burned into the video (on hooks found before 2026-09-26, every slide of a photo post in order)
- `transcript`: what the clip says (it can include lyrics)
- `transcript_kind`: "speech", "music", "none" or "other"; when it is not speech, hook and catchy_line come from on-screen text only
- `platform`: "tiktok", "instagram" or, since 2026-10-01, "youtube": YouTube Shorts only, never long-form (its watch_url is always https://www.youtube.com/shorts/{id})
- `creator`: the account that posted it
- `views`: view count when it was found; likes too, when the platform gives it
- `above_floor`: true when it has at least 50,000 views
- `signals`: how far it travelled beyond its creator: followers, shares, saves, comments, posted, reach (views over followers), shares_per_1k, saves_per_1k; null when the platform gave none
- `watch_url`: the clip on its platform
- `player_url`: a 9:16 vertical player for an iframe (null only for an unusable id, or a photo post found before 2026-09-26)
- `video_id`: the platform's id of the clip
- `aspect`: "9:16", the frame to size the player to
- `still_url`: the card image as a JPEG from this API; loads in any <img>, no key
- `media_url`: our own player's source for a <video src>: a redirect to the clip's original file (TikTok and Instagram; null for YouTube and photo posts)
- `cover_url`: the platform's own cover image (links expire; prefer still_url)
- `caption`: the post caption, never used as a quote
- `slide_count`: always null on hooks found since 2026-09-26 (no carousels are delivered); the slide count of a TikTok photo post on older hooks
- `found_by`: which search found it
- `verdict`: "keep", "reject" or null: your decision on it
- `format`: "talking_head", "podcast", "interview", "stage" (a person talking), or "voiceover", "text_on_screen", "broll", "skit", "lipsync", "trending_sound", "slideshow", "call_recording", "live_interaction", "other"; null on hooks found before 2026-09-29
- `section`: always "main" (Bonus was removed on 2026-10-08)
- `creator_country`: the creator's TikTok account country, ISO alpha-2, as TikTok states it; always null on Instagram and on hooks found before 2026-09-30
- `place`: the place the creator tagged on the post (at most 80 characters), or null
- `place_country`: the tagged place's country, ISO alpha-2, only when the platform states it; never guessed
- `banner`: the banner hook: the title-style text placed on the video to hook the viewer, verbatim, at most 200 characters; null when the clip has none (subtitles are never a banner) and on hooks found before 2026-09-30
- `match`: "exact" (about the subject itself) or "close" (a neighbouring subject with the same intent: a close match, delivered after every exact one, to be labelled) on runs with a language or country; null otherwise and on hooks found before 2026-09-30
- `virality`: since 2026-10-01: how viral the clip is, 0 to 1 on one fixed scale since 2026-10-02 (the same clip scores the same in every run); built from the outlier (when known), freshness, travel (shares and saves per 1,000 views), own-sound reuse and a paid penalty; null on older hooks
- `virality_why`: one line naming the strongest signal behind virality ("31x this creator's usual", "1.2M views in 4 days")
- `outlier`: views over the creator's usual views per video (creator_median_views): 31.2 is 31 times this creator's normal; null when the baseline was not priced
- `outlier_basis`: "median_views" or "likes_per_video" (TikTok's proxy), how the baseline was measured; null with no baseline
- `creator_median_views`: the creator's usual views per video, the median of their recent videos, pinned excluded
- `posted_at`: when the clip was posted, ISO 8601, exact when the platform gives it
- `age_days`: days since posted_at at delivery; views_per_day is views over it
- `views_per_day`: views divided by age_days
- `duration_s`: the clip's length in seconds
- `paid`: true when the platform labels it an ad, a paid partnership or branded content
- `sound_reuse`: how many videos reuse the clip's sound; own_sound says whether the sound is the creator's own
- `own_sound`: the sound is the creator's own
- `hashtags`: the post's hashtags, at most 20, without the #
- `hook_type`: since 2026-10-02: the opening's device: curiosity_gap, contrarian_claim, specific_number, identity_callout, stakes_threat, promise_payoff, pattern_interrupt, story_open, question, direct_command or other (hook_type_other names it); read an unknown value as other
- `hook_type_other`: two to five words naming the device when hook_type is other
- `style_match`: since 2026-10-04, on a run that asked for a kind of opening: true when the hook opens that way, false when it opens differently; null when no kind was asked
- `mechanism`: why the opening stops the scroll: two short sentences quoting the clip (every quote checked word for word) and the question it pays off
- `visual_hook`: what the banner or on-screen text adds to the spoken opening, or null
- `audience`: the viewer this opening stops
- `template`: the hook as a fill-in structure with {placeholders}, at most 20 words
- `steal_it`: one sentence on how to reuse the move in another niche

`hook` and `catchy_line` are always word for word from what the clip says or shows, never
invented from a caption. Clips at or above 50,000 views rank first; when a topic has fewer
than you asked for, the rest are the best below that floor, marked `above_floor` false.

## 2. Quick start: three calls

Hook Detector is in beta: a new account needs an access code (section 3, "Beta access").
Create an account with it. The response holds your API key, once:

```bash
curl -X POST https://api.hookdetector.com/v1/accounts -H "Content-Type: application/json" \
  -d '{"access_code": "HD-XXXX-XXXX"}'
export HOOKDETECTOR_API_KEY=hd_...    # the api_key from the response
```

Start a run. The Idempotency-Key makes a retry safe: the same key never starts or pays
for a second run.

```bash
curl -X POST https://api.hookdetector.com/v1/research \
  -H "Authorization: Bearer $HOOKDETECTOR_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"topic": "cold plunge recovery", "count": 10}'
```

Wait for it. `?wait=55` holds the call until the run is done or 55 seconds
pass; repeat while `status` is `queued` or `running` (a fresh run takes about 2 to 3
minutes, so 3 or 4 calls; a dialect request up to about 5 minutes, so up to 6 calls):

```bash
curl "https://api.hookdetector.com/v1/runs/$RUN_ID?wait=55" -H "Authorization: Bearer $HOOKDETECTOR_API_KEY"
```

When `status` is `done`, `hooks` holds the result.

The same in Python (httpx):

```python
import os, time, uuid
import httpx

BASE = "https://api.hookdetector.com"
KEY = os.environ["HOOKDETECTOR_API_KEY"]
H = {"Authorization": f"Bearer {KEY}"}

with httpx.Client(base_url=BASE, headers=H, timeout=55 + 15) as c:
    r = c.post("/v1/research", json={"topic": "cold plunge recovery", "count": 10},
               headers={"Idempotency-Key": str(uuid.uuid4())})
    r.raise_for_status()
    run_id = r.json()["run_id"]
    while True:
        run = c.get(f"/v1/runs/{run_id}", params={"wait": 55}).json()
        if run["status"] in ("done", "failed"):
            break
    for h in run["hooks"]:
        print(h["rank"], h["hook"], h["watch_url"])
```

The same in JavaScript (Node 18 or later, or a server route; never in a browser page,
where the key would be public):

```javascript
const BASE = "https://api.hookdetector.com";
const H = { Authorization: `Bearer ${process.env.HOOKDETECTOR_API_KEY}` };

const start = await fetch(`${BASE}/v1/research`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({ topic: "cold plunge recovery", count: 10 }),
});
const { run_id } = await start.json();
let run;
do {
  const r = await fetch(`${BASE}/v1/runs/${run_id}?wait=55`, {
    headers: H, signal: AbortSignal.timeout((55 + 15) * 1000),
  });
  run = await r.json();
} while (run.status === "queued" || run.status === "running");
for (const h of run.hooks ?? []) console.log(h.rank, h.hook, h.watch_url);
```

Set your HTTP client's read timeout above the wait (for example 55 + 15 seconds),
or it gives up before the server answers.

## 3. Your API key

### Beta access

Hook Detector is in beta. A new account needs an access code on every door: REST, MCP and
the website. One code works anywhere. It looks like `HD-XXXX-XXXX`; case and dashes do not
matter. Accounts and keys made before the beta keep working and never need a code.

How to get a code:

- Ask at https://hookdetector.com/access (email, and an optional note), or from a script:

  ```bash
  curl -X POST https://api.hookdetector.com/v1/access/requests -H "Content-Type: application/json" \
    -d '{"email": "you@example.com", "note": "what you will use it for"}'
  ```

  It needs no key and answers 202 with `status` (`pending`, or `emailed` when approval is
  automatic) and a `message`; MCP: `request_access`. When the owner approves the request,
  the code is emailed from access@hookdetector.com. Requests are limited to
  5 per address per hour; a malformed email is 422.
- Or ask the owner directly: contact@hookdetector.com.

A code can be single use or allow several uses, and can expire. A code the owner binds to
one email works only for a Google sign-in with that email. An agent cannot make a code for
itself: the person it works for requests one and hands it over.

How to pass it:

- REST: `access_code` in the body of `POST /v1/accounts`.
- MCP: the `access_code` argument of `create_account`.
- Website: enter it at hookdetector.com/signin, or sign in with Google (below).

What a refusal looks like: no code is 403 `access_code_required`; a code that is unknown,
used up, expired, revoked, or bound to an email (and so only for Google sign-in) is 403
`invalid_access_code` (one code
for all of them, so a refusal reveals nothing about which codes exist). Both details point
to https://hookdetector.com/access. Failed codes are limited to 10 per address per
hour. If the server cannot check codes (it is not configured for them), a signup is 503
`access_unavailable` and nothing is created: the gate fails closed, never open.

### Getting one

- `POST https://api.hookdetector.com/v1/accounts` with `{"access_code": "HD-XXXX-XXXX"}` (and an optional
  `"label": "<your note>"`) creates an account with 500 free credits and
  returns its first key. Signups are limited per address per hour.
- Or open hookdetector.com/account in a browser after signing in, which keeps a key for
  you and makes more keys with a click.
- An agent can do it itself with the MCP tool `create_account`, passing `access_code`.

### Signing in with Google (people, on the website)

A person can sign in on hookdetector.com with Google, so the account follows them across
devices. The first sign-in needs an access code, or an approved access request for that
Google email; after that, none. Behind the button:

1. The browser opens `GET https://api.hookdetector.com/v1/auth/google/start?return_to=/chat` (an optional `code`
   carries an access code for a first sign-in). It redirects to Google.
2. Google sends the browser back to `GET https://api.hookdetector.com/v1/auth/google/callback`, which checks the
   Google identity (verified email only) and sends the browser to
   hookdetector.com/signin/callback with a one-time token (60 seconds, single use). No key
   ever travels in a URL.
3. The website trades the token at `POST https://api.hookdetector.com/v1/auth/exchange` for a key named
   "web sign-in": all four scopes, 30 days, at most 3 per account (a fourth sign-in
   revokes the oldest). An unknown, used or expired token is 401
   `invalid_exchange_token`.
4. `POST https://api.hookdetector.com/v1/auth/signout` revokes the key that calls it.

`GET /v1/me` carries `user` (`email`, `name`, `avatar_url`, `is_admin`) for an account a
person signed in to, and `null` otherwise. When Google sign-in is not configured on a
server, the start is 503 `google_signin_unavailable`: use an access code. A sign-in that
fails is never an error page: the browser lands on
hookdetector.com/signin?error=<reason>, one of `access_code_required`, `invalid_access_code`, `access_unavailable`, `account_conflict`, `email_not_verified`, `google_denied`, `google_failed`, `google_signin_unavailable`, `invalid_id_token`, `invalid_state`, `rate_limited`, `signin_failed`. Agents never need Google:
a code and a key are the whole story.

### The owner's admin routes

The owner (an account signed in with an email the server lists as admin) reviews access
requests and makes codes at hookdetector.com/admin, or through the API:
`GET /v1/admin/requests` (optional `status`), `POST /v1/admin/requests/{request_id}/approve`
(makes a single use code, emails it, and returns it once as `code` with `emailed`: when
`emailed` is false the mail did not go out, so send the code yourself), `POST /v1/admin/requests/{request_id}/reject`,
`POST /v1/admin/codes` (optional `email`, `note`, `max_uses`, `expires_in_days`; the code
is shown once), `GET /v1/admin/codes` (never the code itself) and
`DELETE /v1/admin/codes/{code_id}`; over MCP `list_access_requests`,
`approve_access_request`, `reject_access_request`, `create_access_code`, `list_access_codes`
and `revoke_access_code`. A key without the `admin` scope is 403 `insufficient_scope`; an
admin-scoped key whose account is not the owner's is 403 `admin_required`. An unknown
request or code is 404 (`access_request_not_found`, `access_code_not_found`); deciding a
request twice is 409 `request_already_decided`.

The same owner also runs the service from there:

- `GET /v1/admin/overview` (MCP `admin_overview`): accounts, people, runs (total, last 24
  hours and 7 days, failed, in flight), hooks, credits charged and every balance summed,
  pending requests, usable codes, redemptions, the search provider's `credits_left`,
  `low` and `accepting_runs`, and the running `commit`.
- `GET /v1/admin/accounts` (MCP `admin_list_accounts`): every account, newest first, with
  `q` (up to 200 characters: part of an email or label, or the start of an id),
  `limit` (1 to 200) and `offset`; `total` counts every match.
- `POST /v1/admin/accounts/{account_id}/credits` (MCP `admin_adjust_credits`) with
  `{"delta": 100, "note": "why"}`: `delta` from -1000000 to 1000000, not
  zero; `note` 1 to 200 characters, required. Below zero is 422
  `credits_below_zero` and nothing changes; an unknown account is 404 `account_not_found`.
  The change and its audit row commit together.
- `GET /v1/admin/runs` (MCP `admin_list_runs`): every account's runs, newest first, with
  `limit`, `status` and `account_id`.
- `GET /v1/admin/actions` (MCP `admin_list_actions`): the audit of password sign-ins and
  credit changes: who, on which account, and the detail.

The owner signs in on the website with Google (an email in the server's `HD_ADMIN_EMAILS`
needs no access code) or with the owner's password: `POST https://api.hookdetector.com/v1/auth/admin-signin` with
`{"password": "..."}` and no key answers `{"api_key", "key_id", "expires_at", "user"}`, a
web sign-in key like the Google one. The server holds only `HD_ADMIN_PASSWORD_HASH`, an
scrypt hash made by `scripts/admin_password_hash.py`, never the password. A wrong
password is 401 `invalid_admin_password`; failed tries are limited to
5 per address per 15 minutes and 20 from all
addresses per hour (429 `rate_limited` with `Retry-After`, the right password included);
without a valid hash or an admin email it is 503 `admin_password_unavailable` and nobody
is signed in.

The key starts with `hd_`, is 256 bits of randomness, and is shown exactly once. We store
only its SHA-256 fingerprint and its first 12 characters (`prefix`, so you can tell keys
apart), so nobody, us included, can show it to you again. Lost it? Create a new one.

Send it on every call as the header `Authorization: Bearer hd_...`. MCP clients send the
same header; a client that cannot send headers passes `api_key` as a tool argument.

### Permissions (scopes)

Every key holds some of four scopes:

| scope | lets the key |
|---|---|
| `read` | read the account, usage, runs, hooks, finds, embeds, keeps, conversations and exports |
| `write` | keep, reject or undo a hook or a find, and send chat turns |
| `research` | start research runs (POST /v1/research, find_hooks, or a chat turn that starts one; POST /v1/longform and /v1/packaging, find_long_hooks and find_packaging), which spend credits |
| `admin` | list, create and revoke keys, and delete the account: full control |

A call the key lacks the scope for is refused with 403 `insufficient_scope`, and the
detail names the missing scope. Nothing is reserved or changed. Any key may revoke itself.
Your signup key holds all four, and so does a key created without `scopes`. Give an agent
only what it needs: an agent that finds and curates hooks needs `read`, `write` and
`research`; a dashboard that only shows results needs `read`. Keep `admin` for yourself:
it can make keys and delete the account.

`GET /v1/me` (MCP `balance`) shows the calling key its own `scopes`, `credit_limit` and
`expires_at`.

### Spend caps

`credit_limit` (1 to 100000) is the most credits runs started with a key may
spend over its whole life. It counts the charge of every finished run started with the
key, plus what its runs in flight hold. A run on a capped key reserves at most what is
left of the cap (and the provider search shrinks to fit, exactly as it does on a small
balance). When less than 40 credits of the cap are left, a start is 403
`key_credit_limit_reached` and nothing is reserved. `GET /v1/keys` shows each key's
`credits_used`. The balance is still checked first: an account with too few credits gets
402 `insufficient_credits` whatever the cap.

### Expiry

`expires_in_days` (1 to 365) sets `expires_at`. After it, every call with the
key is 401 `key_expired` (not `invalid_api_key`), so you know to make a new one. An
expired key is still listed, marked `"expired": true`, holds none of the
10 key slots, and can be revoked to tidy up.

### Creating, listing and revoking keys

An account holds up to 10 active keys; up to 20 may be
created per hour. One key per agent, machine or teammate means you can cut one off
without touching the rest.

```bash
curl -X POST https://api.hookdetector.com/v1/keys -H "Authorization: Bearer $HOOKDETECTOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "research agent", "scopes": ["read", "write", "research"], "credit_limit": 1000, "expires_in_days": 90}'
```

Returns 201 `{"key_id", "name", "prefix", "api_key", "created_at"}`; `api_key` is the
only copy. Every field is optional. Unknown scopes, an empty list, or numbers out of range
are 422 `invalid_request` and use up no creation slot.

`GET /v1/keys` returns `keys` (each key's `key_id`, `name`, `prefix`, `created_at`,
`last_used_at`, `current`), `scopes` (the calling key's) and `access`: for each `key_id`,
its `scopes`, `credit_limit`, `credits_used`, `expires_at` and `expired`. The key itself is
never shown again.

`DELETE /v1/keys/{key_id}` revokes one key at once (`key_id` from the list, or `original`
for the signup key). The account's last usable key cannot be revoked (409
`last_active_key`), nor its last usable key with `admin` unless you sign in with Google (409
`last_admin_key`), so you can never lock yourself out. A run start checks its key again
when it reserves: a key revoked a moment before starts nothing (401); a run already started
finishes.

### If a key leaks

Revoke every key except the one you are holding, in one call:

```bash
curl -X DELETE https://api.hookdetector.com/v1/keys/others -H "Authorization: Bearer $HOOKDETECTOR_API_KEY"
```

It answers `revoked_count` and `kept` (your key_id). MCP: `revoke_key` with key_id
`others`. Then create fresh keys for your agents. It needs `admin`.

### Storing it safely

- Keep it in an environment variable (`HOOKDETECTOR_API_KEY`) or a secrets manager
  (1Password, Doppler, AWS Secrets Manager, GitHub Actions secrets, your host's
  environment settings).
- Never put it in client-side code (a web page, a mobile app bundle), a public repository,
  a screenshot, a shared document or a chat. Anyone holding it can act as you, within its
  scopes and cap.
- Give each agent its own key with only the scopes it needs, a `credit_limit` and an
  expiry. Rotate: create the new key, move the agent to it, revoke the old one.

### What 401 and 403 mean

- 403 `access_code_required`: a signup with no beta access code. Get one at
  hookdetector.com/access.
- 403 `invalid_access_code`: the code is unknown, used up, expired or revoked.
- 403 `admin_required`: an owner-only route called by any other account.

- 401 `invalid_api_key`: no key, a wrong key, or a revoked one. Check the header.
- 401 `key_expired`: the key passed its `expires_at`. Make a new one.
- 403 `insufficient_scope`: the key works but may not do this. Use a key with that scope.
- 403 `key_credit_limit_reached`: the key has spent its cap. Use another key or make one
  with a higher cap.

## 4. Connect your agent or tool

Get a key first, then paste the block for your client. Replace `hd_YOUR_KEY`, or better,
keep the key in `HOOKDETECTOR_API_KEY` where the client supports it. The MCP server is at
https://api.hookdetector.com/mcp over streamable HTTP (`/mcp` and `/mcp/` both work).

### Claude Code

Run it once in your terminal. Checked against the live server on 2026-09-22.

```bash
claude mcp add --transport http hookdetector https://api.hookdetector.com/mcp \
  --header "Authorization: Bearer hd_YOUR_KEY"
```

### Codex CLI

Codex reads the key from the environment variable, so the key never sits in a config file. Checked against the live server on 2026-09-22.

```bash
export HOOKDETECTOR_API_KEY=hd_YOUR_KEY
codex mcp add hookdetector --url https://api.hookdetector.com/mcp --bearer-token-env-var HOOKDETECTOR_API_KEY
```

### Cursor and Windsurf

Cursor: ~/.cursor/mcp.json (or .cursor/mcp.json in a project). Windsurf: ~/.codeium/windsurf/mcp_config.json. Same block for both.

```json
{
  "mcpServers": {
    "hookdetector": {
      "url": "https://api.hookdetector.com/mcp",
      "headers": { "Authorization": "Bearer hd_YOUR_KEY" }
    }
  }
}
```

### VS Code

.vscode/mcp.json in your project (GitHub Copilot agent mode reads it).

```json
{
  "servers": {
    "hookdetector": {
      "type": "http",
      "url": "https://api.hookdetector.com/mcp",
      "headers": { "Authorization": "Bearer hd_YOUR_KEY" }
    }
  }
}
```

### Claude Desktop

claude_desktop_config.json (Settings, Developer, Edit Config). It connects through mcp-remote, which needs Node.js and passes the key as a header. Restart Claude Desktop after saving.

```json
{
  "mcpServers": {
    "hookdetector": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://api.hookdetector.com/mcp",
               "--header", "Authorization: Bearer hd_YOUR_KEY"]
    }
  }
}
```


### Any other MCP client

Point it at `https://api.hookdetector.com/mcp` with transport "streamable HTTP" (sometimes called "HTTP" or
"remote") and the header `Authorization: Bearer hd_YOUR_KEY`. A client that can only take
a URL: add https://api.hookdetector.com/mcp with no auth, call `create_account` once with your `access_code`, and
pass the returned key as `api_key` on every tool.

### Plain HTTP tools: n8n, Make, Zapier

Use the platform's HTTP request module (n8n "HTTP Request" node, Make "HTTP > Make a
request", Zapier "Webhooks by Zapier" or "API Request"):

- Method POST, URL `https://api.hookdetector.com/v1/research`, header `Authorization: Bearer hd_...` (store the
  key in the platform's credentials, not in the step), header `Content-Type:
  application/json`, header `Idempotency-Key` set to something unique per run (for
  example the trigger's id), body `{"topic": "your topic", "count": 10}`.
- Then GET `https://api.hookdetector.com/v1/runs/{run_id}?wait=55` with the same Authorization header,
  and repeat (a loop or a delay step) until `status` is `done` or `failed`. Set the
  request timeout to at least 55 + 15 seconds.
- Map `hooks[].hook`, `hooks[].watch_url` and the other fields into your sheet, CRM or
  doc, or GET `https://api.hookdetector.com/v1/runs/{run_id}/export?format=csv` for a ready file.

Machine-readable descriptions: OpenAPI 3.1 at https://api.hookdetector.com/openapi.json (browsable at https://api.hookdetector.com/docs),
the MCP registry entry at https://api.hookdetector.com/server.json and https://api.hookdetector.com/.well-known/mcp.json.

## 5. Research: start, wait, read

`POST /v1/research` (MCP `find_hooks`), needs `research`. Body: `topic` (3 to
500 characters, required; a topic over 100 characters or with a language or
country is searched by the subject read out of it, never verbatim), `count` (1 to
30, default 10), `reuse` (default true), `conversation_id` (optional), `language`
and `country` (optional, below), and since 2026-10-01 `posted_within_days` (7, 30, 90 or
365 days, or null for any time: the search asks the platforms for that window and a clip
under 7 days old passes the floor at 10,000 views a day) and `seed_hook_ids` (up to
10 of your own hooks: "find more like these"; their lines, topics, creators and
languages steer the search, and a seed is never delivered again). It returns 202 at once
with `run_id` and `credits_reserved`.

- Language and country: `language` (up to 60 characters) is the language or
  dialect every clip must be in, in words or as a tag ("Moroccan Darija", "ar-MA"). It may
  be a regional variety ("Quebec French"), a mixed, code-switched one ("Hinglish",
  "Taglish", "Moroccan Darija with French"), or two joined by "or" ("Tagalog or Taglish");
  the judges get it exactly as written, and how well they honour a mixed or either-of
  value is not measured yet. `country` is an assigned ISO 3166-1 alpha-2 code (UK is read as
  GB). A region that is not a country is its country's code plus the variety as
  language: Quebec is `CA` with "Quebec French", Flanders `BE` with "Flemish", Catalonia
  `ES` with "Catalan". The country places the TikTok search there, the language judge does
  the regional filtering, and Instagram reports no country. A region code ("CA-QC", "QC")
  is 422 with that advice.

- Idempotency: send `Idempotency-Key` (1 to 255 visible characters). The
  same key within 24 hours returns the first run with 200 and the header
  `Idempotent-Replayed: true`, and reserves nothing. The same key with a different body is
  422 `idempotency_key_reused`.
- Waiting: `GET /v1/runs/{run_id}?wait=N` (0 to 55; MCP `get_run` with
  `wait_seconds` 0 to 50) returns as soon as the run is done or failed, or
  after N seconds either way. Add `since=<the last progress.updated_at>` to also return
  as soon as progress changes (URL-encode the timestamp). One account may hold
  10 waiting calls at a time.
- Progress: `progress` has `stage` (one of queued, starting, reusing, searching_instagram, searching_tiktok, judging, transcribing, writing, searching_youtube, saving, done, failed, other), `message` (one plain sentence) and
  `updated_at`; then `counts` (`found`, `above_floor`, `on_topic`, `read`, `held`,
  `written`, `wanted`, and since 2026-09-29 `talking`) once the research
  reports them, and `preview` (the hooks written
  so far: hook, platform, creator, views, topic, cover_url, and since 2026-10-07 why,
  hook_type, watch_url, player_url and thumb_url, so a client can show each hook as a
  playable card the moment it is written) while a running run holds one.
  Since 2026-09-30 a running run also carries `feed`, the live research wall: at most 24
  clips it is working on, newest first, each `key` (opaque, stable in the run), `platform`,
  `creator`, `views` (null when unknown), `state` (`found`, `reading`, `talking`,
  `passed`: dropped), `thumb_url` (an absolute URL of the cover as a JPEG,
  `GET /v1/runs/{run_id}/thumbs/{key}`, no key needed, so any `<img>` loads it; 404
  `thumb_unavailable` once the run no longer shows the clip) and `line` (the banner or the
  opening words, only once the judges passed the clip and it is on its way to delivery,
  else null). It changes as the run moves, so `?wait=N&since=<updated_at>` returns with
  each new state (progress is written at most once a second, a new card at once). Built from what the run
  already holds: no extra credit and no extra call. Gone once the run is done or failed.
  A Long form or Packaging run carries `progress.scan` instead (since 2026-10-08), for a
  wait screen of the videos it is looking at: `tiles`, at most 60, each `{v, s, x}` in the
  order the run first saw the video (a tile never changes place). `v` is the YouTube video
  id: draw its picture from https://i.ytimg.com/vi/{v}/mqdefault.jpg yourself. `s` is
  `found`, `on_topic`, `off_topic`, `measured` (under the bar), `beat` (at least the bar
  times its channel's usual), `reading`, `ready` (written up) or `passed`; read an unknown
  one as `found`. `x` is the multiple of the channel's usual, once known. `lanes` counts
  `found`, `on_topic`, `measured`, `beat`, `reading` and `ready`; `measured` and `beat` are
  also `progress.counts.measured` and `.beat`, and count the same videos as
  `report.landscape` (`videos`, `outliers`), so the number shown while the run works is the
  one it ends on. Written on each state change through the same once-a-second progress
  write; present while a running run has seen a video, gone once it is done or failed.
  Alongside it, while a Long form or Packaging run works, `progress.items` carries each
  finished result as a card, not the whole find (the whole find arrives in `finds`), and a
  Packaging run reports stage `transcribing` while it looks at thumbnails (`counts.read`
  counts the looks done) and `writing` only for the playbook.
- Showcase: `GET /v1/showcase?n=12` (MCP `showcase`, no key) returns real hooks this
  service delivered, for a wait screen before a run's own feed fills:
  `[{"hook_id", "still_url", "platform", "creator", "views", "line"}]`, talking heads
  only, the most viewed first, one per creator, `n` clamped to 1 to 24, cached 10
  minutes. Only what is already public on TikTok or Instagram: never a topic, an account
  or a run.
- Results: when `status` is `done`, `hooks` holds the ranked hooks (fields in section 1),
  ranked since 2026-10-01 by `virality` (0 to 1 within the run: the outlier against the
  creator's usual views when known, freshness, travel, own-sound reuse, a paid penalty;
  `virality_why` names the strongest signal), and the run carries `patterns`, up to 3 hook
  structures winning in the deck (name, template with {placeholders}, why, hook_ids,
  count), computed after delivery in one cheap model call (null until it lands and on
  older runs):
  since 2026-09-29 only clips where a person on camera is talking. `bonus` is always []
  (Bonus was removed on 2026-10-08; the key stays for existing clients).
  A `failed` run has `error` in plain words, ending with the request id, and costs
  nothing. A `done` run with fewer hooks than asked for, or none, has `outcome`: why, in
  one paragraph (`stats.rejections` and `stats.held_back` hold the counts behind it), and what to try next; a
  full run's `outcome` is null or one sentence on hooks under the view floor. A
  run that finds no hooks is free, 3 times per account per UTC day and
  500 credits in all; past either it is charged what it used. Its spend still counts against a key's `credit_limit`
  (`stats.credits_waived`).
- Reuse: with `reuse` true (the default), a run on a topic you already researched starts
  from the clips you already paid for, so a second cut is close to free and often takes
  seconds.
- Credits and the quote: one credit is one provider call. A run reserves up to
  200 credits before it spends anything (the most it could cost, never a guess)
  and charges only what it used; the rest is refunded when it finishes. With less than
  200 left it reserves what you have, down to 40. New accounts get
  500 credits. `GET /v1/usage` shows `credits`, `spent_total`,
  `reserved_now`, `runs_total`, a 30 day history and your 20 newest runs; `credits +
  spent_total + reserved_now` is everything the account was given.
- `GET /v1/runs` lists your runs, newest first, a page at a time (`next_cursor`). A long
  or packaging run asked for by a YouTube address carries `label`: the channel's name or
  the video's title once the run knows it (null for a subject in words).

### Long form and packaging

Two more kinds of run read long-form YouTube only (never Shorts), keep only outliers, and
never deliver a video posted before 2023-01-01. An outlier is a video whose views are at
least `min_outlier` times (1.5 to 100, default 2) the
median views of its own channel's recent uploads.

- `POST /v1/longform` (MCP `find_long_hooks`, needs `research`): how those videos open.
  Body: `topic` (a subject; or a channel's YouTube address for that channel's own
  outliers; or a long-form video's address for that one video), `count` (1 to 30,
  default 8), optional `language`, `country`, `min_outlier`,
  `posted_within_days` (30, 90, 365, 730), `channel_size` (small, medium, large),
  `intro_seconds`
  (20 to 120, default 60), `openers` (0 to 6,
  default 3) and `reuse`. Each result carries the opening as it was said
  (`hook`, `intro` as timed lines) and why it holds (`hook_type`, `mechanism`, `promise`,
  `open_loop`, `title_link`, `beats`, `template`, `steal_it`). The run's `report` holds
  `patterns`, `shape` (how the openings are built, counted from the results), `landscape`
  (the subject at a glance, counted) and `openers`: openings written for your topic,
  always labelled "Written by Hook Detector from the structure of these real openings", never counted as a result.
- `POST /v1/packaging` (MCP `find_packaging`, needs `research`): their titles and
  thumbnails. Body: `topic`, `count` (1 to 30, default 12), the same
  optional fields, and `ideas` (0 to 10, default 6). Each result
  carries the real `title` and `thumbnail_url` and what the pair does (`title_device`,
  `title_template`, `thumbnail_text`, `thumbnail_subject`, `thumbnail_composition`,
  `thumbnail_devices`, `gap`). The run's `report` holds `title_patterns`,
  `thumbnail_patterns`, `numbers` (the packages counted) and `ideas`: packages written for
  your topic, always labelled
  "Written by Hook Detector from the patterns in these real packages", never counted as a result.

Both answer 202 with a `run_id` and are read with `GET /v1/runs/{run_id}?wait=55`
like any run: `kind` says which it is, the results are in `finds` (and, while it runs, in
`progress.items`), and `hooks` is always empty. Measured on 2026-10-07 ("home espresso", 6
videos): a long run on a new subject took 61 to 95 seconds and was charged 35 to 37 credits; a
packaging run on a subject a run had just read took under 35 seconds and was charged 0. A long run
reserves up to 90 credits for 20 videos or fewer, 5 more
for each above that (up to 140 for 30), and a packaging run up to
70 for 24 packages or fewer, 3 more for each above that
(up to 88 for 30); both go down to 25 on a smaller balance and
each is charged only the provider calls it made; a run of 30 has not been measured live
yet; a failed run costs nothing and an empty one is free inside the allowance above. Keep and
reject work on `POST` and `DELETE /v1/finds/{find_id}/decision`, the kept list is
`GET /v1/finds/keeps`, and `GET /v1/runs?kind=long` lists one kind. `GET /v1/finds` (MCP
`list_finds`) is the index of every video your runs found, one entry a video on each tab,
each with its title and thumbnail as YouTube shows them, its `outlier` and its
`outlier_score`; `q` also searches hooks and what a thumbnail prints and shows. A keep or
reject is the video's on that tab, on every find of it. `POST /v1/finds/{find_id}/remix`
(MCP `remix_find`, 1 credit, needs `research`) writes openings or packages for your niche
from one find's structure, always labelled as written. With `{"mode": "intro",
"video_brief": "<your video, 10 to 600 characters>"}` it is Write my intro, for a long find:
your own timed intro in that opening's structure, one `beats` entry for each beat of the real
opening with its `from_s`, `to_s` and `words_budget` (from the find's own timing), the words
to `say` with `{placeholders}` for what only you know (a number your `video_brief` did not
give becomes `{number}`), and `suggested_on_screen` and `suggested_broll` as suggestions.

## 6. Chat, decisions, the kept set, exports and stills

Chat: `POST /v1/chat` with `{"message": "...", "conversation_id": "<optional>"}` (MCP
`chat`, needs `write`). Each turn is one of three actions: `research` (it starts a run,
in `run`, and reserves credits like a research call, so the key also needs `research`),
`clarify` (one question, no run) or `answer` (it discusses the hooks this conversation
delivered, or explains the tool; no credits). Every response has `suggestions`: zero to
four short follow-ups (none for research). Messages are 1 to 4000 characters;
turns are limited per account per hour. `GET /v1/conversations` and
`GET /v1/conversations/{conversation_id}` read them back with their runs.

Make it mine: `POST /v1/hooks/{hook_id}/remix` with `{"niche": "teeth whitening", "n": 2}`
(MCP `remix_hook`, needs `research`) writes up to n (1 to 5) new opening lines for your
niche that keep the real hook's structure. The answer is `{"hook_id", "niche", "n",
"written": [{"line", "kept_structure"}], "label", "credits_charged": 1, "credits"}`, and
`label` is always "Written by Hook Detector from a real hook's structure": written lines, never found hooks, never added to a
deck, a keep or an export. One credit per call, taken before the model is asked and
refunded when it does not answer (503); 404 for a hook that is not yours; limited to
60 per account per hour. In chat, "make hook 2 mine for dentists" does the
same, and a deck size or a recency phrase typed into a message ("15 hooks", "this month")
sets the run's `count` and `posted_within_days`.

Decisions: `POST /v1/hooks/{hook_id}/decision` with `{"verdict": "keep"}` or
`{"verdict": "reject"}` (MCP `keep_hook`); the last verdict wins.
`DELETE /v1/hooks/{hook_id}/decision` (MCP `clear_decision`) undoes it and is safe to
repeat. Both need `write`.

The kept set: `GET /v1/keeps` (optionally `?run_id=`) returns every hook you kept, newest
decision first, as full hook objects, a page at a time (MCP `list_keeps`).

Exports: `GET /v1/runs/{run_id}/export?format=csv` (or `json`, `md`) downloads a finished
run as a file named hookdetector-<run_id>.<ext> (`&bom=true` starts a csv with the UTF-8
byte-order mark for Excel; off by default, so a program reads the first header as named); the CSV has one row per hook, ending
with the columns `video_id`, `player_url`, `still_url`, `section` (always main) and
`format` (`creator_country`, `place`,
`place_country`, `banner` and `match` are in the JSON and the Markdown, not in the CSV), and CSV cells a spreadsheet would run
as a formula are defused. A run still in flight is 409 `run_not_finished`. MCP `export_run`
returns the same file as text.

Stills and players: every hook's `still_url` is a JPEG this API serves with no key, so it
loads in any `<img>`. `player_url` is a 9:16 player for an iframe.
`GET /v1/hooks/{hook_id}/embed` (MCP `embed_hook`) gives the official, sanitized
TikTok or Instagram embed HTML (`kind` "embed", or "fallback" when the creator turned
embedding off; `thumbnail` is then never empty: it falls back to cover_url, then to the
hook's `still_url`).

No carousels, since 2026-09-26 (owner ruling): a run delivers single videos only, TikTok
videos and Instagram reels. TikTok photo posts (slideshows) and Instagram carousels are
dropped at the search row, before any credit, slide, OCR or model call is spent on them,
and a short run is never filled up with one; `stats.dropped_carousels` counts what a run
met. Hooks found before that date keep their fields: their `slide_count`, their null
`player_url` (a photo post has no player, so its embed is a "fallback"), their
`onscreen_text` read slide by slide, and their still (the first slide, kept when the post
was found; the first view of an older one may answer 404 `still_unavailable` while the
slide is fetched, and a retry after about ten seconds shows it).

Deleting everything: `DELETE /v1/account` with exactly
`{"confirm": "delete my account"}` (MCP `delete_account`, needs `admin`) erases the
account, every key, run, hook, decision and conversation. It cannot be undone and is
refused while a run is in flight.

## 7. Every REST route

Base URL https://api.hookdetector.com. Every route under /v1 except signup, access requests, the Google sign-in
redirects and the token exchange, stills, live wall pictures and the showcase needs `Authorization: Bearer hd_...`; the scope column says which permission it needs.

| method and path | scope | what it does | parameters | responses |
|---|---|---|---|---|
| POST /v1/accounts | none (public) | Sign up with a beta access code. No auth, no form, no human step. Create an account and get an API key with free credits. Needs a beta access code as `access_code` (403 access_code_required without one, 403 invalid_access_code for one that does not work; ask at hookdetector.com/access). The key in this response is the only copy that will ever exist: store it, then send it as `Authorization: Bearer hd_...` on every other call. Signups are limited per address per hour (an IPv6 caller per /64), and so are wrong codes; the X-RateLimit headers say where you stand. Free. MCP: create_account. | body `label` (optional), body `access_code` (optional) | 201, 403, 413, 422, 429, 500 |
| GET /v1/me | `read` | Who am I, what is my balance, what may this key do. The account behind the key: account_id, credits (the balance right now), key_prefix and key_id of the key making this call, label and created_at, and that key's scopes, credit_limit and expires_at (null: no cap, never expires), and `user`: the person signed in with Google who uses the account (null for a key-only account). Needs read. Free. MCP: balance. | none | 200, 401, 403, 413, 422, 500 |
| DELETE /v1/account | `admin` | Erase your account and everything in it. Erase the account and every row it owns: all its API keys, runs, hooks, keep or reject decisions, conversations and messages. It cannot be undone. The body must be exactly `{"confirm": "delete my account"}` (422 confirmation_required otherwise), and it is refused with 409 run_in_flight while a run is still queued or running. Any unspent credits go with the account. Every key stops working at once. Needs admin. MCP: delete_account. | body `confirm` (optional) | 200, 401, 403, 409, 413, 422, 500 |
| GET /v1/usage | `read` | Credits, spend, what in-flight runs hold, and the last 30 days. credits (the balance now), spent_total (charged by finished runs), reserved_now (held by runs still in flight), runs_total, last_30_days (one entry per UTC day, oldest first) and recent (the 20 newest runs). credits + spent_total + reserved_now is everything the account was given. Needs read. Free. MCP: usage. | none | 200, 401, 403, 413, 422, 500 |
| GET /v1/keys | `admin` | Your API keys and what each may do. Never the keys themselves. Every key not revoked: in keys, key_id, name, prefix, created_at, last_used_at (updated at most every 5 minutes) and current (true for the key making this call); in access, per key_id, its scopes, credit_limit, credits_used (what runs started with it spent), expires_at and expired. scopes is the calling key's. The signup key is key_id "original". A key itself is never shown again. Needs admin. MCP: list_keys. | none | 200, 401, 403, 413, 422, 500 |
| POST /v1/keys | `admin` | Create another API key. The key is in this response only. A new key on the same account, for example one per agent or machine, so one can be revoked without touching the others. Give it only what it needs: scopes (read, write, research, admin; all four when left out), credit_limit (the most its runs may spend over its life, 1 to 100000) and expires_in_days (1 to 365). The full key is in this response and nowhere else, ever. Needs admin. 409 key_limit_reached at 10 active keys; creation is limited per account per hour (429). MCP: create_key. | body `name` (optional), body `scopes` (optional), body `credit_limit` (optional), body `expires_in_days` (optional) | 201, 401, 403, 409, 413, 422, 429, 500 |
| DELETE /v1/keys/{key_id} | `admin`; any key may revoke itself | Revoke a key ("original", a key_id, or "others" for all but yours). Revoke one key at once. You may revoke the key you are calling with, whatever its scopes (the response says so in current); revoking another needs admin. Never the last usable key (409 last_active_key: create another first). An unknown or already revoked key is 404 key_not_found. key_id "others" revokes every key of the account except the one calling, for a key that leaked, and answers revoked_count and kept. MCP: revoke_key. | `key_id` | 200, 401, 403, 404, 409, 413, 422, 500 |
| POST /v1/research | `research` | Find hooks on a topic. Returns immediately; wait on the run. Start a research run across TikTok and Instagram and return its run_id at once. It reserves up to 200 credits (less on a smaller balance, down to 40) and charges only what it used; a failed run costs nothing, and so does a run that finds no hooks, within a per-account allowance (llms.txt has the numbers). A fresh run takes about 2 to 3 minutes (up to about 5 on a dialect request): then GET /v1/runs/{run_id}?wait=55 until status is done or failed. Needs research. A key with a credit_limit reserves at most what is left of it (403 key_credit_limit_reached under the minimum). Any language, dialect or country: set language ("Moroccan Darija", "ar-MA") and country ("MA"), or say it in the topic. Since 2026-10-01: posted_within_days (7, 30, 90 or 365) keeps the search to recent clips, and seed_hook_ids (up to 10 of your hooks) means "find more like these". Hooks carry virality (the v2 score), outlier (views over the creator's usual), posted_at and the other signals; a done run carries patterns, the structures winning in its deck. Since 2026-10-10: platforms (any of "tiktok", "instagram", "youtube"; all three when left out) searches and delivers only those platforms. MCP: find_hooks. | body `topic`, body `count` (optional), body `conversation_id` (optional), body `reuse` (optional), body `language` (optional), body `country` (optional), body `posted_within_days` (optional), body `seed_hook_ids` (optional), body `platforms` (optional) | 200, 202, 401, 402, 403, 413, 422, 429, 500, 503 |
| GET /v1/runs/{run_id} | `read` | A run and its hooks. One run: status, progress, reserved, charged, error (ending with the request id that started it, on a failed run) and every hook it produced. ?wait=N holds the request until the run is done or failed, or N seconds pass. With since set to the last progress.updated_at, it also returns on progress change. A done run with fewer hooks than asked for, or none, has outcome: why, in plain words; a full run has none, or one sentence on hooks under the view floor. `hooks` holds only clips where a person on camera is talking; `bonus` is always [] (removed 2026-10-08). Every hook has format and section, and since 2026-10-01 virality, virality_why, outlier, posted_at, age_days, views_per_day and the other virality v2 fields (null on older hooks). A done run also carries patterns: up to 3 hook structures winning in this deck with the hook_ids using each, computed after delivery (null until then, and on older runs). Free. MCP: get_run. | `run_id`, `wait` (optional), `since` (optional) | 200, 401, 403, 404, 413, 422, 429, 500 |
| GET /v1/runs/{run_id}/export | `read` | Download a run's hooks as CSV, JSON or Markdown. The hooks of a finished run as a file, one row or section per hook, ready to open in a spreadsheet or paste into notes. CSV cells that a spreadsheet would run as a formula are prefixed with a quote. With bom=true a csv starts with the UTF-8 byte-order mark for Excel. 409 run_not_finished while the run is in flight. Free. MCP: export_run. | `run_id`, `format` (optional), `bom` (optional) | 200, 401, 403, 404, 409, 413, 422, 500 |
| GET /v1/runs | `read` | Your runs, newest first. Your runs without their results, newest first, a page at a time: pass next_cursor back as ?cursor= for the next page (null on the last). Each says its kind (short, long or packaging); ?kind= narrows the list to one. Open one with GET /v1/runs/{run_id}. Free. MCP: list_runs. | `limit` (optional), `cursor` (optional), `kind` (optional) | 200, 401, 403, 413, 422, 500 |
| POST /v1/longform | `research` | Find long-form hooks: how the YouTube outliers on a topic open. Start a long-form run and return its run_id at once. It searches YouTube for long-form videos on the topic (never Shorts), keeps only the outliers (videos whose views are at least min_outlier times the median of their own channel's recent uploads, 2 by default) posted in 2023 or later, reads the first intro_seconds (60 by default) of each one's transcript and says why that opening holds a viewer. Then GET /v1/runs/{run_id}?wait=55 until status is done or failed: the results are the run's `finds`, each with hook and intro (verbatim, timed lines), hook_type, mechanism, promise, open_loop, beats, template and steal_it; `report.patterns` says which opening types several of them share, `report.shape` how the openings are built (how long the hook runs, which beats they use and when, counted from the finds) and `report.openers` holds up to `openers` (3 by default) openings written for your topic from those structures, always labelled as written, never counted as a find. While it runs, `progress.items` holds the finds written so far, as cards, and `progress.scan` the videos it is looking at. It reserves up to 90 credits for 20 videos or fewer and 5 more for each video above that (140 for the largest count, 30; less on a smaller balance, down to 25) and charges only the provider calls it made (the searches, one call per channel measured, one per transcript read); a failed run costs nothing, and so does one that finds nothing, inside the same allowance a hook run has. How long it takes has not been measured yet: poll, never assume a time. A second long or packaging run on the same topic within a week starts from the videos the first one bought (reuse), and a video you already had read is delivered from that read (`analysis_from_earlier_read`). | body `topic`, body `count` (optional), body `language` (optional), body `country` (optional), body `min_outlier` (optional), body `posted_within_days` (optional), body `channel_size` (optional), body `intro_seconds` (optional), body `openers` (optional), body `reuse` (optional) | 200, 202, 401, 402, 403, 413, 422, 429, 500, 503 |
| POST /v1/packaging | `research` | Find packaging: the titles and thumbnails that earned the click on a topic. Start a packaging run and return its run_id at once. It finds the long-form YouTube videos on the topic that beat their own channel (the same outlier search as POST /v1/longform, 2023 or later), looks at each one's thumbnail beside its title, and returns the pairs as the run's `finds`: title and thumbnail_url (YouTube's own), views and outlier, and what the package does: title_device, title_template, thumbnail_text, thumbnail_subject, thumbnail_composition, thumbnail_devices and gap (what the picture shows that the title does not say). `report` then holds title_patterns and thumbnail_patterns (what at least two of them share) and ideas: up to `ideas` (6 by default) titles and thumbnail concepts written for your topic from those patterns, always labelled as written, never counted as a find, and `numbers`: the packages counted (title length, how many carry a number or a question, how many thumbnails print words, which devices repeat). GET /v1/runs/{run_id}?wait=55 until done; while it runs, `progress.items` holds the packages looked at so far, as cards, and `progress.scan` the videos it is looking at. It reserves up to 70 credits for 24 packages or fewer and 3 more for each package above that (88 for the largest count, 30; down to 25) and charges only the provider calls it made (the searches, one call per channel measured, and one for each video whose date had to be read exactly): looking at a thumbnail costs none. A failed run costs nothing, and so does one that finds nothing, inside the allowance. How long it takes has not been measured yet: poll, never assume a time. `topic` may be a channel's or a video's YouTube address, and `channel_size` narrows to channels of one size, exactly as on POST /v1/longform; no ideas are written for a link. Starts are limited per account per hour (429). Needs research. MCP: find_packaging. | body `topic`, body `count` (optional), body `language` (optional), body `country` (optional), body `min_outlier` (optional), body `posted_within_days` (optional), body `channel_size` (optional), body `ideas` (optional), body `reuse` (optional) | 200, 202, 401, 402, 403, 413, 422, 429, 500, 503 |
| GET /v1/finds | `read` | The index: every video your long and packaging runs found. Everything your long and packaging runs found, as one index: one entry a video on each tab (its newest read, with the keep or reject you made on the video), each a full find object with its title and thumbnail as YouTube shows them, its `outlier` (views over its channel's usual) and its `outlier_score` (0 to 100 on one fixed scale, so entries from different runs rank together). `q` looks in the title, the channel, the hook as it was said, and what the thumbnail prints and shows, so a package can be found by its thumbnail ("red arrow", "before and after"). `total` is how many entries match, on any page; page with next_cursor. Free. MCP: list_finds. | `kind` (optional), `q` (optional), `sort` (optional), `tier` (optional), `min_score` (optional), `device` (optional), `limit` (optional), `cursor` (optional) | 200, 401, 403, 413, 422, 500 |
| GET /v1/finds/keeps | `read` | The long-form hooks and packages you kept. The videos you kept, newest decision first: one entry a video on each tab (its newest find), or the kept finds of one run when run_id is given; full find objects, a page at a time. count is the number on this page. Free. MCP: list_kept_finds. | `kind` (optional), `run_id` (optional), `limit` (optional), `cursor` (optional) | 200, 401, 403, 413, 422, 500 |
| GET /v1/finds/{find_id} | `read` | One find, every field. One result of a long or packaging run with every field. find_id comes from a run's `finds`. Free. MCP: get_find. | `find_id` | 200, 401, 403, 404, 413, 422, 500 |
| POST /v1/finds/{find_id}/decision | `write` | Keep or reject a find. Record keep or reject on one find; the last verdict wins. The verdict is the video's on that tab: every find of the same video you have there (an earlier run's, a later one's) carries it. Kept videos come back from GET /v1/finds/keeps. Free. MCP: keep_find. | `find_id`, body `verdict`, body `reason` (optional), body `note` (optional) | 200, 401, 403, 404, 413, 422, 500 |
| DELETE /v1/finds/{find_id}/decision | `write` | Clear a keep or reject on a find. Undo a verdict: the video goes back to having none, on every find of it, and leaves GET /v1/finds/keeps. Answers {"find_id", "verdict": null}. Clearing a find with no decision is the same 200. Free. MCP: clear_find_decision. | `find_id` | 200, 401, 403, 404, 413, 422, 500 |
| POST /v1/finds/{find_id}/remix | `research` | Make it mine, or Write my intro: written from this find. Write my intro: body {"mode": "intro", "video_brief": "<your video in a few sentences, 10 to 600 characters>"} (a long find only; niche and n are not used) writes your own intro in this real opening's structure: one entry in `written` holding a `beat` for each beat of the real opening, in order, each with its seconds (from_s, to_s) and words_budget taken from the real opening's own timing, the words to `say` with {placeholders} for what only you know, and suggested_on_screen and suggested_broll (suggestions, never part of what is said). A number you did not write becomes {number} (numbers_to_placeholders counts them). Same price and rules as below: 1 credit, labelled as written, 422 remix_not_possible when the model copied the real opening or broke a budget. | `find_id`, body `niche` (optional), body `n` (optional), body `mode` (optional), body `video_brief` (optional) | 200, 401, 402, 403, 404, 413, 422, 429, 500, 503 |
| GET /v1/finds/{find_id}/remixes | `read` | Your saved remixes of this find. The openings or packages you paid to have written from this find, newest first, each as its answer held it, with remix_id and created_at. Free. 404 for a find that is not yours. MCP: list_remixes. | `find_id` | 200, 401, 403, 404, 413, 422, 500 |
| GET /v1/remixes/{remix_id} | `read` | One saved remix. One remix you paid for (a hook's or a find's), as its answer held it. Free. 404 remix_not_found for one that is not yours. MCP: get_remix. | `remix_id` | 200, 401, 403, 404, 413, 422, 500 |
| GET /v1/hooks/{hook_id} | `read` | One hook, every field. One hook with every field: the verbatim opening line, the most quotable line, main idea, why it travelled, on-screen text, transcript, watch_url and the 9:16 player_url. hook_id comes from a run. Free. MCP: get_hook. | `hook_id` | 200, 401, 403, 404, 413, 422, 500 |
| GET /v1/hooks/{hook_id}/embed | `read` | Playable embed so the clip runs in your page. Official TikTok or Instagram embed HTML. kind is "embed", or "fallback" when the creator disabled embedding or the clip is a photo post found before 2026-09-26 (then show thumbnail, which falls back to the hook's still_url, and the transcript; a fallback is remembered for an hour). For a plain vertical player use the hook's player_url. Free. MCP: embed_hook. | `hook_id` | 200, 401, 403, 404, 413, 422, 500 |
| GET /v1/hooks/{hook_id}/still | none (public) | The hook's still as a JPEG, for an <img> tag. The card image for a hook, fetched and re-encoded by this API, so it loads on any page (platform image hosts refuse to be shown on other sites, and their links expire). No key needed: the id comes from a run and the image is the platform's public still. Cached for a day. Free. On MCP the same URL is the hook's still_url. | `hook_id` | 200, 404, 422, 429, 500 |
| GET /v1/hooks/{hook_id}/media | none (public) | The clip's original video file, for a <video> tag. Play a TikTok or Instagram clip in your own player: put this URL (the hook's media_url) in a <video src>. It answers a 302 to the clip's original file on the platform's CDN, which plays with seeking; no video byte is stored or proxied. No key needed. 404 media_unavailable for a clip with no file (a YouTube Short, a photo post, a deleted post) and 503 media_busy when it cannot be looked up now: show the official embed instead. After a playback error, ask once with ?retry=1 for a fresh address. With ?format=json it answers 200 {url, expires_in} instead of redirecting. Free. | `hook_id`, `retry` (optional), `format` (optional) | 200, 302, 404, 422, 429, 503 |
| GET /v1/runs/{run_id}/thumbs/{key} | none (public) | A clip on a run's live research wall, as a JPEG for an <img> tag. The cover of one clip a run in flight is working on: the thumb_url of an entry in progress.feed. No key needed (an <img> cannot send one): the URL is the secret, an HMAC of the run and the clip under the server's secret, so it cannot be guessed. Served from the cover the run already holds, re-encoded; 404 once the run no longer shows it. Cached for a day. Free. On MCP the same URL is in get_run's progress.feed. | `run_id`, `key` | 200, 404, 422, 429, 500 |
| GET /v1/showcase | none (public) | Real delivered talking-head hooks, for a wait screen. Hooks this service has delivered, for the first seconds of a wait before a run's own feed fills: talking heads only, a banner or opening line, a still (still_url, no key), the most viewed first and one per creator. Only what is already public on TikTok or Instagram: never a topic, an account or a run. No key needed. Cached 10 minutes. Free. MCP: showcase. | `n` (optional) | 200, 422, 429, 500 |
| POST /v1/hooks/{hook_id}/decision | `write` | Keep or reject a hook. Record keep or reject on one hook; the last verdict wins. Kept hooks come back from GET /v1/keeps across every run. Free. MCP: keep_hook. | `hook_id`, body `verdict`, body `reason` (optional), body `note` (optional) | 200, 401, 403, 404, 413, 422, 500 |
| DELETE /v1/hooks/{hook_id}/decision | `write` | Clear a keep or reject (undo). Undo a verdict: the hook goes back to having none, so it leaves GET /v1/keeps and its export row carries no verdict. Answers {"hook_id", "verdict": null}. Clearing a hook that has no decision is the same 200, so a retried undo is safe. Free. MCP: clear_decision. | `hook_id` | 200, 401, 403, 404, 413, 422, 500 |
| POST /v1/hooks/{hook_id}/remix | `research` | Make it mine: lines written from this hook's structure for your niche. Write up to n new opening lines (1 to 5, default 3) for your niche that keep this real hook's structure: the device that made it travel, with the subject swapped for yours. Body: {"niche": "teeth whitening", "n": 2}. Every answer carries the label "Written by Hook Detector from a real hook's structure", and each line names the structure it kept: these are written lines, never found hooks; they are never mixed into a deck, a keep or an export. Costs 1 credit per call (a cheap model), taken before the model is asked and refunded if it does not answer (503). 404 for a hook that is not yours. Limited per account per hour (429). Needs research. The answer carries remix_id; GET /v1/hooks/{hook_id}/remixes and GET /v1/remixes/{remix_id} read it back, free. MCP: remix_hook. | `hook_id`, body `niche` (optional), body `n` (optional), body `mode` (optional) | 200, 401, 402, 403, 404, 413, 422, 429, 500, 503 |
| GET /v1/hooks/{hook_id}/remixes | `read` | Your saved remixes of this hook. The lines you paid to have written from this hook, newest first, each as its answer held it, with remix_id and created_at. Free. 404 for a hook that is not yours. MCP: list_remixes. | `hook_id` | 200, 401, 403, 404, 413, 422, 500 |
| GET /v1/keeps | `read` | The hooks you kept. The hooks you kept, newest decision first, across all runs or within one run_id, as full hook objects, a page at a time (100 per page by default, and at most). count is the number on this page. Free. MCP: list_keeps. | `run_id` (optional), `limit` (optional), `cursor` (optional) | 200, 401, 403, 413, 422, 500 |
| POST /v1/chat | `write` | One conversational turn. The chat surface uses this. One turn: an answer about hooks or the tool (action "answer", no credits), one clarifying question (action "clarify"), or a started run (action "research", with the run in `run`, reserving credits like POST /v1/research). A deck size typed into the message ("..., 15 hooks") and a recency phrase ("this month", "last week") set the run's count and posted_within_days; the body's posted_within_days (the recency chip) wins over the phrase and is kept on the conversation for later turns (an explicit null clears it); seed_hook_ids applies to this turn's research ("find more like these"); count sets how many hooks a run this turn starts delivers (a size typed into the message wins). Asked to make one of the conversation's hooks yours for a niche ("make hook 2 mine for dentists"), the turn writes lines from that hook's structure (1 credit, like POST /v1/hooks/{hook_id}/remix) and answers with them labelled, the result in `remix`. Every response includes suggestions (0 to 4 follow-ups, [] for research). Each turn is a model call, so turns are limited per account per hour. 503 model_unavailable when the model is down; nothing is charged then. Needs write, and research for a turn that starts a run (403 insufficient_scope before anything is reserved). MCP: chat. | body `message`, body `conversation_id` (optional), body `language` (optional), body `country` (optional), body `posted_within_days` (optional), body `seed_hook_ids` (optional), body `count` (optional), body `platforms` (optional) | 200, 401, 402, 403, 404, 413, 422, 429, 500, 503 |
| GET /v1/conversations | `read` | Your conversations. Your chat conversations, most recently active first, a page at a time, each with its title and message count. Free. MCP: list_conversations. | `limit` (optional), `cursor` (optional) | 200, 401, 403, 413, 422, 500 |
| GET /v1/conversations/{conversation_id} | `read` | One conversation with its messages and runs. One conversation: its newest messages in order (a page of `limit`; earlier_cursor pages back through older ones), and the runs those messages hold (started by the chat, or attached with conversation_id in the same stretch of time) with their hooks. Free. MCP: get_conversation. | `conversation_id`, `limit` (optional), `before` (optional) | 200, 401, 403, 404, 413, 422, 500 |
| GET /health | none (public) | Liveness: is the process up and can it reach the database. A cheap check for load balancers: always 200. status is ok, or degraded when the database does not answer. database is "up", "busy" (every pooled connection is in use right now: the service is loaded, not broken, and status stays ok) or "down". commit is the git commit this image was built from, "unknown" outside a deploy. runs_in_flight counts research runs queued or running across the service (null when the database did not answer): a restart waits for 0, because Railway's in-place restart kills the process about 10 seconds after SIGTERM. It waits at most about 2 seconds for a connection and never calls a provider. | none | 200 |
| GET /health/deep | none (public) | Readiness: database, migrations, provider keys, provider status. For monitoring and for the web: a timed database round trip, whether every migration this code needs is applied, whether each provider key is configured (never a value), and provider_status: whether the language model or the search provider is refusing right now, read from how recent runs ended (cached 30 seconds, never a paid probe), and for the search provider our own credits_left (read in the background at most every 10 minutes from its free balance endpoint, never on this request), low (under HD_SEARCH_CREDITS_LOW) and accepting_runs (false while it refuses us: new runs are then 503 search_unavailable). A refusing provider does not make this 503; it is a warning for new runs. fallback_providers says whether the optional anthropic model fallback is configured (never a value); it never makes this degraded. commit is the git commit the image was built from ("unknown" outside a deploy), and credits_today is how much of the global daily research ceiling is reserved (UTC day). database.state is "up", "busy" or "down". Busy means every pooled connection is in use: it answers 200 with status ok, the schema as last read, and no provider_status. | none | 200, 503 |
| POST /v1/access/requests | none (public) | Ask for a beta access code by email. No auth. Ask for the beta access code a new account needs. Send your email and, if you like, a short note. The owner approves the request and the code is emailed to you at once; it works once, on the web or as access_code on POST /v1/accounts. The answer is 202 with status pending (waiting for approval) or emailed (sent now). Asking again while a request is pending changes nothing. Limited to 5 requests per address per hour (429). No key needed. Free. MCP: request_access. | body `email`, body `note` (optional) | 202, 413, 422, 429, 500 |
| GET /v1/admin/requests | `admin` | Access requests, newest first. Owner only. Every access request, newest first (at most 500): request_id, email, note, status (pending, approved, rejected), the code_id and code_prefix of the code made when it was approved, created_at and decided_at. Never the code itself. Needs the admin scope and an admin account (403 admin_required). MCP: list_access_requests. | `status` (optional) | 200, 401, 403, 413, 422, 500 |
| POST /v1/admin/requests/{request_id}/approve | `admin` | Approve a request: make a one-use code and email it. Owner only. Make a one-use access code for the request, mark it approved and email the code to the person. The code is in this response once, with emailed true or false: when the email did not go out, send the code yourself or approve again, which revokes the unused code and sends a new one. A request whose code was already used is 409 request_already_decided. Needs the admin scope and an admin account. MCP: approve_access_request. | `request_id` | 200, 401, 403, 404, 409, 413, 422, 500 |
| POST /v1/admin/requests/{request_id}/reject | `admin` | Reject a pending request. Owner only. Mark a pending access request rejected. Nothing is emailed. A request already approved or rejected is 409 request_already_decided (revoke an approved request's code with DELETE /v1/admin/codes/{code_id} instead). Needs the admin scope and an admin account. MCP: reject_access_request. | `request_id` | 200, 401, 403, 404, 409, 413, 422, 500 |
| GET /v1/admin/codes | `admin` | Every access code, never the code itself. Owner only. Every access code, newest first (at most 500): code_id, code_prefix (the first 7 characters), email (when bound), note, source (owner, request, proof), max_uses, uses, expires_at, revoked_at, created_at and usable. The full code is never shown again after it was made. Needs the admin scope and an admin account. MCP: list_access_codes. | none | 200, 401, 403, 413, 422, 500 |
| POST /v1/admin/codes | `admin` | Make an access code to hand out. The code is in this response only. Make a beta access code to send however you like. max_uses (1 to 100000, default 1) is how many accounts it may create; expires_in_days (1 to 365) is optional; note is your own. email binds it to one person: then only Google sign-in with that verified email redeems it, never REST or MCP; leave it out for a code that works on every door. The full code is in this response and nowhere else, ever. Needs the admin scope and an admin account. MCP: create_access_code. | body `email` (optional), body `note` (optional), body `max_uses` (optional), body `expires_in_days` (optional) | 201, 401, 403, 413, 422, 500 |
| DELETE /v1/admin/codes/{code_id} | `admin` | Revoke an access code. Owner only. The code admits nobody from now on. Accounts it already created keep working. Revoking a revoked code answers the same. 404 access_code_not_found for an unknown id. Needs the admin scope and an admin account. MCP: revoke_access_code. | `code_id` | 200, 401, 403, 404, 409, 413, 422, 500 |
| POST /v1/auth/exchange | none (public) | Trade a one-time sign-in token for a web API key. The web calls this with the token from /signin/callback#t=... after Google sign-in. The token works once, for 60 seconds. The answer is a new API key named "web sign-in" with every scope and a 30-day expiry, the person (`user`) and where to go next (`return_to`). An account keeps at most 3 web keys: the oldest is revoked. 409 key_limit_reached when the account already holds the most keys it can and none of them is a web key. Free. Called from the web's own origin, the key is also set in the session cookie, which the web then uses instead (FAILURE_MODES.md WS1). | body `token` | 200, 401, 409, 413, 422, 500 |
| POST /v1/auth/signout | none (public) | Sign out: revoke the key making this call. Revokes the calling key and nothing else. A web sign-in key is always revoked; any other key keeps the rule that an account's last active key cannot be revoked (409 last_active_key). Any key may call it, whatever its scopes. Free. Called from the web's own origin, it also clears the session cookie, whatever it answers (FAILURE_MODES.md WS8), so a browser can always sign out. | none | 200, 401, 409, 413, 422, 500 |
| POST /v1/auth/admin-signin | none (public) | The owner signs in with the admin password. No key. The owner's password sign-in for hookdetector.com/admin. Send {"password": "..."}; the answer is a web sign-in key (all scopes, 30 days, at most 3 per account) for the owner's account, the first email of HD_ADMIN_EMAILS, created on first use exactly as a Google sign-in would create it. A wrong password is 401 invalid_admin_password. Failed tries are limited to 5 per address per 15 minutes and 20 from all addresses per hour (429 with Retry-After, the right password included). 503 admin_password_unavailable when the server holds no valid HD_ADMIN_PASSWORD_HASH or no HD_ADMIN_EMAILS. The password is never stored or logged. Browser only: no MCP tool. From the web's own origin the key is also set in the session cookie (FAILURE_MODES.md WS14). | body `password` (optional) | 200, 401, 409, 413, 422, 429, 500, 503 |
| GET /v1/admin/overview | `admin` | The whole service at a glance. Owner only. Counts across every account: accounts and people; runs (total, last 24 hours, last 7 days, failed in the last 24 hours, in flight); hooks; credits charged (last 24 hours, 7 days, ever) and every balance summed; access requests pending, usable codes and redemptions; the search provider's credits_left, low and accepting_runs (null when not read yet); and the running commit. Needs the admin scope and an admin account. MCP: admin_overview. | none | 200, 401, 403, 413, 422, 500 |
| GET /v1/admin/accounts | `admin` | Every account, newest first, searchable. Owner only. Every account, newest first: id, the person's email and name (null for a key-only account), label, credits, created_at, last_seen_at, runs, hooks, charged_total, is_admin, admitted_by (the prefix of the access code that admitted it) and keys_active; total counts every match. Needs the admin scope and an admin account. MCP: admin_list_accounts. | `q` (optional), `limit` (optional), `offset` (optional) | 200, 401, 403, 413, 422, 500 |
| POST /v1/admin/accounts/{account_id}/credits | `admin` | Add or remove an account's credits, with a note. Owner only. Change an account's balance by delta (a whole number from -1,000,000 to 1,000,000, not zero) with a required note (1 to 200 characters). A change that would take the balance below zero is 422 credits_below_zero and nothing changes; an unknown account is 404 account_not_found. Every change is audited in the same transaction (GET /v1/admin/actions). Needs the admin scope and an admin account. MCP: admin_adjust_credits. | `account_id`, body `delta` (optional), body `note` (optional) | 200, 401, 403, 404, 413, 422, 500 |
| GET /v1/admin/runs | `admin` | Every account's runs, newest first. Owner only. Research runs across every account, newest first: run_id, account_id, the person's email, kind, topic, language, country, status, requested, hooks (results delivered: hooks for a short run, videos for a long or packaging run), reserved, charged, failure_kind, error, created_at and finished_at. Needs the admin scope and an admin account. MCP: admin_list_runs. | `limit` (optional), `status` (optional), `account_id` (optional) | 200, 401, 403, 413, 422, 500 |
| GET /v1/admin/actions | `admin` | What admins did (sign-ins, credit changes), newest first. Owner only. The audit: every password sign-in and credit change, newest first, with who (actor_email), on which account (target_account_id) and the detail (for credits: delta, note, before and after). Needs the admin scope and an admin account. MCP: admin_list_actions. | `limit` (optional) | 200, 401, 403, 413, 422, 500 |

Also served, outside the OpenAPI schema:

- GET /.well-known/mcp.json: the MCP registry entry, at the well-known path
- GET /server.json: the MCP registry entry
- GET /llms.txt: the short guide for agents, plain text
- GET /hookdetector-docs.md: this manual, the same file
- GET /docs.md: this manual, as a Markdown download
- GET /v1/auth/google/start: web sign-in with Google: redirects the browser to Google (optional return_to and code)
- GET /v1/auth/google/callback: where Google sends the browser back; redirects to the web
- GET /openapi.json: the OpenAPI 3.1 schema (browsable at https://api.hookdetector.com/docs)
- POST /mcp: the MCP server (streamable HTTP)

## 8. Every MCP tool

Server https://api.hookdetector.com/mcp (streamable HTTP). Send `Authorization: Bearer hd_...`, or pass `api_key`
on any tool. A tool error's whole text is JSON, with no prefix: `{"error": {"code": <the
REST status>, "message": "...", "error_code": "<the REST code>"}}`, plus `errors` (each
with `loc`, the argument's name) on a 422, and `retry_after` (seconds) whenever REST would send `Retry-After`; branch on
`error_code`. Each tool's input
schema carries the same bounds and descriptions as the REST body.

### `admin_adjust_credits`

Scope: `admin`. Arguments: `account_id` (optional), `delta` (optional), `note` (optional), `api_key` (optional).

Owner only: add or remove an account's credits, as POST
/v1/admin/accounts/{account_id}/credits. A change that would take the balance below
zero is 422 credits_below_zero and nothing changes; an unknown account is 404
account_not_found. Every change is audited (admin_list_actions). Returns {account_id,
credits, delta}.

### `admin_list_accounts`

Scope: `admin`. Arguments: `q` (optional), `limit` (optional), `offset` (optional), `api_key` (optional).

Owner only: every account, newest first, as GET /v1/admin/accounts: {total,
accounts: [{id, email, name, label, credits, created_at, last_seen_at, runs, hooks,
charged_total, is_admin, admitted_by, keys_active}]}. Free.

### `admin_list_actions`

Scope: `admin`. Arguments: `limit` (optional), `api_key` (optional).

Owner only: the audit of password sign-ins and credit changes, newest first, as GET
/v1/admin/actions: {actions: [{id, action, actor_email, target_account_id, detail,
created_at}]}. Free.

### `admin_list_runs`

Scope: `admin`. Arguments: `limit` (optional), `status` (optional), `account_id` (optional), `api_key` (optional).

Owner only: every account's research runs, newest first, as GET /v1/admin/runs:
{runs: [{run_id, account_id, email, topic, language, country, status, requested,
hooks, reserved, charged, failure_kind, error, created_at, finished_at}]}. Free.

### `admin_overview`

Scope: `admin`. Arguments: `api_key` (optional).

Owner only: the whole service at a glance, as GET /v1/admin/overview. Counts of
accounts and people; runs (total, last_24h, last_7d, failed_24h, in_flight); hooks;
credits (charged_24h, charged_7d, charged_total, balance_total); access
(pending_requests, active_codes, redemptions); search (credits_left, low,
accepting_runs; null when not read yet); and the running commit. Needs the admin scope
and an admin account (403 admin_required otherwise). Free.

### `approve_access_request`

Scope: `admin`. Arguments: `request_id` (optional), `api_key` (optional).

Owner only: approve an access request. Makes a one-use code, marks the request
approved and emails the code. The code is in this result once, with emailed true or
false; when false, send it yourself or approve again (the unused code is revoked and
a new one sent). 409 request_already_decided once its code was used. Free.

### `balance`

Scope: `read`. Arguments: `api_key` (optional).

Your account and remaining credits right now: account_id, credits, key_prefix and
key_id of the key making this call, label, created_at, and that key's scopes,
credit_limit and expires_at, and user (the person signed in with Google, or null).
Same shape as GET /v1/me. A
run needs at least 40 credits and reserves up to 200. For spend, credits held by
in-flight runs and daily history, call usage. Free.

### `chat`

Scope: `write`. Arguments: `message`, `conversation_id` (optional), `api_key` (optional), `language` (optional), `country` (optional), `idempotency_key` (optional), `posted_within_days` (optional), `seed_hook_ids` (optional), `count` (optional), `platforms` (optional).

One conversational turn, for an agent relaying a person's words. It either asks
ONE clarifying question (action "clarify"), answers about delivered hooks or the tool
(action "answer", run null, no credits), or starts a research run (action
"research", with the started run under "run"), exactly like POST /v1/chat.
Every response has suggestions: 0 to 4 follow-ups, [] for research.

A deck size typed into the message ("..., 15 hooks") and a recency phrase ("this
month", "last week") set the run's count and posted_within_days; the count argument
sets the deck size when the message states none. Asked to make one of
the conversation's hooks yours for a niche ("make hook 2 mine for dentists"), the turn
writes lines from that hook's structure (1 credit, like remix_hook) and answers them
labelled, the full result under "remix".

language and country select the search locale: omitted or null keeps the current
conversation setting; a value sets it for this and later turns; an empty string
clears it to any language / any country. The picker wins over the message's locale;
replies stay in the language the person writes in. Invalid locale: 422 invalid_request.

message is 1 to 4000 characters. Pass conversation_id from an earlier turn to keep
the context; leave it out to start a conversation. Cost: a turn itself is free but is
a model call, so turns are limited per account per hour (429 rate_limited); a turn
that starts a run reserves credits like find_hooks. Next step: on "research", call
get_run with run.run_id and wait_seconds=50. Errors: 503 model_unavailable when the
model is down (nothing charged, try again), 402 insufficient_credits. For direct
research without the question step, call find_hooks instead. Needs the write
permission, and research for a turn that starts a run (403 insufficient_scope, nothing
reserved).

### `clear_decision`

Scope: `write`. Arguments: `hook_id`, `api_key` (optional).

Undo keep_hook: the hook goes back to having no verdict, so it leaves list_keeps and
its export row carries none. Returns {"hook_id", "verdict": null}. Clearing a hook with
no decision is the same success, so a retry is safe. Same as DELETE
/v1/hooks/{hook_id}/decision. Free. Errors: 404 hook_not_found, 422 for an id that is
not a UUID.

### `clear_find_decision`

Scope: `write`. Arguments: `find_id`, `api_key` (optional).

Undo keep_find: the video goes back to having no verdict, on every find of it, and
leaves list_kept_finds.
Returns {"find_id", "verdict": null}. Clearing a find with no decision is the same
success, so a retry is safe. Same as DELETE /v1/finds/{find_id}/decision. Free.

### `create_access_code`

Scope: `admin`. Arguments: `email` (optional), `note` (optional), `max_uses` (optional), `expires_in_days` (optional), `api_key` (optional).

Owner only: make an access code to hand out. max_uses 1 to 100000 (default 1),
expires_in_days 1 to 365 (optional), note your own. email binds it to one person: then
only Google sign-in with that verified email redeems it, never the API or MCP; leave
it out for a code that works on every door. The code is in this result only. Free.

### `create_account`

Scope: none (public). Arguments: `access_code` (optional), `label` (optional).

Create an account and get an API key with free credits. Needs a beta access code
(access_code, HD-XXXX-XXXX; case and dashes do not matter): without one the error is
403 access_code_required, and a code that does not work is 403 invalid_access_code.
Ask for a code with request_access, or at hookdetector.com/access. No key needed for
this call. label is an optional note up to 120 characters.

Keep the api_key it returns: it is the only copy. Send it as the header
'Authorization: Bearer hd_...' or pass it as api_key on every other tool. Signups,
and wrong codes, are limited per address per hour. Next step: find_hooks.

### `create_key`

Scope: `admin`. Arguments: `name` (optional), `scopes` (optional), `credit_limit` (optional), `expires_in_days` (optional), `api_key` (optional).

Create another API key on your account, for example one per agent or machine, so
you can revoke one without touching the others. name is up to 60 characters.

Give the key only what it needs. scopes: any of "read" (read runs, hooks, keeps,
usage), "write" (keep, reject, undo, chat), "research" (start runs, which spend
credits), "admin" (manage keys, delete the account); all four when left out. An agent
that finds hooks needs ["read", "write", "research"]. credit_limit: the most credits
runs started with this key may spend over its life, 1 to 100000 (a run then reserves
at most what is left). expires_in_days: 1 to 365; the key then stops working (401
key_expired).

The new key is in this result once and never again: store it now. Needs admin. An
account holds at most 10 active keys (409 beyond) and may create 20 per hour by
default (429 beyond). Free.
Next step: use the returned api_key; revoke old ones with revoke_key.

### `delete_account`

Scope: `admin`. Arguments: `confirm` (optional), `api_key` (optional).

Erase your account and everything in it, for good: every API key, run, hook, keep
or reject decision, conversation and message. Unspent credits go with it. It cannot
be undone.

confirm must be exactly "delete my account", or nothing happens (422
confirmation_required). Refused with 409 run_in_flight while a run is queued or
running: wait for it with get_run first. Returns {"deleted": true, "account_id",
"erased": {counts per kind}, "message"}. Every key of the account stops working at
once; create_account starts a new one. Same as DELETE /v1/account. Free.

### `embed_hook`

Scope: `read`. Arguments: `hook_id`, `api_key` (optional).

Official TikTok or Instagram embed HTML for a hook, so the clip plays inside your
own page. kind is "embed", or "fallback" when the creator disabled embedding or the
clip is a photo post found before 2026-09-26 (then show thumbnail, which falls back to
the hook's still_url, and the transcript). For a plain vertical iframe use the hook's
player_url instead. Free.

### `export_run`

Scope: `read`. Arguments: `run_id`, `format` (optional), `api_key` (optional).

A finished run's hooks as a file a creator can open: format "csv" (default; one row
per hook, opens in any spreadsheet, formula-like cells defused with a leading quote),
"json" (the full hook objects) or "md" (Markdown notes, one section per hook).

Returns {"filename", "content_type", "format", "content"}, where content is the whole
file as text: write it to filename. The same bytes as GET
/v1/runs/{run_id}/export?format=... . Free.
Errors: 404 run_not_found, 409 run_not_finished while the run is queued or running
(call get_run with wait_seconds=50 first), 422 invalid_request for another format.

### `find_hooks`

Scope: `research`. Arguments: `topic`, `count` (optional), `conversation_id` (optional), `reuse` (optional), `wait_seconds` (optional), `idempotency_key` (optional), `api_key` (optional), `language` (optional), `country` (optional), `posted_within_days` (optional), `seed_hook_ids` (optional), `platforms` (optional).

Start hook research on a topic across TikTok, Instagram and YouTube Shorts (Shorts
only, never long-form YouTube; platform "youtube"). platforms (since 2026-10-10, any of
"tiktok", "instagram", "youtube"; all three when left out) searches and delivers only
those: ["instagram"] is Instagram reels only.

What it does: searches both platforms, reads what each clip says and shows, and
returns ranked hooks. Each hook has its verbatim opening line, most quotable line,
main idea, why it travelled, topic, on-screen text, full transcript, a watch link and
a vertical player_url (9:16 iframe).

Talking heads only (since 2026-09-29): `hooks` holds only clips where a person on
camera is talking (talking head, podcast, interview). A clip where nobody talks is
dropped; Bonus was removed on 2026-10-08 and `bonus` is always []. Every hook carries
format (talking_head, podcast, interview, stage; null on older hooks) and section
(always "main"). A run that finds fewer talking heads than count says so in outcome;
it never fills hooks with clips that do not talk.

Where it comes from (since 2026-09-30): every hook has creator_country (ISO alpha-2
of the creator's TikTok account; always null on Instagram, which states none), place
(the place the creator tagged, or null) and place_country (that place's country when
the platform states it). Never guessed.

Banner (since 2026-09-30): every hook has banner, the title-style text the creator
placed on the video to hook the viewer, verbatim (at most 200 characters), or null
when it has none; subtitles are never a banner. onscreen_text keeps all text read.

Match (since 2026-09-30, runs with a language or country): every hook has match,
"exact" when the clip is about the subject itself or "close" when it is about a
neighbouring subject with the same intent (a close match, delivered after every exact
match; label it as one); null on runs with no language or country and on older hooks.

Analysis (since 2026-10-02): every hook carries hook_type (curiosity_gap,
contrarian_claim, specific_number, identity_callout, stakes_threat, promise_payoff,
pattern_interrupt, story_open, question, direct_command, other with hook_type_other),
mechanism (why the opening stops the scroll, quoting the clip; why_it_travelled holds
the same text), visual_hook, audience, template and steal_it; null on older hooks.

Virality v2 (since 2026-10-01): the deck is ranked by virality (0 to 1, since
2026-10-02 on one fixed scale, the same clip scoring the same in every run), built from outlier (views over the creator's usual views per video,
creator_median_views, with outlier_basis saying how it was measured), freshness
(posted_at, age_days, views_per_day), travel (shares and saves per 1,000 views),
own_sound and sound_reuse, and a paid penalty; virality_why names the strongest
signal in one line. Every hook also carries duration_s and hashtags. Null on older
hooks. posted_within_days (7, 30, 90 or 365) keeps the search to recent clips (a clip
under 7 days old passes the floor at 10,000 views a day). seed_hook_ids (up to 10 of
your hooks, for example kept ones) means "find more like these": their lines, topics,
creators and languages steer the search, and a seed is never delivered again. A done
run carries patterns: up to 3 hook structures winning in the deck (name, template,
why, hook_ids, count), computed after delivery; null until then. remix_hook writes
lines from one hook's structure for your niche (1 credit).

Arguments: topic is 3 to 500 characters, count 1 to 30 (default 10). Instructions
inside the topic are fine: only the subject read out of it is searched. reuse=true
(default) starts from clips you already paid for on the same topic, so a rerun is
close to free and often finishes in seconds. idempotency_key: any string up to 255
characters; the same key within 24 hours returns the first run instead of starting
and paying for a second one, so always pass one and reuse it when you retry.
wait_seconds 0 to 50: 0 returns the run_id at once; above 0 waits for the run and
returns the run object, as get_run does.

Any language, dialect or country: language is optional free text up to 60 characters
naming the language or dialect every clip must be in ("Moroccan Darija", "ar-MA",
"Egyptian Arabic", "Brazilian Portuguese"); country is an optional ISO 3166-1 alpha-2
code for where the creators are (MA, EG, BR). Saying it inside topic works too; these
fields win when both are given. A country alone does not restrict the language. Each
hook reports the language it was judged to be in.

A region that is not a country: send its country's code and name the regional variety
in language (Quebec: country "CA", language "Quebec French"; Flanders: "BE" and
"Flemish"; Catalonia: "ES" and "Catalan"). The country places the TikTok search there
and the language judge does the regional filtering; Instagram reports no country.
language may name a mixed, code-switched variety ("Hinglish", "Taglish", "Moroccan
Darija with French") or two joined by "or" ("Tagalog or Taglish"); it reaches the
judges exactly as written, and how well they honour such a value is not measured yet.

Cost: reserves up to 200 credits up front (less if your balance is smaller, down to
40) and charges only what it used; the rest is refunded. A failed run costs nothing,
and so does a run that finds no hooks, within a per-account allowance (llms.txt has
the numbers; its outcome says which applied).

Timing and next step: a fresh run takes about 2 to 3 minutes, up to about 5 when the
language is a dialect. Call get_run with wait_seconds=50 repeatedly until status is
"done" or "failed", which is usually 3 or 4 calls (up to 6 on a dialect request). Watch progress.stage and progress.message meanwhile.

Needs a key with the research permission (403 insufficient_scope otherwise). A key
with a credit_limit reserves at most what is left of it; under the minimum it is 403
key_credit_limit_reached and nothing is reserved.

### `find_long_hooks`

Scope: `research`. Arguments: `topic`, `count` (optional), `language` (optional), `country` (optional), `min_outlier` (optional), `posted_within_days` (optional), `channel_size` (optional), `intro_seconds` (optional), `openers` (optional), `reuse` (optional), `wait_seconds` (optional), `idempotency_key` (optional), `api_key` (optional).

Find long-form hooks: how the YouTube videos that beat their own channel open, on a
topic. Long-form YouTube only, never Shorts, TikTok or Instagram (for those: find_hooks).

What it does: searches YouTube for long-form videos on the topic, keeps only the
outliers (a video whose views are at least min_outlier times the median views of its
own channel's recent uploads; 2 by default) posted in 2023 or later (nothing older is
ever delivered), reads the first intro_seconds (60 by default) of each one's
transcript, and says why that opening holds a viewer.

What comes back: the run's `finds`, ranked, strongest outlier first. Each has title,
channel, watch_url, player_url (an iframe src), thumbnail_url, views,
channel_median_views, outlier (28.2 means 28 times the channel's usual), outlier_score
(0 to 100 on one fixed scale: nothing else a run found moves it, so finds from
different runs rank together), outlier_tier, channel_size, likes_per_1k, posted_at,
duration_s, and the opening: intro (the first minute as it was said: lines of n, t in
seconds, text) and intro_text, always whole; and inside it the hook (verbatim: the lines
the analysis marks as the hook, ending on a sentence end, at most 25 seconds and 60
words, with hook_words, hook_start_s and hook_end_s, a sponsor read or greeting at the
front left out; hook_seconds is hook_end_s). auto_captions says the opening has almost
no punctuation. about_subject is yes or partly: a video the model judges not to be
about the subject is dropped. The analysis: hook_type (the same list as find_hooks),
mechanism (why it holds, quoting the opening word for word), promise, open_loop,
title_link (how the first lines pay off the title), beats (the opening in parts: from,
to, beat, does),
opens_with (cold_open, greeting, teaser, question, sponsor), format (the kind of
video), angle, template (the hook as a fill-in structure), steal_it and audience. When
the topic asks for a kind of opening ("question hooks", "storytime"), style_match says
whether each find is that kind and the matches come first. analysed is false
when the model could not analyse an opening: the opening is still real, its analysis
fields are null. The run's report.patterns lists the opening types at least two finds
share; report.shape says how the openings are built, counted from the finds
(hook_seconds, how long the hook runs, and hook_words as medians, beats with how many
openings use each and the second it usually starts_at, and the sequence of beats most
of them share); and
report.openers holds up to `openers` (3 by default, 0 for none) openings WRITTEN for
your topic from those structures (hook, then, hook_type, based_on). report.openers_label
is always "Written by Hook Detector from the structure of these real openings": show
it with them, never present one as a real video's opening. report.landscape is the
subject at a glance, counted from the videos the run held against their own channel
(videos, channels, outliers and their share, breakouts, small_channel_outliers,
recent_outliers). The run's hooks and bonus are always empty.

A link instead of a subject: when topic is a channel's YouTube address
(https://www.youtube.com/@name, or just @name) the run returns that channel's OWN
outliers, its recent long-form uploads that beat its usual (one provider call, no
search); when it is a long-form video's address the run returns that one video,
whatever its multiple (requested is 1). options.link says "channel" or "video". No
openings are written for a link (use remix_find on a find to write for your niche).
A Short's address is refused with 422.

Arguments: topic 3 to 500 characters; count 1 to 30 (default 8); language and country
optional; posted_within_days 30, 90, 365 or 730 to narrow the 2023 floor further;
channel_size "small", "medium" or "large" to keep only channels of that size (by what
their uploads usually get), so a small creator sees breakouts from channels their own
size; reuse=true (default) starts from the videos your last long or packaging run on
this topic bought within a week, and a video you already had read is delivered from
that read (analysis_from_earlier_read) at no model or provider cost. seen_before says
an earlier run of yours already found the video. idempotency_key: always pass one and
reuse it on a retry. wait_seconds 0 to 50: 0 returns the run_id at once; above 0 waits
and returns the run. Starts are limited per account per hour (429 rate_limited).

Cost: reserves up to 90 credits for 20 videos or fewer and 5 more for each video above
that (140 at 30; less on a smaller balance, down to 25) and charges
only the provider calls it made: the searches, one call per channel measured, one per
transcript read. A failed run costs nothing, and so does a run that
finds nothing, inside the same allowance a hook run has (the outcome says so).

Timing and next step: how long a run takes has not been measured yet, so never assume
a time. Call get_run with wait_seconds=50 until status is done or failed;
progress.items holds the finds written so far as cards
and progress.scan the videos it is looking at while it runs. Then keep_find, export_run. Same as POST /v1/longform. Needs the research
permission (403 insufficient_scope otherwise).

### `find_packaging`

Scope: `research`. Arguments: `topic`, `count` (optional), `language` (optional), `country` (optional), `min_outlier` (optional), `posted_within_days` (optional), `channel_size` (optional), `ideas` (optional), `reuse` (optional), `wait_seconds` (optional), `idempotency_key` (optional), `api_key` (optional).

Find packaging: the titles and thumbnails that earned the click on a topic, from
long-form YouTube videos that beat their own channel.

What it does: runs the same outlier search as find_long_hooks (long-form YouTube,
2023 or later, views at least min_outlier times the channel's usual), then looks at
each video's thumbnail beside its title and says what the pair is doing.

What comes back: the run's `finds`, ranked. Each is a real package, as YouTube shows
it: title (verbatim) and thumbnail_url (the size YouTube really served, the picture
the analysis looked at; thumbnail_checked says so), channel, watch_url, views,
channel_median_views, outlier, outlier_score (0 to 100 on one fixed scale, so finds
from different runs rank together), outlier_tier, channel_size and posted_at, plus the analysis: title_device (curiosity_gap, contrarian_claim,
specific_number, how_to, listicle, challenge, story, question, warning, comparison,
authority, timeliness, identity_callout, other), title_template (the title as a
fill-in structure), thumbnail_text (the words printed on the thumbnail, read by a vision
model; null when none), thumbnail_text_role (repeats_title, adds_to_title,
contrasts_title, no_text; counted from title_overlap, the share of the thumbnail's words
that are also in the title, where that can be), thumbnail_subject, thumbnail_faces,
thumbnail_emotion,
thumbnail_colors, thumbnail_composition, thumbnail_devices, angle, gap (what the picture shows that the title does not say, and the
question that leaves), promise, steal_it and audience, plus cut_on_phone (the title is
longer than 60 characters, where a phone roughly cuts it; approximate) and on_subject
(yes or partly: whether the look judged the package to be about your topic; a package
judged not to be is dropped and the next one takes its slot; null on a link run). analysed
is false when a package could not be looked at (its look is asked twice, and the next
candidate takes the slot if it fails twice); it is still a real package.

The run's report then holds title_patterns and thumbnail_patterns (what at least two
of the packages share, each naming their video_ids) and ideas: up to `ideas` (6 by
default, 0 for none) packages WRITTEN for your topic from those patterns, each a title
a thumbnail concept and thumbnail_text (the words to print on it), naming the real
packages it is based_on. report.ideas_label is always "Written by Hook Detector from
the patterns in these real packages": show it with them, never present an idea as a
real video's title; an idea over 70 characters, with more than 5 words for its
thumbnail or repeating a real title is dropped. report.numbers counts the packages:
title_chars and title_words
(medians), titles_with_number, titles_with_question, titles_cut_on_phone (with
phone_title_chars, the approximate length a phone cuts a title at), thumbnails_with_text
and thumbnail_words (over the analysed ones), and the title_devices, thumbnail_devices and
emotions that repeat (name, count); age_cohorts repeats the counts for packages posted in
the last split_days days (recent) and for older ones, with the undated apart.
report.landscape is the subject at a glance, as on find_long_hooks.

Arguments: topic 3 to 500 characters, or a channel's or a long-form video's YouTube
address exactly as on find_long_hooks (a channel's own outliers, or that one video; no
ideas are written for a link, use remix_find); count 1 to 30 (default 12); language,
country, min_outlier, posted_within_days, channel_size and reuse as on
find_long_hooks; idempotency_key and wait_seconds as on find_hooks.

Cost: reserves up to 70 credits for 24 packages or fewer and 3 more for each package
above that (88 at 30; down to 25) and charges only the provider calls it
made: the searches, one call per channel measured, and one for each video whose date
had to be read exactly. Looking at a thumbnail costs no credit. After a find_long_hooks run on the same topic it is close
to free. A failed run costs nothing, and so does a run that finds nothing, inside the
allowance.

Timing and next step: how long a run takes has not been measured yet, so never assume
a time. Call get_run with wait_seconds=50 until status is done or failed;
progress.items holds the packages looked at so far as cards and progress.scan the videos
it is looking at while it runs. Same as POST /v1/packaging. Needs the research
permission.

### `get_conversation`

Scope: `read`. Arguments: `conversation_id`, `limit` (optional), `before` (optional), `api_key` (optional).

One chat conversation: its newest messages in order (role, content, run_id,
created_at), a page of `limit`, with earlier_cursor for the older ones (null when the
page reaches the first message), and the runs those messages hold, with their hooks,
plus nullable language and country search picker settings. Same shape as GET
/v1/conversations/{conversation_id}. Free. Errors: 404 conversation_not_found, 422 for
an id that is not a UUID or a cursor this API did not issue.

### `get_find`

Scope: `read`. Arguments: `find_id`, `api_key` (optional).

One result of a long or packaging run with every field: the video, its numbers and
the opening or the package analysis. find_id comes from a run's finds (get_run) or from
list_kept_finds. Same shape as GET /v1/finds/{find_id}. Free. Errors: 404
find_not_found, 422 for an id that is not a UUID.

### `get_hook`

Scope: `read`. Arguments: `hook_id`, `api_key` (optional).

One hook with every field: its opening line, transcript, watch_url and the
vertical player_url (null only for an unusable id or a photo post found before
2026-09-26; runs deliver single videos only). hook_id comes from get_run. Free.

### `get_remix`

Scope: `read`. Arguments: `remix_id`, `api_key` (optional).

One remix you paid for (from remix_hook, remix_find or a chat turn), as its answer
held it. Free. 404 remix_not_found for one that is not yours. Same as GET
/v1/remixes/{remix_id}.

### `get_run`

Scope: `read`. Arguments: `run_id`, `wait_seconds` (optional), `api_key` (optional), `since` (optional).

A research run: its status, progress, charge and every hook it produced.

wait_seconds 0 to 50: wait up to that long for the run to finish, returning the
moment it is done or failed. Use wait_seconds=50 and call again while status is
"queued" or "running"; a fresh run needs about 3 or 4 such calls. progress has
stage, message and updated_at, plus counts once the research reports them, preview
(hooks written so far) while a running run holds one, and feed (the live research
wall: up to 24 clips the run is working on, newest first, each key, platform, creator,
views, state found|reading|talking|passed, thumb_url, a JPEG any <img> can load
with no key, and line, the banner or opening words once the judges passed the clip)
while it shows one, and on a Long form or Packaging run scan (the videos it is looking
at: up to 60 tiles {v: YouTube video id, s: found|on_topic|off_topic|measured|beat|
reading|ready|passed, x: multiple of the channel's usual or null}, oldest first, and
lanes with found, on_topic, measured, beat, reading, ready; draw a tile from
https://i.ytimg.com/vi/{v}/mqdefault.jpg; counts.measured and counts.beat are the
numbers report.landscape ends on). Pass since with the last
progress.updated_at to return on change or completion. It must be an ISO timestamp
with a timezone (422 invalid_request otherwise). `hooks` is the deck (a person
talking); `bonus` is always [] (removed 2026-10-08). Every hook has format and
section (always "main"), and
creator_country (TikTok only), place and place_country (null when unknown), banner
(the title placed on the video, null when none) and match ("exact" or "close", a
close match being a neighbouring subject delivered after the exact ones; null on runs
with no language or country and on older hooks). Since 2026-10-04 the run carries
understood (what the request was read to ask: subject, language, country, core,
audience, neighbours, styles, style_other, wants; null until read) and, on a run that
asked for a kind of opening ("controversial hooks", "storytime"), every hook carries
style_match (true when it opens that way; null otherwise). Since 2026-10-01 every hook carries
the virality v2 fields: virality (0 to 1 on one fixed scale since 2026-10-02),
virality_why, outlier, outlier_basis, creator_median_views, posted_at, age_days,
views_per_day, duration_s, paid, sound_reuse, own_sound, hashtags (null on older
hooks), and since 2026-10-02 the analysis: hook_type, hook_type_other, mechanism,
visual_hook, audience, template, steal_it; the run echoes posted_within_days and
seed_hook_ids, and once done carries
patterns: up to 3 hook structures winning in this deck, each name, template, why,
hook_ids and count, grouped by hook_type and computed before the run is done, in the
deck's language (null on older runs and when nothing is shared). Hooks include
transcript_kind:
speech, music, none, other, or null when unknown. conversation_id names the chat conversation the run
belongs to (null outside one). A done run with fewer hooks than asked for, or none,
has outcome: why in one plain paragraph, what to try next, and whether it was free
(stats.rejections holds the counts); a full run's outcome is null, or one sentence
when some hooks are under the view floor because the country filter set clips aside.
Free: reading a run costs no credits.

### `keep_find`

Scope: `write`. Arguments: `find_id`, `verdict` (optional), `api_key` (optional).

Keep or reject a find (a long-form hook or a package): verdict is "keep" (default)
or "reject", and the last verdict wins. The verdict is the video's on that tab: every
find of the same video you have there carries it, so a later run that finds the video
again shows it already kept. Kept videos come back from list_kept_finds, across all
runs. Returns {"find_id", "verdict"}. Same as POST
/v1/finds/{find_id}/decision. Free. Errors: 404 find_not_found.

### `keep_hook`

Scope: `write`. Arguments: `hook_id`, `verdict` (optional), `reason` (optional), `note` (optional), `api_key` (optional).

Keep or reject a hook: verdict is "keep" (default) or "reject", and the last
verdict wins. A reject may say why: reason is off_subject, weak_opening, wrong_language,
low_quality, already_have or other, and note (up to 280 characters) your own words.
Kept hooks come back from list_keeps, across all runs. Free.

### `list_access_codes`

Scope: `admin`. Arguments: `api_key` (optional).

Owner only: every access code, newest first, with code_id, code_prefix, email,
note, source, max_uses, uses, expires_at, revoked_at, created_at and usable. Never the
code itself. Free.

### `list_access_requests`

Scope: `admin`. Arguments: `status` (optional), `api_key` (optional).

Owner only: every access request, newest first, optionally only one status
(pending, approved, rejected). Each has request_id, email, note, status, code_id,
code_prefix, created_at and decided_at. Never a code. Needs the admin scope and an
admin account (403 admin_required otherwise). Free.

### `list_conversations`

Scope: `read`. Arguments: `limit` (optional), `cursor` (optional), `api_key` (optional).

Your chat conversations, most recently active first, limit 1 to 100 (default 30)
per page: conversation_id, title, created_at, updated_at, language, country and
messages (the count). Nullable language/country are the saved search picker settings.
Returns {"conversations": [...], "next_cursor"}; pass next_cursor back as cursor for
more. Same shape as GET /v1/conversations. Next step: get_conversation. Free.

### `list_finds`

Scope: `read`. Arguments: `kind` (optional), `q` (optional), `sort` (optional), `tier` (optional), `min_score` (optional), `device` (optional), `limit` (optional), `cursor` (optional), `api_key` (optional).

The index: every video your long and packaging runs found, across all runs, one
entry a video on each tab (its newest read, with the keep or reject made on the
video). Each is a full find object: the title and thumbnail_url as YouTube shows them,
views, channel_median_views, outlier (views over the channel's usual) and
outlier_score (0 to 100 on one fixed scale, so entries from different runs rank
together), plus the analysis its run wrote. q (at most 120 characters) looks in the
title, the channel, the hook as it was said, and what the thumbnail prints and shows,
so a package can be found by its thumbnail ("red arrow", "before and after"). Returns
{"finds", "count", "total" (every match, on any page), "next_cursor"}; pass
next_cursor as cursor for the next page. Use it to search what you already paid for
before starting a new run. Same as GET /v1/finds. Free. Errors: 422 for a kind, sort,
tier, min_score, device or q that is not allowed.

### `list_keeps`

Scope: `read`. Arguments: `run_id` (optional), `limit` (optional), `cursor` (optional), `api_key` (optional).

The hooks you kept with keep_hook, newest decision first, across all runs or within
one run_id, limit 1 to 100 (default 100) per page. Returns {"kept": [...], "count": n
on this page, "next_cursor"} with full hook objects; pass next_cursor back as cursor
for more. Same shape as GET /v1/keeps. Free. Errors: 422 for a bad run_id or cursor.

### `list_kept_finds`

Scope: `read`. Arguments: `kind` (optional), `run_id` (optional), `limit` (optional), `cursor` (optional), `api_key` (optional).

The videos you kept with keep_find (long-form hooks and packages), newest decision
first: one entry a video on each tab across all runs, or the kept finds of one run_id,
limit 1 to 100 (default 100) per page.
Returns {"kept": [...], "count": n on this page, "next_cursor"} with full find objects.
Same shape as GET /v1/finds/keeps. Free. Errors: 422 for a bad kind, run_id or cursor.

### `list_keys`

Scope: `admin`. Arguments: `api_key` (optional).

Your API keys and what each may do. keys: key_id, name, prefix, created_at,
last_used_at and current (true for the key making this call). access, per key_id:
scopes, credit_limit, credits_used, expires_at and expired. scopes: the calling key's.
The original signup key has key_id "original". The keys themselves are never shown
again. Needs admin. Same shape as GET /v1/keys. Free.

### `list_remixes`

Scope: `read`. Arguments: `hook_id` (optional), `find_id` (optional), `api_key` (optional).

The remixes you paid for from one hook (hook_id) or one find (find_id), newest
first, each as its answer held it, with remix_id and created_at. Returns {"remixes":
[...]}. Free. 404 hook_not_found or find_not_found for one that is not yours. Same as
GET /v1/hooks/{hook_id}/remixes and GET /v1/finds/{find_id}/remixes.

### `list_runs`

Scope: `read`. Arguments: `limit` (optional), `cursor` (optional), `api_key` (optional), `kind` (optional).

Your research runs, newest first, without their results, limit 1 to 100 (default 20)
per page. Each run says its kind: "short" (find_hooks), "long" (find_long_hooks) or
"packaging" (find_packaging); pass kind to list only one. Returns {"runs": [...],
"next_cursor"}: pass next_cursor back as cursor for the next page; it is null on the
last. Same shape as GET /v1/runs. Next step: open one with get_run, or download it with
export_run. Free. Errors: 422 invalid_cursor for a cursor this API did not issue, 422
for a kind that is not one of the three.

### `reject_access_request`

Scope: `admin`. Arguments: `request_id` (optional), `api_key` (optional).

Owner only: reject a pending access request. Nothing is emailed. An approved or
rejected request is 409 request_already_decided. Free.

### `remix_find`

Scope: `research`. Arguments: `find_id` (optional), `niche` (optional), `n` (optional), `mode` (optional), `video_brief` (optional), `idempotency_key` (optional), `api_key` (optional).

Make it mine, for a long-form opening or a package: write up to n (1 to 5, default
3) new ones for your niche that keep one real find's structure. From a long find,
`written` holds openings: hook (what the creator says first), then (what the next half
minute has to deliver) and kept_structure, built on the find's template and its order
of beats. From a packaging find it holds packages: title, thumbnail (what to show and
how to frame it), thumbnail_text (the words to print, or null) and kept_structure,
built on its title template and its thumbnail's composition.

Write my intro: with mode "intro" and video_brief (your own video in a few sentences,
10 to 600 characters; a long find only, a packaging find is 422; niche and n are not
used) it writes YOUR intro in the real opening's structure. `written` then holds one
object with a beat for each beat of the real opening, in order: beat, does, from_s,
to_s, seconds and words_budget (the real opening's own timing, never the model's), say
(the words to say, with {placeholders} for what only you know), words, placeholders,
suggested_on_screen and suggested_broll (suggestions, never part of what is said); and
seconds, timing (measured or estimated), kept_structure, placeholders and
numbers_to_placeholders. The answer carries mode "intro" and video_brief, and niche is
null. A number you did not write in video_brief becomes {number}. One that copies the
real opening or runs over a word budget is 422 remix_not_possible.

Returns {"find_id", "kind", "niche", "n", "written", "label", "credits_charged": 1,
"credits"}. label says they were written by Hook Detector from a real opening's (or
package's) structure: show it with them, never present one as a real video's. One that
is the real find's own words is dropped. They are never added to a run, a keep or an
export. Costs 1 credit per call, taken before the model is asked and refunded when it
does not answer (503 model_unavailable) or when nothing it wrote keeps the rules or it
declines the content (422 remix_not_possible, the reason in the message; another try
or another niche may work). 402 insufficient_credits at a balance of 0;
404 find_not_found; limited per account per hour (429 rate_limited). Needs the
research permission. The answer carries remix_id: list_remixes and get_remix read it
back, free. Same as POST /v1/finds/{find_id}/remix.

### `remix_hook`

Scope: `research`. Arguments: `hook_id` (optional), `niche` (optional), `n` (optional), `mode` (optional), `idempotency_key` (optional), `api_key` (optional).

Make it mine: write up to n new opening lines (1 to 5, default 3) for your niche
that keep a real hook's structure, the device that made it travel, with the subject
swapped for yours. hook_id is one of your hooks (from get_run, get_hook or
list_keeps); 404 hook_not_found otherwise. Leave mode out: "intro" (Write my intro) is
remix_find on a long-form find, and is 422 here, before any charge.

Returns {"hook_id", "niche", "n", "written": [{"line", "kept_structure"}], "label",
"credits_charged": 1, "credits"}. label is always
"Written by Hook Detector from a real hook's structure":
these are written lines, never found hooks; show the label
with them, never present one as a real clip's hook. They are never added to a deck, a
keep or an export. Costs 1 credit per call, taken before the model is asked and
refunded when it does not answer (503 model_unavailable) or when nothing it wrote
keeps the rules or it declines the content (422 remix_not_possible, the reason in the
message; another try or another niche may work). 402 insufficient_credits at
a balance of 0; limited per account per hour (429 rate_limited). Needs the research
permission. The answer carries remix_id: list_remixes and get_remix read it back,
free. Same as POST /v1/hooks/{hook_id}/remix.

### `request_access`

Scope: none (public). Arguments: `email`, `note` (optional).

Ask for the beta access code a new account needs (create_account's access_code).
email is where the code is sent; note is optional (who you are, up to 500
characters). The owner approves the request and the code is emailed at once; it works
once, in any agent or on the web. Returns status pending (waiting for approval) or
emailed (sent now). No key needed. Limited to 5 per address per hour. Free.

### `revoke_access_code`

Scope: `admin`. Arguments: `code_id` (optional), `api_key` (optional).

Owner only: revoke an access code (code_id from list_access_codes). It admits
nobody from now on; accounts it already made keep working. 404 access_code_not_found
for an unknown id. Free.

### `revoke_key`

Scope: `admin`; any key may revoke itself. Arguments: `key_id`, `api_key` (optional).

Revoke one of your API keys at once: key_id from list_keys, or "original" for the
signup key. Any key may revoke itself (the result says so, and that key stops working
immediately); revoking another needs admin. Never the last active key on the account
(409): create another with create_key first.

If a key leaked, pass key_id "others": every key of the account except the one you
are calling with is revoked at once, and the result says how many (revoked_count) and
which key is kept. Needs admin. Free.

### `showcase`

Scope: none (public). Arguments: `n` (optional).

Real hooks this service has delivered, for a wait screen: talking heads only, each
hook_id, still_url (a JPEG, no key needed), platform, creator, views and line (the
banner on the video, else its opening line). The most viewed first, one per creator,
only what is already public on TikTok or Instagram: never a topic, an account or a run.
No key needed. Cached 10 minutes. Free. REST: GET /v1/showcase?n=12.

### `usage`

Scope: `read`. Arguments: `api_key` (optional).

Your credits and what you have spent: credits (balance now), spent_total,
reserved_now (credits held by runs still in flight, refunded in part when they
finish), runs_total, last_30_days (one entry per UTC day: date, runs, charged) and
recent (your 20 newest runs). Same shape as GET /v1/usage. Free.


## 9. Errors, limits and health

Every REST error body is `{"detail": "<one sentence>", "code": "<machine code>"}` (every
422 also carries `errors`, a list of `{"type", "loc", "msg"}` whose `loc` names the input:
`["body", "country"]`, `["query", "wait"]`, `["header", "Idempotency-Key"]`). Show `detail` to people; branch on `code`. Every
response carries `X-Request-Id`; send your own to trace a call. The codes (a code you do
not know: fall back on the HTTP status):

| code | meaning |
|---|---|
| `invalid_request` | 422: the input failed validation; detail says which field |
| `invalid_cursor` | 422: the pagination cursor is not one this API issued |
| `idempotency_key_reused` | 422: this Idempotency-Key was used with a different body |
| `confirmation_required` | 422: DELETE /v1/account needs {"confirm": "delete my account"} |
| `invalid_api_key` | 401: the API key is missing, unknown or revoked |
| `invalid_admin_password` | 401: the owner's password sign-in (POST /v1/auth/admin-signin) failed; nothing was signed in |
| `invalid_exchange_token` | 401: the one-time sign-in token is unknown, used or older than 60 seconds; sign in again |
| `key_expired` | 401: the API key passed its expiry; create a new one with a key that still works (POST /v1/keys) or at hookdetector.com/account |
| `insufficient_credits` | 402: the balance is under the minimum a run reserves |
| `insufficient_scope` | 403: this API key lacks the permission the call needs; detail names it (read, write, research or admin) |
| `access_code_required` | 403: a new account needs a beta access code (access_code); ask for one at https://hookdetector.com/access |
| `invalid_access_code` | 403: the access code does not work (mistyped, used up, expired or revoked); ask for one at https://hookdetector.com/access |
| `admin_required` | 403: only the Hook Detector owner may call this route |
| `key_credit_limit_reached` | 403: this API key has spent its credit_limit, or what is left of it is under the minimum a run reserves; nothing was reserved |
| `not_found` | 404: no such route |
| `run_not_found` | 404: no such run on this account |
| `hook_not_found` | 404: no such hook on this account |
| `find_not_found` | 404: no such find (a long-form or packaging result) on this account |
| `conversation_not_found` | 404: no such conversation on this account |
| `remix_not_found` | 404: no such finished remix on this account |
| `key_not_found` | 404: no active key with that id on this account |
| `access_request_not_found` | 404: no access request with that id |
| `access_code_not_found` | 404: no access code with that id |
| `account_not_found` | 404: no account with that id (an admin route) |
| `media_unavailable` | 404: this clip has no video file to play (a YouTube Short, a photo post, a deleted or private post); play the official embed instead |
| `media_busy` | 503: the clip's video file cannot be looked up right now; play the official embed and try again after Retry-After |
| `thumb_unavailable` | 404: no picture for this research wall entry (an unknown key, or a run that no longer shows the clip) |
| `method_not_allowed` | 405: the route exists, the method does not |
| `conflict` | 409: the request conflicts with the account's current state |
| `key_limit_reached` | 409: the account already holds the most active keys it can |
| `last_active_key` | 409: the account's last active key cannot be revoked |
| `run_in_flight` | 409: a run is still in flight; wait for it before deleting the account |
| `remix_in_flight` | 409: a remix is still being written; wait for it before deleting the account |
| `remix_in_progress` | 409: a remix with this Idempotency-Key is still being written; send the same request again in a few seconds |
| `turn_in_progress` | 409: a chat turn with this Idempotency-Key is still being answered; send the same request again in a few seconds |
| `request_already_decided` | 409: the access request was already decided that way, or its code was used |
| `run_not_finished` | 409: the run is still in flight, so there is nothing final to export |
| `remix_not_possible` | 422: the model wrote lines from this hook or find but none kept the rules (detail says which they broke), or it declined the content; nothing was charged. Not an outage: another try or another niche may work |
| `credits_below_zero` | 422: the owner's credit change would take the balance below zero; nothing changed |
| `payload_too_large` | 413: the request body is over the size cap |
| `rate_limited` | 429: a limit was hit; see Retry-After |
| `too_many_runs` | 429: the account already has the most research runs in flight it may; wait for one to finish (see Retry-After) |
| `internal_error` | 500: our fault; the detail names the request id. Credits a failed request held are returned automatically; GET /v1/usage shows the balance |
| `model_unavailable` | 503: the language model is unavailable; nothing was charged |
| `unavailable` | 503: the service cannot answer right now |
| `access_unavailable` | 503: access codes cannot be checked or made right now (the server is not configured for them); nothing was created |
| `at_capacity` | 503: today's research capacity is used up; new runs start again after the next UTC midnight (see Retry-After); nothing was charged |
| `restarting` | 503: the server is restarting for an update and starts no new run; nothing was charged, try again after Retry-After (a few seconds) |
| `admin_password_unavailable` | 503: the owner's password sign-in is not configured on this server (no valid password hash or no admin email); nobody was signed in |
| `google_signin_unavailable` | 503: Google sign-in is not configured on this server; use an access code instead |
| `search_unavailable` | 503: the video search provider is refusing our requests right now, so a run would fail; nothing was reserved, try again after Retry-After |

Limits and spend bounds:

| limit | value |
|---|---|
| active keys per account | 10 |
| new keys per account per hour | 20 |
| signups per address per hour | 5 |
| access requests per address per hour | 5 |
| failed access codes per address per hour | 10 |
| failed owner password sign-ins | 5 per address per 15 minutes, 20 from all addresses per hour |
| web sign-in keys per account | 3 (the oldest revoked first) |
| chat turns per account per hour | 30 |
| research runs in flight per account | 2 (then 429 `too_many_runs`) |
| credits a run reserves | up to 200, at least 40 |
| research across the whole service per UTC day | 20000 credits (then 503 `at_capacity` until midnight UTC) |
| free runs with no hooks per account per UTC day | 3 (then charged what they used) |
| credits waived for runs with no hooks, per account, ever | 500 (then charged what they used) |
| waiting calls per account | 10 |
| request body | 64 KB (then 413) |
| page size | 1 to 100 |
| key credit_limit | 1 to 100000 |
| key expires_in_days | 1 to 365 |

Rate limited responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
`X-RateLimit-Reset` and, on a 429, `Retry-After` (seconds). A 503 carries `Retry-After`
too. Nothing refused is ever charged. During an update a start is 503 `restarting` for a
few seconds, and very rarely our hosting edge answers instead of the API: a 5xx whose
body is not JSON. Both are transient: retry the same request with the same
`Idempotency-Key`, and a start that already reached us returns its run.

Health: `GET /health` (liveness: `status`, `version`, `commit`, `database`) and
`GET /health/deep` (database round trip, applied migrations, provider keys configured,
`provider_status` of the model and the search provider, `credits_today`). A refusing
provider in `provider_status` means a run started now will probably fail (and cost
nothing): check it before starting one. `provider_status.search` also reports
`credits_left`, `low` and `accepting_runs`; while `accepting_runs` is false a start is 503
`search_unavailable` with `Retry-After`, nothing reserved.

## 10. FAQ

**How do I get an access code?** Ask at https://hookdetector.com/access, or `POST
/v1/access/requests` with your email; the code arrives by email once the owner approves.
Or write to contact@hookdetector.com.

**I had a key before the beta. Do I need a code?** No. Every account and key made before
the beta keeps working. A code is needed only to make a new account.

**Can my agent get a code by itself?** It can ask (`POST /v1/access/requests` with your
email), but the code goes to that email and needs the owner's approval, so a person is in
the loop once. After that the agent signs up and works on its own.

**Does a failed run cost anything?** No. A run is charged only for what it used, never
more than it reserved, and a failed run is charged nothing. A run that finishes with no
hooks is free too, 3 times per account per UTC day and 500 credits
in all, and its `outcome` says why it found nothing.

**How long does a run take?** About 2 to 3 minutes fresh, up to about 5 when the language
asked for is a dialect (its TikTok clips are heard again); seconds when it reuses clips
you already paid for.

**Can I use the API from a web page?** Not with your key in the page: anyone could read
it. Call Hook Detector from your server, or from an agent on your machine.

**I lost my key.** It cannot be shown again. Use another key with `admin` (or
hookdetector.com/account) to create a new one and revoke the lost one.

**A key leaked.** `DELETE /v1/keys/others` with a key you still hold, then make new keys.
If the leaked key was your only one, revoke it with itself after creating a replacement.

**Which platforms?** Short form searches TikTok, Instagram and YouTube Shorts; pick
any of them with `platforms` (all three when it is left out). Long form and Packaging
search long-form YouTube.

**How do I reach a person?** Email contact@hookdetector.com: questions, removing your data when you no
longer hold a key, or a security problem you found. Never send a key in an email.

**Why is `hook` null on some clips?** There was nothing to quote word for word (no
speech and no on-screen text), and Hook Detector never invents a line.

**Is there an SDK?** The API is plain HTTP and JSON; the Python and JavaScript snippets in
section 2 are the whole client. OpenAPI at https://api.hookdetector.com/openapi.json generates one in any language.

Hook Detector 2.4.0. Short guide for agents: https://api.hookdetector.com/llms.txt.
