---
title: Integration Examples
description: >-
  Real-world patterns: Next.js, Node.js batch processing, React dashboards,
  and error handling
---

This page collects working, end-to-end patterns for wiring the SDK into real applications — Next.js App Router with Clerk, Node.js batch processing, React dashboards, and error handling. Reach for it once you've read the reference pages and want a complete, copyable example rather than a single method's signature. It ties together the building blocks documented on [React Hooks](/docs/api/sdk-react), [Jobs](/docs/api/sdk-jobs), and [WebSocket](/docs/api/sdk-websocket).

## SDK Integration Examples

> **Planned specification.** `@ivybiosciences/sdk` is not released, and there is no package to install. This page describes the SDK's planned interface. To call the platform today, use the [REST API](/docs/api/rest-jobs), the [ivybloom CLI](/docs/cli), or the [MCP server](/docs/cli/mcp).

Real-world integration patterns for common use cases.

---

## Next.js App Router + Clerk

Full setup for a Next.js application with Clerk authentication.

### Layout Provider

```tsx
// app/providers.tsx
"use client"

import { IvyProvider } from "@ivybiosciences/sdk/react"
import { useAuth } from "@clerk/nextjs"

export function IvyProviderWrapper({ children }: { children: React.ReactNode }) {
  const { getToken } = useAuth()

  return (
    <IvyProvider
      config={{
        getToken: () => getToken({ template: "ivy_api" }),
        timeout: 60_000,
        retries: 3,
        onError: (err) => console.error("[IvySDK]", err.message),
      }}
    >
      {children}
    </IvyProvider>
  )
}
```

```tsx
// app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs"
import { IvyProviderWrapper } from "./providers"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html>
        <body>
          <IvyProviderWrapper>{children}</IvyProviderWrapper>
        </body>
      </html>
    </ClerkProvider>
  )
}
```

### Job Submission Page

```tsx
// app/fold/page.tsx
"use client"

import { useIvyJob } from "@ivybiosciences/sdk/react"
import { useState } from "react"

export default function FoldPage() {
  const [sequence, setSequence] = useState("")
  const { submit, status, isLoading, error, cancel } = useIvyJob({
    onComplete: (result) => {
      if (result.presignedUrl) {
        // Trigger PDB download
        window.open(result.presignedUrl)
      }
    },
  })

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await submit("esmfold", { protein_sequence: sequence }, {
      analysisLevel: "comprehensive",
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={sequence}
        onChange={(e) => setSequence(e.target.value)}
        placeholder="Enter protein sequence (e.g., MKFLILLFNILCLFPVLAADNH)"
        rows={4}
      />

      <button type="submit" disabled={isLoading}>
        {isLoading ? "Folding..." : "Predict Structure"}
      </button>

      {isLoading && (
        <div>
          <progress value={status?.progress ?? 0} max={100} />
          <span>{status?.phase ?? "queued"} — {status?.progress ?? 0}%</span>
          <button type="button" onClick={cancel}>Cancel</button>
        </div>
      )}

      {error && <p className="text-red-600">{error.message}</p>}

      {status?.status === "COMPLETED" && status.result && (
        <div>
          <h3>Results</h3>
          <pre>{JSON.stringify(status.result, null, 2)}</pre>
        </div>
      )}
    </form>
  )
}
```

### Server Action (Route Handler)

```ts
// app/api/jobs/route.ts
import { createIvyClient } from "@ivybiosciences/sdk"
import { auth } from "@clerk/nextjs/server"

const ivy = createIvyClient({ apiKey: process.env.IVY_API_KEY! })

export async function POST(req: Request) {
  const { userId } = await auth()
  if (!userId) return Response.json({ error: "Unauthorized" }, { status: 401 })

  const { toolName, parameters } = await req.json()

  const { id, status } = await ivy.jobs.submit({
    toolName,
    parameters,
    orgId: userId,
  })

  return Response.json({ jobId: id, status })
}
```

---

## Node.js Batch Processing

Run multiple jobs sequentially or concurrently from a server-side script.

### Sequential Pipeline

