Harnyx Research API
Use only the public Research Task endpoints:
POST https://api.harnyx.ai/v1/research/tasksGET https://api.harnyx.ai/v1/research/tasksGET 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:
Authorization: Bearer <HARNYX_API_KEY>
Do not use X-Api-Key or a query parameter.
Connect an MCP client
The Streamable HTTP endpoint is:
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:
[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:
{
"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:
{
"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
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:
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, 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.
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
{
"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 completeinput,status, andcreated_at.finished_at:nulluntil the task reaches a terminal status.output: text, a structured JSON value, or explicit JSONnull.citations:nullor an array whose positions may also benull. Each citation hasurl, nullabletitle, andexcerpts.error:nullunless the task failed.credits_charged:nullbefore 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
{
"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
{
"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
inputandoutput_schemareturns the same task: HTTP202JSON by default, or HTTP200SSE withstream: true. Delivery flags are excluded from task identity. - The same key with a different parsed request returns HTTP
409withidempotency_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. |