---
name: maxaeo-api
description: Integration guide for the MaxAEO Developer Platform (open API and MCP server). Use when writing code that calls the MaxAEO API to ask AI platforms such as ChatGPT, Perplexity, Gemini, and Claude a question and get back the raw answer, cited sources, and share link; when handling ret error codes, asynchronous task polling, or credit billing; or when configuring the MaxAEO MCP server.
---

# MaxAEO API integration guide (for AI coding assistants)

This file is a condensed version of the MaxAEO Developer Platform docs, meant for AI coding assistants to read before writing integration code. If it conflicts with the online docs, the online docs win.

- Full docs: https://maxaeo.ai/docs/developers/
- Every Developer Platform page merged into a single text file: https://maxaeo.ai/docs/_llms-txt/developer-platform.txt
- Latest version of this file: https://maxaeo.ai/docs/developers/maxaeo-api.md

## What it does

You submit "one question + one target AI platform". MaxAEO asks that platform for real and returns the AI's raw answer (Markdown), cited sources, and share link; some platforms also expose execution steps (thinking, the search queries actually used) and media cards. Tasks run asynchronously, and only successful tasks cost credits.

Two ways to integrate, sharing the same API key, credit balance, and rate limits:

- **Open API**: for backend services, scripts, and data pipelines. This is the main subject of this file.
- **MCP server**: lets AI clients such as Claude Code and Cursor call it directly — see the "MCP" section at the end.

