Skill Tracer
Skills › Scientific Agent Skills › paperclip › python-sdk.md

python-sdk.md

304 lines · skills/paperclip/references/python-sdk.md at 5f2bc03

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# GXL Paperclip Python SDK and HTTP contracts
2
3Reviewed on 2026-09-30 against **gxl_paperclip 0.7.92** installed client/source, request-construction
4smoke tests with fake HTTP responses, and the public
5[OpenAPI 3.1 schema](https://paperclip.gxl.ai/api/v1/openapi.json). Public metadata/unauthenticated
6probes were checked; authenticated retrieval, LLM execution, account permissions, uploads, and
7repository mutations were not exercised. Remote examples below are illustrative.
8

GXL Paperclip Python SDK and HTTP contracts

Reviewed on 2026-09-30 against gxl_paperclip 0.7.92 installed client/source, request-construction smoke tests with fake HTTP responses, and the public OpenAPI 3.1 schema. Public metadata/unauthenticated probes were checked; authenticated retrieval, LLM execution, account permissions, uploads, and repository mutations were not exercised. Remote examples below are illustrative.

Raw lines 1 to 8
9## Install and connect
11Install the official wheel in an environment you control; the managed CLI's private library is not
12on an arbitrary Python interpreter's path. See [installation.md](installation.md).
14```bash
15uv pip install https://paperclip.gxl.ai/paperclip.whl
16```
18```python
19import gxl_paperclip
20from gxl_paperclip import PaperclipClient
22print(gxl_paperclip.__version__)
23client = PaperclipClient.from_env(no_color=True)
24```
26Ordinary `from_env()` precedence is `PAPERCLIP_BEARER_TOKEN`, `PAPERCLIP_API_KEY`, then stored OAuth.
27It also accepts `base_url`, `timeout`, `user_agent`, `session`, and `no_color`. API keys use
28`X-API-Key`; bearer auth uses `Authorization`. `FileCredentialsAuth` can refresh stored OAuth on
29401 and retry once. Do not print credentials, authorization headers, or a complete private config.
31Explicit auth is useful when a notebook must select exactly one configured identity:
33```python
34import os
35from gxl_paperclip import APIKeyAuth, PaperclipClient
37client = PaperclipClient(auth=APIKeyAuth(os.environ["PAPERCLIP_API_KEY"]), no_color=True)
38```
40The ordinary request timeout is 120 seconds. In this version map defaults to 1,500 seconds,
41structured/exhaustive maps have separate longer defaults, and reduce/ask-image default to 300.
42These can be environment-configured. Pass a **per-call** `timeout` to override a slow command;
43setting the constructor timeout alone does not override every command-specific default.

Install and connect

Install the official wheel in an environment you control; the managed CLI's private library is not on an arbitrary Python interpreter's path. See installation.md.

uv pip install https://paperclip.gxl.ai/paperclip.whl
import gxl_paperclip
from gxl_paperclip import PaperclipClient

print(gxl_paperclip.__version__)
client = PaperclipClient.from_env(no_color=True)

Ordinary from_env() precedence is PAPERCLIP_BEARER_TOKEN, PAPERCLIP_API_KEY, then stored OAuth. It also accepts base_url, timeout, user_agent, session, and no_color. API keys use X-API-Key; bearer auth uses Authorization. FileCredentialsAuth can refresh stored OAuth on 401 and retry once. Do not print credentials, authorization headers, or a complete private config.

Explicit auth is useful when a notebook must select exactly one configured identity:

import os
from gxl_paperclip import APIKeyAuth, PaperclipClient

client = PaperclipClient(auth=APIKeyAuth(os.environ["PAPERCLIP_API_KEY"]), no_color=True)

The ordinary request timeout is 120 seconds. In this version map defaults to 1,500 seconds, structured/exhaustive maps have separate longer defaults, and reduce/ask-image default to 300. These can be environment-configured. Pass a per-call timeout to override a slow command; setting the constructor timeout alone does not override every command-specific default.

Raw lines 9 to 44
45## Structured search and lookup
47```python
48result = client.search("CRISPR delivery", source="pmc", limit=5)
49if result.exit_code != 0 or result.output.lstrip().startswith("ERR:"):
50 raise RuntimeError(result.output)
51for paper in result.papers:
52 doc_id = paper.get("document_id") or paper.get("id") or paper.get("paper_id")
53 if not doc_id:
54 raise ValueError("Search hit has no document identifier")
55 print(doc_id, paper.get("title"), paper.get("doi"))
56print(result.result_id)
57```
59`ExecuteResult` has `output`, `exit_code`, `elapsed_ms`, `result_id`, `result_data`, `raw`, `cwd`,
60`download_url`, and `download_filename`. Newer `papers` and `count` properties expose structured
61search/lookup hits. `papers` reads the `papers` or `results` array in `result_data`; `count` uses its
62length when nonempty, otherwise a declared count. It is not an independent completeness check.
63Search/lookup attempt saved-result hydration if data is absent/truncated; hydration failures can
64leave a preview. Compare declared counts/truncation with retrieved rows before calling a cohort
65complete. Do not parse ANSI-colored terminal titles to reconstruct IDs.
67Supported `search` keyword arguments in 0.7.92:
69```text
70limit, source, exact, since, sort, author, journal, year, type, category,
71mode, min_embedding_similarity, min_bm25_score, all, timeout
72```
74The wrapper serializes `mode` as `-m`; the CLI's documented spelling is `--ranking`.
75The score floors also exist as CLI flags and affect only their respective retrieval legs.
76Source/date/ranking caveats are in [search-and-retrieval.md](search-and-retrieval.md).
78```python
79identified = client.lookup("doi", "10.1073/pnas.2307796121", limit=1)
80counts = client.sql("SELECT source, COUNT(*) AS n FROM documents GROUP BY source")
81protein_count = client.sql("SELECT COUNT(*) FROM uniprot_v.proteins", source="proteins")
82```
84Load the current protein domain reference before constructing protein queries. SQL output remains
85an `ExecuteResult`, not a guaranteed pandas table.

Structured search and lookup

result = client.search("CRISPR delivery", source="pmc", limit=5)
if result.exit_code != 0 or result.output.lstrip().startswith("ERR:"):
    raise RuntimeError(result.output)
for paper in result.papers:
    doc_id = paper.get("document_id") or paper.get("id") or paper.get("paper_id")
    if not doc_id:
        raise ValueError("Search hit has no document identifier")
    print(doc_id, paper.get("title"), paper.get("doi"))
print(result.result_id)

ExecuteResult has output, exit_code, elapsed_ms, result_id, result_data, raw, cwd, download_url, and download_filename. Newer papers and count properties expose structured search/lookup hits. papers reads the papers or results array in result_data; count uses its length when nonempty, otherwise a declared count. It is not an independent completeness check. Search/lookup attempt saved-result hydration if data is absent/truncated; hydration failures can leave a preview. Compare declared counts/truncation with retrieved rows before calling a cohort complete. Do not parse ANSI-colored terminal titles to reconstruct IDs.

Supported search keyword arguments in 0.7.92:

limit, source, exact, since, sort, author, journal, year, type, category,
mode, min_embedding_similarity, min_bm25_score, all, timeout

The wrapper serializes mode as -m; the CLI's documented spelling is --ranking. The score floors also exist as CLI flags and affect only their respective retrieval legs. Source/date/ranking caveats are in search-and-retrieval.md.

identified = client.lookup("doi", "10.1073/pnas.2307796121", limit=1)
counts = client.sql("SELECT source, COUNT(*) AS n FROM documents GROUP BY source")
protein_count = client.sql("SELECT COUNT(*) FROM uniprot_v.proteins", source="proteins")

Load the current protein domain reference before constructing protein queries. SQL output remains an ExecuteResult, not a guaranteed pandas table.

Raw lines 45 to 86
87## Reading and arbitrary commands
89```python
90import json
92path = "/papers/PMC10945750"
93metadata_result = client.papers.cat(f"{path}/meta.json")
94if metadata_result.exit_code != 0 or metadata_result.output.lstrip().startswith("ERR:"):
95 raise RuntimeError(metadata_result.output)
96meta = json.loads(metadata_result.output)
97opening = client.papers.head(f"{path}/content.lines", lines=40)
98ending = client.papers.tail(f"{path}/content.lines", lines=20)
99sections = client.papers.ls(f"{path}/sections/")
100hits = client.papers.grep("lipid nanoparticle", f"{path}/content.lines", ignore_case=True)
101scanned = client.papers.scan(f"{path}/content.lines", ["IC50", "EC50", "dose"])
102```
104These typed methods preserve argument boundaries and support per-call `timeout`. `papers.grep`
105also accepts `extended=True`. Do not assume every metadata source has `journal` or `pub_year`; inspect
106keys and use optional access for nullable source-specific fields. `L<n>` prefixes in text are the
107source for line citations.
109```python
110client.execute("grep", ["-n", "-C", "3", "IC50", "/papers/PMC10945750/content.lines"])
111```
113Use `execute(command, args)` for commands without a wrapper. `bash(script)` exists for server-side
114pipelines, but old versions failed on this path and current authenticated behavior was not tested.
115Prefer argument lists and local Python composition. `ask_image(path, question=None, fn=None)` returns
116an `ExecuteResult`; list the figure directory first. `pull(target, dest=None)` returns textual
117metadata and any server-provided download URL; **the SDK does not stream image bytes to `dest`**.
118Never interpret `exit_code == 0` as proof that a local image was written.

Reading and arbitrary commands

import json

path = "/papers/PMC10945750"
metadata_result = client.papers.cat(f"{path}/meta.json")
if metadata_result.exit_code != 0 or metadata_result.output.lstrip().startswith("ERR:"):
    raise RuntimeError(metadata_result.output)
meta = json.loads(metadata_result.output)
opening = client.papers.head(f"{path}/content.lines", lines=40)
ending = client.papers.tail(f"{path}/content.lines", lines=20)
sections = client.papers.ls(f"{path}/sections/")
hits = client.papers.grep("lipid nanoparticle", f"{path}/content.lines", ignore_case=True)
scanned = client.papers.scan(f"{path}/content.lines", ["IC50", "EC50", "dose"])

These typed methods preserve argument boundaries and support per-call timeout. papers.grep also accepts extended=True. Do not assume every metadata source has journal or pub_year; inspect keys and use optional access for nullable source-specific fields. L<n> prefixes in text are the source for line citations.

client.execute("grep", ["-n", "-C", "3", "IC50", "/papers/PMC10945750/content.lines"])

Use execute(command, args) for commands without a wrapper. bash(script) exists for server-side pipelines, but old versions failed on this path and current authenticated behavior was not tested. Prefer argument lists and local Python composition. ask_image(path, question=None, fn=None) returns an ExecuteResult; list the figure directory first. pull(target, dest=None) returns textual metadata and any server-provided download URL; **the SDK does not stream image bytes to dest**. Never interpret exit_code == 0 as proof that a local image was written.

Raw lines 87 to 119
120## Map events and schemas
122```python
123from gxl_paperclip import MapProgressEvent, MapResultEvent
125schema = {
126 "type": "object",
127 "required": ["effect"],
128 "additionalProperties": False,
129 "properties": {"effect": {"type": ["number", "null"]}},
131for event in client.map_(
132 "Extract the reported effect; use null when absent.",
133 from_results="s_ID", output_schema=schema, timeout=1500.0,
134):
135 if isinstance(event, MapProgressEvent):
136 print(event.completed, event.failed, event.total)
137 elif isinstance(event, MapResultEvent):
138 if event.exit_code:
139 raise RuntimeError(event.output)
140 print(event.result_id, event.output)
141```
143Use `isinstance`; these dataclasses do not provide the `event.type` used in an old website example.
144`map_` returns an iterator. On the unified `paperclip.gxl.ai` host, API-key and OAuth maps use the
145streaming REST route; a direct `mcp.*` host with API-key auth emits only a final result event.
146The typed wrapper accepts `from_results`, `output_schema`, and `timeout`, not worker/repo parameters;
147use `stream("map", argv)` for specialized options after inspecting the relevant workflow.
149```python
150summary = client.reduce("Compare the effects", from_map="m_ID", strategy="table", columns=["paper", "effect"])
151```
153A schema validates JSON shape; source evidence still needs scientific checks. See
154[map-reduce.md](map-reduce.md).

Map events and schemas

from gxl_paperclip import MapProgressEvent, MapResultEvent

schema = {
    "type": "object",
    "required": ["effect"],
    "additionalProperties": False,
    "properties": {"effect": {"type": ["number", "null"]}},
}
for event in client.map_(
    "Extract the reported effect; use null when absent.",
    from_results="s_ID", output_schema=schema, timeout=1500.0,
):
    if isinstance(event, MapProgressEvent):
        print(event.completed, event.failed, event.total)
    elif isinstance(event, MapResultEvent):
        if event.exit_code:
            raise RuntimeError(event.output)
        print(event.result_id, event.output)

Use isinstance; these dataclasses do not provide the event.type used in an old website example. map_ returns an iterator. On the unified paperclip.gxl.ai host, API-key and OAuth maps use the streaming REST route; a direct mcp.* host with API-key auth emits only a final result event. The typed wrapper accepts from_results, output_schema, and timeout, not worker/repo parameters; use stream("map", argv) for specialized options after inspecting the relevant workflow.

summary = client.reduce("Compare the effects", from_map="m_ID", strategy="table", columns=["paper", "effect"])

A schema validates JSON shape; source evidence still needs scientific checks. See map-reduce.md.

Raw lines 120 to 155
156## Results and pagination
158```python
159recent = client.results.list(limit=10)
160saved = client.results.get("s_ID")
161page = client.results.page("s_ID", cursor=0, limit=500)
162sample = client.results.sample("s_ID", count=10, seed=42)
163```
165`list()` returns `ResultRow` objects (recent results, not hit pages); `get()` returns `ResultData`
166with `output` and `raw`. `page()` returns a dict with `papers`, total `count`, and `next_cursor`.
167The initial cursor is integer **0**, page size is **1–500**, and `next_cursor is None` terminates.
168These contracts are taken from the SDK and bundled cohort exporter, not an authenticated page.
169A 404 can mean missing results or a server lacking cohort-page support.
171Bound traversal and reject a nonadvancing cursor or an inconsistent count:
173```python
174def saved_papers(client, result_id, max_pages=20):
175 cursor = 0
176 rows = []
177 expected = None
178 for _ in range(max_pages):
179 page = client.results.page(result_id, cursor=cursor, limit=500)
180 batch = page.get("papers")
181 count = page.get("count")
182 if not isinstance(batch, list) or isinstance(count, bool) or not isinstance(count, int) or count < 0:
183 raise ValueError("Invalid saved-cohort page")
184 if expected is None:
185 expected = count
186 if count != expected:
187 raise ValueError("Cohort changed during pagination")
188 rows.extend(batch)
189 next_cursor = page.get("next_cursor")
190 if next_cursor is None:
191 if len(rows) != expected:
192 raise ValueError("Incomplete saved cohort")
193 return rows
194 if isinstance(next_cursor, bool) or not isinstance(next_cursor, int) or next_cursor <= cursor:
195 raise ValueError("Invalid cohort cursor")
196 cursor = next_cursor
197 raise ValueError("Saved cohort exceeded the page budget")
198```
200This retrieves a saved cohort; it does not extend the original search into unsaved hits.
201`results.sample` accepts 1–100 papers and an integer seed. The CLI portable bundle exporter uses
202these pages, verifies count/cursor advancement, and caps exports at 10,000 rows in 0.7.92.

Results and pagination

recent = client.results.list(limit=10)
saved = client.results.get("s_ID")
page = client.results.page("s_ID", cursor=0, limit=500)
sample = client.results.sample("s_ID", count=10, seed=42)

list() returns ResultRow objects (recent results, not hit pages); get() returns ResultData with output and raw. page() returns a dict with papers, total count, and next_cursor. The initial cursor is integer 0, page size is 1–500, and next_cursor is None terminates. These contracts are taken from the SDK and bundled cohort exporter, not an authenticated page. A 404 can mean missing results or a server lacking cohort-page support.

Bound traversal and reject a nonadvancing cursor or an inconsistent count:

def saved_papers(client, result_id, max_pages=20):
    cursor = 0
    rows = []
    expected = None
    for _ in range(max_pages):
        page = client.results.page(result_id, cursor=cursor, limit=500)
        batch = page.get("papers")
        count = page.get("count")
        if not isinstance(batch, list) or isinstance(count, bool) or not isinstance(count, int) or count < 0:
            raise ValueError("Invalid saved-cohort page")
        if expected is None:
            expected = count
        if count != expected:
            raise ValueError("Cohort changed during pagination")
        rows.extend(batch)
        next_cursor = page.get("next_cursor")
        if next_cursor is None:
            if len(rows) != expected:
                raise ValueError("Incomplete saved cohort")
            return rows
        if isinstance(next_cursor, bool) or not isinstance(next_cursor, int) or next_cursor <= cursor:
            raise ValueError("Invalid cohort cursor")
        cursor = next_cursor
    raise ValueError("Saved cohort exceeded the page budget")

This retrieves a saved cohort; it does not extend the original search into unsaved hits. results.sample accepts 1–100 papers and an integer seed. The CLI portable bundle exporter uses these pages, verifies count/cursor advancement, and caps exports at 10,000 rows in 0.7.92.

Raw lines 156 to 203
204## SDK transport and workspace contracts
206The SDK is not a wrapper around the newer `/api/v1/search` API. Inspected 0.7.92 routing:
208| Operation | Method/path and request | Response contract |
209|---|---|---|
210| Ordinary command with OAuth | `POST /api/cli/execute`, JSON `command`, shell-quoted `raw` | Execute response; 404 can fall back to MCP |
211| Ordinary command with API key, unified host | `POST /mcp`, JSON-RPC `tools/call`, legacy tool `paperclip`, `arguments.command` | Text blocks; result ID extracted and search/lookup hydrated |
212| Direct `mcp.*` hostname | MCP uses `/papers` | Compatibility path, not the public `/api/v1` contract |
213| Map on unified host | `POST /api/cli/execute?stream=1`, JSON `command=map`, `raw` | Progress/result NDJSON, or one normalized JSON result |
214| Recent/saved results | `GET /api/cli/results?limit=N`; `GET /api/cli/results/{id}` | Array of rows; saved-result object |
215| Saved cohort page | `GET /api/cli/results/{id}/page?cursor=0&limit=500` | `papers`, `count`, `next_cursor` |
216| Artifact upload | `POST /api/user/upload` multipart `file`, form `folder_path` | Server response dict; do not assume a fixed success field |
218The legacy MCP text parser can report `exit_code=0` even when returned text contains a virtual-shell
219error; this is still visible in the 0.7.92 implementation. HTTP status, typed exceptions, command
220status, and expected result content all matter. The SDK `health()` dispatches authenticated `status`;
221it is not the public `/health` probe used by CLI config.
223Repo/library methods return mostly raw dicts and accept internal repo/entry IDs rather than CLI
224names. Examples are **mutations** and require the corresponding user task:
226```python
227repo = client.repos.create_repo("my-review", "Delivery vectors")
228client.repos.add_papers(repo["id"], [{"paper_id": "PMC10945750"}])
229```
231The item key is `paper_id`, not the old example's `document_id`. Resolve an existing repo with
232`get_repo_by_name()` and handle `None` before use. The route mapping for the formerly documented
233SDK operations is:
235| SDK operation | Route/body or query |
236|---|---|
237| `repos.get_repo_by_name(name)` | `GET /api/paper-repos/by-name/{name}`; 404 becomes `None` |
238| `repos.create_repo(name, description)` | `POST /api/paper-repos/`, JSON name/description |
239| `repos.add_papers(repo_id, items, branch=None)` | `POST /api/paper-repos/{id}/papers`, JSON items/branch |
240| `repos.annotate_paper(repo_id, entry_id, note, lines=...)` | `PATCH .../{id}/papers/{entry_id}`, `append_annotation: {note, lines}` |
241| `repos.commit(repo_id, message)` | `POST .../{id}/commits`, JSON message/branch |
242| `repos.get_status(repo_id)` | `GET .../{id}/status`, optional branch query |
243| `repos.create_branch(repo_id, name)` | `POST .../{id}/branches`, JSON name/from_branch |
244| `repos.merge_branches(repo_id, source, target)` | `POST .../{id}/merge`, JSON source/target |
245| `repos.export(repo_id, format)` | `GET .../{id}/export`, format/branch query; returns bytes |
246| `library.list_papers(page=1, per_page=50, search=...)` | `GET /api/library/`, one-based page/per_page plus sort_by/sort_dir; raw dict |
247| `library.upload_pdfs([(name, bytes)])` | `POST /api/library/import/pdf`, multipart repeated `files`; job_id/status dict |
248| `library.poll_import_job(job_id, timeout_s=900)` | Repeated `GET /api/library/import/jobs/{id}`; returns completed `result`, raises on failure/timeout |
249| `library.delete_paper(id)` | `DELETE /api/library/{id}`; returns `None`, tolerates 404 |
251SDK `repos.commit()` only sends the commit request; it does not run the CLI's client-side verifier
252or its scientific gates. Do not substitute it for `paperclip repo commit` and claim equivalent
253verification. Permissions are not established merely because a method exists:
254`client.list_paper_repos()` explicitly rejects API-key auth, while `client.repos.list_repos()`
255dispatches directly. This inconsistency was not resolved with live account tests. Library and repo
256history paging are separate one-based APIs; do not reuse the cohort's zero-based cursor.

SDK transport and workspace contracts

The SDK is not a wrapper around the newer /api/v1/search API. Inspected 0.7.92 routing:

OperationMethod/path and requestResponse contract
Ordinary command with OAuthPOST /api/cli/execute, JSON command, shell-quoted rawExecute response; 404 can fall back to MCP
Ordinary command with API key, unified hostPOST /mcp, JSON-RPC tools/call, legacy tool paperclip, arguments.commandText blocks; result ID extracted and search/lookup hydrated
Direct mcp.* hostnameMCP uses /papersCompatibility path, not the public /api/v1 contract
Map on unified hostPOST /api/cli/execute?stream=1, JSON command=map, rawProgress/result NDJSON, or one normalized JSON result
Recent/saved resultsGET /api/cli/results?limit=N; GET /api/cli/results/{id}Array of rows; saved-result object
Saved cohort pageGET /api/cli/results/{id}/page?cursor=0&limit=500papers, count, next_cursor
Artifact uploadPOST /api/user/upload multipart file, form folder_pathServer response dict; do not assume a fixed success field

The legacy MCP text parser can report exit_code=0 even when returned text contains a virtual-shell error; this is still visible in the 0.7.92 implementation. HTTP status, typed exceptions, command status, and expected result content all matter. The SDK health() dispatches authenticated status; it is not the public /health probe used by CLI config.

Repo/library methods return mostly raw dicts and accept internal repo/entry IDs rather than CLI names. Examples are mutations and require the corresponding user task:

repo = client.repos.create_repo("my-review", "Delivery vectors")
client.repos.add_papers(repo["id"], [{"paper_id": "PMC10945750"}])

The item key is paper_id, not the old example's document_id. Resolve an existing repo with get_repo_by_name() and handle None before use. The route mapping for the formerly documented SDK operations is:

SDK operationRoute/body or query
repos.get_repo_by_name(name)GET /api/paper-repos/by-name/{name}; 404 becomes None
repos.create_repo(name, description)POST /api/paper-repos/, JSON name/description
repos.add_papers(repo_id, items, branch=None)POST /api/paper-repos/{id}/papers, JSON items/branch
repos.annotate_paper(repo_id, entry_id, note, lines=...)PATCH .../{id}/papers/{entry_id}, append_annotation: {note, lines}
repos.commit(repo_id, message)POST .../{id}/commits, JSON message/branch
repos.get_status(repo_id)GET .../{id}/status, optional branch query
repos.create_branch(repo_id, name)POST .../{id}/branches, JSON name/from_branch
repos.merge_branches(repo_id, source, target)POST .../{id}/merge, JSON source/target
repos.export(repo_id, format)GET .../{id}/export, format/branch query; returns bytes
library.list_papers(page=1, per_page=50, search=...)GET /api/library/, one-based page/per_page plus sort_by/sort_dir; raw dict
library.upload_pdfs([(name, bytes)])POST /api/library/import/pdf, multipart repeated files; job_id/status dict
library.poll_import_job(job_id, timeout_s=900)Repeated GET /api/library/import/jobs/{id}; returns completed result, raises on failure/timeout
library.delete_paper(id)DELETE /api/library/{id}; returns None, tolerates 404

SDK repos.commit() only sends the commit request; it does not run the CLI's client-side verifier or its scientific gates. Do not substitute it for paperclip repo commit and claim equivalent verification. Permissions are not established merely because a method exists: client.list_paper_repos() explicitly rejects API-key auth, while client.repos.list_repos() dispatches directly. This inconsistency was not resolved with live account tests. Library and repo history paging are separate one-based APIs; do not reuse the cohort's zero-based cursor.

Raw lines 204 to 257
Uses an outside tool MCP :210
Starts an agent dispatches :220
258## Public JSON API
260For stable search/metadata JSON without the CLI transport, the current public contract is based at
261`https://paperclip.gxl.ai/api/v1` and accepts `X-API-Key` or an API key as `Authorization: Bearer ...`.
262The OpenAPI security declaration lists HTTPBearer; both key forms are described in its info and
263the official HTTP guide. These endpoints are distinct from SDK command transport.
265| Endpoint | Request and response |
266|---|---|
267| `POST /search` | JSON `query`, explicit `sources`, `num_results` 1–200 (default 50), `ranking` hybrid/bm25/vector, optional `filters`; response has `papers`, `results`, `usage`, and optional `search_id` |
268| `POST /lookup` | One identifier/metadata key (e.g. `pmid`) or `field` plus `value`; `num_results` 1–100 (default 25); response has query/papers/results/usage |
269| `GET /documents/{doc_id}` | Document metadata (id/object/title/url plus optional identifiers, abstract, metadata); not a full-text download |
271`/search` defaults to `abstracts`, not PMC. The schema describes that default index as residual
272PubMed/Crossref title/abstract content; do not assume the CLI's broader `-s abstracts` wording.
273`analogical` is not a v1 ranking enum even though the CLI supports it. Neither search nor lookup
274exposes a page, cursor, or offset in this schema: increasing `num_results` is bounded retrieval,
275not arbitrary pagination. Year bounds are inclusive; records with unknown year are retained by the
276published filter contract. `published_after/before` are **year-granular aliases**, not day precision.
278Illustrative request, schema-checked locally but not submitted with credentials:
280```python
281import os
282import requests
284response = requests.post(
285 "https://paperclip.gxl.ai/api/v1/search",
286 headers={"X-API-Key": os.environ["PAPERCLIP_API_KEY"]},
287 json={"query": "CRISPR delivery", "sources": ["pmc"], "num_results": 5, "ranking": "hybrid"},
288 timeout=120,
290response.raise_for_status()
291papers = response.json()["papers"]
292```
294Public schema and health reads returned 200 during review; unauthenticated document/results reads
295returned 401. That verifies authentication boundaries, not successful retrieval or billing behavior.

Public JSON API

For stable search/metadata JSON without the CLI transport, the current public contract is based at https://paperclip.gxl.ai/api/v1 and accepts X-API-Key or an API key as Authorization: Bearer .... The OpenAPI security declaration lists HTTPBearer; both key forms are described in its info and the official HTTP guide. These endpoints are distinct from SDK command transport.

EndpointRequest and response
POST /searchJSON query, explicit sources, num_results 1–200 (default 50), ranking hybrid/bm25/vector, optional filters; response has papers, results, usage, and optional search_id
POST /lookupOne identifier/metadata key (e.g. pmid) or field plus value; num_results 1–100 (default 25); response has query/papers/results/usage
GET /documents/{doc_id}Document metadata (id/object/title/url plus optional identifiers, abstract, metadata); not a full-text download

/search defaults to abstracts, not PMC. The schema describes that default index as residual PubMed/Crossref title/abstract content; do not assume the CLI's broader -s abstracts wording. analogical is not a v1 ranking enum even though the CLI supports it. Neither search nor lookup exposes a page, cursor, or offset in this schema: increasing num_results is bounded retrieval, not arbitrary pagination. Year bounds are inclusive; records with unknown year are retained by the published filter contract. published_after/before are year-granular aliases, not day precision.

Illustrative request, schema-checked locally but not submitted with credentials:

import os
import requests

response = requests.post(
    "https://paperclip.gxl.ai/api/v1/search",
    headers={"X-API-Key": os.environ["PAPERCLIP_API_KEY"]},
    json={"query": "CRISPR delivery", "sources": ["pmc"], "num_results": 5, "ranking": "hybrid"},
    timeout=120,
)
response.raise_for_status()
papers = response.json()["papers"]

Public schema and health reads returned 200 during review; unauthenticated document/results reads returned 401. That verifies authentication boundaries, not successful retrieval or billing behavior.

Raw lines 258 to 296
297## Errors
299Catch `PaperclipError` and its subclasses: `AuthError` (401), `ForbiddenError` (403),
300`RateLimitError` (429), `NotFoundError`, `ServerError`, `RequestTimeoutError`, and `NetworkError`.
301Do not retry bad credentials or forbidden operations automatically. Bound retries for transient
302failures; do not restart a mutation whose outcome is unknown without reconciling server state.
303Inspect typed command status and returned content as well as exceptions. Never log request headers
304or credential-bearing diagnostics when reporting an error.

Errors

Catch PaperclipError and its subclasses: AuthError (401), ForbiddenError (403), RateLimitError (429), NotFoundError, ServerError, RequestTimeoutError, and NetworkError. Do not retry bad credentials or forbidden operations automatically. Bound retries for transient failures; do not restart a mutation whose outcome is unknown without reconciling server state. Inspect typed command status and returned content as well as exceptions. Never log request headers or credential-bearing diagnostics when reporting an error.

Raw lines 297 to 304