返回 Skills
k-dense-ai/scientific-agent-skills· MIT 内容可用

adaptyv

How to use the Adaptyv Bio Foundry API and Python SDK for protein experiment design, submission, and results retrieval. Use this skill whenever the user mentions Adaptyv, Foundry API, protein binding assays, protein screening experiments, BLI/SPR assays, thermostability assays, or wants to submit protein sequences for experimental characterization. Also trigger when code imports `adaptyv`, `adaptyv_sdk`, or `FoundryClient`, or references `foundry-api-public.adaptyvbio.com`.

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: adaptyv author: "K-Dense, Inc." description: "How to use the Adaptyv Bio Foundry API and Python SDK for protein experiment design, submission, and results retrieval. Use this skill whenever the user mentions Adaptyv, Foundry API, protein binding assays, protein screening experiments, BLI/SPR assays, thermostability assays, or wants to submit protein sequences for experimental characterization. Also trigger when code imports adaptyv, adaptyv_sdk, or FoundryClient, or references foundry-api-public.adaptyvbio.com." license: MIT compatibility: Requires Python 3.10+, an Adaptyv Foundry account, and an API key from foundry.adaptyvbio.com. Install adaptyv-sdk from GitHub with uv pip install. metadata: {"version": "1.2", "skill-author": "K-Dense Inc."}

Adaptyv Bio Foundry API

Adaptyv Bio is a cloud lab that turns protein sequences into experimental data. Users submit amino acid sequences via API or UI; Adaptyv's automated lab runs assays (binding, thermostability, expression, fluorescence) and delivers results in ~21 days.

Official docs: docs.adaptyvbio.com/api-reference · llms.txt index · OpenAPI spec

Quick Start

Base URL: https://foundry-api-public.adaptyvbio.com/api/v1

Authentication: Bearer token in the Authorization header. Tokens are obtained from foundry.adaptyvbio.com sidebar.

When writing code, always read the API key from the environment variable ADAPTYV_API_KEY or from a .env file — never hardcode tokens. Check for a .env file in the project root first; if one exists, use a library like python-dotenv to load it.

The official API docs use FOUNDRY_API_TOKEN in curl examples; that is the same bearer token — prefer ADAPTYV_API_KEY in Python and new shell scripts for consistency with the SDK.

export ADAPTYV_API_KEY="abs0_..."
curl https://foundry-api-public.adaptyvbio.com/api/v1/targets?limit=3 \
  -H "Authorization: Bearer $ADAPTYV_API_KEY"

Every request except GET /openapi.json requires authentication. Store tokens in environment variables or .env files — never commit them to source control.

Python SDK

Version note: adaptyv-sdk 0.1.0 (beta) is not yet on PyPI — install from GitHub:

uv pip install "git+https://github.com/adaptyvbio/adaptyv-sdk.git"

In a project with pyproject.toml:

uv add "adaptyv-sdk @ git+https://github.com/adaptyvbio/adaptyv-sdk.git"

Environment variables (set in shell or .env file):

ADAPTYV_API_KEY=your_api_key
ADAPTYV_API_URL=https://foundry-api-public.adaptyvbio.com/api/v1
ADAPTYV_ORGANIZATION_ID=your_org_id  # optional

The @lab.experiment decorator and FoundryClient both read ADAPTYV_API_KEY and ADAPTYV_API_URL from the environment when not passed explicitly.

Decorator Pattern

from adaptyv import lab

@lab.experiment(target="PD-L1", experiment_type="screening", method="bli")
def design_binders():
    return {"design_a": "MVKVGVNG...", "design_b": "MKVLVAG..."}

result = design_binders()
print(f"Experiment: {result.experiment_url}")

Client Pattern

import os
from adaptyv import FoundryClient

client = FoundryClient(
    api_key=os.environ["ADAPTYV_API_KEY"],
    base_url=os.environ.get(
        "ADAPTYV_API_URL",
        "https://foundry-api-public.adaptyvbio.com/api/v1",
    ),
)

# Browse targets
targets = client.targets.list(search="EGFR", selfservice_only=True)

