# Tamarind Pipelines — Schema Guide for Agents

This document teaches an AI agent how to author a **Tamarind pipeline** as JSON and
submit it through the public API. A pipeline is a directed acyclic graph (DAG) of nodes
that chains scientific tools (AlphaFold, Boltz, RFdiffusion, ProteinMPNN, docking, …) so
one tool's outputs become another's inputs.

- **Machine-readable schema:** `https://app.tamarind.bio/pipeline-ir.schema.json`
  (JSON Schema 2020-12, `$id: https://tamarind.bio/schemas/pipeline-v1.json`). Validate
  your document against it before submitting.
- **API base URL:** `https://app.tamarind.bio/api`
- **Auth:** every request needs the header `x-api-key: <YOUR_API_KEY>`.

---

## 1. Mental model

A pipeline document is a **schema version plus a map of node ids to nodes**. The map keys
are *your* node ids — they must be unique, and are how nodes reference each other. There
is **no separate edge list**: the topology lives entirely inside each node's `inputs`,
where a node names the upstream nodes that feed it.

```json
{
  "schema_version": "1.0",
  "nodes": {
    "<node_id>": { "kind": "user_input" | "tool" | "filter" | "container", ... }
  },
  "metadata": {}
}
```

- `schema_version` *(optional)* — the constant string `"1.0"`; defaults to `"1.0"` when omitted.
- `nodes` **(required)** — object mapping a unique node id → a node.
- `metadata` *(optional)* — free-form object.

Molecules flow through the graph **per molecule**: each molecule advances to a downstream
node as soon as its upstream job finishes, rather than waiting for the whole batch (except
at barriers — see §7).

---

## 2. Node kinds

Every node has a `kind` discriminator. There are four kinds.

### 2.1 `user_input` — an entry slot

Brings molecules or a file *into* the pipeline. It is a source: it has **no** `inputs`.
The actual molecule/file is bound **per run** at submit time, not baked into the template.

| Field | Required | Notes |
|---|---|---|
| `kind` | ✅ | const `"user_input"` |
| `label` | — | display name, e.g. `"Structures"` |
| `flow` | — | `"molecule"` (default) or `"file"` |
| `molecule_type` | — | `"protein"` (default), `"small_molecule"`, `"nucleic_acid"` (molecule flow only) |
| `contains_structure` | — | `true` if the input carries a 3D structure (pdb/cif), not just a sequence |
| `chain_labels` | — | `{ "A": "heavy chain", "B": "light chain" }` — free-text description per reference chain |
| `metadata` | — | see the reference-group rule below |

> **⚠️ Reference group is REQUIRED on molecule inputs.** Every `flow: "molecule"`
> `user_input` MUST name a reference molecule group in `metadata.defaultGroup` — the
> example molecules the template is authored against. Creating or publishing a template
> whose molecule input lacks it is **rejected `422 input-missing-reference`**. `flow:
> "file"` inputs are **exempt** (bound entirely per run). The reference group is only a
> *default*: each run binds its own group at submit.

```json
"input": {
  "kind": "user_input",
  "label": "Structures",
  "contains_structure": true,
  "metadata": { "defaultGroup": "<groupId>" }
}
```

### 2.2 `tool` — runs a scientific tool

| Field | Required | Notes |
|---|---|---|
| `kind` | ✅ | const `"tool"` |
| `tool` | ✅ | Tool URI (see below) |
| `inputs` | ✅ | object mapping a **port name** → `InputPort` |
| `settings` | — | tool parameters, validated against the tool's field schema |
| `combination_mode` | — | for 2-input tools: `"all"` (cartesian) or `"1:1"` (matched) |
| `combination_primary_field` | — | which port the combined pose attaches to (e.g. `"ligandFile"`) |
| `container` | — | id of a container node this tool belongs to |
| `metadata` | — | e.g. `editableSettings` (which settings a submitter may change) |

**Tool URI grammar** (`tool` field): `name[@version]` |
`tamarind://name[@version]` | `mcp://provider/tool` | `user:org/tool[@version]`. In
practice a bare tool name like `"proteinmpnn"`, `"alphafold"`, `"boltz"`, `"boltzgen"`,
`"rfdiffusion"` is what you use.

