---
name: harnyx-research-api
description: Use the Harnyx Research API or MCP endpoint to list, create, retrieve, and interpret account-owned research tasks with text or structured output.
---

# Harnyx Research API

Use only the public Research Task endpoints:

- `POST https://api.harnyx.ai/v1/research/tasks`
- `GET https://api.harnyx.ai/v1/research/tasks`
- `GET https://api.harnyx.ai/v1/research/tasks/{id}`

The generated schema reference is `https://harnyx.ai/api/research.md`.

Human-readable documentation starts at `https://harnyx.ai/docs`. The cURL
quickstart is also available at `https://harnyx.ai/docs/quickstart.md`, and the
customer-only OpenAPI contract is `https://harnyx.ai/api/research.openapi.json`.
`https://harnyx.ai/llms.txt` indexes these public resources. Reading the
documentation does not require signing in; executing research requires an
approved account, an API key, and sufficient credits.

## Access

Create and manage Harnyx API keys in the Developer Console at `https://console.harnyx.ai/api-keys`. Supply the key through your client's secret manager as `HARNYX_API_KEY`. Never put it in source control, URLs, task input, logs, or copied output.

Authenticate every request with:

```text
Authorization: Bearer <HARNYX_API_KEY>
```

Do not use `X-Api-Key` or a query parameter.

## Connect an MCP client

The Streamable HTTP endpoint is:

```text
https://api.harnyx.ai/mcp/research
```

It exposes exactly three tools:

- `create_research_task(input, idempotency_key, output_schema?)` creates or replays one asynchronous task and returns its current representation.
- `list_research_tasks(limit?, cursor?)` returns the account's newest task summaries with opaque keyset pagination.
- `get_research_task(task_id)` reads that account-owned task later.

The create tool requires an idempotency key. Generate one key for each logical task, retain it with the exact input and output schema, and reuse all three after an uncertain result. Do not generate a replacement key for a retry. Poll with a delay rather than calling get continuously; the shared MCP Gateway limit is 30 requests per 60 seconds for each client IP. Back off after HTTP 429.

Standard local MCP clients support configured HTTP authorization headers. Keep the key in an environment variable or client secret store; do not paste a literal key into a checked-in MCP configuration.

### Codex

Add this to Codex `config.toml`, then set `HARNYX_API_KEY` in the environment that launches Codex:

```toml
[mcp_servers.harnyx_research]
url = "https://api.harnyx.ai/mcp/research"
bearer_token_env_var = "HARNYX_API_KEY"
```

### Claude Code

Add this server to `.mcp.json`, with `HARNYX_API_KEY` set in the environment that launches Claude Code:

```json
{
  "mcpServers": {
    "harnyx-research": {
      "type": "http",
      "url": "https://api.harnyx.ai/mcp/research",
      "headers": {
        "Authorization": "Bearer ${HARNYX_API_KEY}"
      }
    }
  }
}
```

### VS Code

Use a password input so VS Code stores the value as a secret instead of writing it into `mcp.json`:

```json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "harnyx-api-key",
      "description": "Harnyx API key",
      "password": true
    }
  ],
  "servers": {
    "harnyx-research": {
      "type": "http",
      "url": "https://api.harnyx.ai/mcp/research",
      "headers": {
        "Authorization": "Bearer ${input:harnyx-api-key}"
      }
    }
  }
}
```

Hosted MCP connectors that require OAuth cannot connect to this API-key-authenticated endpoint. Use a client that supports configured HTTP headers. OAuth and browser sign-in are not part of this integration.

## Call the API from Python

This example uses Requests. Before the first POST, create a unique idempotency key and retain it with the exact `input` and `output_schema` for that logical request. The example reads the retained key from `HARNYX_IDEMPOTENCY_KEY`.

### Python example

```python
import os
import time

import requests

base_url = "https://api.harnyx.ai"
auth = {"Authorization": f"Bearer {os.environ['HARNYX_API_KEY']}"}
request_body = {
    "input": "Compare the privacy and data-retention policies of Pinecone and Weaviate Cloud. Cite primary sources."
}

response = requests.post(
    f"{base_url}/v1/research/tasks",
    headers={**auth, "Idempotency-Key": os.environ["HARNYX_IDEMPOTENCY_KEY"]},
    json=request_body,
    timeout=30,
)
response.raise_for_status()
task = response.json()

while task["status"] not in {"completed", "failed"}:
    time.sleep(2)
    response = requests.get(
        f"{base_url}/v1/research/tasks/{task['id']}",
        headers=auth,
        timeout=30,
    )
    response.raise_for_status()
    task = response.json()
```

The POST returns HTTP `202` with the complete current task representation. Read `id` from that JSON body and use it in the GET route. Poll with a delay and stop at `completed` or `failed`.

If the POST outcome is uncertain or lost, send the same POST again with the retained key and identical `input` and `output_schema`. The replay returns the same task, including its latest state. Never generate a replacement key after an uncertain outcome.

## Stream tool-call progress