# Estimate cost
estimate = client.experiments.cost_estimate({
    "experiment_spec": {
        "experiment_type": "screening",
        "method": "bli",
        "target_id": "target-uuid",
        "sequences": {"seq1": "EVQLVESGGGLVQ..."},
        "n_replicates": 3
    }
})

# Create and submit
exp = client.experiments.create({...})
client.experiments.submit(exp.experiment_id)

# Later: retrieve results
results = client.experiments.get_results(exp.experiment_id)

Experiment Types

TypeMethodMeasuresRequires Target
affinitybli or sprKD, kon, koff kineticsYes
screeningbli or sprYes/no bindingYes
thermostabilityMelting temperature (Tm)No
expressionExpression yieldNo
fluorescenceFluorescence intensityNo

Experiment Lifecycle

Draft → WaitingForConfirmation → QuoteSent → WaitingForMaterials → InQueue → InProduction → DataAnalysis → InReview → Done
StatusWho ActsDescription
DraftYouEditable, no cost commitment
WaitingForConfirmationAdaptyvUnder review, quote being prepared
QuoteSentYouReview and confirm the quote
WaitingForMaterialsAdaptyvGene fragments and target ordered
InQueueAdaptyvMaterials arrived, queued for lab
InProductionAdaptyvAssay running
DataAnalysisAdaptyvRaw data processing and QC
InReviewAdaptyvFinal validation
DoneYouResults available
CanceledEitherExperiment canceled

The results_status field on an experiment tracks: none, partial, or all.

Common Workflows

1. Submit a Binding Screen (Step by Step)

# 1. Find a target
targets = client.targets.list(search="EGFR", selfservice_only=True)
target_id = targets.items[0].id

# 2. Preview cost
estimate = client.experiments.cost_estimate({
    "experiment_spec": {
        "experiment_type": "screening",
        "method": "bli",
        "target_id": target_id,
        "sequences": {"seq1": "EVQLVESGGGLVQ...", "seq2": "MKVLVAG..."},
        "n_replicates": 3
    }
})

# 3. Create experiment (starts as Draft)
exp = client.experiments.create({
    "name": "EGFR binder screen batch 1",
    "experiment_spec": {
        "experiment_type": "screening",
        "method": "bli",
        "target_id": target_id,
        "sequences": {"seq1": "EVQLVESGGGLVQ...", "seq2": "MKVLVAG..."},
        "n_replicates": 3
    }
})

# 4. Submit for review
client.experiments.submit(exp.experiment_id)

# 5. Poll or use webhooks until Done
# 6. Retrieve results
results = client.experiments.get_results(exp.experiment_id)

2. Automated Pipeline (Skip Draft + Auto-Accept Quote)

exp = client.experiments.create({
    "name": "Auto pipeline run",
    "experiment_spec": {...},
    "skip_draft": True,
    "auto_accept_quote": True,
    "webhook_url": "https://my-server.com/webhook"
})
# Webhook fires on each status transition; poll or wait for Done

3. Using Webhooks

Pass webhook_url when creating an experiment. Adaptyv POSTs to that URL on every status transition with the experiment ID, previous status, and new status.

Sequences

  • Simple format: {"seq1": "EVQLVESGGGLVQPGGSLRLSCAAS"}
  • Rich format: {"seq1": {"aa_string": "EVQLVESGGGLVQ...", "control": false, "metadata": {"type": "scfv"}}}
  • Multi-chain: use colon separator — "MVLS:EVQL"
  • Valid amino acids: A, C, D, E, F, G, H, I, K, L, M, N, P, Q, R, S, T, V, W, Y (case-insensitive, stored uppercase)
  • Sequences can only be added to experiments in Draft status

Filtering, Sorting, and Pagination

All list endpoints support pagination (limit 1-100, default 50; offset), search (free-text on name fields), and sorting.

Filtering uses s-expression syntax via the filter query parameter:

  • Comparison: eq(field,value), neq, gt, gte, lt, lte, contains(field,substring)
  • Range/set: between(field,lo,hi), in(field,v1,v2,...)
  • Logic: and(expr1,expr2,...), or(...), not(expr)
  • Null: is_null(field), is_not_null(field)
  • JSONB: at(field,key) — e.g., eq(at(metadata,score),42)
  • Cast: float(), int(), text(), timestamp(), date()

