ProteinIQ
DocumentationAPI referenceChangelog
Talk to usGet started

Quickstart

Run an analysis from a script and save its results.

This example runs ESMfold on a short example protein sequence. It checks the credit estimate, asks before submitting the paid job, waits for completion, and saves structured results and available output files.

You need Python 3.10 or later, an Enterprise workspace or active legacy Lite workspace, and an API key with jobs:read and jobs:write.

Set credentials

In a Bash-compatible terminal, set the key and create an identifier for this intended run:

Bash
export PROTEINIQ_API_KEY="YOUR_API_KEY"
export PROTEINIQ_RUN_KEY="$(python3 -c 'import uuid; print(uuid.uuid4())')"

Keep PROTEINIQ_RUN_KEY unchanged if you retry the same submission after a connection failure. Generate a new value for a new analysis or changed request. Store the API key outside source control.

Run the script

Save this as run_analysis.py, then run python3 run_analysis.py. It uses only the Python standard library.

Python
import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from uuid import UUID

BASE_URL = "https://proteiniq.io"
API_KEY = os.environ["PROTEINIQ_API_KEY"]
RUN_KEY = str(UUID(os.environ["PROTEINIQ_RUN_KEY"]))
TERMINAL = {"COMPLETED", "FAILED", "TIMEOUT", "CANCELLED", "BUDGET_EXCEEDED"}

def retry_delay(headers):
    value = headers.get("Retry-After", "5")
    try:
        return max(0, float(value))
    except ValueError:
        reset = parsedate_to_datetime(value)
        return max(0, (reset - datetime.now(timezone.utc)).total_seconds())

def request_json(method, path, body=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {API_KEY}"}
    if body is not None:
        headers["Content-Type"] = "application/json"
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    data = None if body is None else json.dumps(body).encode()
    for attempt in range(6):
        request = urllib.request.Request(
            BASE_URL + path, data=data, headers=headers, method=method
        )
        try:
            with urllib.request.urlopen(request, timeout=60) as response:
                return json.load(response), response.headers
        except urllib.error.HTTPError as exc:
            payload = json.loads(exc.read())
            error = payload.get("error", {})
            retryable = exc.code == 429 or (
                method == "GET" and error.get("code") == "job_not_completed"
            )
            if not retryable or attempt == 5:
                raise RuntimeError(
                    f"{exc.code} {error.get('code')}: {error.get('message')}"
                ) from exc
            time.sleep(retry_delay(exc.headers))

request = {
    "tool": "esmfold",
    "input": {"inputs": [{
        "id": "protein_1",
        "slotId": "protein",
        "kind": "protein",
        "format": "fasta",
        "content": ">example\nACDEFGHIKLMNPQRSTVWY",
        "source": {"type": "text"},
    }]},
    "settings": {},
}

saved_request = Path("submissions") / f"{RUN_KEY}.json"
if saved_request.exists():
    # Recover the exact approved request, including its accepted price.
    submission = json.loads(saved_request.read_text(encoding="utf-8"))
    print("Reusing saved submission:", saved_request)
else:
    tool, _ = request_json("GET", "/api/v1/tools/esmfold")
    print("Tool:", tool["name"])
    quote, _ = request_json("POST", "/api/v1/jobs/quote", request)
    print(json.dumps(quote, indent=2))
    if quote.get("blocking_errors"):
        raise RuntimeError("Resolve the quote's blocking_errors before submitting.")
    if input("Submit this paid analysis? [y/N] ").strip().lower() != "y":
        raise SystemExit("No job submitted.")
    submission = {
        **request,
        "name": "ESMfold API example",
        "max_quoted_credits": quote["billable_credits"],
    }
    saved_request.parent.mkdir(parents=True, exist_ok=True)
    saved_request.write_text(json.dumps(submission, indent=2), encoding="utf-8")
job, _ = request_json("POST", "/api/v1/jobs", submission, RUN_KEY)
job_id = job["id"]
print("Job ID:", job_id, flush=True)
print("Results page:", BASE_URL + "/app/jobs/" + job_id)

delay = 5
deadline = time.monotonic() + 3600
while job["status"] not in TERMINAL:
    if time.monotonic() >= deadline:
        raise TimeoutError(f"Stopped waiting; job {job_id} may still be running.")
    time.sleep(delay)
    job, headers = request_json("GET", f"/api/v1/jobs/{job_id}/status")
    print("Status:", job["status"], flush=True)
    delay = max(min(delay * 2, 30), retry_delay(headers))

if job["status"] != "COMPLETED":
    raise RuntimeError(
        f"Job {job_id} ended as {job['status']}: {job.get('error')}. "
        "Check the Results reference for partial-output availability."
    )

result, _ = request_json("GET", f"/api/v1/results/{job_id}")
output_dir = Path("outputs") / job_id
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8")

downloaded = 0
for index, file in enumerate(result.get("files", []), start=1):
    if not file.get("url"):
        continue
    # Use a local filename, never an untrusted path from the response.
    name = Path(file.get("name") or "result-file").name
    destination = output_dir / f"{index}-{name}"
    # Signed URLs carry their own access: do not send the API key.
    with urllib.request.urlopen(file["url"], timeout=60) as response:
        with destination.open("wb") as output:
            while chunk := response.read(1024 * 1024):
                output.write(chunk)
    downloaded += 1
    print("Saved:", destination)

print(f"Saved result.json and {downloaded} files in {output_dir}")

Check the outcome

A successful run prints its job ID, reaches COMPLETED, and writes outputs/<jobId>/result.json. Each downloadable file is saved beside it. An empty file list is valid; inspect the structured results data too.

The one-hour wait limit stops the script's polling. It does not cancel the job. Use the printed ID to check job status.

Recover from an error

The script prints the API's error code and message:

  • Quote has blocking errors: Resolve the listed input, credit, or workspace limit before submission.
  • price_changed: Review a fresh quote and create a new run key if you accept the new price.
  • Connection failure during submission: Rerun the script with the same run key. It reads the approved payload from submissions/<runKey>.json and skips the quote so it can recover an already accepted job. Keep that file unchanged and private; it contains your scientific input.
  • rate_limited or job_not_completed: The script makes bounded retries and honors Retry-After.
  • Terminal status other than COMPLETED: Inspect the job error. Failed or budget-limited jobs may have partial results.
  • Expired download URL: Fetch the result again to obtain fresh URLs.

Adapt the example

Change the tool, input slots, and settings using the tool contract. For large inputs, upload a workspace file and submit its reference. Time-based tools also support a total job credit limit.

PreviousOverviewNextAuthentication

Table of contents

Get started
OverviewQuickstartAuthentication
Run analyses
ToolsJobsResultsWorkflows
Workspace data
FilesProjectsEvents
Reference
ErrorsRate limitsOpenAPIPython SDKMCP
Appearance
Back to ProteinIQ