```ts
// scripts/batch-fold.ts
import { createIvyClient, IvyApiError, IvyTimeoutError } from "@ivybiosciences/sdk"

const ivy = createIvyClient({
  apiKey: process.env.IVY_API_KEY!,
  timeout: 120_000,
  retries: 5,
})

const sequences = [
  { name: "Insulin", seq: "MALWMRLLPLLALLALWGPDPAAAFVNQH" },
  { name: "GFP", seq: "MSKGEELFTGVVPILVELDGDVNGHKFSVS" },
  { name: "Lysozyme", seq: "MRSLLILVLCFLPLAALGKVFGRCELAAAM" },
]

async function main() {
  for (const { name, seq } of sequences) {
    console.log(`\nFolding ${name}...`)

    try {
      const result = await ivy.jobs.submitAndWait(
        {
          toolName: "esmfold",
          parameters: { protein_sequence: seq },
          analysisLevel: "comprehensive",
          jobTitle: `Batch: ${name}`,
        },
        {
          timeout: 300_000, // 5 minutes per job
          onProgress: (p) => process.stdout.write(`\r  [${p.phase}] ${p.progress}%`),
        },
      )

      if (result.status === "COMPLETED") {
        console.log(`\n  Done — pLDDT: ${(result.result as any)?.plddt_mean}`)

        // Download the PDB file
        if (result.presignedUrl) {
          const pdb = await ivy.artifacts.downloadText(result.presignedUrl)
          const fs = await import("fs/promises")
          await fs.writeFile(`output/${name}.pdb`, pdb)
          console.log(`  Saved to output/${name}.pdb`)
        }
      } else {
        console.error(`  Failed: ${result.error}`)
      }
    } catch (e) {
      if (e instanceof IvyTimeoutError) {
        console.error(`  Timeout after ${e.timeoutMs / 1000}s`)
      } else if (e instanceof IvyApiError) {
        console.error(`  API error: ${e.status} — ${e.detail}`)
      } else {
        throw e
      }
    }
  }
}

main()
```

### Concurrent Processing

```ts
// scripts/batch-concurrent.ts
import { createIvyClient } from "@ivybiosciences/sdk"

const ivy = createIvyClient({ apiKey: process.env.IVY_API_KEY! })

const molecules = [
  "CC(=O)Oc1ccccc1C(=O)O",  // Aspirin
  "CC12CCC3C(C1CCC2O)CCC4=CC(=O)CCC34C",  // Testosterone
  "CN1C=NC2=C1C(=O)N(C(=O)N2C)C",  // Caffeine
]

async function main() {
  // Submit all jobs concurrently
  const promises = molecules.map((smiles, i) =>
    ivy.jobs.submitAndWait({
      toolName: "admet_ai",
      parameters: { smiles },
      jobTitle: `ADMET Molecule ${i + 1}`,
    })
  )

  const results = await Promise.allSettled(promises)

  results.forEach((result, i) => {
    if (result.status === "fulfilled") {
      const admet = result.value.result as any
      console.log(`Molecule ${i + 1}: LogP=${admet?.logP}, Solubility=${admet?.solubility}`)
    } else {
      console.error(`Molecule ${i + 1} failed: ${result.reason.message}`)
    }
  })
}

main()
```

---

## Multi-Tool Pipeline

Chain multiple tools together: dock a ligand, then predict ADMET properties.

```ts
import { createIvyClient } from "@ivybiosciences/sdk"

const ivy = createIvyClient({ apiKey: process.env.IVY_API_KEY! })

async function dockAndAnalyze(proteinPdb: string, ligandSmiles: string) {
  // Step 1: Dock the ligand
  console.log("Step 1: Docking...")
  const docking = await ivy.jobs.submitAndWait(
    {
      toolName: "diffdock",
      parameters: {
        protein_pdb: proteinPdb,
        ligand_smiles: ligandSmiles,
        num_poses: 5,
      },
      analysisLevel: "comprehensive",
    },
    { onProgress: (p) => console.log(`  [dock] ${p.phase}: ${p.progress}%`) },
  )

  if (docking.status !== "COMPLETED") {
    throw new Error(`Docking failed: ${docking.error}`)
  }

  const poses = (docking.result as any)?.poses ?? []
  const bestScore = poses[0]?.score
  console.log(`  Best docking score: ${bestScore}`)

  // Step 2: ADMET prediction on the ligand
  console.log("Step 2: ADMET prediction...")
  const admet = await ivy.jobs.submitAndWait({
    toolName: "admet_ai",
    parameters: { smiles: ligandSmiles },
  })

  if (admet.status !== "COMPLETED") {
    throw new Error(`ADMET failed: ${admet.error}`)
  }

  const admetResult = admet.result as any
  console.log(`  LogP: ${admetResult?.logP}, Solubility: ${admetResult?.solubility}`)

  // Step 3: Retrosynthetic analysis
  console.log("Step 3: Retrosynthesis planning...")
  const retro = await ivy.jobs.submitAndWait({
    toolName: "aizynthfinder",
    parameters: { smiles: ligandSmiles },
  })

  if (retro.status === "COMPLETED") {
    const routes = (retro.result as any)?.routes ?? []
    console.log(`  Found ${routes.length} synthetic routes`)
  }

  return {
    dockingScore: bestScore,
    admet: admetResult,
    synthesizable: retro.status === "COMPLETED",
  }
}
```