Sorting uses asc(field) or desc(field), comma-separated (max 8):

sort=desc(created_at),asc(name)

Example: filter=and(gte(created_at,2026-01-01),eq(status,done))

Error Handling

All errors return:

{
  "error": "Human-readable description",
  "request_id": "req_019462a4-b1c2-7def-8901-23456789abcd"
}

The request_id is also in the x-request-id response header — include it when contacting support.

Token Management

Tokens use Biscuit-based cryptographic attenuation. You can create restricted tokens scoped by organization, resource type, actions (read/create/update), and expiry via POST /tokens/attenuate. Revoking a token (POST /tokens/revoke) revokes it and all its descendants.

Detailed API Reference

For the full list of all 32 endpoints with request/response schemas, read references/api-endpoints.md.

附带文件

references/api-endpoints.md
# Adaptyv Bio Foundry API — Complete Endpoint Reference

Base URL: `https://foundry-api-public.adaptyvbio.com/api/v1`
OpenAPI spec: `GET /openapi.json`

## Table of Contents

- [Experiments](#experiments)
- [Sequences](#sequences)
- [Results](#results)
- [Targets](#targets)
- [Quotes](#quotes)
- [Tokens](#tokens)
- [Updates](#updates)
- [Feedback](#feedback)

---

## Experiments

### POST /experiments — Create experiment

Creates a new experiment. Starts in `Draft` status by default.

**Request body:**

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Human-readable name |
| `experiment_spec` | ExperimentSpec | Yes | Experiment definition (see below) |
| `skip_draft` | boolean | No (default false) | Bypass Draft, go straight to WaitingForConfirmation |
| `auto_accept_quote` | boolean | No (default false) | Auto-accept quote and create invoice |
| `webhook_url` | string/null | No | URL for status-change POST notifications |

**ExperimentSpec:**

| Field | Type | Required | Description |
|---|---|---|---|
| `experiment_type` | string | Yes | `affinity`, `screening`, `thermostability`, `fluorescence`, or `expression` |
| `method` | string | Required for binding types | `bli` or `spr` |
| `target_id` | uuid | Required for binding types | Target UUID from catalog |
| `sequences` | object | Yes | Map of name → amino acid string or rich object |
| `n_replicates` | integer | Recommended (default 3) | Technical replicates (min 1) |
| `antigen_concentrations` | number[] | No (affinity only) | Defaults to `[1000.0, 316.2, 100.0, 31.6, 0.0]` nM |
| `parameters` | object | No | Experiment-specific settings |

**Field requirements by experiment type:**

| Field | Affinity | Screening | Thermostability | Fluorescence | Expression |
|---|---|---|---|---|---|
| `experiment_type` | required | required | required | required | required |
| `method` | required | required | — | — | — |
| `target_id` | required | required | — | — | — |
| `sequences` | required | required | required | required | required |
| `n_replicates` | recommended | recommended | optional | optional | optional |
| `antigen_concentrations` | optional | — | — | — | — |

**Response (201):**

| Field | Type | Description |
|---|---|---|
| `experiment_id` | string | UUID of new experiment |
| `error` | string/null | Error message if validation fails |
| `stripe_hosted_invoice_url` | string/null | Present when `auto_accept_quote` created an invoice |
| `stripe_invoice_id` | string/null | Stripe invoice ID |

**Status codes:** 201, 400, 401, 403, 404

---

### GET /experiments — List experiments

Lists experiments accessible to caller, sorted by creation date (newest first).

**Query params:** `limit`, `offset`, `filter`, `search`, `sort`

**Response item:**

| Field | Type | Description |
|---|---|---|
| `id` | uuid | Unique identifier |
| `code` | string | e.g., "EXP-2024-001" |
| `name` | string/null | Human-readable name |
| `status` | ExperimentStatus | Current lifecycle status |
| `experiment_type` | ExperimentType | affinity/screening/thermostability/fluorescence/expression |
| `results_status` | ResultsStatus | none/partial/all |
| `created_at` | datetime | ISO 8601 |
| `experiment_url` | string | URL to Foundry portal |
| `stripe_invoice_url` | string/null | Invoice URL |
| `stripe_quote_url` | string/null | Quote URL |

**Status codes:** 200, 401

---

### GET /experiments/{experiment_id} — Get experiment

Returns full metadata for a single experiment.

**Path param:** `experiment_id` (uuid)

**Response:**

| Field | Type | Description |
|---|---|---|
| `id` | uuid | Unique identifier |
| `code` | string | Experiment code |
| `status` | ExperimentStatus | Current status |
| `experiment_spec` | ExperimentSpec | Full experiment definition |
| `results_status` | ResultsStatus | none/partial/all |
| `created_at` | datetime | ISO 8601 |
| `experiment_url` | string | Portal URL |
| `costs` | object | Cost breakdown |

**Status codes:** 200, 401, 404, 500

---

### PATCH /experiments/{experiment_id} — Update experiment

Modify an existing experiment. Draft experiments allow full edits; after quote generation, only `name`, `description`, and `webhook_url` are editable.

**Path param:** `experiment_id` (uuid)

**Request body:** All fields optional — only provided fields are updated.

**Status codes:** 200, 400, 401, 404, 409

---

### POST /experiments/{experiment_id}/submit — Submit experiment

Submits a draft experiment for review. Advances from `Draft` to `WaitingForConfirmation`.

**Path param:** `experiment_id` (uuid)

**Response:**

| Field | Type | Description |
|---|---|---|
| `experiment_id` | string | Experiment UUID |

**Status codes:** 200, 401, 403, 404, 409, 500

---

### POST /experiments/cost-estimate — Estimate cost

Calculates cost without creating an experiment.

**Request body:**
```json
{
  "experiment_spec": {
    "experiment_type": "screening",
    "method": "bli",
    "target_id": "...",
    "sequences": {"seq1": "MKTL..."},
    "n_replicates": 3
  }
}
```

**Response:**

| Field | Type | Description |
|---|---|---|
| `pricing_version` | string | e.g., "v1_2026-01-20" |
| `assay` | object | Per-type costs with base and replicate pricing |
| `materials` | object | Target material costs (binding experiments) |
| `total_cents` | integer | Sum in USD cents |

All prices exclude VAT; taxes calculated at invoicing. Targets without self-service pricing return incomplete estimates.

**Status codes:** 200, 400, 401

---

### GET /experiments/{experiment_id}/quote — Get quote

Returns quote metadata (totals, currency, status, expiration).

**Path param:** `experiment_id` (uuid)

**Response:**

| Field | Type | Description |
|---|---|---|
| `experiment_id` | string | Experiment UUID |
| `stripe_quote_url` | string | Stripe quote URL |
| `amount_total` | int64 | Total in smallest currency unit |
| `amount_subtotal` | int64 | Subtotal |
| `currency` | string | ISO currency code (e.g., "usd") |
| `status` | string | Quote status |
| `expires_at` | datetime/null | Expiration time |

**Status codes:** 200, 401, 403, 404, 500

---

### GET /experiments/{experiment_id}/quote/pdf — Get quote PDF

Returns the quote as a PDF file (`application/pdf`).

**Path param:** `experiment_id` (uuid)

**Status codes:** 200, 401, 403, 404, 500

---

### POST /experiments/{experiment_id}/quote/confirm — Accept quote (by experiment)

Accepts Stripe quote, creates draft invoice, transitions to `WaitingForMaterials`.

**Path param:** `experiment_id` (uuid)

**Request body:**

| Field | Type | Required | Description |
|---|---|---|---|
| `purchase_order_number` | string/null | No | PO number for your records |
| `notes` | string/null | No | Reserved |

**Response:**

| Field | Type | Description |
|---|---|---|
| `id` | string | Quote ID |
| `status` | StripeQuoteStatus | New status |
| `hosted_invoice_url` | string/null | Stripe payment URL |
| `invoice_id` | string/null | Generated invoice ID |

**Status codes:** 200, 401, 403, 404, 409

---

### GET /experiments/{experiment_id}/invoice — Get invoice

Returns invoice metadata including hosted payment URL.

**Path param:** `experiment_id` (uuid)

**Status codes:** 200, 401, 403, 404, 500

---

### GET /experiments/{experiment_id}/results — List results for experiment

Returns all analysis results for a specific experiment.

**Path param:** `experiment_id` (uuid)
**Query params:** `limit`, `offset`, `filter`, `sort`

**Status codes:** 200, 400, 401, 403, 404

---

### GET /experiments/{experiment_id}/sequences — List sequences for experiment

Returns all sequences for a specific experiment, sorted newest first.

**Path param:** `experiment_id` (uuid)
**Query params:** `limit`, `offset`, `search`, `sort`

**Status codes:** 200, 400, 401, 403, 404

---

### GET /experiments/{experiment_id}/updates — List experiment updates

Returns updates for one experiment, oldest first. Types: `status_change`, `progress`, `error`.

**Path param:** `experiment_id` (uuid)
**Query params:** `limit`, `offset`, `filter`, `sort`

Filter example: `filter=eq(type,status_change)`

---

## Sequences

### GET /sequences — List sequences

Returns sequences from all experiments, sorted newest first.

**Query params:** `limit`, `offset`, `search`, `sort`, `experiment_id` (filter by experiment UUID)

**Response item:**

| Field | Type | Description |
|---|---|---|
| `id` | uuid | Unique identifier |
| `name` | string/null | Optional name |
| `aa_preview` | string/null | Truncated preview (first 50 chars) |
| `length` | int32 | Sequence length in amino acids |
| `experiment_id` | uuid | Parent experiment |
| `experiment_code` | string | Human-readable experiment code |
| `is_control` | boolean | Whether this is a control |
| `created_at` | datetime | Creation timestamp |

**Status codes:** 200, 401

---

### GET /sequences/{sequence_id} — Get sequence

Returns full details including complete amino acid string.

**Path param:** `sequence_id` (uuid)

**Response:**

| Field | Type | Description |
|---|---|---|
| `id` | uuid | Unique identifier |
| `aa_string` | string/null | Complete amino acid sequence |
| `length` | int32 | Length in amino acids |
| `is_control` | boolean | Control flag |
| `metadata` | object | Sequence-level annotations |
| `experiment` | object | Parent experiment reference |
| `created_at` | datetime | Creation timestamp |

**Status codes:** 200, 401, 403, 404, 500

---

### POST /sequences — Add sequences to experiment

Appends sequences to a **Draft** experiment identified by its human-readable code.

**Request body:**

| Field | Type | Required | Description |
|---|---|---|---|
| `experiment_code` | string | Yes | e.g., "PROJ-001" |
| `sequences` | array | Yes | Array of sequence entries |

**Each sequence entry:**

| Field | Type | Required | Description |
|---|---|---|---|
| `aa_string` | string | Yes | Amino acid sequence |
| `name` | string | No | Human-readable name |
| `control` | boolean | No | Whether this is a control |
| `metadata` | object | No | Annotations |

**Response (201):**

| Field | Type | Description |
|---|---|---|
| `added_count` | int32 | Number of sequences added |
| `experiment_id` | string | Experiment UUID |
| `experiment_code` | string | Experiment code |
| `sequence_ids` | array | IDs of added sequences |

**Status codes:** 201, 400, 404, 409 (experiment not in Draft), 500

---

## Results

### GET /results — List results

Lists completed analysis results, sorted newest first. Results appear when `results_status` reaches `partial` or `all`.

**Query params:** `limit`, `offset`, `filter`, `search`, `sort`

**Response item:**

| Field | Type | Description |
|---|---|---|
| `id` | uuid | Result identifier |
| `title` | string | Human-readable title |
| `experiment_id` | uuid | Associated experiment |
| `result_type` | string | e.g., "affinity", "thermostability" |
| `summary` | array | Key results (type-specific, see below) |
| `metadata` | object | Extended metadata (e.g., instrument info) |
| `data_package_url` | string/null | Download URL for raw data package |
| `created_at` | datetime | When result was generated |

**AffinityResult summary fields:** `kd_mean`, `kd_std`, `kon_mean`, `kon_log_std`, `koff_mean`, `koff_std`, `replicates` (array with per-replicate `kd`, `kon`, `koff`, `binding_strength`, `kon_method`, `koff_method`, `replicate` index), `sequence`, `target_id`

**ThermostabilityResult summary fields:** Tm values and melting curves

**Status codes:** 200, 401

---

### GET /results/{result_id} — Get result

Returns detailed result data including full summary array.

**Path param:** `result_id` (uuid)

**Status codes:** 200, 401, 403, 404, 500

---

## Targets

### GET /targets — List targets

Lists validated antigens available for experiments.

**Query params:**

| Parameter | Type | Description |
|---|---|---|
| `limit` | int | Max items (1-100, default 50) |
| `offset` | int | Skip count |
| `search` | string | Free-text search on product name |
| `sort` | string | Sort expression |
| `selfservice_only` | boolean | Only targets with self-service pricing |
| `show_conjugated` | boolean | Include conjugated targets (default: unconjugated only) |
| `detailed` | boolean | Populate `details` block with enrichment data |

**Response item:**

| Field | Type | Description |
|---|---|---|
| `id` | uuid | Target UUID (use as `experiment_spec.target_id`) |
| `name` | string | Target name |
| `vendor_name` | string | Vendor name |
| `catalog_number` | string | Vendor catalog/SKU number |
| `url` | string | Target URL |
| `pricing` | object/null | Self-service pricing (null = custom quote required) |
| `details` | object/null | Enrichment data (gene names, structures, sequence, bioactivity) |

**Status codes:** 200, 401

---

### GET /targets/{target_id} — Get target

Returns catalog record for a single target.

**Path param:** `target_id` (uuid)

**Status codes:** 200, 400, 401, 403, 404, 500

---

### POST /targets/request-custom — Submit custom target request

Submit a new custom target for staff review. At least one of `sequence` or `pdb_id` must be provided.

**Request body:**

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Display name |
| `product_id` | string | Yes | Must be unique within organization |
| `sequence` | string/null | At least one | Amino acid sequence |
| `pdb_id` | string/null | At least one | PDB identifier |
| `pdb_file` | string/null | No | PDB file content |
| `molecular_weight` | number/null | No | Weight in kDa |
| `note` | string/null | No | Additional notes |

**Status codes:** 201, 400, 401, 403, 500

---

### GET /targets/request-custom — List custom target requests

Returns custom target requests for your organization, sorted newest first.

**Query params:** `limit`, `offset`, `filter`, `sort`

Filter example: `filter=eq(status,pending_review)`

---

### GET /targets/request-custom/{request_id} — Get custom target request

**Path param:** `request_id` (uuid)

**Response:**

| Field | Type | Description |
|---|---|---|
| `id` | uuid | Request identifier |
| `name` | string | Target name |
| `product_id` | string | Your product ID |
| `status` | string | e.g., "pending_review" |
| `material_id` | string/null | Linked catalog ID if approved |
| `molecular_weight` | number/null | Weight in kDa |
| `note` | string/null | User notes |
| `created_at` | datetime | Created |
| `updated_at` | datetime | Last updated |

**Status codes:** 200, 401, 403, 404, 500

---

## Quotes

### GET /quotes — List quotes

Returns all quotes for caller's organization.

**Query params:** `limit`, `offset`, `filter`, `sort`

**Response item:**

| Field | Type | Description |
|---|---|---|
| `id` | string | Quote identifier |
| `quote_number` | string | Human-readable quote number |
| `organization_id` | uuid | Organization |
| `amount_cents` | int | Amount in cents |
| `currency` | string | ISO 4217 code |
| `status` | StripeQuoteStatus | Quote status |
| `valid_until` | datetime | Expiration |
| `created_at` | datetime | Creation timestamp |

---

### GET /quotes/{quote_id} — Get quote

Returns full quote document with itemized pricing.

**Path param:** `quote_id` (string, e.g., "qt_1Abc2DefGhi")

**Response:**

| Field | Type | Description |
|---|---|---|
| `id` | string | Quote identifier |
| `quote_number` | string | Reference number |
| `organization_id` | uuid | Organization |
| `organization_name` | string | Organization name |
| `line_items` | array | Itemized pricing |
| `subtotal_cents` | int | Subtotal in cents |
| `tax_cents` | int | Tax in cents |
| `total_cents` | int | Total in cents |
| `currency` | string | ISO 4217 |
| `status` | StripeQuoteStatus | Current status |
| `valid_until` | datetime | Expiration |
| `notes` | string | Special pricing info |
| `terms_and_conditions` | string | Terms |
| `stripe_quote_url` | string | Stripe URL |
| `created_at` | datetime | Created |

**Status codes:** 200, 401, 403, 404, 500

---

### POST /quotes/{quote_id}/confirm — Accept quote

Finalizes quote, creates draft invoice, advances experiment to `WaitingForMaterials`.

**Path param:** `quote_id` (string)

**Request body:**

| Field | Type | Required | Description |
|---|---|---|---|
| `purchase_order_number` | string/null | No | PO number |
| `notes` | string/null | No | Reserved |

**Response:** `id`, `status`, `hosted_invoice_url`, `invoice_id`

**Status codes:** 200, 403, 404, 409, 500

---

### POST /quotes/{quote_id}/reject — Reject quote

Cancels quote; linked experiment reverts to `Draft`.

**Path param:** `quote_id` (string)

**Request body:**

| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | QuoteRejectionReason | Yes | Primary reason |
| `feedback` | string/null | No | Additional feedback |

**Response:** `id`, `status` (canceled)

**Status codes:** 200, 403, 404, 409, 500

---

## Tokens

### GET /tokens — List tokens

Returns all tokens (root and attenuated) the caller owns.

**Query params:** `limit`, `offset`

**Response item:**

| Field | Type | Description |
|---|---|---|
| `id` | string | Token identifier |
| `name` | string | Human-readable label |
| `kind` | string | "root" or "attenuated" |
| `created_at` | datetime | Created |
| `expires_at` | datetime/null | Expiration (null = no expiry) |
| `revoked_at` | datetime/null | Revocation timestamp |
| `parent_token_id` | string/null | Parent (null for root) |
| `root_token_id` | string/null | Root of derivation tree |
| `attenuation_spec` | object/null | Restrictions (null for root) |

---

### POST /tokens/attenuate — Attenuate token

Creates a restricted version of an existing token using Biscuit cryptographic attenuation.

**Request body:**

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | Yes | Existing token (`abs0_{slug}{biscuit_base64}`) |
| `attenuation` | AttenuationSpec | Yes | Restrictions to apply |
| `name` | string | Yes | Human-readable label |
| `attenuated_parent_token_id` | uuid/null | No | Parent ID for chained attenuation |

**Restriction types:** Organization, Resource (experiments/results), Action (read/create/update), Expiry

**Response (201):** `id` (database ID), `token` (new attenuated token string)

**Status codes:** 201, 400, 401, 403

---

### POST /tokens/revoke — Revoke token and lineage

Revokes the calling token's root and all attenuated descendants. Idempotent.

**Response:**

| Field | Type | Description |
|---|---|---|
| `token_id` | string | Root token ID revoked |
| `revoked_at` | datetime | Revocation timestamp |
| `children_revoked` | int64 | Child tokens newly revoked |

**Status codes:** 200, 403, 404

---

## Updates

### GET /updates — List updates

Returns the experiment update feed (newest first): status changes, progress, errors.

**Query params:** `limit`, `offset`, `filter`, `sort`

**Filter examples:**
- `filter=eq(experiment_id,<uuid>)`
- `filter=in(experiment_id,uuid1,uuid2)`
- `filter=eq(type,status_change)`

**Response item:**

| Field | Type | Description |
|---|---|---|
| `id` | string | Update identifier |
| `experiment_id` | uuid | Associated experiment |
| `experiment_code` | string | Human-readable code |
| `name` | string | Update description |
| `timestamp` | datetime | When the update occurred |

---

## Feedback

### POST /feedback/submit — Submit feedback

For bug reports, feature requests, or general feedback.

**Request body:**

| Field | Type | Required | Description |
|---|---|---|---|
| `request_uuid` | uuid | Yes | UUID from the problematic API request |
| `feedback_type` | FeedbackType | Yes | `feature_request`, `feedback`, or `bug_report` |
| `title` | string/null | No | Short title |
| `json_body` | object/null | At least one | Structured error details |
| `human_note` | string/null | At least one | Free-form description |

**Response (201):** `reference` (feedback reference), `message` (confirmation)

**Status codes:** 201, 400, 401, 500