# Tamarind Bio API — Guide for LLMs and Coding Agents > You are an AI assistant or coding agent. A user pasted this document into your > context so you can drive the Tamarind Bio REST API on their behalf. Tamarind runs > hundreds of computational-biology tools (structure prediction, protein/antibody/ > binder design, docking, binding affinity, MSA generation, molecular dynamics, > and more) on managed GPUs behind ONE uniform job API. There is no local GPU and > no SDK — you make plain HTTP requests. This guide is REST-only and self-contained: > everything you need to find the right tool, submit a job, poll it, and download > results is below. **Every path in this document is relative to `/api/`.** Paths are written bare, so `GET /tools` means `GET {base}/api/tools`, where `{base}` is the host you fetched this file from (see Base URL under Setup). There is NO rewrite from the bare path: `POST /submit-job` without the prefix is a **404**, not a redirect. If you are reading one section in isolation, or through a tool that summarises rather than quoting, re-attach the prefix yourself before writing any code — an agent that did not reported that every call in its generated script would have 404'd. ## Discover tools live, not from memory There are hundreds of tools, and the catalog changes constantly (tools are added, renamed, and versioned). **Don't invent a tool name or its parameters from memory.** Before you recommend or submit anything, call `GET /tools` and work from what it returns — a tool name or parameter you didn't read from `/tools` is a guess, and guesses fail validation. Skipping discovery is the single most common mistake agents make with this API. There is deliberately no shortlist of tools in this document. The catalog is large and evolving; the authoritative, account-scoped list is whatever `GET /tools` returns right now. Explore it. **No API key yet? Use `/tools.json`.** `GET /tools` needs a key, which is a problem if you are still deciding whether to use Tamarind at all. `/tools.json` at the root of the host you fetched this file from (or `/tools.md` for a table) needs no key and is generated live from the same registry, so it cannot go stale. It gives you: - the exact, case-sensitive `type` string for every publicly submittable tool — confirm the name here instead of guessing it; - each tool's REQUIRED settings, with types and enum options, so you can build a correct payload *before* the user has an account; - `?type=alphafold` for one tool or `?tag=protein-ligand-docking` for a category, so you do not have to pull the whole file into context. Read `$comment` in that response before using `requiredSettings`: requiredness is usually CONDITIONAL. A field with `tasks` applies only for certain values of the tool's task selector (named by `taskSetting`, and it is not always called `task`), and one with `conditionals` only while another setting holds a given value — so a tool's required fields are typically alternative branches, not a joint checklist. It lists only what is public. A feature-flagged, org-restricted or custom tool may still be submittable with your key, so absence there is not proof a name is invalid — check `GET /tools` once you have one. ## TL;DR workflow 1. **Discover** — `GET /tools`, then filter the returned array by the user's INTENT. 2. **Read the schema** — each tool entry carries its own `settings` param list; read which params are `required` and their types/options/defaults. 3. **Validate** — `POST /validate-job` to dry-run your settings for free. It returns the exact payload to submit, or every missing/invalid field. Fix, then submit. 4. **Submit** — `POST /submit-job` with `{ jobName, type, settings }`. 5. **Poll** — `GET /jobs?jobName=` until `JobStatus` is terminal. 6. **Download** — `POST /result` with `{ jobName }` (two-step; see below). ## Setup - **Base URL:** `https://app.tamarind.bio/api/` — **unless you fetched this guide from a different host.** An organisation with a dedicated deployment (e.g. `https://immunocore.tamarind.bio`) has its own jobs, files and API keys in its own account, reachable only from that host. Every absolute URL in this document uses the shared host; if you loaded this file from somewhere else, substitute that origin everywhere below. Sending to `app.tamarind.bio` from a tenant account does not error — the request succeeds and the work lands where that user will never see it. - **Auth:** every request sends the header `x-api-key: `. - **Get a key:** sign in at https://app.tamarind.bio and create an API key in account/API settings. Read it from an env var (e.g. `TAMARIND_API_KEY`); never hardcode or commit it. - **No SDK:** there is no official Python package. Use `requests` or `curl`. (The PyPI package named `tamarind` is an unrelated project — do not install it.) - **Billing:** usage is metered in "weighted hours" (a single number per job that scales with runtime and GPU tier). New accounts get a free allotment. Jobs run minutes to hours, so submit and poll asynchronously — don't block on a job. First-call self-check (also the cheapest way to prove your key works): ```bash curl -s https://app.tamarind.bio/api/tools -H "x-api-key: $TAMARIND_API_KEY" | head -c 300 ``` A JSON array back = key is good. A `400` naming the key = missing or wrong key (this surface answers 400, not 401). ## Finding the right tool (read this carefully) `GET /tools` returns a JSON **array**. Each element looks like: ```json { "name": "alphafold", "displayName": "AlphaFold", "description": "Predict a protein's 3D structure from its sequence.", "github": "https://github.com/...", // optional "paper": "https://...", // optional "settings": [ { "name": "sequence", "required": true, "type": "sequence", "description": "...", "options": [...], "default": ... }, ... ] } ``` How to use it: - **Filter client-side.** Fetch the whole array and match on `name`, `displayName`, and `description`. The one query param that does something is `?custom=true`, which returns *only* your organization's custom tools (a different, smaller element shape) instead of the built-in catalogue. - **Match INTENT, not keywords.** The tool whose name literally echoes the user's wording is often the wrong pick. Anchor on the input they have, the output they need, and their constraints (speed, "no MSA", known pocket vs blind search), then scan descriptions for the tool that actually fits. - **In each `settings` param, only `name` and `required` are guaranteed.** `type`, `default`, `description`, and `options` appear only when applicable. Read them defensively (`param.get("type")`, not `param["type"]`). - Build the `settings` object you'll submit from this schema: include every `required` param, respect `options` (enums) and types, and keep the tool's `default` values unless the user asked to change them (defaults are tuned — generative tools default to large design counts on purpose). - **Do not put any settings key in your submit that you did not read from this tool's `settings` list.** A key name or value from memory (`target_sequence`, `num_designs`, `backbone_pdb`, …) is a guess — use the exact names the schema gives you (e.g. it may be `sequence`, or `pdbFile`, not what you'd assume). Before submitting, check every `required` param is present; if one has no value yet, ask the user or run the upstream step that produces it — don't submit a partial job. Selection principles that keep recommendations correct: 1. **Identify upstream dependencies first.** Many tools need more than the obvious field: some need a structure, an MSA, a prepared ligand, a fixed backbone, or a bounding box. If a required input isn't in hand, do the upstream step first (e.g. fold a structure before inverse-folding it). 2. **Name a primary tool plus 1–2 conditional alternatives** ("use X by default; Y if you don't have a target structure"). Avoid superlatives like "best"/"SOTA". 3. **Prefer the recognized standard tool** over a niche name that keyword-matches harder, especially for de novo design. 4. **Always validate generative output.** After any design/generative step (binder, sequence, structure, docked pose), add a validation step — re-predict the structure, compute interface/confidence metrics, or run a developability filter. 5. **Don't burn a job on a trivial local calculation.** For things you can compute in seconds locally (hydrophobicity, GRAVY, net charge, sequence stats, a Cα distance map, basic small-molecule descriptors), use a local library (BioPython, RDKit) instead of submitting a Tamarind job. 6. **Some tools are gated.** `/tools` is scoped to the caller's account, so a tool that doesn't appear is one this account can't run — don't recommend it. ### Orientation map (starting points, NOT the catalog) This maps common goals to search keywords and a few *recognized* anchor tools to look for. It is **not** the tool list — there are hundreds of tools and many strong alternatives to every name below. Treat these as starting points: always confirm a tool actually appears in your live `GET /tools` response and read its schema before using it, and scan the full array's `description` fields when nothing here fits. | Goal | Search `/tools` name/description for | Anchor tools to look for (verify live; not exhaustive) | |---|---|---| | Predict / co-fold a structure from sequence | "fold", "structure", "predict" | boltz, alphafold, chai, esmfold | | Design a de novo binder | "binder", "design", "diffusion" | bindcraft, rfdiffusion, boltzgen | | Engineer an antibody / nanobody | "antibody", "nanobody", "VHH" | rfantibody, immunebuilder, proteinmpnn (abmpnn model) | | Design sequence for a fixed backbone (inverse folding) | "mpnn", "inverse", "sequence design" | proteinmpnn, ligandmpnn, esm-if1 | | Score developability (stability, aggregation, solubility) | "developability", "stability", "aggregation", "solubility" | tap, thermompnn, netsolp | | Dock a ligand / predict binding affinity | "dock", "affinity", "binding" | autodock-vina, diffdock, boltz — classical (vina-style) docking also needs a search box (center + size); some tools need a ligand-format flag. Read the schema. | | Enzyme, small molecule, MD, nucleic acid, cryo-EM, other | search the relevant keyword | scan the full `GET /tools` array | A tool you don't see in `GET /tools` is not available to this account — don't recommend it. When more than one tool fits, prefer the recognized field-standard choice, then confirm with the tool's schema. **If the user names a tool that isn't in `GET /tools`, don't stop — recover.** The name may be fabricated, renamed, versioned, or gated (e.g. a request for `alphafold4-multimer-v3`, which doesn't exist). Do NOT submit the name as-is. Search the live catalog's `name` AND `description` fields for the closest tool that does the same job — for a multimer/complex fold, search `"multimer"`/`"complex"` and consider options like `boltz`, `alphafold`-multimer variants, or `chai` — pick the best live match, **submit that**, and tell the user you substituted `` because the requested name isn't in the catalog. Fulfilling the intent with a real tool beats failing on a name. Discover + read a schema in Python: ```python import os, requests H = {"x-api-key": os.environ["TAMARIND_API_KEY"]} BASE = "https://app.tamarind.bio/api/" tools = requests.get(BASE + "tools", headers=H).json() # full catalog hits = [t for t in tools if "fold" in (t["name"] + t["description"]).lower()] for t in hits[:10]: print(t["name"], "-", t.get("description", "")) schema = next(t for t in tools if t["name"] == "alphafold")["settings"] required = [p["name"] for p in schema if p.get("required")] print("required params:", required) ``` ## Endpoint reference | Method | Path | Purpose | |---|---|---| | GET | `/tools` | List available tools + inline parameter schemas. Full list; filter client-side. | | GET | `/tools/{name}/schema` | That tool's settings as JSON Schema — validate a payload before submitting. | | POST | `/submit-job` | Submit one job. Body: `jobName`, `type`, `settings`. | | POST | `/validate-job` | Dry-run one job's settings (free, no submit); returns `normalized`, or the missing/invalid fields. | | POST | `/submit-batch` | Submit many jobs of one tool at once. | | GET | `/jobs` | Inspect one job (`?jobName=`) or list jobs (no `jobName`). | | POST | `/result` | Get a presigned URL to download results (two-step). | | POST | `/stop-job` | Stop a running/queued job. Body: `jobName`. | | DELETE | `/delete-job` | Soft-delete a job (hides it; result files remain). Body: `jobName`. | | PUT | `/upload/{filename}` | Upload an input file (`--data-binary`; `?folder=` optional). | | GET | `/files` | List your uploaded files (flat array of filename strings). | | DELETE | `/delete-file` | Delete a file (`?filePath=`) or a folder (`?folder=`). | | GET | `/finetuned-models` | List your finetuned models (use `name` as `modelName`). | | POST | `/run-pipeline` | Run a saved multi-step pipeline by name (legacy; see "Pipelines and Molecules" below for the newer template/run API). | | POST | `/submit-pipeline` | Define a multi-stage pipeline inline and run it (legacy; body: `jobName`, `stages`). | | GET | `/models` | Your deployed custom models, plus your organization's. | | POST | `/deploy-model` | Deploy your own code as a tool (body: `name`, `entrypoint`). | | GET | `/usage-statistics` | Weighted-hours / job-count usage (org-scoped). | > Two newer surfaces — **Pipelines** and the **Molecules API** — live under > `/api/pipelines/...` and `/api/molecules/...` and are documented at the end of this guide. ### GET /tools and GET /tools/{name}/schema `/tools` returns the array described in "Finding the right tool" above — the source of truth for what exists and what parameters each tool takes. `/tools/{name}/schema` returns the SAME parameters as a standard JSON Schema document, scoped to your account. It covers built-in tools and custom tools deployed on the current platform; an older custom tool may 404 there, in which case read its `settings` from the `/tools` listing. Prefer it when you want to check a `settings` object before spending a submit: hand it to any JSON Schema validator and it will catch a missing required field, an unknown field name, a bad enum value, a wrong type, and a number outside its bounds when you send it as a JSON number. It is a SHAPE check, not the full validator, and two limits are worth knowing. A number sent as a STRING ("8" rather than 8) is accepted by the endpoint, which parses it — but JSON Schema applies `minimum`/`maximum` only to the number branch and cannot coerce, so an out-of-range value in string form passes here. And a sequence length limit is stated in the field's description rather than as `maxLength`, because the endpoint strips whitespace before counting and JSON Schema does not. Both are noted on the fields they affect. For those, and for the domain rules no schema can express (SMILES parsing, ligand codes, residue-range syntax, whether an uploaded file exists), use `POST /validate-job` — it runs the same validator `/submit-job` does and so cannot disagree with it. Domain types that JSON Schema can't express (a PDB file, a residue selection) travel as strings and keep their original type under `x-tamarind-type`. ### POST /submit-job ```json { "jobName": "my-protein-analysis", "type": "alphafold", "settings": { "sequence": "MKT..." } } ``` - `jobName` — unique per account. NORMALIZED, not validated: `cleanName` strips anything outside `[A-Za-z0-9_.-]` and turns whitespace into `_`, with no length check and no rejection. `"my run!"` is stored as `my_run`, so polling for the name you sent then reports an unknown job. Send a name that is already clean. - `type` — a tool `name` from `/tools`. Never hardcode; the list changes. - `settings` — tool-specific. **Every key must be the exact `name` string from that tool's `/tools` schema — never a synonym you assume.** The field is whatever the schema calls it (`sequence`, `pdbFile`, `receptorFile`, `ligandFormat`, …), not `target_sequence` / `backbone_pdb` / `receptor` from memory. A wrong key name is **not dropped and not rejected** — it is flagged internally and carried through, so the synonym never satisfies the field it was meant to be. What happens next depends on which field you meant. If it was a **required** field, the submit fails as *"missing required field"*, naming the field you thought you had just set — never the key you actually sent. If it was an **optional** field, nothing fails at all: the default applies silently and you pay for a job that ignored your setting. `POST /api/validate-job` is the one place the platform names the key you sent: it returns `unrecognized_settings: ["your_typo"]`, on a valid verdict as well as an invalid one. It costs nothing to run, so run it before any submit you did not copy verbatim from the schema. - **Do not submit until every `required` param has a value.** Many tools need more than one field — e.g. classical (vina-style) docking requires the receptor, the ligand, AND a search box (`center` + `size`); ProteinMPNN needs a `pdbFile` AND the residues to design. If a required field is unset, resolve it first: use the schema's `default` if it has one, otherwise ask the user, compute it, or run the upstream job that produces it — **never submit a job with a known-missing required field** just to see what happens; it fails validation. - **Don't switch tools just to dodge a required field.** If a tool's schema requires something you don't have (a docking box, a structure), the fix is to *supply* it — not to hop to a different tool that happens to omit it. Choose a different tool only for a real modeling reason (e.g. blind vs known-pocket docking), and check that its own schema doesn't require the same thing (it usually does). Avoiding the field is not the same as satisfying the task. - Response (200): a confirmation string, e.g. `my-protein-analysis submitted to queue.` - On a bad payload you get a `400` naming the specific problem (missing/invalid field, or an un-uploaded file). Better: call `POST /validate-job` first (below) to catch these for free, before you spend a job. ```python requests.post(BASE + "submit-job", headers=H, json={ "jobName": "my-af-job", "type": "alphafold", "settings": {"sequence": "MKTAYIAKQR"} }).text ``` ### POST /validate-job (free pre-flight — run this before /submit-job) Runs `/submit-job`'s **exact** validation **without submitting and at no cost**. Send the same `type` + `settings` you plan to submit (plus an optional `jobName`); it tells you whether the payload is good and, if not, exactly what's wrong — so you fix it before spending a job. `settings` is a single object (to check a batch, validate one representative job). It returns HTTP 200 (for a valid key); read the `valid` field: - **Valid** — `{ "valid": true, "normalized": { ... } }`. `normalized` is your settings with the schema's defaults filled in and internal/unknown keys stripped — i.e. exactly what to submit. Pass it straight to `/submit-job`. - **Invalid** — `{ "valid": false, "error": "", "missing_fields": [ { "name", "displayName", "description", "type", "example" }, ... ] }`. Treat `error` as the authoritative first problem (a missing field, an un-uploaded file, or a bad value): fix it, then re-validate, and repeat until `valid` is true. `missing_fields` is a best-effort list of required inputs still needed (each with an `example`), but it can be **empty even when `valid` is false** (the validator stops at the first `error`) — so don't read an empty `missing_fields` as "nothing else is wrong." An unknown or gated tool returns `{ "valid": false, "error": " is not supported in Tamarind API" }`. ```python v = requests.post(BASE + "validate-job", headers=H, json={"type": "esmfold", "settings": {"sequence": "MKT..."}}).json() if v["valid"]: requests.post(BASE + "submit-job", headers=H, json={"jobName": "my-job", "type": "esmfold", "settings": v["normalized"]}) else: print(v["error"], "— still need:", [f["name"] for f in v.get("missing_fields", [])]) ``` It's the cheapest way to catch a missing field, a wrong field name, or a bad enum before submitting — use it whenever you're unsure a payload is complete. ### POST /submit-batch Run ONE tool across MANY inputs. Body: - `batchName` (required) — unique name for the batch. - `type` (required) — the tool name (same values as `/submit-job`'s `type`). - `settings` (required) — a **non-empty array** of settings objects, one per job. - `jobNames` (optional) — array of names, **same length** as `settings`; omit to have jobs auto-named. ```json { "batchName": "my-batch", "type": "alphafold", "settings": [ { "sequence": "MKT..." }, { "sequence": "GVA..." } ], "jobNames": ["seq1", "seq2"] } ``` Note the shape difference from `/submit-job`: here `settings` is an **array** (a list of per-job settings), not a single object. Max 30,000 jobs per batch. A batch creates a **parent** job named `batchName`; poll the parent (see below). ### GET /jobs (response shape depends on the query) - **By name** — `GET /jobs?jobName=` returns the **job row object directly** (no `jobs` wrapper). Do not index `["jobs"][0]`. - **List** — `GET /jobs` (no `jobName`) returns `{ "jobs": [...], "startKey": "...", "statuses": {...} }`. For >1000 jobs, loop with the returned `startKey` and `limit`. Useful query params: `jobName`, `batch=` (a batch's subjobs), `organization=true` (all org jobs), `includeSubjobs=true`, `jobEmail=`, `startKey`, `limit`. Each job row includes `JobName`, `Type`, `JobStatus`, `Created`, `Started`, `Completed`, `Settings` (JSON string), and `Score` (a string — usually a single summary/confidence number when applicable, sometimes empty; see below). A **batch parent** row has `Type: "batch"` and carries `batchStatus` — one of `Running`, `Aggregating`, `Complete`, `Stopped`, `AggregationFailed`. Poll `batchStatus` on the parent, NOT the subjobs' `JobStatus` (subjobs read `Complete` before the aggregated output is ready). A complete parent fetched by name also includes a presigned `resultUrl`. ### POST /result (two-step download) `POST /result` returns the presigned URL as a **JSON-encoded string** (the URL wrapped in double quotes). Strip the quotes, then GET that URL to download the results zip: ```python url = requests.post(BASE + "result", headers=H, json={"jobName": "my-af-job"}).text.strip('"') open("my-af-job.zip", "wb").write(requests.get(url).content) ``` **A `202 {"status": "preparing"}` is not a failure.** The archive is still being built — wait and retry the same call. Treat 202 as "not ready yet", never as an error and never as a reason to re-submit the job. The snippet above assumes a 200; branch on the status code before stripping quotes, or the retry body is written to disk as if it were a URL. Optional body fields: `fileName` (download one file instead of the zip), `pdbsOnly: true` (PDB outputs only), `jobEmail` (a teammate's job in your org). ### Reading a job's summary score (don't download files for a number) A tool's headline confidence/quality number (e.g. pLDDT, an affinity, a design score) is usually **already on the job row you polled** — the `Score` field. It is a **string**, typically a single number (sometimes empty/absent), **not** a dict of named sub-metrics — so parse it defensively, don't index into it: ```python job = requests.get(BASE + "jobs", headers=H, params={"jobName": "my-af-job"}).json() raw = job.get("Score") score = float(raw) if raw not in (None, "") else None # a string number, or absent ``` For anything richer than that one number (per-residue confidence, full metric tables), read the actual output **files** via `POST /result`. Use `Score` for the single summary number; use `/result` for the files — don't fabricate a download just to get a scalar. ### POST /stop-job / DELETE /delete-job Both take `{ "jobName": "" }` in the body. `stop-job` halts a running/queued job; `delete-job` marks it deleted and hides it from listings. It is a SOFT delete — the job's result files are not removed from storage. ### PUT /upload/{filename} and GET /files Upload a file to reference as an input: ```bash curl -X PUT "https://app.tamarind.bio/api/upload/target.pdb?folder=inputs" \ -H "x-api-key: $TAMARIND_API_KEY" \ -H "Content-Type: application/octet-stream" -L \ --data-binary @./target.pdb ``` `?folder=` is optional; without it the file lands in your root and is referenced as `target.pdb` (with it, `inputs/target.pdb`). `GET /files` lists your uploaded files as a flat array of filename strings. `DELETE /delete-file` takes `?filePath=` or `?folder=` (one, not both). ### GET /finetuned-models, POST /run-pipeline, GET /usage-statistics - `GET /finetuned-models` (optional `?type=` filter, e.g. `plm-finetune`) lists your finetuned models. Use a model's `name` as the `modelName`/`type` when submitting. - `POST /run-pipeline` runs a saved pipeline. The body is `{ "jobName": "...", "pipelineName": "...", "initialInputs": [...] }` — `initialInputs` is an ARRAY of uploaded `.pdb` filenames or raw sequences, matching the pipeline's configured input type, and their basenames must be unique. This is the **legacy** pipeline call on the core `/api/` base; the newer **Pipelines (v4)** template/run REST API is documented under "Pipelines and Molecules" below. - `GET /usage-statistics` (optional `statistic` = `hours`|`weighted_hours`|`jobs`, `scope` = `user`|`organization`) returns usage. It is **org-scoped** — rows cover every member of your organization, so don't read a teammate's row as your own. ## File-handling rules (critical — these cause most 400s) - **A file-typed field with a plain string value is treated as INLINE CONTENT, not a path.** Passing `"target.pdb"` as file content sends the literal text `target.pdb`, not the file. To reference an **uploaded** file, first `PUT /upload/target.pdb`, then pass the **bare filename** `target.pdb`. - **A redundant `{email}/` prefix is now stripped for you**, so `{email}/target.pdb` and `target.pdb` both resolve. Prefer the bare filename. - To reference a **prior job's output** as an input, use `JobName/path/to/file.ext`. - **Never pass platform-internal routing fields** (`submit_method`, `msa`, `monomer_msa`) — the platform sets these. ### Chaining jobs (use a prior job's output as the next input) When a tool needs a structure you don't have yet (e.g. inverse folding / ProteinMPNN needs a `pdbFile`, but the user only gave a sequence), run the upstream job first, then reference its output **by path** — `"/"` — in the next job's settings. No download-and-reupload needed: ```python # 1. Fold the sequence (discover the fold tool + confirm its schema via GET /tools first) submit("fold-step", "esmfold", {"sequence": "MKT..."}) # then poll fold-step to Complete # 2. Inverse-fold the produced structure — reference the fold job's output PDB directly. # Confirm proteinmpnn's real field names via its /tools schema (it needs a pdb file # AND the residues to design — names come from the schema, not memory). submit("design-step", "proteinmpnn", {"pdbFile": "fold-step/output.pdb", ...}) ``` ## Polling and the job lifecycle `JobStatus` is exactly one of `In Queue`, `Running`, `Complete`, `Stopped`, or `Deleted` — **there is no `Error` or `Failed` status**, so don't poll for one. Poll `GET /jobs?jobName=` until the status is terminal (`Complete` / `Stopped` / `Deleted`), then read the job's `Score`/results to confirm it produced valid output. For batches poll the parent's `batchStatus` until `Complete` (or `Stopped` / `AggregationFailed`). Jobs are addressable by name, so you can re-attach and poll from any process later. Use a sensible interval (e.g. 15–60s) and don't block the whole run on a single job. ```python import time TERMINAL = ("Complete", "Stopped", "Deleted") while True: job = requests.get(BASE + "jobs", headers=H, params={"jobName": "my-af-job"}).json() if job.get("JobStatus") in TERMINAL: break time.sleep(30) ``` ## Status codes | Code | Meaning | |---|---| | 200 | Success | | 400 | Bad request — invalid parameters/settings, an un-uploaded file (the message names the field), an unknown job, **or a missing/invalid `x-api-key`** | | 403 | Budget exceeded (org/team), or the tool is not available to your account | | 400 | ...also `jobName` already in use. The ordinary duplicate pre-check (submit-job) answers 400 with a message naming the job, NOT 409 | | 409 | Lost a concurrent-submit lock race on the same `jobName`, or a `batchName` still held by its 15-minute lock. Rarer than the 400 above | | 500 | Server error | Two things to note, because they differ from the usual REST conventions: a bad API key answers **400**, not 401, and an unknown job answers **400**, not 404 — in both cases the body says which. Don't branch on 401/404 here. `POST /validate-job` is the exception in the other direction: it answers **200** even when the payload is invalid, so read its `valid` field rather than the status. ## End-to-end example (discover → validate → submit → poll → download) ```python import os, time, requests H = {"x-api-key": os.environ["TAMARIND_API_KEY"]} BASE = "https://app.tamarind.bio/api/" # 1. Discover — find a structure-prediction tool by INTENT, never from memory. tools = requests.get(BASE + "tools", headers=H).json() tool = next(t for t in tools if t["name"] == "alphafold") # pick from live catalog required = [p["name"] for p in tool["settings"] if p.get("required")] print("submitting", tool["name"], "with required params", required) # 2. Build settings by ITERATING THE SCHEMA — the keys come from the schema, not you. # Put the raw values you have in `inputs`, then let the loop key each onto the # schema's exact field name. This makes a wrong synonym (target_sequence, # backbone_pdb, …) impossible: if your input doesn't match a real field name it # simply won't be placed, and the required-check below will stop you. inputs = {"sequence": "MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQ"} # the raw values the user gave you settings = {} for p in tool["settings"]: # drive construction from the live schema if p["name"] in inputs: settings[p["name"]] = inputs[p["name"]] elif p.get("default") is not None: settings[p["name"]] = p["default"] # keep the schema's tuned default missing = [p["name"] for p in tool["settings"] if p.get("required") and p["name"] not in settings] assert not missing, f"{tool['name']} still needs {missing} — get these from the schema (ask the user / run the upstream step that produces them). Do NOT submit a partial job." # 3. Validate for free before spending a job — use the normalized payload it returns. v = requests.post(BASE + "validate-job", headers=H, json={"type": tool["name"], "settings": settings}).json() assert v["valid"], f"validation failed: {v.get('error')} (missing: {[f['name'] for f in v.get('missing_fields', [])]})" settings = v["normalized"] # 4. Submit name = "demo-fold-1" requests.post(BASE + "submit-job", headers=H, json={"jobName": name, "type": tool["name"], "settings": settings}).raise_for_status() # 5. Poll to terminal (statuses: In Queue, Running, Complete, Stopped, Deleted) while True: job = requests.get(BASE + "jobs", headers=H, params={"jobName": name}).json() if job.get("JobStatus") in ("Complete", "Stopped", "Deleted"): break time.sleep(30) # 6. Download if job["JobStatus"] == "Complete": url = requests.post(BASE + "result", headers=H, json={"jobName": name}).text.strip('"') open(f"{name}.zip", "wb").write(requests.get(url).content) ``` ## Pipelines and Molecules (a separate, newer API surface) Everything above is the core **job API**, based at `https://app.tamarind.bio/api/`. Tamarind also has two newer REST surfaces — **Pipelines (v4)** for multi-step workflows and the **Molecules API** for storing and querying molecules — on a DIFFERENT base: > **Base URL:** `https://app.tamarind.bio/api/` — the same base as everything above. > Pipelines live under `/api/pipelines/...` and molecules under `/api/molecules/...`. Both are gated by account feature flags (they return `404` for accounts that don't have them enabled). The field-level request/response shapes — especially the pipeline description — live in the interactive docs (https://app.tamarind.bio/api-docs, the "Pipelines" and "Molecules Database" sections) and the OpenAPI spec; read those for detail. This section is the map and the workflow. ### Pipelines (v4): build a template, run it, read the outputs A **template** is a reusable, versioned graph of tool steps — its "pipeline description" (IR). You save a template once, then **run** it with concrete inputs; a run's per-step outputs land as **molecule groups** (see the Molecules API below). This is NOT the same as the legacy `POST /run-pipeline` on the core API (which runs a pre-saved pipeline by name) — v4 is the template/run REST API. | Method | Path | Purpose | |---|---|---| | GET | `/pipelines/templates` | List your templates. | | POST | `/pipelines/templates` | Create a template. Body: `name`, `pipeline` (the IR), optional `description`. Returns the template `id` + its `inputs`. | | GET | `/pipelines/templates/{id}` | Get a template — includes `inputs` (the slots a run must bind) for the version a default run uses. | | POST | `/pipelines/submit` | Start a run — EITHER from an existing template (`templateId` + optional `version`) OR from an inline `pipeline` graph (creates a template you own, then runs it). Body: `bindings` (inputs), optional `settings` (per-node overrides, limited to a referenced template's editable settings), `config`, `idempotencyKey`, and `name` (inline). Returns a run `id` + `status` (+ `templateId`). Replaces the old `/templates/{id}/runs`. | | POST | `/pipelines/validate` | Validate a would-be RUN without starting/persisting it — SAME body as `/pipelines/submit`. Returns `{ valid, errors }`. A referenced template runs the full pre-submit check incl. engine resolution; an inline pipeline is validated in memory. | | POST | `/pipelines/templates/{id}/validate` | Validate a TEMPLATE against its own reference groups (`metadata.defaultGroup`) — no run/bindings. Checks it's runnable as authored (structure, tool settings, reference-group chains). Returns `{ valid, errors }`. | | GET | `/pipelines/runs` · `/pipelines/runs/{id}` | List runs · poll one run (`status`, per-step `steps`). | | POST | `/pipelines/runs/{id}/stop` | Stop a run. | | POST | `/pipelines/templates/{id}/publish` · `/duplicate` | Publish a version · duplicate a template. | | GET | `/pipelines/templates/{id}/versions` | List a template's versions. | Workflow: **build** the pipeline description (get its shape from the interactive docs' "The pipeline description" page — it's large and authored, so don't invent it) → `POST /pipelines/templates` to save (the response carries the `id` + the `inputs` a run must bind) → `POST /pipelines/validate` with `{templateId, bindings}` to pre-flight → `POST /pipelines/submit` with those `bindings` → poll `GET /pipelines/runs/{id}` until `status` is terminal → read each step's output molecule groups via the Molecules API. (Or skip the save: `POST /pipelines/submit` with an inline `pipeline` creates the template and runs it in one call.) ```python BASE = "https://app.tamarind.bio/api" pipeline = { ... } # the pipeline IR — get its shape from the "pipeline description" docs page tpl = requests.post(BASE + "/pipelines/templates", headers=H, json={"name": "my-pipeline", "pipeline": pipeline}).json() tid = tpl["id"] tpl["inputs"] # the slots a run must bind (already on the create/get response — no extra call) bindings = { ... } # one binding per input slot, keyed by the slot's node id # Pre-flight the exact submit (optional). Returns {valid, errors}: valid:true + empty errors = runnable; # otherwise each error carries a stable code (+ optional node). (An over-budget run validates true but # submit returns 403.) requests.post(f"{BASE}/pipelines/validate", headers=H, json={"templateId": tid, "bindings": bindings}).json() rid = requests.post(f"{BASE}/pipelines/submit", headers=H, json={"templateId": tid, "bindings": bindings}).json()["id"] # poll GET /pipelines/runs/{rid} until run["status"] is terminal, then read outputs (below) ``` ### Molecules API: store, organize, and query molecules A managed store of molecules (proteins, ligands, nucleic acids) organized into **groups**. Three ideas to hold: - **A molecule's id is DERIVED from its chains**, so re-uploading the same molecule is a find-or-attach — duplicates are impossible. A molecule is one or more chains: a protein sequence, a SMILES for a small molecule, etc., carried in its `entity`. - **A group can carry a schema** (typed columns — affinity, expression, …). A schema-bound group is strict: non-conforming molecules added to it are rejected. - **Scores accumulate per run** — job/pipeline outputs attach scores to the molecules they touch, readable per group. | Method | Path | Purpose | |---|---|---| | GET | `/molecules/groups` | List your groups (defaults to your own; org-wide is opt-in). | | POST | `/molecules/groups` | Create a group. Body: `name`, optional `schemaId`, `tags`, `metadata`. Returns `id`. | | GET | `/molecules/groups/{id}` | Get a group. | | GET | `/molecules/groups/{id}/molecules` | List a group's molecules and their scores. | | DELETE | `/molecules/groups/{id}/molecules` | Remove molecules from a group. | | POST | `/molecules/upload` | Add molecules inline. Body: `groupId`, `molecules: [{ entity, name?, tags? }]`. Returns `202` + `importId` — queued; poll it before reading the molecules back. | | POST | `/molecules/import-file` | Import from a file (CSV/FASTA/…): `fileName`, `groupId`, `fileFormat`. Then `GET /molecules/imports/{id}` → `POST /molecules/imports/{id}/commit`. | | GET · DELETE | `/molecules/{id}` | Get · delete one molecule. | | GET | `/molecules/jobs/{job_id}/molecules` | The molecules a job produced. | | GET · POST | `/molecules/schemas` | List · create schemas. Create body: `name`, `fields`. | | GET · PATCH | `/molecules/schemas/{id}` | Get · update a schema. | Workflow: `POST /molecules/groups` (optionally with a `schemaId`) → add molecules with `POST /molecules/upload` (inline) or the `import-file` → `commit` flow (from a CSV/FASTA) → read them back with `GET /molecules/groups/{id}/molecules`. ```python import time gid = requests.post(BASE + "/molecules/groups", headers=H, json={"name": "my-binders"}).json()["id"] up = requests.post(BASE + "/molecules/upload", headers=H, json={ "groupId": gid, "molecules": [{"name": "cand-1", "entity": { ... }}], # entity = the chains; see the Molecules docs }) # Upload is QUEUED, not synchronous: you get 202 with an importId, and the molecules are not # readable until the worker has ingested them. `moleculeIds` is already exact — it is # content-addressed from what you sent — so record it now and poll only to learn WHEN the # molecules become readable. Never re-upload on a 202: the batch is already on the queue. # There is no synchronous variant: polling the importId is the only way to learn an # upload finished. if up.status_code == 202: imp = up.json()["importId"] while True: status = requests.get(f"{BASE}/molecules/imports/{imp}", headers=H).json()["status"] if status == "ingested": break # `failed` is terminal too — stopping on it and reading the group anyway would # report an empty (or stale) group as a successful upload. if status in ("failed", "error"): raise RuntimeError(f"ingestion {status} for import {imp}") time.sleep(2) mols = requests.get(f"{BASE}/molecules/groups/{gid}/molecules", headers=H).json() ``` ## Live sources (fetch these; don't trust a stale copy) - **Live tool catalog:** `GET https://app.tamarind.bio/api/tools` — the source of truth for what tools exist and their parameters. - **OpenAPI spec:** https://app.tamarind.bio/openapi.yaml — exact request/response shapes for the core job endpoints. - **Interactive docs:** https://app.tamarind.bio/api-docs - **This guide:** https://app.tamarind.bio/llms-full.txt