Back to Claude Scientific Skills

The `gxl_paperclip` Python SDK

skills/paperclip/references/python-sdk.md

2.61.010.7 KB
Original Source

The gxl_paperclip Python SDK

A typed client over the same server the CLI talks to. Reach for it when Paperclip work belongs inside a script, notebook, or service — batch retrieval, joining results against your own data, streaming map progress into a UI. For interactive one-off queries the CLI is faster.

Signatures below were read from the installed package with inspect on 0.7.14 and re-checked on 0.7.15; search, health, papers.head, results.list, and execute were executed live. Repo, library, and map calls are transcribed from their signatures and not exercised here.

Installing

The curl installer bundles the SDK inside its private tree at ~/.paperclip/lib/, which is not on your sys.path. For real use, install it into the environment you control:

bash
pip install https://paperclip.gxl.ai/paperclip.whl
# or
uv add https://paperclip.gxl.ai/paperclip.whl

Install from the full URL, not by name: gxl-paperclip is not on PyPI, and the paperclip package that is on PyPI is unrelated software. The wheel URL is unversioned, so a rebuild of your environment can pick up a newer SDK — check gxl_paperclip.__version__ if behavior shifts.

Verify:

python
import gxl_paperclip
print(gxl_paperclip.__version__)   # 0.7.15 at time of writing

If you only need a quick script on a machine that has the CLI, you can borrow the bundled copy — but it is tied to the installer's layout and will break if that changes:

python
import sys
sys.path.insert(0, "/Users/you/.paperclip/lib")
from gxl_paperclip import PaperclipClient

Connecting

python
from gxl_paperclip import PaperclipClient

client = PaperclipClient.from_env()

from_env() picks up PAPERCLIP_API_KEY, falling back to the OAuth credentials paperclip login wrote to ~/.paperclip/credentials.json. Optional keyword arguments: base_url, timeout (default 120.0), user_agent, session.

Explicit auth strategies are available when the environment is not enough:

python
from gxl_paperclip import PaperclipClient, APIKeyAuth, BearerAuth, FileCredentialsAuth

client = PaperclipClient(APIKeyAuth("gxl_..."), timeout=300.0)

Check the connection:

python
status = client.health()
# HealthStatus(reachable=True, output='Health:  healthy\nInit:    True', exit_code=0, elapsed_ms=15)

ExecuteResult

Almost every call returns one:

FieldMeaning
outputRendered text, as the CLI would print it
exit_code0 on success
elapsed_msServer-side latency
result_ids_* id to pass to map_/reduce/results
result_dataStructured payload when the command produces one
download_url, download_filenameSet for commands that produce a file
cwdVirtual working directory after the call
rawUnparsed server response

output from search carries ANSI colour codes. Strip them before parsing or logging:

python
import re
ANSI = re.compile(r"\x1b\[[0-9;]*m")
clean = ANSI.sub("", result.output)

papers.* output is plain text and needs no stripping.

Search and retrieval

python
result = client.search("CRISPR delivery", limit=5, source="pmc")
print(result.result_id)      # s_5e9cc4f4
python
client.search(
    query,
    limit=None, source=None, exact=False, since=None, sort=None,
    author=None, journal=None, year=None, type=None, category=None,
    mode=None, min_embedding_similarity=None, min_bm25_score=None,
    all=False, timeout=None,
)

source is the SDK's equivalent of -s and behaves the same way — pass it. mode corresponds to --ranking. min_embedding_similarity and min_bm25_score are score floors with no CLI equivalent, useful for suppressing weak tail matches in a batch job.

python
client.lookup("doi", "10.1073/pnas.2307796121", limit=None)
client.sql("SELECT source, COUNT(*) FROM documents GROUP BY source")
client.sql("SELECT COUNT(*) FROM uniprot_v.proteins", source="proteins")

Reading documents — client.papers

python
client.papers.ls("/papers/PMC10945750/")
client.papers.cat("/papers/PMC10945750/meta.json")
client.papers.head("/papers/PMC10945750/content.lines", lines=40)
client.papers.tail("/papers/PMC10945750/content.lines", lines=20)
client.papers.grep("lipid nanoparticle", "/papers/PMC10945750/content.lines",
                   ignore_case=True, extended=False)
client.papers.scan("/papers/PMC10945750/content.lines", ["IC50", "EC50", "dose"])

head returns text with the L<n>: prefixes intact, so line numbers for citations come straight out:

python
out = client.papers.head("/papers/PMC10945750/content.lines", lines=3).output
# 'L1: Targeted nonviral delivery of genome editors in vivo\nL2: Proceedings of the National...'

Reading metadata as a dict:

python
import json
meta = json.loads(client.papers.cat("/papers/PMC10945750/meta.json").output)
meta["doi"], meta["journal"], meta["pub_year"]

map and reduce

map_ streams — it returns an iterator of events rather than a single result:

python
from gxl_paperclip import MapProgressEvent, MapResultEvent

for event in client.map_("What delivery vector and efficiency were reported?",
                         from_results="s_abc123"):
    if isinstance(event, MapProgressEvent):
        print("progress:", event)
    elif isinstance(event, MapResultEvent):
        print("result:", event)
python
client.reduce(
    "Compare vector, cell type, and efficiency",
    from_map="m_def456",
    strategy="table",                       # summarize | table | themes | consensus | bullet_points | extract
    columns=["paper", "vector", "efficiency"],
)