**Not supported** (don't invent parameters or endpoints for these):

- One submission is exactly one platform + one question. For several platforms or questions, submit several times.
- No webhook callbacks — results are only available by polling.
- No endpoint to cancel a task.
- You can't choose the model or toggle web search. (Thinking mode can be toggled — see the `thinking` parameter of `/task/submit`.)
- No multi-turn conversations — every task is an independent single-turn question.
- No official SDK and no sandbox environment.

## How to work

1. **API key**: ask the user to create one on the **API Keys** page of the Developer Console at https://maxaeo.ai/developers/ (the full key is shown only once) and set the `MAXAEO_API_KEY` environment variable themselves. Don't ask the user to paste the full key into the conversation, and don't write the key into code or into config files that get committed.
2. **Start with a free connectivity check**: call `/platforms/get`. `ret` equal to `1` means the key works, and you also get the platform IDs and prices.
3. **Submitting tasks spends the user's credits**: tell the user before running any code that calls `/task/submit`. When testing, start with one question on P2. Read endpoints are free, and failed tasks cost nothing.
4. After writing the code, check it against every item in "Rules you must follow".

## Basics

| Item | Value |
|---|---|
| Base URL | `https://maxaeo.ai/geoapi/geodata/developer` |
| Authentication | Header `Authorization: Bearer <API key>`. Keys start with `gdev_` and are 48 characters long. Only the header works — a key in the URL or request body is ignored |
| Method | Every endpoint is `POST` |
| Request body | A JSON object, UTF-8, at most 64 KiB; endpoints without parameters still need `{}` |
| Success response | `{"ret": 1, "data": {"ent": {…}}}` — the payload is in `data.ent` |
| Error response | `{"ret": <code>, "msg": "{\"zh\":\"…\",\"en\":\"…\"}"}` — the HTTP status is still `200` |
| Field naming | Outer request and response fields are snake_case (`task_id`); fields inside `raw_result` are camelCase (`rawContent`) |
| Time | Unix timestamps in seconds; `0` means it hasn't happened yet. `monitor_date` is `YYYY-MM-DD` in China Standard Time (UTC+8) |

| Endpoint | Purpose | Billing |
|---|---|---|
| `POST /platforms/get` | Platform IDs, status, prices for both tiers, 24-hour stats | Free |
| `POST /task/submit` | Submit one question; returns a `task_id` | Successful tasks cost credits |
| `POST /task/get` | Get a task's status and result by `task_id` | Free |
| `POST /task/key/list` | List tasks submitted with the current key, to recover a `task_id` or reconcile | Free |

Flow: pick a platform with `/platforms/get` → submit with `/task/submit` and **save the `task_id` immediately** → call `/task/get` about every 15 seconds until a final status → read the result. If you can't tell whether a submission went through, recover it with `/task/key/list`.

## Rules you must follow

Breaking any of these causes bugs, and some cause double charges.

1. **Decide success from `ret`.** Only `ret == 1` is success. Business errors also come back with HTTP `200`; don't branch on the HTTP status or on the `msg` wording.
2. **Parse the body as JSON directly.** Don't rely on the `Content-Type` response header (it may currently be `text/plain`).
3. **`msg`, `error_message`, and `raw_result.error` are JSON strings.** Parse them once more to get `{"zh": "…", "en": "…"}`; if parsing fails, use the raw string as the message.
4. **Request bodies may only contain fields the endpoint defines.** Any extra field — including typos like `taskId` or `waitSeconds`, or an `api_key` field — makes the whole request return `1001`; it's never silently ignored.
5. **Submission is not idempotent.** Every `/task/submit` call creates a new task, each charged when it succeeds; the server doesn't deduplicate by question. If a submit times out, the connection drops, you get a non-200 status, or `ret == -1`, **don't retry directly**: call `/task/key/list` first (`keyword` = the first 256 characters of the question, `start_time` = a time before the submit). If you find the task, keep polling that `task_id`; only resubmit once you've confirmed it doesn't exist. Exception: HTTP `405` or `404` means the method or path is wrong, so no task was created.
6. **Whether a task is finished, and whether it succeeded, depends only on `status`.** The only final statuses are `succeeded`, `failed`, and `canceled`. A non-empty `error_code` doesn't mean failure (`dispatch_uncertain` appears while the status is `dispatching`).
7. **Poll about every 15 seconds** — polling faster just returns the same status. Tasks have no server-side deadline and queue time has no upper bound; your own business timeout doesn't mean the task failed. Keep the `task_id` and fetch the result later. With many tasks, query them in one scheduled loop instead of a blocking wait per task.
8. **Persist the `task_id` as soon as you get it**, together with the submission time and parameters.
9. **Fetch the platform list and prices from `/platforms/get`** — don't hardcode them — and skip platforms whose `platform_status` is `maintenance` (submitting returns `1029`).
10. **`raw_result` may be absent** (queued, canceled, and dispatch-failed tasks don't have it). Zero-value fields inside it are omitted (`false`, `0`, `""`, and empty arrays don't appear), so fill in defaults when reading. `elapsedMs` is a string.
11. **`references[].cited` has three states**: `true` = cited in the answer text, `false` = found but not cited, **field absent** = the platform doesn't distinguish — don't treat it as `false`.
12. **Ignore response fields you don't recognize**, so new fields don't break you. An empty list may be `[]` or `null`; treat both as an empty array.
13. **Responses can be large**: `answer`, `result_json`, and `raw_result` are three forms of the same result, and each field can be up to 8 MiB. Give your HTTP client enough response size and read timeout.
14. **Use the API key on the server only**, read from an environment variable. Never call the API directly from a browser or mobile app. Log only the first 12 characters of a key.
15. **Log the endpoint, `ret`, latency, and `task_id` for every request**, plus the `ipalfish-trace-id` response header when present, to help with troubleshooting.
16. **`"thinking": true` is only a request.** Platforms without a thinking switch run with it off, without an error. Whether thinking was actually on is `raw_result.thinking` alone (omitted when `false`); don't infer it from the request, the task's top-level `thinking`, or whether there are `thinking` agent steps.

## API reference

### `POST /platforms/get`: List platforms

Request body: `{}`

`data.ent`:

- `platforms`: string[], the platform IDs, **including platforms under maintenance**.
- `items`: an array in the same order as `platforms`; each item:

| Field | Type | Description |
|---|---|---|
| `platform` | string | Platform ID, such as `chatgpt` |
| `region` | string | `overseas` (Global) / `domestic` (China) |
| `platform_status` | string | `normal` operational; `degraded` (you can submit, but it may be slower or fail occasionally); `maintenance` (can't submit) |
| `p1_credits` / `p2_credits` | integer | Credits per **successful** call on P1 and P2 |
| `has_stats` | boolean | When `false`, `success_rate` and `p50_duration_seconds` are both `0` — don't display that as a "0% success rate" |
| `success_rate` | number | Success rate over the last 24 hours, 0–1 |
| `p50_duration_seconds` | integer | Median time from submission to completion for successful tasks over the last 24 hours (seconds, queueing included) |
| `sample_count` | integer | Number of tasks that reached a final status in the last 24 hours |
| `stats_updated_at` | integer | When the stats were computed (Unix seconds) |

Returns an empty list when developer API billing isn't enabled.

### `POST /task/submit`: Submit a task

| Parameter | Type | Required | Description |
|---|---|---|---|
| `platform` | string | Yes | Platform ID from `/platforms/get`, case-insensitive |
| `query` | string | Yes | The question. At most 4096 characters after trimming whitespace, and must contain visible text |
| `priority` | integer | No | `1` = P1 Priority (dequeued first, higher price); `2` = P2 Standard (default, lower price). Omitted or `0` means P2 |
| `thinking` | boolean | No | Whether to request the platform's thinking mode (such as ChatGPT's thinking or DeepSeek's DeepThink), default `false`. Must be a JSON boolean — the string `"true"` returns `1001`. Noticeably slower, no extra cost |
| `country` | string | No | Two-letter country code (such as `US`, `JP`). Only affects the network exit for Global platforms, at no extra cost. Usually not needed |
| `monitor_date` | string | No | Business date `YYYY-MM-DD` (UTC+8), defaults to today. It's only a filing label — neither a run time nor a deadline. Also accepts an RFC 3339 timestamp with an offset |
| `wait_seconds` | integer | No | `0`–`120`, default `0`. When greater than 0, the server waits up to that long and returns as soon as the task reaches a final status; if it still hasn't finished, you get the current status and **must keep polling**. Set your HTTP client timeout at least 15 seconds longer than this |

`data.ent` is a task object: `status` is `queued`, `credits` is the reserved amount, and the result fields are empty.

Choosing P1 vs. P2: use P1 when someone is waiting on the result in a UI or a downstream step is blocked on it; use P2 for daily batch monitoring and offline analysis. Neither tier guarantees a queue time.

Thinking mode: pass `"thinking": true` only when the user explicitly wants answers from the platform's thinking mode. Some platforms have no thinking switch (see the platform snapshot at the end) and run with it off; with it on, tasks are more likely not to finish within `wait_seconds`, so the code must keep polling.

### `POST /task/get`: Retrieve a task

Request body: `{"task_id": "task_…"}` (at most 64 characters). Works for any task the same user submitted, with any key or from the console Playground.

`data.ent` is a task object — see the next section.

### `POST /task/key/list`: List tasks

Lists only tasks submitted with **this key**, newest first. Items don't include `answer`, `result_json`, `share_link`, `raw_result`, or `updated_at`. All parameters are optional:

| Parameter | Type | Description |
|---|---|---|
| `page` | integer | Page number starting at 1, default `1` |
| `page_size` | integer | Default `20`, max `100` |
| `start_time` | integer | Earliest submission time (Unix seconds, inclusive) |
| `end_time` | integer | Latest submission time (Unix seconds, exclusive); must be greater than `start_time` |
| `platform` | string | Platform ID |
| `status` | string | Task status, exact match — except `pending`, which also returns `queued` and `dispatching` tasks |
| `keyword` | string | If it starts with `task_`, an exact task ID match; otherwise matches questions containing the text. At most 256 characters |

`data.ent`: `total`, `page`, `page_size`, `items`. When paging for reconciliation, fix `end_time` so tasks submitted mid-paging don't cause duplicates.

## The task object

Outer fields are always returned, even when empty (`""`, `0`, or `false`):

| Field | Type | Description |
|---|---|---|
| `task_id` | string | `task_` followed by a UUID |
| `platform` / `query` | string | Platform ID (lowercased) / question (whitespace trimmed) |
| `status` | string | See the status table below |
| `answer` | string | The answer text (Markdown), identical to `raw_result.rawContent` |
| `result_json` | string | The original result as a JSON string, same shape as `raw_result`; may include new fields `raw_result` doesn't expose yet |
| `share_link` | string | The platform's share link; may be empty |
| `error_code` / `error_message` | string | Task-level error code / `{"zh","en"}` JSON string |
| `created_at` / `updated_at` / `started_at` / `finished_at` | integer | Unix seconds. `started_at` can be `0` (not recorded); compute duration as `finished_at - created_at` |
| `monitor_date` | string | Business date |
| `priority` | integer | The tier at submission; the tier it actually ran on is `raw_result.priority` |
| `country` | string | The requested exit country (uppercased); not a verified actual exit |
| `thinking` | boolean | The thinking mode **requested** at submission; the actual mode is `raw_result.thinking` |
| `credits` | integer | Not finished: reserved; `succeeded`: charged; `failed` / `canceled`: `0` |
| `raw_result` | object | The structured result, **present only once there is a result** (success, or a failed platform run) |

Status flow:

```text
queued → dispatching → pending ⇄ running → succeeded | failed | canceled
```

| `status` | Meaning | Final |
|---|---|---|
| `queued` | Accepted, waiting for dispatch | |
| `dispatching` | Being dispatched, or confirming the dispatch result | |
| `pending` | Dispatched, waiting in the platform queue (a P2 task with a retryable failure goes back here from `running`) | |
| `running` | Asking the AI platform | |
| `succeeded` | Got an answer (a very short answer or empty `references` can be a normal result) | ✓ |
| `failed` | Failed — see `error_code`; free | ✓ |
| `canceled` | Canceled; free | ✓ |

### `raw_result`

Uses protobuf's JSON encoding: camelCase field names, zero-value fields omitted.

| Field | Type | Description |
|---|---|---|
| `success` | boolean | `true` when an answer was obtained; absent on failure |
| `rawContent` | string | The raw answer (Markdown). The platform's own citation markers and card placeholders are kept as-is |
| `references` | Reference[] | Every source the platform found or read; sources cited in the answer come first |
| `shareLink` | string | Share link |
| `error` | string | Failure message, a `{"zh","en"}` JSON string |
| `elapsedMs` | string | Execution time (milliseconds, **as a string**, queueing excluded) |
| `completedAt` | string | Completion time, ISO 8601 UTC |
| `priority` | integer | The tier it actually ran on — use this for reconciliation |
| `thinking` | boolean | Whether thinking was actually on: `true` if so; absent when it was off, the platform has no switch, or the task failed |
| `agentSteps` | AgentStep[] | Execution steps, in order |
| `mediaCards` | MediaCard[] | Media cards, in order of appearance |
| `taskId`, `platform`, `monitorDate`, `query`, `conversationId`, `messageId` | string | For cross-reference |

**Reference**: `rank` (starts at 1), `title`, `url` (may be empty or an internal platform ID — check that it starts with `http://` or `https://` before using it), `snippet`, `source` (site name), `domain` (may be empty — parse it from `url` if needed), `contentPlatform`, `cited` (three states, see rule 11), `publishTime` (ISO 8601, `YYYY-MM-DD`, or the platform's relative wording such as "1 day ago"), `author`.

**AgentStep**: read the fields for its `type`. `thinking` (mostly only with thinking mode on): `content`, `summary`; `search`: `name`, `queries` (the search queries the AI actually used), `results` (`title`, `url`, `siteName`, `snippet` — not necessarily cited); `tool`: `name`, `args`.

**MediaCard**: `type` (`image` / `video` / `product` / `ranking` / `other`), `platformType`, `anchor` (the card's placeholder text inside `rawContent`), `items` (`title`, `url`, `imageUrl`, `source`, `author`, `duration`, `price`, `rating`, `publishTime`, `description`). `imageUrl` is usually a signed link that expires — copy the image soon if you need to keep it.

Use `references` and `cited` to determine what was cited. Don't parse citation markers in the answer text: their format is up to each platform and can change at any time.

## Error codes

### Request errors: `ret` is not `1`

Except for `-1`, these all happen before a task is created: no task is created and nothing is charged.

| `ret` | Meaning | What to do |
|---|---|---|
| `1001` | Invalid parameters: the body isn't a single JSON object (including an empty body), exceeds 64 KiB, contains undefined fields, or has an invalid value; `msg` names the field | Fix the request; don't retry automatically |
| `1022` | Developer API billing isn't enabled | Contact sales; don't retry |
| `1023` | Not enough available credits | Tell the user to top up, or switch to a cheaper platform / P2; don't retry automatically |
| `1024` | Too many tasks in progress | No task was created — retrying after a wait is safe |
| `1025` | Too many submissions | No task was created — retrying with backoff is safe |
| `1029` | Platform unavailable or under maintenance | Fetch the platform list again and switch platforms |
| `1030` | API key missing, invalid, disabled, or expired (the reason isn't distinguished) | Check the key; don't retry |
| `1031` | Task not found, or not owned by the current user | Check the `task_id` |
| `1003` | Account permission issue (rare, e.g. ownership of the key's account was transferred) | Contact MaxAEO; don't retry |
| `-1` | Service temporarily unavailable | Retry read endpoints with exponential backoff (2, 4, 8 seconds…); **on submit, first confirm whether the task was created, per rule 5** |

HTTP `405` (method isn't `POST`) and `404` (wrong path) mean the request never reached the business logic. `502`, `504`, timeouts, and dropped connections mean you can't tell whether the server processed it: retry read endpoints directly, and handle submissions per rule 5.

### Task failures: `status` is `failed` or `canceled`

Neither costs credits. Branch on `error_code`:

| `error_code` | Meaning | What to do |
|---|---|---|
| `crawl_failed` | The platform didn't return a valid answer | Retry after a while, or switch platforms |
| `credential_invalid` | The submitting key became invalid before dispatch | Resubmit with a valid key |
| `crawler_rejected` | The platform isn't supported or has been retired | Fetch the platform list again and switch platforms |
| `crawler_internal_error`, `crawler_submit_failed` | The crawler didn't accept the task | Retry later |
| `crawler_invalid_request` | The crawler rejected the request parameters | Check `query` and `country`, then resubmit |
| `crawler_method_unavailable`, `crawler_auth_failed` | Server-side configuration issue | Contact MaxAEO |
| `crawl_canceled` | The task was canceled (`status` is `canceled`) | Resubmit if needed |
| Any other value | — | Treat it as "failed, not charged" and log `error_message` |

These two values appear on **unfinished** tasks and don't mean failure — keep polling: `dispatch_uncertain` (while `dispatching`; the server confirms it on its own and won't dispatch twice) and `crawler_query_unavailable` (progress temporarily can't be synced).

Resubmitting a failed task creates a new task, which is charged only if it succeeds.

## Rate limits and quotas

| Limit | Default | Counted over | When exceeded |
|---|---|---|---|
| Concurrent tasks | 5 | Tasks in your account that are `queued`, `dispatching`, or `running` (**not `pending`**) | `1024` |
| Submissions per minute | 120 | Your account's last 60 seconds (rolling window) | `1025` |
| API keys | 10 | Non-deleted keys in your account | The console won't create new keys |

- Only task submissions count toward concurrency and rate; read endpoints don't use up either limit, but still keep polling at about 15 seconds.
- Every key, MCP call, and console Playground request in the same account shares the limits and the credit balance. The limits in effect for your account are shown under **Usage → Limits** in the console.
- When submitting in bulk, throttle on the client (for example, at most one submit every 0.6 seconds). On `1024` / `1025`, back off starting at 15 seconds and doubling each time, then retry the same question.

## Billing

- Submitting **reserves** credits at the current price for that platform and tier, locked at submission. They're charged on `succeeded` and released on `failed` / `canceled`.
- Available credits = credit balance − credits reserved by tasks in progress. If that's less than the price of this submission, submit returns `1023` — it never overdraws.
- `country` and `thinking` cost nothing extra; read endpoints are free; the console Playground always bills at the P1 price.
- Cost estimate: number of questions × the sum of the prices of the platforms each question goes to (only successful tasks are charged).
- The first time someone uses the Developer Platform (opens the console or calls any endpoint with an API key), 1,000 credits that never expire are added to their personal account, once per account. Contact sales for more credits.

## Reference implementation

Both implementations share the same logic: read endpoints retry with backoff; when a submission's outcome is unknown, recover the task first and resubmit only once it's confirmed missing; then poll to a final status and read the answer and cited sources.

### Python (`pip install requests`)

```python
"""Minimal MaxAEO API client: safe submit (no double charges) + polling + reading results."""
import json
import os
import time

import requests

BASE_URL = "https://maxaeo.ai/geoapi/geodata/developer"
HEADERS = {"Authorization": f"Bearer {os.environ['MAXAEO_API_KEY']}"}
TERMINAL = {"succeeded", "failed", "canceled"}
RETRYABLE = {-1, 1024, 1025}


class MaxAEOError(Exception):
    def __init__(self, ret, msg):
        self.ret = ret
        try:
            self.message = json.loads(msg).get("en") or msg  # msg is a {"zh","en"} JSON string
        except (TypeError, ValueError, AttributeError):
            self.message = msg
        super().__init__(f"ret={ret}: {self.message}")


def post(path, body, timeout=30):
    resp = requests.post(BASE_URL + path, headers=HEADERS, json=body, timeout=timeout)
    resp.raise_for_status()
    resp.encoding = "utf-8"  # Content-Type may be text/plain, which requests would decode as ISO-8859-1
    data = resp.json()
    if data["ret"] != 1:
        raise MaxAEOError(data["ret"], data.get("msg", ""))
    return data["data"]["ent"]


def read(path, body, retries=4):
    """Read endpoints (/platforms/get, /task/get, /task/key/list): safe to retry."""
    for attempt in range(retries + 1):
        try:
            return post(path, body)
        except (requests.RequestException, MaxAEOError) as e:
            retryable = not isinstance(e, MaxAEOError) or e.ret in RETRYABLE
            if not retryable or attempt == retries:
                raise
            time.sleep(2 ** (attempt + 1))


def submit(platform, query, **options):
    """Submit a task. If the outcome is unknown, recover the task first and resubmit only if it doesn't exist.
    options may only be priority, thinking, country, monitor_date, wait_seconds."""
    body = {"platform": platform, "query": query, **options}
    timeout = 30 + options.get("wait_seconds", 0)
    started = int(time.time()) - 60  # allow for clock skew
    for attempt in range(4):
        try:
            return post("/task/submit", body, timeout=timeout)
        except MaxAEOError as e:
            if e.ret in (1024, 1025):  # rate limited: no task was created, wait and retry
                time.sleep(15 * (attempt + 1))
                continue
            if e.ret != -1:
                raise
        except requests.HTTPError as e:
            if e.response.status_code in (404, 405):  # wrong path or method: definitely no task created
                raise
        except requests.RequestException:
            pass
        # Timeout, dropped connection, non-200, or ret=-1: the task may exist, so look for it first
        found = read("/task/key/list", {
            "keyword": query.strip()[:256], "platform": platform, "start_time": started, "page_size": 5,
        })
        if found["items"]:
            return read("/task/get", {"task_id": found["items"][0]["task_id"]})
    raise RuntimeError("Submission failed after several attempts; try again later")


def wait(task_id, interval=15, timeout=1800):
    """Poll every 15 seconds until a final status. After `timeout`, return the task as-is:
    it's still running, and you can fetch it later by task_id."""
    deadline = time.time() + timeout
    while True:
        task = read("/task/get", {"task_id": task_id})
        if task["status"] in TERMINAL or time.time() >= deadline:
            return task
        time.sleep(interval)


if __name__ == "__main__":
    platforms = read("/platforms/get", {})
    usable = [p["platform"] for p in platforms["items"] or [] if p["platform_status"] != "maintenance"]
    print("Platforms accepting tasks:", usable)

    task = submit("chatgpt", "best mechanical keyboards for programmers")  # spends credits
    print("task_id:", task["task_id"])  # production code should persist task_id here
    task = wait(task["task_id"])

    if task["status"] == "succeeded":
        print(task["answer"])
        for ref in (task.get("raw_result") or {}).get("references", []):
            print(ref.get("rank"), ref.get("cited"), ref.get("title"), ref.get("url"))  # cited None = not distinguished
        print("Credits charged:", task["credits"])
    elif task["status"] in TERMINAL:
        print("Task did not succeed:", task["error_code"], task["error_message"])
    else:
        print("Still running; fetch it later by task_id:", task["status"])
```

### Node.js (18 or later, save as a `.mjs` file)

```js
// Minimal MaxAEO API client: safe submit (no double charges) + polling + reading results
const BASE_URL = "https://maxaeo.ai/geoapi/geodata/developer";
const headers = {
  Authorization: `Bearer ${process.env.MAXAEO_API_KEY}`,
  "Content-Type": "application/json",
};
const TERMINAL = new Set(["succeeded", "failed", "canceled"]);
const RETRYABLE = new Set([-1, 1024, 1025]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

class MaxAEOError extends Error {
  constructor(ret, msg) {
    let message = msg;
    try {
      message = JSON.parse(msg).en || msg; // msg is a {"zh","en"} JSON string
    } catch {}
    super(`ret=${ret}: ${message}`);
    this.ret = ret;
  }
}

class HTTPError extends Error {
  constructor(status) {
    super(`HTTP ${status}`);
    this.status = status;
  }
}

async function post(path, body, timeoutMs = 30_000) {
  const resp = await fetch(BASE_URL + path, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(timeoutMs),
  });
  if (!resp.ok) throw new HTTPError(resp.status);
  const data = await resp.json(); // Content-Type may be text/plain; parse as JSON regardless
  if (data.ret !== 1) throw new MaxAEOError(data.ret, data.msg ?? "");
  return data.data.ent;
}

// Read endpoints (/platforms/get, /task/get, /task/key/list): safe to retry
async function read(path, body, retries = 4) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await post(path, body);
    } catch (e) {
      const retryable = !(e instanceof MaxAEOError) || RETRYABLE.has(e.ret);
      if (!retryable || attempt === retries) throw e;
      await sleep(2 ** (attempt + 1) * 1000);
    }
  }
}

// Submit a task. If the outcome is unknown, recover the task first and resubmit only if it doesn't exist.
// options may only be priority, thinking, country, monitor_date, wait_seconds
async function submit(platform, query, options = {}) {
  const body = { platform, query, ...options };
  const timeoutMs = 30_000 + (options.wait_seconds ?? 0) * 1000;
  const started = Math.floor(Date.now() / 1000) - 60; // allow for clock skew
  for (let attempt = 0; attempt < 4; attempt++) {
    try {
      return await post("/task/submit", body, timeoutMs);
    } catch (e) {
      if (e instanceof MaxAEOError && (e.ret === 1024 || e.ret === 1025)) {
        await sleep(15_000 * (attempt + 1)); // rate limited: no task was created, wait and retry
        continue;
      }
      if (e instanceof MaxAEOError && e.ret !== -1) throw e;
      if (e instanceof HTTPError && (e.status === 404 || e.status === 405)) throw e; // wrong path or method
    }
    // Timeout, dropped connection, non-200, or ret=-1: the task may exist, so look for it first
    const found = await read("/task/key/list", {
      keyword: Array.from(query.trim()).slice(0, 256).join(""),
      platform,
      start_time: started,
      page_size: 5,
    });
    const items = found.items ?? [];
    if (items.length > 0) return read("/task/get", { task_id: items[0].task_id });
  }
  throw new Error("Submission failed after several attempts; try again later");
}

// Poll every 15 seconds until a final status. After timeoutMs, return the task as-is:
// it's still running, and you can fetch it later by task_id
async function wait(taskId, intervalMs = 15_000, timeoutMs = 30 * 60_000) {
  const deadline = Date.now() + timeoutMs;
  for (;;) {
    const task = await read("/task/get", { task_id: taskId });
    if (TERMINAL.has(task.status) || Date.now() >= deadline) return task;
    await sleep(intervalMs);
  }
}

const platforms = await read("/platforms/get", {});
const usable = (platforms.items ?? []).filter((p) => p.platform_status !== "maintenance").map((p) => p.platform);
console.log("Platforms accepting tasks:", usable);

let task = await submit("chatgpt", "best mechanical keyboards for programmers"); // spends credits
console.log("task_id:", task.task_id); // production code should persist task_id here
task = await wait(task.task_id);

if (task.status === "succeeded") {
  console.log(task.answer);
  for (const ref of task.raw_result?.references ?? []) {
    console.log(ref.rank, ref.cited, ref.title, ref.url); // cited undefined = not distinguished
  }
  console.log("Credits charged:", task.credits);
} else if (TERMINAL.has(task.status)) {
  console.log("Task did not succeed:", task.error_code, task.error_message);
} else {
  console.log("Still running; fetch it later by task_id:", task.status);
}
```

## MCP

| Item | Value |
|---|---|
| URL | `https://maxaeo.ai/geoapi/geodata/developer/mcp` |
| Transport | Streamable HTTP, stateless, JSON responses only (no SSE) |
| Authentication | Header `Authorization: Bearer <API key>`; no OAuth. An invalid key returns HTTP `401` |
| Billing and rate limits | Exactly the same as the open API |

Claude Code:

```bash
claude mcp add --transport http maxaeo https://maxaeo.ai/geoapi/geodata/developer/mcp \
  --header "Authorization: Bearer $MAXAEO_API_KEY"
```

Clients that support remote MCP servers with custom headers (such as Cursor's `mcp.json`):

```json
{
  "mcpServers": {
    "maxaeo": {
      "url": "https://maxaeo.ai/geoapi/geodata/developer/mcp",
      "headers": { "Authorization": "Bearer gdev_your_full_key" }
    }
  }
}
```

Don't commit config files that contain a key. Clients that only support stdio can bridge with `npx -y mcp-remote https://maxaeo.ai/geoapi/geodata/developer/mcp --header "Authorization:${MAXAEO_AUTH}"`, with the environment variable `MAXAEO_AUTH` set to `Bearer gdev_…`.

Tool parameter names and value ranges match the corresponding endpoints, and undefined fields are rejected the same way:

| Tool | Parameters | Notes |
|---|---|---|
| `list_platforms` | None | Same as `/platforms/get` |
| `submit_task` | `platform`, `query`; optional `priority`, `thinking`, `country`, `monitor_date`, `wait_seconds` | Every call creates a new task, charged when it succeeds |
| `get_task` | `task_id`; optional `include_raw` | Returns a compact summary by default: no `result_json` or `raw_result`, each of `references` has only `rank`, `title`, `url`, `domain`, `cited`, and empty fields are omitted. `thinking` is the requested thinking mode; `effective_thinking` is the actual mode, present once there's a result (returned even when `false`). `include_raw: true` adds the full `raw_result` |
| `list_tasks` | Same as `/task/key/list` | Lists tasks submitted with the current key |

Recommended flow: `list_platforms` → `submit_task` → call `get_task` repeatedly until a final status. If a submission's outcome is unclear, recover it with `list_tasks` first instead of submitting again. Tool errors come back with `isError: true` and the text `error <code>: <English message> (<Chinese message>)`, where the code is the same as the open API's `ret`; invalid parameters produce `invalid arguments: …`.

## Platform IDs (snapshot)

The live response from `/platforms/get` is the source of truth — don't hardcode this list.

- Global: `chatgpt` ChatGPT, `gemini` Gemini, `claude` Claude, `perplexity` Perplexity, `copilot` Copilot, `grok` Grok, `google-ai-mode` Google AI Mode, `google-ai-overview` Google AI Overview
- China: `doubao` Doubao, `deepseek` DeepSeek, `kimi` Kimi, `yuanbao` Tencent Yuanbao, `qianwen` Qwen, `wenxin` ERNIE Bot, `zhipu` Zhipu ChatGLM, `xunfei` iFlytek Spark, `metaso` Metaso, `xiaohongshu` Xiaohongshu Diandian

Thinking switch (October 2026; platform redesigns can change it): `chatgpt`, `deepseek`, `doubao`, `kimi`, `yuanbao`, `qianwen`, `zhipu`, `metaso`, and `xunfei` have one; every other platform runs with thinking off even when `"thinking": true` is passed. DeepSeek's citation markers in the answer text change with it: `[citation:N]` with thinking off, `[reference:N]` with it on.

`cited` support (observed; platform redesigns can change it): Global platforms don't set `cited`. Among China platforms, Doubao, Kimi, Tencent Yuanbao, Zhipu ChatGLM, Metaso, and ERNIE Bot distinguish `true` / `false`; DeepSeek marks `true` only for sources it can tie to a specific page and leaves the rest unset; iFlytek Spark is always `true`; Qwen and Xiaohongshu Diandian don't set it.

## More docs

- Quickstart: https://maxaeo.ai/docs/developers/quickstart/
- Authentication: https://maxaeo.ai/docs/developers/authentication/
- API Overview: https://maxaeo.ai/docs/developers/api/
- The Task Object: https://maxaeo.ai/docs/developers/api/task-object/
- Task Lifecycle: https://maxaeo.ai/docs/developers/guides/task-lifecycle/
- Working with Results: https://maxaeo.ai/docs/developers/guides/results/
- Error Handling: https://maxaeo.ai/docs/developers/guides/errors/
- Rate Limits & Quotas: https://maxaeo.ai/docs/developers/guides/rate-limits/
- Billing & Credits: https://maxaeo.ai/docs/developers/guides/billing/
- Production Checklist: https://maxaeo.ai/docs/developers/guides/production-checklist/
- Connect via MCP: https://maxaeo.ai/docs/developers/mcp/
- MCP Tools Reference: https://maxaeo.ai/docs/developers/mcp/tools/
- FAQ: https://maxaeo.ai/docs/developers/faq/
