# 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 https://app.tamarind.bio/api/tools`. 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. `https://app.tamarind.bio/tools.json` (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** — use a clean `jobName` of at most 200 characters, then call `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/` - **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 and at most 200 characters so the compatibility endpoint can look it up directly. For an existing name longer than 200 characters, page through `GET /jobs` with `startKey` and match `JobName`. - `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 r = requests.post(BASE + "validate-job", headers=H, json={"type": "esmfold", "settings": {"sequence": "MKT..."}}) # Status FIRST. A missing/invalid key answers 400 with {"valid": false, "error": # "Missing or incorrect api key"} — reading `valid` alone reports your SETTINGS as # wrong and sends you editing a payload that was fine. r.raise_for_status() v = r.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]`. This lookup accepts names up to 200 characters; find an existing longer name by paging the list and matching `JobName`. - **List** — `GET /jobs` (no `jobName`) returns `{ "jobs": [...], "startKey": "...", "statuses": {...} }`. Each page contains at most 1,000 jobs. Continue with the returned `startKey` until it is omitted. - **Batch children** — `GET /jobs?batch=` returns the batch's child jobs in the list response shape. Poll the parent with `jobName=` for `batchStatus`. Supported query params are `jobName`, `batch`, `startKey`, `limit`, `batchOnly`, `includeSubjobs`, `includeSequences`, `jobEmail`, and `organization`. Use `batchOnly=true` to return only batch parents and `includeSubjobs=true` to include batch children in ordinary list pages. `includeSequences` is accepted for legacy clients; run settings are already returned in `Settings`. Use `jobEmail` to list or look up jobs for one organization member when sharing permits it, or `organization=true` to list visible jobs across the organization. Each job row includes `JobName`, `Type`, `JobStatus`, and `Created`. It may also include `Started`, `Completed`, `Batch`, `TamarindSchemaVersion`, `WeightedHours`, and `batchStatus` for batch parents. Responses selected with `jobEmail` also include `User`. `Settings`, scores, and the batch aggregation error (when present) are returned. Logs, typed input objects, and result URLs are not returned by this compatibility route. ### 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). For a `Stopped` or `Failed` job, request `{ "jobName": "", "fileName": "output.log" }`; `output.log` is the one file `/result` permits before completion. ### Reading job results The Jobs compatibility response reports lifecycle metadata only. Use `POST /result` to download a completed job's output files. For normalized molecule scores, query the Molecules API by `jobName`. ### 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 one of `In Queue`, `Running`, `Complete`, `Stopped`, or `Failed`. Poll `GET /jobs?jobName=` until the status is terminal — **treat `Complete`, `Stopped`, and `Failed` as terminal** — then inspect the results to confirm the job produced useful output. Keep all three terminal values in the poll condition. Status alone does not prove useful scientific output: download results after `Complete`, and request `output.log` after `Stopped` or `Failed`. **A pipeline RUN and its execution rows are not pollable here.** They are excluded from this api-key export (`isPublicApiVisible`), so `GET /jobs?jobName=` answers a **404 `job_not_found` problem** — the same result as a misspelled job name. Poll `GET /pipelines/runs/{run_id}` for the run itself. That surface has its OWN, disjoint vocabulary — lowercase `queued` / `running` / `finished` / `partial` / `stopped` / `failed` — so a `failed` there is not the `JobStatus` above, and code that switches on one will not handle the other. Compatibility jobs with names up to 200 characters are addressable by name, so you can re-attach and poll from another process later. Page the bounded list to find existing longer names. 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", "Failed") while True: response = requests.get(BASE + "jobs", headers=H, params={"jobName": "my-af-job"}) response.raise_for_status() job = response.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), or a missing/invalid `x-api-key` on classic endpoints | | 401 | Missing/invalid `x-api-key` on `/jobs`, `/models`, `/finetuned-models`, or `/usage-statistics` | | 404 | Unknown `jobName` on `/jobs` | | 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 | The auth status is not uniform: a bad API key answers **401** on `/jobs`, but **400** on classic submit, result, file, tool, and validation endpoints. An unknown job on `/jobs` answers **404**. Branch on "not 2xx", not on a specific auth status. `POST /validate-job` answers **200** even when the payload is invalid, so the status alone cannot tell you the verdict. Check the status FIRST, then read `valid` — in that order. An auth failure wears the same clothes as a science failure: a keyless call answers **400** with `{"valid": false, "error": "Missing or incorrect api key"}`. Read `valid` on its own and you will conclude the payload is wrong and start mutating settings to fix a missing key. Treat non-2xx as auth/transport, and only interpret `valid` on a 200. ## 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. r = requests.post(BASE + "validate-job", headers=H, json={"type": tool["name"], "settings": settings}) r.raise_for_status() # status FIRST: a bad key is a 400 that also says valid=false v = r.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 (In Queue, Running, Complete, Stopped, Failed) while True: r = requests.get(BASE + "jobs", headers=H, params={"jobName": name}) r.raise_for_status() job = r.json() if job.get("JobStatus") in ("Complete", "Stopped", "Failed"): 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) else: log_response = requests.post(BASE + "result", headers=H, json={"jobName": name, "fileName": "output.log"}) log_response.raise_for_status() log_url = log_response.json() log_download = requests.get(log_url) log_download.raise_for_status() open(f"{name}.log", "wb").write(log_download.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/imports/{id}` | Poll status · cancel an unclaimed import or delete its terminal lifecycle and source objects. | | 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. The authoritative molecule IDs are returned by # the completed import. 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: result = requests.get(f"{BASE}/molecules/imports/{imp}", headers=H).json() if result["status"] == "ingested": molecule_ids = result["moleculeIds"] break # `failed` and `cancelled` are terminal too — stopping on either and reading the group anyway would # report an empty (or stale) group as a successful upload. if result["status"] in ("failed", "cancelled", "error"): raise RuntimeError(f"ingestion {result['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` - **Get an API key:** `https://app.tamarind.bio/api-docs/api-key` - **This guide:** `https://app.tamarind.bio/llms-full.txt`