Use the same endpoints with an explicit streaming option. `POST /v1/research/tasks` with `"stream": true` returns HTTP `200`, `Content-Type: text/event-stream` and `Location`. Omitted/false `stream` retains HTTP `202` JSON. `GET /v1/research/tasks/{id}` with `Accept: text/event-stream` observes an existing task; ordinary GET remains JSON. Both delivery modes run the same durable asynchronous research task.

Events are named `task.snapshot`, `tool_call.started`, `tool_call.completed`, `tool_call.failed`, `task.completed` and `task.failed`. Each JSON envelope has `type` and `task_id`. Task events include the ordinary task representation in `task`. Call events include `call_id`, `tool`, `started_at`, nullable `finished_at` and nullable `outcome`.

For example:

```text
event: tool_call.started
data: {"type":"tool_call.started","task_id":"48e06953-fc0f-44af-bb97-886031083e26","call_id":"call-1","tool":"search_web","started_at":"2026-10-06T07:00:00Z","finished_at":null,"outcome":null}
```

Use call IDs to update independent activities; a completed/failed call must not return to started after reconnect. The console displays searching, reading and analyzing labels from the canonical tool names. These are tool-call lifecycle events, not answer-token deltas or internal model reasoning.

Raw tool-call request/response payloads are opt-in: add `"include_raw_tool_calls": true` to streaming POST or `?include_raw_tool_calls=true` to streaming GET. Permitted payloads appear as `raw.request` and `raw.response`; credentials and internal accounting/operator metadata are excluded.

Disconnect does not cancel execution. Reconnect reads the same task's current state and recorded call results, then continues observing; it does not guarantee historical ordering of every missed transition. A completed task still sends its snapshot, call history and final event. Close only on `task.completed` or `task.failed`, even if the initial snapshot already says completed. Connections close after five minutes to renew authorization: reconnect by GET. EOF is not task completion.

### Read SSE events from JavaScript

