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
Browser: Create Download Link
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 routerPolling-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?
SDK errors
IvyError hierarchy — IvyApiError, IvyWebSocketError, IvyTimeoutError with handling patterns
Ivy Biosciences Harness API (v1.0.0)
Session-based agent runtime for multi-turn AI orchestration with tool execution, checkpointing, and real-time streaming. ## Authentication All endpoints accept three auth methods (tried in order): 1. **Clerk JWT** — `Authorization: Bearer <clerk_session_token>` or `__session` cookie (browser sessions) 2. **ivy_ API key via Bearer** — `Authorization: Bearer ivy_xxx` (CLI / SDK — primary programmatic method) 3. **ivy_ API key via custom header** — `X-Ivy-Api-Key: ivy_xxx` (useful when Bearer is reserved for other tokens) The two `ivy_` methods are equivalent. Generate API keys in the .bloom* dashboard under Settings → API Keys.