**Port names** are tool-specific (e.g. ProteinMPNN's `pdbFile`, AlphaFold's `sequence`,
docking's `ligandFile`), and so are a tool's settings. **Always check the tool's schema before
authoring a `tool` node** rather than guessing:

- `GET /api/tools` — list the available tool names.
- `GET /api/tools/{name}/schema` — that tool's field schema (JSON Schema). From it, read:
  - the **input port names** to key `inputs` by;
  - each **setting**: its `type`, allowed values/range, default, and whether it's `required`;
  - any **residue/chain selection field** — if one is `required`, the run must supply it at
    submit via the binding's `residuesByChain` / `chainMapping` (see §6), NOT as a `setting`.

Send `x-api-key` on both. The names in this guide's examples are illustrative — the schema is
the authoritative source for ports and required settings per tool.

```json
"design": {
  "kind": "tool",
  "tool": "proteinmpnn",
  "inputs": { "pdbFile": [ { "node": "input" } ] },
  "settings": { "numDesigns": 8 }
}
```

### 2.3 `filter` — narrow or combine flows

Runs inline in the orchestrator (not a job). It gathers its sources and narrows them by an
ordered list of rules.

| Field | Required | Notes |
|---|---|---|
| `kind` | ✅ | const `"filter"` |
| `inputs` | ✅ | object mapping port → `InputPort` (conventionally one port named `in`) |
| `intersect` | — | const `true` — intersect sources by molecule identity (needs **≥2** sources). Absent ⇒ union |
| `filters` | — | ordered array of `Filter` (jsonlogic or top_k) |
| `label`, `container`, `metadata` | — | |

A filter must **do something**: either `intersect: true`, or carry ≥1 rule in `filters`.

### 2.4 `container` — a named group of member nodes

A container groups several nodes behind **one shared input handle and one shared output
handle**. Connecting another node or container to a container's input is the same as
connecting it to **every member node** inside; wiring a downstream node from the
container's output is the same as wiring it from each member separately. It lets you fan
one source into a set of tools (and gather their outputs) with a single edge. Members are
tagged `container: <this id>`. Containers are an authoring convenience — they're expanded
into those plain per-member connections before a run, so the executed pipeline is
identical to one wired without them.

---

## 3. Wiring nodes together (`InputPort` / `NodeRef`)

An **`InputPort`** is a **non-empty, unique array** of `NodeRef` objects. Listing several
sources on one port **unions** them.

A **`NodeRef`** names an upstream node:

```json
{ "node": "<upstream_node_id>", "select": { "chains": ["A"] } }
```

- `node` **(required)** — the upstream node id whose output feeds this slot.
- `select` *(optional)* — a within-group slice. `select.chains` is **always a list** of
  reference chain labels (one label for a single chain, several for a multimer subset).

**Union example** — two designers feeding one fold:

```json
"fold": {
  "kind": "tool", "tool": "alphafold",
  "inputs": { "sequence": [ { "node": "mpnn" }, { "node": "abmpnn" } ] }
}
```

---

## 4. Filters in detail

`filters` is an ordered array; each entry is one of:

### 4.1 `jsonlogic` — a predicate over molecule attributes

```json
{ "kind": "jsonlogic",
  "rule": { ">": [
    { "var": { "producerNodeId": "fold", "columnName": "overall_confidence" } },
    0.8
  ] } }
```

The `rule` is a [JsonLogic](https://jsonlogic.com/) expression. Leaves are literals or
`{ "var": { "producerNodeId": "<node-id>", "columnName": "<column>" } }`; composites
are `{ "op": [operands] }`. Use `and`/`or`/`!` to build arbitrary boolean logic over
score columns. `producerNodeId` is the exact upstream pipeline node id, not a tool name;
this keeps two runs of the same tool unambiguous.

### 4.2 `top_k` — a ranked cut

```json
{ "kind": "top_k",
  "k": 10,
  "rank_by": [ {
    "field": { "producerNodeId": "fold", "columnName": "overall_confidence" },
    "order": "desc"
  } ] }
```

- `k` **(required)** — integer ≥ 1, how many to keep.
- `rank_by` **(required)** — non-empty array of `{ field, order }`; `order` is `"asc"` or
  `"desc"`. `field` is a `FilterFieldRef`: `{ producerNodeId, columnName }`.
  `columnName` is the exact score/CSV header and may contain spaces and units, e.g.
  `"Energy (kcal/mol)"`.

`top_k` is valid only on a source that carries a rankable score.

---

## 5. Full worked examples

Each example is a **valid pipeline** once you replace `<groupId>` on every molecule
input's `metadata.defaultGroup` with a real group id from `GET /api/molecules/groups`.
The `settings` shown are a few real keys — call `GET /api/tools/{name}/schema` for the
full, authoritative list for each tool.

### 5.1 Diversify and predict structures

Diversify an input structure's sequence with ProteinMPNN, then predict a structure for
each design with AlphaFold.

```json
{
  "schema_version": "1.0",
  "nodes": {
    "input": {
      "kind": "user_input", "label": "Structures",
      "contains_structure": true,
      "metadata": { "defaultGroup": "<groupId>" }
    },
    "diversify": {
      "kind": "tool", "tool": "proteinmpnn",
      "inputs": { "pdbFile": [ { "node": "input" } ] },
      "settings": { "numSequences": 8, "temperature": 0.2 }
    },
    "predict": {
      "kind": "tool", "tool": "alphafold",
      "inputs": { "sequence": [ { "node": "diversify" } ] },
      "settings": { "numModels": "5" }
    }
  }
}
```

### 5.2 With a confidence filter in between

The same line with a filter inserted **between** diversify and predict, keeping only
high-confidence designs before folding.

```json
{
  "schema_version": "1.0",
  "nodes": {
    "input": {
      "kind": "user_input", "label": "Structures",
      "contains_structure": true,
      "metadata": { "defaultGroup": "<groupId>" }
    },
    "diversify": {
      "kind": "tool", "tool": "proteinmpnn",
      "inputs": { "pdbFile": [ { "node": "input" } ] },
      "settings": { "numSequences": 16, "temperature": 0.2 }
    },
    "confident": {
      "kind": "filter",
      "inputs": { "in": [ { "node": "diversify" } ] },
      "filters": [
        { "kind": "jsonlogic",
          "rule": { ">": [
            { "var": { "producerNodeId": "diversify", "columnName": "overall_confidence" } },
            0.8
          ] } }
      ]
    },
    "predict": {
      "kind": "tool", "tool": "alphafold",
      "inputs": { "sequence": [ { "node": "confident" } ] },
      "settings": { "numModels": "5" }
    }
  }
}
```

### 5.3 De novo binder design (VHH)

Design de novo VHH nanobody binders against a target antigen with BoltzGen, then score
each design in parallel for immunogenicity (TNP) and thermostability (Tempro).
`select.chains` picks the designed binder chain out of the target+binder complex.

```json
{
  "schema_version": "1.0",
  "nodes": {
    "input": {
      "kind": "user_input", "label": "Target antigen",
      "contains_structure": true,
      "metadata": { "defaultGroup": "<groupId>" }
    },
    "design": {
      "kind": "tool", "tool": "boltzgen",
      "inputs": { "targetFile": [ { "node": "input" } ] },
      "settings": { "targetChains": ["A"], "numDesigns": 100 }
    },
    "immunogenicity": {
      "kind": "tool", "tool": "tnp",
      "inputs": { "sequence": [ { "node": "design", "select": { "chains": ["B"] } } ] }
    },
    "thermostability": {
      "kind": "tool", "tool": "tempro",
      "inputs": { "sequence": [ { "node": "design", "select": { "chains": ["B"] } } ] }
    }
  }
}
```

### 5.4 Small-molecule scoring

Score a set of small molecules in parallel for ADMET properties, aqueous solubility, and
logP. Every input molecule flows into each scorer independently.

```json
{
  "schema_version": "1.0",
  "nodes": {
    "input": {
      "kind": "user_input", "label": "Small molecules",
      "molecule_type": "small_molecule",
      "metadata": { "defaultGroup": "<groupId>" }
    },
    "admet": {
      "kind": "tool", "tool": "admet",
      "inputs": { "smilesStrings": [ { "node": "input" } ] }
    },
    "solubility": {
      "kind": "tool", "tool": "aqueous-solubility",
      "inputs": { "smiles": [ { "node": "input" } ] }
    },
    "logp": {
      "kind": "tool", "tool": "logp",
      "inputs": { "smiles": [ { "node": "input" } ] },
      "settings": { "method": "crippen" }
    }
  }
}
```

---

## 6. Create → validate → publish → run (API flow)

All paths are relative to `https://app.tamarind.bio/api`; send `x-api-key` on every call.

1. **Find a molecule group id** for your reference group:
   `GET /molecules/groups` → pick a group's `id` → substitute for each `<groupId>`.

2. **Create a template** — the pipeline JSON goes in the `pipeline` field:
   ```
   POST /pipelines/templates
   { "name": "My pipeline", "description": "...", "pipeline": { ...IR... } }
   ```
   A molecule input without `metadata.defaultGroup` is rejected `422
   input-missing-reference`. The response carries the template `id` and a version id.

3. **(Optional) Validate before running** — both return `{ valid, errors }` without creating
   or executing anything:
   - `POST /pipelines/validate` — validate a would-be run. Takes the **same body** as
     `/pipelines/submit` (step 5): an inline `pipeline` **or** a `templateId`, plus `bindings`
     and `settings`.
   - `POST /pipelines/templates/{template_id}/validate` — validate a saved template against its
     own reference groups (no bindings needed).

4. **(Optional) Publish** a version to your org:
   `POST /pipelines/templates/{template_id}/publish` with `{ "version": "<versionId>" }`.

5. **Submit a run** — `POST /pipelines/submit`. Run a saved template by `templateId`, or an
   inline graph via `pipeline`. Bind each `user_input` slot to real data; `bindings` is keyed
   by the input node's id. Residue selection lives **inside each binding's `residuesByChain`**,
   in one of two shapes (auto-detected by the value — a chain maps to a range *string*, a tool node
   maps to a per-field *object*):
   ```
   POST /pipelines/submit
   {
     "name": "My run",                  // required; names the run (and, inline, the new template)
     "templateId": "<template_id>",     // OR "pipeline": { ...IR... } for an inline run
     "version": "v2",                   // optional vN handle; absent → published-then-latest
     "bindings": {
       "input": {
         "group": "<groupId>",
         "residuesByChain": {           // ADVANCED (the default): per (tool node, field) this input feeds
           "<toolNodeId>": { "designedResidues": { "A": "42-44,58-59" } }
         }
       }
     },
     "idempotencyKey": "optional-retry-key"
   }
   ```
   - **Molecule binding:** `{ "group": "<groupId>", "chainMapping"?: {...}, "residuesByChain"?: {...} }`
     (a `"flow": "molecule"` field is accepted but optional — it defaults to `molecule`).
     `chainMapping` is only needed when your molecule's chain labels differ from the template's
     reference chains.
   - **Residue selection (default = advanced, per tool/field):** a binding's `residuesByChain` in
     ADVANCED shape is `{ toolNodeId → { field → { referenceChain → ranges } } }` — pick residues
     **independently** for each tool node's residue field this input feeds, so two settings targeting
     the same chain can differ. Put each `(node, field)` pick under the binding whose input feeds it;
     picks are merged across bindings. Each value is a reference-chain-keyed range STRING (NOT a list):
     space- or comma-separated residue numbers and inclusive ranges, e.g. `"1-76"` or `"42 43 44 58 59"`
     or `"10-20,45,60-64"`. Find each tool node's residue fields (and their ids) via `GET
     /pipelines/templates/{template_id}` or the "Submit with API" example on the submit page. Advanced
     is the default for **newly created** templates. **How the mode is chosen:** an inline submit
     (`pipeline`) is stamped ADVANCED when any binding uses this node-keyed shape, and SIMPLIFIED when
     every residue pick is chain-keyed (below). For an existing `templateId`, the template's stored mode
     is used, and sending the wrong shape is rejected `422`.
   - **Simplified fan-out:** a binding's `residuesByChain` in SIMPLE shape is `{ referenceChain → ranges }`
     (each chain maps to a range string), applying ONE residue selection to every residue field this
     input feeds. Because a simple pick's value is a string and an advanced pick's is an object, the
     shape is auto-detected regardless of chain-label length. Use it when a template maker has turned on
     "Simplified residue selection", or (inline) when every field should get the same residues.
   - **⚠️ Required for tools with a residue-selection field.** A tool whose schema marks a residue
     field required (e.g. ProteinMPNN's designed residues) needs a selection at submit — via its
     binding's `residuesByChain` (advanced per `(node, field)`, or simple chain-keyed).
     Omit it and the submit is rejected `422 required-field-unset` for that node. Check the tool's
     schema (see §2.2) to see whether it needs one.
   - **File binding:** `{ "flow": "file", "file": "<path>" }`

6. **Poll the run:** `GET /pipelines/runs/{run_id}` for status/steps.
   Stop with `POST /pipelines/runs/{run_id}/stop`.

Retrieve templates with `GET /pipelines/templates` (summaries only) and the full IR with
`GET /pipelines/templates/{template_id}`.

---

## 7. Validation rules to satisfy (checklist)

Author your document to pass these before submitting — the editor and the API enforce the
same rules:

- ✅ `schema_version`, if present, is exactly `"1.0"` (it may be omitted — it defaults to
  `"1.0"`); `nodes` keys are unique and non-empty.
- ✅ Every molecule `user_input` has `metadata.defaultGroup` (file inputs exempt).
- ✅ Every `NodeRef.node` points at an existing node id; **no cycles** and no dangling refs.
- ✅ Each `InputPort` is a non-empty array with no duplicate refs.
- ✅ **Representation compatibility:** an upstream node must produce the representation the
  downstream tool consumes (e.g. ProteinMPNN emits *sequence* → AlphaFold consumes
  *sequence*; a `small_molecule` input cannot feed a sequence/pdb tool like AlphaFold).
- ✅ `tool.settings` conform to that tool's field schema (`GET /tools/{name}/schema`).
- ✅ Every tool with a **required residue-selection field** (per its schema) gets that selection
  at submit via the binding's `residuesByChain` (a string like `"1-76"`), not a node setting —
  else `422 required-field-unset`.
- ✅ A `filter` either sets `intersect: true` (needs ≥2 sources) or has ≥1 rule.
- ✅ `top_k.k ≥ 1` and `rank_by` is non-empty; `top_k` only on rankable scores.
- ⏳ **Chain compatibility** (`select.chains`) is checked only **at dispatch**, when the
  bound molecule's real chains are known — not at template-save time.

### Barriers (execution note)

Most nodes stream per molecule. These **barrier** on the whole upstream set: a `filter`
with `intersect: true`, a `top_k` filter, and a two-input `tool` with `combination_mode`
set. Downstream of a barrier, nothing dispatches until every upstream molecule is terminal.

---

## 8. Quick reference — the type tree

```
Pipeline           { schema_version?:"1.0", nodes: {id → Node}, metadata? }
Node               UserInputNode | ToolNode | FilterNode | ContainerNode   (discriminated on `kind`)

UserInputNode      { kind:"user_input", label?, flow?, molecule_type?, contains_structure?, chain_labels?, metadata? }
ToolNode           { kind:"tool", tool, inputs:{port → InputPort}, settings?, combination_mode?, combination_primary_field?, container?, metadata? }
FilterNode         { kind:"filter", inputs:{port → InputPort}, intersect?, filters?, label?, container?, metadata? }
ContainerNode      { kind:"container", inputs?:{port → InputPort}, label?, metadata? }

InputPort          NodeRef[]                       (non-empty, unique)
NodeRef            { node, select? }
Select             { chains: string[] }

Filter             JsonLogicFilter | TopKFilter    (discriminated on `kind`)
JsonLogicFilter    { kind:"jsonlogic", rule }
TopKFilter         { kind:"top_k", k, rank_by:[{field,order}] }
FilterFieldRef     { producerNodeId, columnName }
```

For the authoritative, machine-checkable shape, always defer to
`https://app.tamarind.bio/pipeline-ir.schema.json`.