This Node.js example uses the [WHATWG-compatible `eventsource` package](https://github.com/EventSource/eventsource), rather than a custom SSE parser. Install `eventsource@5.1.2`; keep the API key and the retained idempotency key in environment variables. `HARNYX_API_BASE_URL` is optional and defaults to the production API.

```javascript
import { EventSource } from "eventsource";

const baseUrl = process.env.HARNYX_API_BASE_URL ?? "https://api.harnyx.ai";
const headers = { Authorization: `Bearer ${process.env.HARNYX_API_KEY}` };
const response = await fetch(`${baseUrl}/v1/research/tasks`, {
  method: "POST",
  headers: {
    ...headers,
    "Content-Type": "application/json",
    "Idempotency-Key": process.env.HARNYX_IDEMPOTENCY_KEY,
  },
  body: JSON.stringify({ input: "Compare Pinecone and Weaviate Cloud privacy policies." }),
  signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`Task creation failed: HTTP ${response.status}`);
const task = await response.json();
const calls = new Map();

const finalTask = await new Promise((resolve, reject) => {
  const source = new EventSource(`${baseUrl}/v1/research/tasks/${task.id}`, {
    fetch: (url, init) => fetch(url, {
      ...init,
      headers: { ...init.headers, ...headers },
    }),
  });
  const eventTypes = [
    "task.snapshot", "tool_call.started", "tool_call.completed",
    "tool_call.failed", "task.completed", "task.failed",
  ];
  for (const type of eventTypes) {
    source.addEventListener(type, (message) => {
      try {
        const event = JSON.parse(message.data);
        if (event.type !== type || event.task_id !== task.id) {
          throw new Error("Unexpected research event");
        }
        if (type.startsWith("tool_call.")) {
          const previous = calls.get(event.call_id);
          if (previous && previous.type !== "tool_call.started" && type === "tool_call.started") return;
          calls.set(event.call_id, event);
          console.log(event.tool, type);
        }
        if (type === "task.completed" || type === "task.failed") {
          source.close();
          resolve(event.task);
        }
      } catch (error) {
        source.close();
        reject(error);
      }
    });
  }
  source.addEventListener("error", (error) => {
    if ([400, 401, 403, 404, 422].includes(error.code)) {
      source.close();
      reject(new Error(`Stream rejected: HTTP ${error.code}`));
    } else {
      console.error("Stream interrupted; reconnecting to the same task.");
    }
  });
});
console.log(finalTask.status, finalTask.output, finalTask.credits_charged);
```

This example creates with ordinary JSON, then uses authenticated streaming GET. The client reconnects GET after transport interruption, without repeating creation. If creation itself times out, retry explicitly with the same retained key and input. JavaScript JSON parsing cannot preserve integers beyond its safe range; use lossless JSON parsing if your structured output requires exact large integers. `credits_charged` remains an exact decimal string.

## Request bodies

Omit `output_schema` for text output.

For structured output, add a JSON Schema Draft 2020-12 schema. `$ref` and `$dynamicRef` must be local fragments beginning with `#`; external references are rejected. Schemas may contain at most 80,000 compact JSON characters.

Structured responses may also contain at most 80,000 compact JSON characters. A schema that requires a larger result can be accepted by POST, but the task will fail with `error.code: "invalid_output"` because no valid response can fit.

### Structured request body

```json
{
  "input": "Compare the privacy and data-retention policies of Pinecone and Weaviate Cloud. Cite primary sources.",
  "output_schema": {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "properties": {
      "services": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "name": { "type": "string" },
            "retention_summary": { "type": "string" }
          },
          "required": ["name", "retention_summary"],
          "additionalProperties": false
        }
      }
    },
    "required": ["services"],
    "additionalProperties": false
  }
}
```

## Read a task

List tasks with `GET /v1/research/tasks?limit=50`. The maximum limit is 100.
Each item contains `id`, `input_preview`, `status`, `created_at`, and `finished_at`. `input_preview` contains at most the first 500 input characters; use `GET /v1/research/tasks/{task_id}` when the complete input is required.
Follow a non-null `next_cursor` by sending it unchanged as the next request's
`cursor`; do not parse or modify this opaque value.

Every exact task representation contains:

- `id`, the complete `input`, `status`, and `created_at`.
- `finished_at`: `null` until the task reaches a terminal status.
- `output`: text, a structured JSON value, or explicit JSON `null`.
- `citations`: `null` or an array whose positions may also be `null`. Each citation has `url`, nullable `title`, and `excerpts`.
- `error`: `null` unless the task failed.
- `credits_charged`: `null` before terminal status. A completed task is currently charged exactly `"0.10"`; a failed task may report `"0.00"`. These are fixed-two-decimal strings, not floating-point values.

An exact `[[n]]` marker in text output, or in a string within structured output, is a one-based pointer to `citations[n - 1]`. For example, `[[1]]` points to the first citation. Ordinary `[n]` text is not a citation. If the referenced position is `null`, it provides no factual support; the position remains in the array, so later citation markers are never renumbered or remapped.

Statuses are `queued`, `in_progress`, `completed`, and `failed`. Use `status`, not `output`, to determine success: a completed structured task may validly return JSON `null` when its schema permits it.

### Completed text response

```json
{
  "id": "48e06953-fc0f-44af-bb97-886031083e26",
  "input": "Compare the published data-retention controls for Pinecone and Weaviate.",
  "status": "completed",
  "created_at": "2026-08-24T06:00:00Z",
  "finished_at": "2026-08-24T06:01:15Z",
  "output": "Pinecone and Weaviate describe different retention controls...",
  "citations": [
    {
      "url": "https://example.com/privacy",
      "title": "Privacy policy",
      "excerpts": ["Customer data is retained according to..."]
    },
    null
  ],
  "error": null,
  "credits_charged": "0.10"
}
```

### Completed structured-null response

```json
{
  "id": "14a4a45e-331c-4d60-8c22-6f46dfa3e99e",
  "input": "Return null when no matching primary source exists.",
  "status": "completed",
  "created_at": "2026-08-24T06:00:00Z",
  "finished_at": "2026-08-24T06:00:45Z",
  "output": null,
  "citations": null,
  "error": null,
  "credits_charged": "0.10"
}
```

## Idempotency

`Idempotency-Key` is optional but should be sent whenever a create request may be retried.

- It must be 1 to 255 characters and not blank or whitespace-only.
- It is opaque, case-sensitive, unnormalized, and scoped to the authenticated account.
- The same key with the same parsed `input` and `output_schema` returns the same task: HTTP `202` JSON by default, or HTTP `200` SSE with `stream: true`. Delivery flags are excluded from task identity.
- The same key with a different parsed request returns HTTP `409` with `idempotency_key_conflict`.

## HTTP errors

HTTP error bodies are `{"error_code":"...","message":"..."}`.

| Status | `error_code` | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | The `Idempotency-Key` is invalid. |
| 400 | `invalid_cursor` | The list cursor is malformed. |
| 401 | `missing_authorization` | The Authorization header is missing. |
| 401 | `invalid_authorization_header` | The Bearer header is malformed, or the API key is unknown or revoked. |
| 404 | `not_found` | The task does not exist or belongs to another account. |
| 409 | `idempotency_key_conflict` | This account already used the key for a different parsed request. |
| 409 | `research_task_limit_exceeded` | The account's effective queued or active task limit prevents admission. |
| 409 | `invalid_account_status` | Research Task creation requires an active account. A concurrent account deactivation or deregistration prevented new work. |
| 422 | `validation_failed` | The request body or task ID fails validation. |

## Failed-task errors

Insufficient credits is a terminal task failure, not a create-time HTTP error: creation may return `202`, then retrieval returns `status: "failed"`.

| `error.code` | Exact public `error.message` |
| --- | --- |
| `insufficient_credits` | The account has insufficient credits to run this research task. |
| `execution_timeout` | The research task exceeded its execution time limit. |
| `invalid_output` | The research task did not produce a valid output. |
| `execution_failed` | The research task failed during execution. |
