# Seneca API quickstart

This document is the plain-text contract for a program or AI agent. It does not require JavaScript. The machine-readable schema is [https://seneca-research.fly.dev/v1/openapi.json](https://seneca-research.fly.dev/v1/openapi.json).

Base URL: `https://seneca-research.fly.dev/v1`

Seneca submits one question to a panel of independent AI models and returns a structured report: the finding, claims, positions, disagreements, minority insights, uncertainties, attribution, and methodology. Panel models do not browse the web, call tools, or use extended reasoning. Agreement in the report is not proof that a claim is factually correct.

## Authentication

Create a key while signed in at [https://seneca.professionalaiagents.com/developers#api-keys](https://seneca.professionalaiagents.com/developers#api-keys). The secret starts with `seneca_sk_` and is shown once.

Send it on every call except the OpenAPI document:

```http
Authorization: Bearer $SENECA_API_KEY
```

A browser session token is not a Seneca API key. Keep the key in an environment variable or a secret manager. Do not put it in public browser JavaScript.

`GET /v1/openapi.json` does not require a key.

## Account, panels, and credit cost

```bash
curl https://seneca-research.fly.dev/v1/account \
  -H "Authorization: Bearer $SENECA_API_KEY"
```

The account object includes `plan`, `subscription_status`, `available_credits`, `entitled`, and `monthly_credit_grant`. Reads work without an active subscription. Paid research requires `entitled: true` and enough `available_credits`.

The server owns the panel and the price. Send a panel id. Do not send a model list. Extra JSON fields are rejected and do not reserve credits.

| panel | credits reserved | what runs |
| --- | ---: | --- |
| `wide` | 15 | 10 curated models |
| `consensus` | 45 | 40 curated models |
| `seneca` | 100 | Every production-eligible model |

Choosing `seneca` reserves 100 credits. Choosing `wide` reserves 15. The price is not taken from the client.

## Create research

`POST /v1/research` requires `Content-Type: application/json` and `Idempotency-Key`.

The key is 1–255 characters: letters, numbers, or `.` `_` `:` `-`. Use one key per logical question. Save it with the run id.

A new run returns `202` and a body with `id`, `status`, `panel`, `credits_reserved`, `credits_charged`, and `progress`. Store `id`. The create connection does not stay open until the research finishes.

```bash
curl -sS -D - -X POST https://seneca-research.fly.dev/v1/research \
  -H "Authorization: Bearer $SENECA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: legal-tool-names-001" \
  -d '{"prompt": "An AI customer-service agent must select a tool to remove sensitive customer information before sending text to an LLM. Compare obfuscate_pii, redact_personally_identifiable_information, and remove_private_and_secret_customer_data. Which name is clearest, what ambiguities could lead to incorrect tool selection, and what execution tests would validate the recommendation?", "panel": "wide"}'
```

## Poll

Research often takes several minutes. Poll with a new request. Do not keep the original POST open.

```bash
curl -sS https://seneca-research.fly.dev/v1/research/$RUN_ID \
  -H "Authorization: Bearer $SENECA_API_KEY"
```

In progress: `queued`, `running`, `synthesizing`.

Finished: `completed`, `partial`, `failed`.

`progress` counts models invited, completed, successful, and failed. `phase` is `panel` while models are still answering.

## Read the report

When status is `completed` or `partial` and a customer report exists:

```bash
curl -sS "https://seneca-research.fly.dev/v1/research/$RUN_ID/result?detail=compact" \
  -H "Authorization: Bearer $SENECA_API_KEY"
```

`detail=compact` removes `question` and leaves the finding. Omit `detail`, or pass `detail=full`, for the stored report unchanged. These reads do not start research and do not write a credit charge.

Read the finding from the JSON. Do not scrape HTML.

- `report.answer.headline`
- `report.answer.direct_answer`
- `report.answer.key_points`
- `report.answer.recommendation`
- `report.answer.ranked_options`
- `report.evidence.claims`
- `report.evidence.camps` (the positions)
- `report.analysis.disagreements`
- `report.analysis.minority_insights`
- `report.analysis.uncertainties`
- `report.research_record.provenance` (model, lab, and inclusion)
- `report.methodology`

A run that is still queued, running, or synthesizing returns `409` with `result_not_ready`. A finished run with no customer report returns `409` with `research_failed`.

The report does not include original model text.

## Read one model's original response

Use this after a `completed` or `partial` run when you need the text behind a finding. `GET /v1/research/{run_id}/responses` is metadata. `GET /v1/research/{run_id}/responses/text` returns one stored model, and only a window of its text.

```bash
curl -sS "https://seneca-research.fly.dev/v1/research/$RUN_ID/responses?limit=20" \
  -H "Authorization: Bearer $SENECA_API_KEY"
```

Each item has `model_id`, `display_name`, `laboratory`, `status` (`success`, `error`, or `timeout`), `input_tokens`, `output_tokens`, `text_available`, and `text_characters`. `limit` is 1–50. Pass `next_cursor` back as `cursor`. The list has no response text.

```bash
curl -sS --get "https://seneca-research.fly.dev/v1/research/$RUN_ID/responses/text" \
  -H "Authorization: Bearer $SENECA_API_KEY" \
  --data-urlencode "model_id=anthropic/claude-3-haiku" \
  --data-urlencode "offset=0" \
  --data-urlencode "max_characters=8000"
```

`model_id` is the id stored on the run. Take it from the list or from `report.analysis.minority_insights[].supporting_model_ids`. A slash in the id stays in the query string. If that model is later retired, the stored id still works. The replacement id does not.

`text` starts at `offset` and contains at most `max_characters` characters. The default window is 8000. The maximum is 32000. `offset` is 0–1000000. `text_characters` is the full stored length. When `truncated` is true, add `returned_characters` to `offset` and request the next window. A model that stored no text returns `text: null`.

These calls do not start research, call a model provider, or reserve credits. A queued, running, or synthesizing run returns `409` `responses_not_ready`. A failed run returns `409` `research_failed`. An unknown model id on this run returns `404` `model_response_not_found`. Another workspace's run returns `404` `run_not_found`.

To read the original text behind the first minority finding:

```python
from urllib.parse import urlencode

insight = (report.get("analysis") or {}).get("minority_insights") or []
model_id = insight[0]["supporting_model_ids"][0]
status, _, original = call(
    "GET",
    "/research/" + run_id + "/responses/text?" + urlencode({
        "model_id": model_id,
        "offset": 0,
        "max_characters": 8000,
    }),
)
if status != 200:
    raise SystemExit(original["error"]["code"])
print(original["model_id"], original["laboratory"])
print(original["text"])
```

`report` and `run_id` come from the lifecycle above. `call` is the helper in that example.

## Download the whole panel

`GET /v1/research/{run_id}/responses/export` returns every stored model response in one request. The body is UTF-8 JSONL: one JSON object per line, not a JSON array. Each line has `model_id`, `display_name`, `laboratory`, `status`, `input_tokens`, `output_tokens`, and `text`. `text` is the complete stored response, or null when that model stored none. A retired model keeps the id saved on the run. Error and timeout rows are included.

The same readiness rules apply. A queued, running, or synthesizing run returns `409` `responses_not_ready`. A failed run returns `409` `research_failed`. Another workspace's run returns `404` `run_not_found`. These checks happen before the file starts. The download does not start research, call a provider, or reserve credits.

Save the file for your own storage and analysis. Do not load every model response into an AI agent's context. To inspect one finding, use the single-model read above.

```bash
curl -fS -H "Authorization: Bearer $SENECA_API_KEY" \
  "https://seneca-research.fly.dev/v1/research/$RUN_ID/responses/export" \
  -o seneca-responses.jsonl
```

The response sets `Content-Disposition: attachment; filename="seneca-{run_id}-responses.jsonl"`.

## Retry without buying the research twice

If the create response is lost, repeat the same `POST` with the same `Idempotency-Key`, the same prompt, and the same panel. For 24 hours the server returns `200`, header `Idempotent-Replayed: true`, and the original `id`. It does not create or charge a second run.

A different prompt or panel with that key returns `409` `idempotency_conflict`.

After 24 hours the same key starts a new paid run. A new key always can. Do not mint a new idempotency key only because the first response did not arrive.

## Reserved credits and charged credits

`credits_reserved` is the panel price held when the run is accepted. It stays at that number after the run finishes.

`credits_charged` is null while the run is in progress.

A delivered report is charged in full, including a partial panel that still produced a report. Then `credits_charged` equals `credits_reserved`.

A verified technical failure that finishes with no customer report restores the reserve. `credits_charged` is `0`. `credits_reserved` is still the panel price. `available_credits` on `GET /v1/account` increases by the restored amount. `failure_code` is `research_failed` for every failed run, whether or not the reserve was restored. Do not treat `failure_code` alone as a refund.

`GET /v1/usage` lists `credits_reserved` only. Read the run for `credits_charged`. Pass `next_cursor` back as `cursor`. `limit` is 1–100.

Credits are research capacity, not a cash refund.

## Errors

Failures use `{ "error": { "code", "message", "details" } }`.

| HTTP | code |
| --- | --- |
| 401 | `authentication_required`, `invalid_api_key` |
| 400 | `invalid_request`, `idempotency_key_required`, `invalid_idempotency_key`, `invalid_cursor`, `quick_pulse_unavailable` |
| 402 | `insufficient_credits`, `subscription_required` |
| 404 | `run_not_found`, `model_response_not_found` |
| 409 | `idempotency_conflict`, `concurrency_limit_reached`, `result_not_ready`, `responses_not_ready`, `research_failed` |
| 429 | `rate_limit_exceeded` |
| 500 | `internal_error` |

`insufficient_credits` includes `details.required` and `details.available`. A validation failure does not reserve credits. Another workspace's run is `404`, not the report.

Reads are limited to 120 requests per minute per key. Creates are limited to 30 per minute per workspace. A workspace can have 3 runs in progress at once.

## Complete lifecycle

```python
import json
import os
import time
import urllib.error
import urllib.request

BASE = "https://seneca-research.fly.dev/v1"
KEY = os.environ["SENECA_API_KEY"]
IDEMPOTENCY_KEY = "legal-tool-names-001"
PROMPT = """An AI customer-service agent must select a tool to remove sensitive customer information before sending text to an LLM. Compare obfuscate_pii, redact_personally_identifiable_information, and remove_private_and_secret_customer_data. Which name is clearest, what ambiguities could lead to incorrect tool selection, and what execution tests would validate the recommendation?"""
PANEL = "wide"


def call(method, path, body=None, headers=None):
    request = urllib.request.Request(
        BASE + path,
        data=None if body is None else json.dumps(body).encode(),
        method=method,
        headers={
            "Authorization": f"Bearer {KEY}",
            "Accept": "application/json",
            **(headers or {}),
        },
    )
    try:
        with urllib.request.urlopen(request) as response:
            return response.status, dict(response.headers), json.loads(response.read().decode())
    except urllib.error.HTTPError as exc:
        payload = json.loads(exc.read().decode())
        return exc.code, dict(exc.headers), payload


def main():
    status, _, account = call("GET", "/account")
    if status != 200:
        raise SystemExit(account["error"]["code"])

    status, headers, run = call(
        "POST",
        "/research",
        {"prompt": PROMPT, "panel": PANEL},
        {"Content-Type": "application/json", "Idempotency-Key": IDEMPOTENCY_KEY},
    )
    if status not in (200, 202):
        raise SystemExit(run["error"]["code"])
    run_id = run["id"]

    while run["status"] not in {"completed", "partial", "failed"}:
        time.sleep(5)
        status, _, run = call("GET", f"/research/{run_id}")
        if status != 200:
            raise SystemExit(run["error"]["code"])

    if run["status"] == "failed":
        if run["credits_charged"] == 0 and run["credits_reserved"] > 0:
            print("technical-failure refund restored the reserve")
        else:
            print("failed", run["failure_code"], "charged", run["credits_charged"])
        return

    status, _, result = call("GET", f"/research/{run_id}/result?detail=compact")
    if status != 200:
        raise SystemExit(result["error"]["code"])
    report = result["report"]
    answer = report.get("answer") or {}
    analysis = report.get("analysis") or {}
    print(answer.get("headline"))
    print(answer.get("direct_answer"))
    print("disagreements", len(analysis.get("disagreements") or []))
    print("minority_insights", len(analysis.get("minority_insights") or []))
    print("uncertainties", len(analysis.get("uncertainties") or []))


if __name__ == "__main__":
    main()
```

If that program loses the `202` response, run it again with the same `IDEMPOTENCY_KEY`, prompt, and panel. The second call returns the same `run_id`.

Custom model selection is not part of this API. MCP is documented at https://seneca.professionalaiagents.com/developers.md. A2A is not available. This document is the REST API.