---

## React Progress Dashboard

A full-featured dashboard showing multiple concurrent jobs with real-time progress.

```tsx
// components/JobDashboard.tsx
"use client"

import { useIvyClient } from "@ivybiosciences/sdk/react"
import { useIvyWebSocket } from "@ivybiosciences/sdk/react"
import { useState, useEffect } from "react"
import type { JobProgress } from "@ivybiosciences/sdk"

interface ActiveJob {
  id: string
  title: string
  toolName: string
}

function JobCard({ job }: { job: ActiveJob }) {
  const { status, isConnected } = useIvyWebSocket(job.id)

  const progressPct = status?.progress ?? 0
  const phase = status?.phase ?? "queued"
  const isDone = status?.status === "COMPLETED" || status?.status === "FAILURE"

  return (
    <div className="border rounded-lg p-4">
      <h3 className="font-medium">{job.title}</h3>
      <p className="text-sm text-gray-500">{job.toolName}</p>

      {!isConnected && !isDone && (
        <p className="text-yellow-600 text-sm">Connecting...</p>
      )}

      <div className="mt-2">
        <div className="bg-gray-200 rounded-full h-2">
          <div
            className="bg-blue-600 rounded-full h-2 transition-all"
            style={{ width: `${progressPct}%` }}
          />
        </div>
        <p className="text-xs mt-1">{phase} — {progressPct}%</p>
      </div>

      {status?.status === "COMPLETED" && (
        <p className="text-green-600 text-sm mt-2">Complete</p>
      )}
      {status?.status === "FAILURE" && (
        <p className="text-red-600 text-sm mt-2">{status.error}</p>
      )}
    </div>
  )
}

export function JobDashboard() {
  const ivy = useIvyClient()
  const [activeJobs, setActiveJobs] = useState<ActiveJob[]>([])

  const submitJob = async (toolName: string, params: Record<string, unknown>) => {
    const { id } = await ivy.jobs.submit({
      toolName,
      parameters: params,
      analysisLevel: "standard",
    })

    setActiveJobs((prev) => [
      ...prev,
      { id, title: `${toolName} job`, toolName },
    ])
  }

  return (
    <div>
      <div className="flex gap-2 mb-4">
        <button onClick={() => submitJob("esmfold", { protein_sequence: "MKFLI..." })}>
          Run ESMFold
        </button>
        <button onClick={() => submitJob("admet_ai", { smiles: "CCO" })}>
          Run ADMET
        </button>
      </div>

      <div className="grid grid-cols-2 gap-4">
        {activeJobs.map((job) => (
          <JobCard key={job.id} job={job} />
        ))}
      </div>
    </div>
  )
}
```

---

## Trying the platform before sign-up

