Skip to main content
    Ivy Biosciences Docs/Call the platform/API/SDKs/Integration Examples

    Integration Examples

    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, Jobs, and 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, the ivybloom CLI, or the MCP server.

    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

    // 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>
      )
    }
    // 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

    // 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)

    // 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

    // 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

    // 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.

    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.

    // 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; it needs no SDK.


    Artifact Download Patterns

    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

    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

    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

    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

    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.

    "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.

    "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.

    // 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).

    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.

    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.

    "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.

    // 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}
        />
      )
    }
    // 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.

    // 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(),
        })
      }),
    ]
    // 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.

    "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.

    Was this page helpful?

    Loading...