Give map_ a generous timeout, or set one on the client — an LLM reader over ten papers routinely exceeds the 120 s default.

Results

python
for row in client.results.list(limit=10):
    print(row.result_id, row.command, row.created_at)
    # s_5e9cc4f4 search 2026-07-28T01:05:31.651263+00:00

data = client.results.get("s_5e9cc4f4")     # ResultData: result_id, output, command, latency_ms, ...

Figures, files, and arbitrary commands

Resolve the filename from papers.ls first — figures are named by the publisher, not fig1.jpg:

python
client.papers.ls("/papers/PMC10945750/figures/")
# pnas.2307796121fig01.gif  pnas.2307796121fig01.jpg

fig = "/papers/PMC10945750/figures/pnas.2307796121fig01.jpg"
client.ask_image(fig, "What is on each axis?")
client.ask_image(fig, fn="extract-data")

# pull() does NOT work for binaries in 0.7.14–0.7.15: returns exit_code 0 with download_url None
# and writes no file. There is no working route to a local image — use ask_image server-side.
client.pull(fig, "fig01.jpg")

client.upload_document("analysis.json", data_bytes,
                       folder_path="analyses/my-topic",
                       content_type="application/json")

Anything the SDK does not wrap is reachable through the escape hatches:

python
client.execute("search", ["-s", "pmc", "CRISPR delivery", "-n", "5"])

for event in client.stream("map", ["--from", "s_abc123", "question"]):
    ...

client.bash() does not work in 0.7.14–0.7.15. It passes the whole script as a single command name, so even a plain command fails, and pipes and redirection fail with it:

python
r = client.bash("grep -c CRISPR /papers/PMC10945750/content.lines")
r.output
# 'ERR: vsh: grep -c CRISPR /papers/PMC10945750/content.lines: command not found. ... [exit 126]'

Use execute() with an argument list instead, and do any piping in Python:

python
client.execute("grep", ["-c", "CRISPR", "/papers/PMC10945750/content.lines"])

exit_code does not report sandbox failures

The call above returned exit_code == 0 while the sandbox reported [exit 126] inside output. The ExecuteResult.exit_code field describes the HTTP-level command dispatch, not the command that ran inside vsh. Check the output too:

python
r = client.execute("cat", ["/papers/PMC99999999/meta.json"])
failed = r.exit_code != 0 or r.output.lstrip().startswith("ERR:")

The CLI does surface the real status — paperclip search -s pmc "x" with a bad key exits 1 — so shell callers can rely on $? where SDK callers cannot.

Repos and library

client.repos and client.library are lower-level than the CLI: they take repo ids and entry ids rather than names, so resolve the name first.

python
repo = client.repos.get_repo_by_name("my-review")
client.repos.create_repo("my-review", "Delivery vectors")
client.repos.add_papers(repo["id"], [{"document_id": "PMC10945750"}])
client.repos.annotate_paper(repo["id"], entry_id, "Claim text", lines="L45-L52")
client.repos.commit(repo["id"], "Initial citations")
client.repos.get_status(repo["id"])
client.repos.create_branch(repo["id"], "safety")
client.repos.merge_branches(repo["id"], "safety", "main")
bibtex = client.repos.export(repo["id"], "bibtex")        # returns bytes
python
client.library.list_papers(page=1, per_page=50, search="fine-tuning")
client.library.upload_pdfs([("paper.pdf", pdf_bytes)])
job = client.library.poll_import_job(job_id, timeout_s=900)
client.library.delete_paper(paper_id)

The same rule as the CLI applies: repositories are opt-in. Do not create one unless the user asked.

Errors

All inherit from PaperclipError:

python
from gxl_paperclip import (
    PaperclipError, AuthError, RateLimitError, NotFoundError,
    ServerError, RequestTimeoutError, NetworkError,
)

try:
    result = client.search("query", source="pmc", limit=5)
except AuthError:
    ...            # missing or expired credentials — re-login, or check PAPERCLIP_API_KEY
except RateLimitError:
    ...            # back off and retry
except RequestTimeoutError:
    ...            # raise the timeout, especially around map_
except PaperclipError:
    ...            # anything else

exit_code is separate from exceptions: a call can return successfully with a non-zero exit_code (for example a corpus grep that matched nothing). Check both.

Batch pattern

python
import json, re
from gxl_paperclip import PaperclipClient

ANSI = re.compile(r"\x1b\[[0-9;]*m")
client = PaperclipClient.from_env(timeout=300.0)

hits = client.search(
    "lipid nanoparticle mRNA delivery to hematopoietic stem cells",
    source="pmc", limit=10,
)

rows = []
for doc_id in extract_ids(ANSI.sub("", hits.output)):        # your parser
    meta = json.loads(client.papers.cat(f"/papers/{doc_id}/meta.json").output)
    ic50 = client.papers.grep("IC50", f"/papers/{doc_id}/content.lines", ignore_case=True)
    rows.append({
        "id": doc_id,
        "title": meta["title"],
        "doi": meta.get("doi"),
        "year": meta.get("pub_year"),
        "ic50_lines": ic50.output,
    })

Prefer result_data over parsing output where the server populates it — the rendered text is a UI surface and its formatting is not a stable contract.