Every example on this page needs an API key or a Clerk JWT. For a first call without a key, send the keyless orchestrate request in [Guest curl](/docs/api#guest-curl); it needs no SDK.

---

## Artifact Download Patterns

### Browser: Create Download Link

```tsx
import { useIvyClient } from "@ivybiosciences/sdk/react"

function DownloadButton({ jobId }: { jobId: string }) {
  const ivy = useIvyClient()
  const [downloading, setDownloading] = useState(false)

  const handleDownload = async () => {
    setDownloading(true)
    try {
      const url = await ivy.artifacts.getDownloadUrl(jobId)
      if (!url) {
        alert("This job has no downloadable artifacts")
        return
      }

      const blob = await ivy.artifacts.download(url)
      const blobUrl = URL.createObjectURL(blob)

      const a = document.createElement("a")
      a.href = blobUrl
      a.download = `${jobId}.pdb`
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(blobUrl)
    } catch (e: any) {
      if (e.status === 403) {
        // Presigned URL expired — get a fresh one
        alert("Download link expired. Please try again.")
      }
    } finally {
      setDownloading(false)
    }
  }

  return (
    <button onClick={handleDownload} disabled={downloading}>
      {downloading ? "Downloading..." : "Download PDB"}
    </button>
  )
}
```

### Server: Parse PDB Content

```ts
import { createIvyClient } from "@ivybiosciences/sdk"

const ivy = createIvyClient({ apiKey: process.env.IVY_API_KEY! })

async function analyzePdb(jobId: string) {
  const url = await ivy.artifacts.getDownloadUrl(jobId)
  if (!url) throw new Error("No artifacts for this job")

  const pdb = await ivy.artifacts.downloadText(url)
  const lines = pdb.split("\n")

  const atoms = lines.filter((l) => l.startsWith("ATOM"))
  const chains = new Set(atoms.map((l) => l.charAt(21)))
  const residues = new Set(atoms.map((l) => l.substring(22, 27).trim()))

  return {
    atomCount: atoms.length,
    chainCount: chains.size,
    residueCount: residues.size,
    chains: [...chains],
  }
}
```

### Server: Download JSON Results

```ts
interface DockingResult {
  poses: Array<{ score: number; rmsd: number; pdb_content: string }>
  metadata: { protein_name: string; ligand_smiles: string }
}

async function getBestPose(jobId: string) {
  const url = await ivy.artifacts.getDownloadUrl(jobId)
  if (!url) throw new Error("No artifacts")

  const result = JSON.parse(await ivy.artifacts.downloadText(url)) as DockingResult
  const best = result.poses.sort((a, b) => a.score - b.score)[0]

  return {
    score: best.score,
    rmsd: best.rmsd,
    pdbLength: best.pdb_content.length,
  }
}
```

---

## Error Handling in Production

### React Error Boundary Integration

```tsx
import { IvyApiError, IvyTimeoutError, IvyWebSocketError } from "@ivybiosciences/sdk"

function handleIvyError(error: unknown): string {
  if (error instanceof IvyApiError) {
    if (error.isRateLimited) return "Too many requests. Please wait a moment."
    if (error.isUnauthorized) return "Session expired. Please sign in again."
    if (error.isForbidden) return "You don't have permission for this action."
    if (error.isNotFound) return "Job not found — it may have expired."
    if (error.isServerError) return "Server error. Our team has been notified."
    return `Error: ${error.detail}`
  }
  if (error instanceof IvyTimeoutError) {
    return `Operation timed out after ${Math.round(error.timeoutMs / 1000)}s. The job may still be running.`
  }
  if (error instanceof IvyWebSocketError) {
    return "Lost connection to the server. Reconnecting..."
  }
  return "An unexpected error occurred."
}

// Usage in a component:
function JobComponent() {
  const { submit, error } = useIvyJob({
    onError: (e) => {
      const message = handleIvyError(e)
      toast.error(message)

      // Report to error tracking
      if (e instanceof IvyApiError && e.isServerError) {
        Sentry.captureException(e, { extra: { status: e.status, body: e.body } })
      }
    },
  })
  // ...
}
```

### Retry with Rate Limit Awareness

```ts
import { createIvyClient, IvyApiError } from "@ivybiosciences/sdk"

const ivy = createIvyClient({
  apiKey: process.env.IVY_API_KEY!,
  retries: 3, // Built-in retry for 429/5xx
})

// Back off when a call hits your plan's rate limit:
async function submitWithBackoff(
  tool: string,
  params: Record<string, unknown>,
  maxAttempts = 3,
) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await ivy.jobs.submit({ toolName: tool, parameters: params })
    } catch (e) {
      if (e instanceof IvyApiError && e.isRateLimited && attempt < maxAttempts - 1) {
        // Wait before retrying; widen the gap on each attempt
        const waitMs = 60_000 * (attempt + 1)
        console.log(`Rate limited. Waiting ${waitMs / 1000}s...`)
        await new Promise((r) => setTimeout(r, waitMs))
      } else {
        throw e
      }
    }
  }
}
```

---

## WebSocket Connection Indicator

`useIvyWebSocket` takes only `{ enabled }` and returns `{ status, isConnected, error }`. The client's socket reconnects on its own (5 attempts, backoff from 1 s); the hook does not expose the attempt count.

```tsx
"use client"

import { useIvyWebSocket } from "@ivybiosciences/sdk/react"

function LiveJobMonitor({ jobId }: { jobId: string }) {
  const { status, isConnected, error } = useIvyWebSocket(jobId)

  return (
    <div>
      {/* Connection status indicator */}
      <div className="flex items-center gap-2">
        <span className={`w-2 h-2 rounded-full ${
          isConnected ? "bg-green-500" : error ? "bg-red-500" : "bg-yellow-500 animate-pulse"
        }`} />
        <span className="text-xs text-gray-500">
          {isConnected ? "Live" : error ? "Disconnected" : "Connecting..."}
        </span>
      </div>

      {/* Progress display */}
      {status && (
        <div className="mt-4">
          <div className="flex justify-between text-sm">
            <span>{status.phase}</span>
            <span>{status.progress}%</span>
          </div>
          <progress value={status.progress ?? 0} max={100} className="w-full" />
        </div>
      )}

      {error && (
        <p className="text-red-600 text-sm mt-2">{error.message}</p>
      )}
    </div>
  )
}
```

---

## Job History List

Paginated job list with status badges and artifact downloads.

```tsx
"use client"

import { useIvyClient } from "@ivybiosciences/sdk/react"
import { useState, useEffect } from "react"

interface JobRecord {
  id: string
  tool_name: string
  status: string
  created_at: string
  job_title?: string
}

export function JobHistory() {
  const ivy = useIvyClient()
  const [jobs, setJobs] = useState<JobRecord[]>([])
  const [page, setPage] = useState(0)
  const pageSize = 10

  useEffect(() => {
    ivy.jobs.list({ skip: page * pageSize, limit: pageSize })
      .then((data) => setJobs(data as JobRecord[]))
  }, [page, ivy])

  const statusColor = (s: string) => {
    switch (s) {
      case "COMPLETED": return "bg-green-100 text-green-800"
      case "FAILURE": return "bg-red-100 text-red-800"
      case "PROCESSING": return "bg-blue-100 text-blue-800"
      default: return "bg-gray-100 text-gray-800"
    }
  }

  return (
    <div>
      <table className="w-full">
        <thead>
          <tr>
            <th>Title</th>
            <th>Tool</th>
            <th>Status</th>
            <th>Created</th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {jobs.map((job) => (
            <tr key={job.id}>
              <td>{job.job_title ?? job.id}</td>
              <td className="font-mono text-sm">{job.tool_name}</td>
              <td>
                <span className={`px-2 py-1 rounded text-xs ${statusColor(job.status)}`}>
                  {job.status}
                </span>
              </td>
              <td className="text-sm">{new Date(job.created_at).toLocaleDateString()}</td>
              <td>
                {job.status === "COMPLETED" && (
                  <button
                    className="text-blue-600 text-sm underline"
                    onClick={async () => {
                      const url = await ivy.artifacts.getDownloadUrl(job.id)
                      if (url) window.open(url)
                    }}
                  >
                    Download
                  </button>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      <div className="flex gap-2 mt-4">
        <button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}>
          Previous
        </button>
        <span>Page {page + 1}</span>
        <button onClick={() => setPage((p) => p + 1)} disabled={jobs.length < pageSize}>
          Next
        </button>
      </div>
    </div>
  )
}
```

---

## Express API Proxy

Proxy SDK calls through your own backend to keep API keys server-side.

```ts
// server/routes/ivy.ts
import express from "express"
import { createIvyClient, IvyApiError } from "@ivybiosciences/sdk"

const router = express.Router()
const ivy = createIvyClient({ apiKey: process.env.IVY_API_KEY! })

// Submit a job
router.post("/jobs", async (req, res) => {
  try {
    const { toolName, parameters, analysisLevel } = req.body
    const userId = req.user?.id // Your auth middleware

    const result = await ivy.jobs.submit({
      toolName,
      parameters,
      analysisLevel,
      orgId: userId,
    })

    res.json(result)
  } catch (e) {
    if (e instanceof IvyApiError) {
      res.status(e.status).json({ error: e.detail })
    } else {
      res.status(500).json({ error: "Internal error" })
    }
  }
})

// Get job status
router.get("/jobs/:id/status", async (req, res) => {
  try {
    const status = await ivy.jobs.getStatus(req.params.id)
    res.json(status)
  } catch (e) {
    if (e instanceof IvyApiError && e.isNotFound) {
      res.status(404).json({ error: "Job not found" })
    } else {
      res.status(500).json({ error: "Internal error" })
    }
  }
})

// Download artifact
router.get("/jobs/:id/download", async (req, res) => {
  try {
    const url = await ivy.artifacts.getDownloadUrl(req.params.id)
    if (!url) return res.status(404).json({ error: "No artifacts" })

    const blob = await ivy.artifacts.download(url)
    const buffer = Buffer.from(await blob.arrayBuffer())

    res.setHeader("Content-Type", "application/octet-stream")
    res.setHeader("Content-Disposition", `attachment; filename="${req.params.id}.pdb"`)
    res.send(buffer)
  } catch (e) {
    if (e instanceof IvyApiError) {
      res.status(e.status).json({ error: e.detail })
    } else {
      res.status(500).json({ error: "Download failed" })
    }
  }
})

export default router
```

---

## Polling-Only Mode (No WebSocket)

For environments where WebSocket is unavailable (serverless functions, testing).

```ts
import { createIvyClient } from "@ivybiosciences/sdk"

const ivy = createIvyClient({ apiKey: process.env.IVY_API_KEY! })

// Force polling mode
const result = await ivy.jobs.submitAndWait(
  {
    toolName: "rdkit_full",
    parameters: { smiles: "CCO", compute_3d: true },
  },
  {
    useWebSocket: false,  // No WebSocket — poll only
    pollInterval: 2000,   // Check every 2 seconds
    timeout: 60_000,      // 1 minute max
    onProgress: (p) => {
      console.log(`[${p.status}] ${p.phase ?? "—"}: ${p.progress ?? 0}%`)
    },
  },
)

console.log("Result:", result.result)
```

---

## TypeScript Type Safety

Leverage the SDK's type system for compile-time guarantees.

```ts
import { createIvyClient } from "@ivybiosciences/sdk"
import type { JobProgress, SubmitJobParams, JobType, JobStatus } from "@ivybiosciences/sdk"
import { JOB_TYPES, TERMINAL_STATUSES } from "@ivybiosciences/sdk"

const ivy = createIvyClient({ apiKey: "ivy_..." })

// Type-safe tool names
function submitTyped(tool: JobType, params: Record<string, unknown>) {
  return ivy.jobs.submit({ toolName: tool, parameters: params })
}
submitTyped("esmfold", { protein_sequence: "..." }) // OK
// submitTyped("invalid_tool", {})                  // TypeScript error

// Type-safe status checking
function isTerminal(status: JobStatus): boolean {
  return TERMINAL_STATUSES.has(status)
}

// Validate tool exists at runtime
function assertValidTool(name: string): asserts name is JobType {
  if (!(name in JOB_TYPES)) {
    throw new Error(`Unknown tool: ${name}. Valid: ${Object.keys(JOB_TYPES).join(", ")}`)
  }
}

// Typed result extraction
interface ESMFoldResult {
  plddt_mean: number
  num_residues: number
  num_chains: number
}

async function foldProtein(sequence: string): Promise<ESMFoldResult> {
  const result = await ivy.jobs.submitAndWait({
    toolName: "esmfold",
    parameters: { protein_sequence: sequence },
  })

  if (result.status !== "COMPLETED" || !result.result) {
    throw new Error(`Folding failed: ${result.error ?? "unknown error"}`)
  }

  return result.result as ESMFoldResult
}
```

---

## Form Integration with React Hook Form

Build validated job submission forms with proper error handling.

```tsx
"use client"

import { useForm, type SubmitHandler } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { useIvyJob } from "@ivybiosciences/sdk/react"

// Validation schema for protein sequence input
const foldSchema = z.object({
  sequence: z
    .string()
    .min(10, "Sequence must be at least 10 amino acids")
    .max(2000, "Sequence must be under 2000 amino acids")
    .regex(
      /^[ACDEFGHIKLMNPQRSTVWY]+$/i,
      "Invalid amino acid sequence — only standard amino acids allowed"
    ),
  analysisLevel: z.enum(["fast", "standard", "comprehensive"]),
  jobTitle: z.string().optional(),
})

type FoldFormData = z.infer<typeof foldSchema>

function ProteinFoldForm() {
  const { submit, status, isLoading, error: jobError } = useIvyJob({
    onComplete: (result) => {
      toast.success(`Folding complete! pLDDT: ${(result.result as any)?.plddt_mean}`)
    },
  })

  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm<FoldFormData>({
    resolver: zodResolver(foldSchema),
    defaultValues: {
      sequence: "",
      analysisLevel: "standard",
    },
  })

  const onSubmit: SubmitHandler<FoldFormData> = async (data) => {
    await submit(
      "esmfold",
      { protein_sequence: data.sequence.toUpperCase() },
      { analysisLevel: data.analysisLevel, jobTitle: data.jobTitle }
    )
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="sequence" className="block text-sm font-medium">
          Protein Sequence
        </label>
        <textarea
          id="sequence"
          {...register("sequence")}
          rows={6}
          className="w-full mt-1 border rounded p-2 font-mono text-sm"
          placeholder="MKFLILLFNILCLFPVLAADNH..."
          disabled={isLoading}
        />
        {errors.sequence && (
          <p className="text-red-600 text-sm mt-1">{errors.sequence.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="analysisLevel" className="block text-sm font-medium">
          Analysis Level
        </label>
        <select
          id="analysisLevel"
          {...register("analysisLevel")}
          className="w-full mt-1 border rounded p-2"
          disabled={isLoading}
        >
          <option value="fast">Fast (basic metrics)</option>
          <option value="standard">Standard (recommended)</option>
          <option value="comprehensive">Comprehensive (all metrics)</option>
        </select>
      </div>

      <div>
        <label htmlFor="jobTitle" className="block text-sm font-medium">
          Job Title (optional)
        </label>
        <input
          id="jobTitle"
          type="text"
          {...register("jobTitle")}
          className="w-full mt-1 border rounded p-2"
          placeholder="My protein analysis"
          disabled={isLoading}
        />
      </div>

      <button
        type="submit"
        disabled={isLoading}
        className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
      >
        {isLoading ? `${status?.phase ?? "Processing"}... ${status?.progress ?? 0}%` : "Predict Structure"}
      </button>

      {jobError && (
        <p className="text-red-600 text-sm">{jobError.message}</p>
      )}
    </form>
  )
}
```

> **Why React Hook Form?** It provides optimized re-renders, built-in validation, and excellent TypeScript support. Combined with Zod schemas, you get compile-time type safety and runtime validation.

---

## Server-Side Rendering (Next.js App Router)

Fetch job status on the server for SEO and initial load performance.

```tsx
// app/jobs/[jobId]/page.tsx
import { createIvyClient } from "@ivybiosciences/sdk"
import { notFound } from "next/navigation"
import { JobResultView } from "./JobResultView"

// Server-side client (API key, not user token)
const ivy = createIvyClient({ apiKey: process.env.IVY_API_KEY! })

interface Props {
  params: { jobId: string }
}

// Generate metadata for SEO
export async function generateMetadata({ params }: Props) {
  const status = await ivy.jobs.getStatus(params.jobId).catch(() => null)

  if (!status) {
    return { title: "Job Not Found" }
  }

  return {
    title: `${status.toolName} Job — ${status.status}`,
    description: `View results for ${status.toolName} analysis`,
  }
}

// Server component: fetch initial data
export default async function JobPage({ params }: Props) {
  const status = await ivy.jobs.getStatus(params.jobId).catch(() => null)

  if (!status) {
    notFound()
  }

  // For completed jobs, also fetch artifact URL
  let artifactUrl: string | null = null
  if (status.status === "COMPLETED") {
    artifactUrl = await ivy.artifacts.getDownloadUrl(params.jobId).catch(() => null)
  }

  // Pass to client component for interactivity
  return (
    <JobResultView
      jobId={params.jobId}
      initialStatus={status}
      initialArtifactUrl={artifactUrl}
    />
  )
}
```

```tsx
// app/jobs/[jobId]/JobResultView.tsx
"use client"

import { useIvyWebSocket } from "@ivybiosciences/sdk/react"
import type { JobProgress } from "@ivybiosciences/sdk"

interface Props {
  jobId: string
  initialStatus: JobProgress
  initialArtifactUrl: string | null
}

export function JobResultView({ jobId, initialStatus, initialArtifactUrl }: Props) {
  // Only subscribe to WebSocket if job is still running
  const isRunning = !["COMPLETED", "FAILURE", "CANCELLED"].includes(initialStatus.status)

  const { status: liveStatus } = useIvyWebSocket(jobId, {
    enabled: isRunning,
  })

  // Use live status if available, otherwise initial
  const status = liveStatus ?? initialStatus
  const artifactUrl = initialArtifactUrl

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold">{status.toolName} Results</h1>

      {status.status === "PROCESSING" && (
        <div className="mt-4">
          <p className="text-gray-600">{status.phase}</p>
          <progress value={status.progress ?? 0} max={100} className="w-full" />
        </div>
      )}

      {status.status === "COMPLETED" && (
        <div className="mt-4">
          <p className="text-green-600 mb-4">Analysis complete</p>

          {status.result && (
            <pre className="bg-gray-100 p-4 rounded overflow-x-auto">
              {JSON.stringify(status.result, null, 2)}
            </pre>
          )}

          {artifactUrl && (
            <a
              href={artifactUrl}
              className="inline-block mt-4 bg-blue-600 text-white px-4 py-2 rounded"
              download
            >
              Download Results
            </a>
          )}
        </div>
      )}

      {status.status === "FAILURE" && (
        <p className="text-red-600 mt-4">{status.error}</p>
      )}
    </div>
  )
}
```

> **Hydration pattern**: The server fetches initial data, then the client optionally subscribes to live updates. This gives fast initial loads while maintaining real-time capability.

---

## Testing with MSW (Mock Service Worker)

Mock SDK responses for reliable, fast tests.

```ts
// tests/mocks/handlers.ts
import { http, HttpResponse } from "msw"
import { DEFAULT_BASE_URL } from "@ivybiosciences/sdk"

// Match the baseUrl your client uses; the SDK posts to `${baseUrl}/jobs/`
const API_BASE = DEFAULT_BASE_URL

export const handlers = [
  // Mock job submission
  http.post(`${API_BASE}/jobs/`, async ({ request }) => {
    const body = await request.json() as { tool_name: string }
    return HttpResponse.json({
      id: "test-job-123",
      status: "PENDING",
      job_type: body.tool_name,
    })
  }),

  // Mock job status polling
  http.get(`${API_BASE}/jobs/:jobId/status`, ({ params }) => {
    return HttpResponse.json({
      id: params.jobId,
      status: "COMPLETED",
      progress: 100,
      phase: "complete",
      result: {
        plddt_mean: 85.5,
        num_residues: 150,
      },
    })
  }),

  // Mock artifact download URL
  http.get(`${API_BASE}/jobs/:jobId/artifacts/download`, () => {
    return HttpResponse.json({
      url: "https://storage.example.com/test-artifact.pdb",
      expiresAt: new Date(Date.now() + 3600000).toISOString(),
    })
  }),
]
```

```tsx
// tests/ProteinFolder.test.tsx
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { setupServer } from "msw/node"
import { http, HttpResponse } from "msw"
import { DEFAULT_BASE_URL } from "@ivybiosciences/sdk"
import { IvyProvider } from "@ivybiosciences/sdk/react"
import { handlers } from "./mocks/handlers"
import ProteinFolder from "@/components/ProteinFolder"

const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

const wrapper = ({ children }: { children: React.ReactNode }) => (
  <IvyProvider config={{ apiKey: "test-key" }}>
    {children}
  </IvyProvider>
)

describe("ProteinFolder", () => {
  it("submits job and shows results", async () => {
    const user = userEvent.setup()
    render(<ProteinFolder />, { wrapper })

    // Enter sequence
    const input = screen.getByPlaceholderText(/enter protein sequence/i)
    await user.type(input, "MKFLILLFNILCLFPVLAADNH")

    // Submit
    const submitBtn = screen.getByRole("button", { name: /predict/i })
    await user.click(submitBtn)

    // Wait for completion
    await waitFor(() => {
      expect(screen.getByText(/plddt.*85\.5/i)).toBeInTheDocument()
    })
  })

  it("handles rate limit errors gracefully", async () => {
    // Override handler for this test
    server.use(
      http.post(`${DEFAULT_BASE_URL}/jobs/`, () => {
        return HttpResponse.json(
          { error: "Rate limit exceeded", code: "RATE_LIMITED" },
          { status: 429 }
        )
      })
    )

    const user = userEvent.setup()
    render(<ProteinFolder />, { wrapper })

    await user.type(screen.getByPlaceholderText(/enter protein sequence/i), "MKFLI")
    await user.click(screen.getByRole("button", { name: /predict/i }))

    await waitFor(() => {
      expect(screen.getByText(/rate limit/i)).toBeInTheDocument()
    })
  })
})
```

---

## Optimistic UI Updates

Show immediate feedback while jobs are submitting.

```tsx
"use client"

import { useIvyClient } from "@ivybiosciences/sdk/react"
import { useState, useTransition } from "react"

interface QueuedJob {
  tempId: string
  toolName: string
  status: "submitting" | "queued" | "error"
  realJobId?: string
  error?: string
}

function OptimisticJobQueue() {
  const ivy = useIvyClient()
  const [jobs, setJobs] = useState<QueuedJob[]>([])
  const [isPending, startTransition] = useTransition()

  const submitJob = async (toolName: string, params: Record<string, unknown>) => {
    const tempId = `temp-${Date.now()}`

    // Optimistically add to UI immediately
    setJobs((prev) => [
      { tempId, toolName, status: "submitting" },
      ...prev,
    ])

    startTransition(async () => {
      try {
        const { id } = await ivy.jobs.submit({ toolName, parameters: params })

        // Update with real job ID
        setJobs((prev) =>
          prev.map((job) =>
            job.tempId === tempId
              ? { ...job, status: "queued", realJobId: id }
              : job
          )
        )
      } catch (e) {
        // Mark as error
        setJobs((prev) =>
          prev.map((job) =>
            job.tempId === tempId
              ? { ...job, status: "error", error: (e as Error).message }
              : job
          )
        )
      }
    })
  }

  return (
    <div>
      <button
        onClick={() => submitJob("esmfold", { protein_sequence: "MKFLI..." })}
        disabled={isPending}
      >
        Add ESMFold Job
      </button>

      <ul className="mt-4 space-y-2">
        {jobs.map((job) => (
          <li
            key={job.tempId}
            className={`p-2 rounded ${
              job.status === "submitting"
                ? "bg-yellow-50 animate-pulse"
                : job.status === "error"
                ? "bg-red-50"
                : "bg-green-50"
            }`}
          >
            <span className="font-mono">{job.toolName}</span>
            {job.status === "submitting" && (
              <span className="text-yellow-600 ml-2">Submitting...</span>
            )}
            {job.status === "queued" && (
              <span className="text-green-600 ml-2">Queued: {job.realJobId}</span>
            )}
            {job.status === "error" && (
              <span className="text-red-600 ml-2">{job.error}</span>
            )}
          </li>
        ))}
      </ul>
    </div>
  )
}
```

> **Why optimistic updates?** Users perceive the app as faster when they see immediate feedback. Even if the actual submission takes 200ms, showing the job in the queue instantly makes the experience feel snappier.
