@darqlabs/curator-sdk
v0.14.0
Published
Official Curator SDK — talk to your deployed agents from server-side JavaScript or TypeScript.
Readme
@darqlabs/curator-sdk
Official Curator SDK.
Beta. The Curator SDKs are in beta. APIs and behavior may change in future versions, including breaking changes to existing behavior, until a 1.0 release. Pin an exact version and review the release notes before upgrading.
Integrate with your deployed agents. Works in Node, the browser, and modern edge runtimes (anywhere fetch exists).
const reply = await curator.agent("support-bot").run("How do I reset my password?")
//console.log(reply.content)Integration guide
Before you start
You'll need three things from your Curator dashboard:
- An API key. Generate one at Settings → Developer. Each key is scoped to a single environment (production, staging, or development), pick the one you're integrating against.
- Your project slug.
- A deployed agent's identifier slug.
1. Install
npm install @darqlabs/curator-sdk
# or
pnpm add @darqlabs/curator-sdk
# or
yarn add @darqlabs/curator-sdkRequires Node 18+ or any runtime with a global fetch. Ships ESM + CommonJS, modern bundlers tree-shake it automatically.
2. Initialize the client
Create one Curator instance and reuse it for the lifetime of your process.
import { Curator } from "@darqlabs/curator-sdk"
export const curator = new Curator({
apiKey: "CURATOR_API_KEY",
project: "default", // your project slug
environment: "production", // 'production' | 'staging' | 'development'
})3. Send your first message
For a single round-trip, use agent.run():
const reply = await curator
.agent("support-bot")
.run("Hi, I lost my receipt, can you resend it for order #4823?")
console.log(reply.content)
// reply.conversationId, store this if you want to follow up
// reply.runId, useful for log correlation
// reply.sequence, per-conversation message index
// reply.metadata, structured result from the agent, or null (see below)4. Multi-turn conversations
For ongoing chat, use agent.chat():
const chat = curator.agent("support-bot").chat()
await chat.send("I'd like to upgrade my plan.")
await chat.send("Actually, what's included in the Pro tier?")
console.log(chat.id) // "conv_8f2a1c4b9e0d"5. Resume a conversation later
Store chat.id somewhere durable, then reattach with .conversation(id):
const chat = curator.agent("support-bot").conversation(conversationId)
const reply = await chat.send("Following up on what we discussed yesterday…")You can also fetch the prior message history:
const page = await chat.history({ limit: 50 })
for (const msg of page.messages) {
console.log(`[${msg.role}] ${msg.content}`)
}
if (page.hasMore) {
const next = await chat.history({ limit: 50, beforeSequence: page.oldestSequence! })
}6. Handle errors
The SDK throws typed errors for non-success states. Catch the ones you care about:
import {
CuratorApprovalRequiredError,
CuratorAgentError,
CuratorTimeoutError,
CuratorAuthError,
CuratorNotFoundError,
} from "@darqlabs/curator-sdk"
try {
const reply = await chat.send("Refund my last order.")
// …use reply.content
} catch (err) {
if (err instanceof CuratorApprovalRequiredError) {
// The agent paused waiting for human approval. Resolution happens in
// the Curator dashboard, surface a friendly message to your user.
showToUser(
`That action needs administrator approval. Please contact your ` +
`administrator to review the request for "${err.approval.tool_name}".`,
)
} else if (err instanceof CuratorTimeoutError) {
// The wait expired but the agent is still working in the background.
// Either give the user a "still thinking…" message, or keep waiting:
const reply = await err.continueWaiting(180000)
} else if (err instanceof CuratorAgentError) {
// Terminal agent failure, LLM quota, provider auth, etc.
// err.failureClass is one of: provider_quota_exhausted | provider_auth |
// provider_bad_request | provider_rate_limited | provider_unavailable |
// internal | unknown. err.isPermanent tells you whether retrying helps.
log.error({ err }, "agent run failed")
} else if (err instanceof CuratorAuthError) {
// 401, bad / revoked API key.
} else if (err instanceof CuratorNotFoundError) {
// 404, wrong deployment slug, project, or conversation id.
} else {
throw err
}
}All errors inherit from CuratorError, so a single catch (err: CuratorError) works as a backstop if you'd rather handle them generically.
7. Attach files
Give the SDK the bytes, get back a reference ready to attach:
const fileRef = await curator.files.upload({
filename: "sales-2026-q2.csv",
mimeType: "text/csv",
data: csvBlob, // Blob, File, ArrayBuffer, Uint8Array, or Buffer
})
const reply = await chat.send("What does this CSV show?", {
files: [fileRef],
})Two-step: useful when you want to drive the PUT yourself (progress UI, resumable uploads, custom transport):
const { uploadUrl, headers, fileReference } = await curator.files.createUpload({
filename: "sales-2026-q2.csv",
mimeType: "text/csv",
size: csvBlob.size,
})
await fetch(uploadUrl, { method: "PUT", headers, body: csvBlob })
const reply = await chat.send("What does this CSV show?", {
files: [fileReference],
})files.upload() asks the server for a signed upload URL and PUTs the bytes straight to storage. On a deployment whose storage cannot sign uploads (local-file storage in development), the server answers 501 and the SDK falls back to POST /api/files/upload (multipart) with the same result, so the call works everywhere.
Common patterns
Stateful chat in a web app
const chat = userSession.conversationId
? curator.agent("support-bot").conversation(userSession.conversationId)
: curator.agent("support-bot").chat()
const reply = await chat.send(userInput)
if (!userSession.conversationId) {
await persistConversationId(userSession.id, chat.id!)
}
return reply.contentLong-running runs
Raise the timeout per-call (server cap 5 minutes):
const reply = await chat.send("Do a deep research pass on this brief.", {
timeoutMs: 300000,
})Structured results (metadata)
An agent whose instructions define a structured output (an itinerary draft, an extracted form, a plan) returns that object on reply.metadata alongside the conversational reply.content. It's null for plain conversational agents. The shape is whatever the agent emits (schema-agnostic); validate it in your app (e.g. with a zod schema).
const reply = await chat.send("Plan 3 days in Kyoto in April, focused on temples.")
console.log(reply.content) // "I've sketched a 3-day Kyoto itinerary…"
console.log(reply.metadata) // { itinerary: { destination: "Kyoto", days: [...] }, changedFields: [...], clarification: null }Live updates: chat.send() returns only the final snapshot. To render progress as the agent fills the object in (turn by turn, before it replies), stream instead and handle state events:
for await (const event of chat.stream("Plan 3 days in Kyoto in April.")) {
if (event.type === "delta") appendText(event.text) // prose tokens
else if (event.type === "state") renderDraft(event.metadata) // live structured snapshot, every turn
else if (event.type === "done") finalize(event.message) // message.metadata = final snapshot
}Using React? @darqlabs/curator-sdk-react's <CuratorChat onState={…} /> wires this up for you.
Using from a browser
CORS: browser requests hit the Curator API directly, so your origin must be on the allowlist. Add it in Settings → Developer.
Optional: verified end-user identity
You don't need this to get started. An API key + project is all it takes to talk to your agents. Everything above works without any user-level identity.
Turn this on only when you want Curator to know which of your users is driving a conversation, for per-user policies, audit attribution, or tenant isolation. It's opt-in per org: until an operator enables it in Settings → Developer → End-user auth, the SDK ignores endUserJwt entirely and requests authenticate on the API key alone.
When enabled, your backend mints a short-lived JWT per user and hands it to the SDK. The server verifies it against your configured JWKS endpoint:
const curator = new Curator({
apiKey: process.env.CURATOR_API_KEY!,
project: "default",
// Static token, or a function the SDK calls per request to refresh it.
endUserJwt: () => mintEndUserJwtForCurrentUser(),
})Standing up a JWKS endpoint is not required for MVP. Skip this section and revisit it when you need verified identity.
API reference
new Curator(options)
| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| apiKey | string | - | Required. curator_live_* (production) or curator_test_* (staging / development), from Settings → Developer. |
| project | string | - | Default project slug. Required unless every call passes { system: true }. |
| environment | 'production' \| 'staging' \| 'development' | 'production' | Default environment. Must match the environment your apiKey was issued for. |
| baseUrl | string | managed host | Override for self-hosted. |
| defaultTimeoutMs | number | 120000 | Server-capped at 300000. |
| defaultHeaders | Record<string,string> | {} | Sent on every request. |
| fetch | typeof fetch | global | Inject a custom fetch. |
| endUserJwt | string \| () => string \| Promise<string> | - | Optional. Only needed if your org has turned on verified end-user identity (see below). Leave it unset otherwise. |
curator.agent(slug, locator?)
Returns an Agent bound to the given deployment. locator accepts { project?, environment?, system? } to override the constructor defaults for this address.
agent.run(content, opts?) → Promise<AssistantMessage>
Send a single message and return the reply. Equivalent to agent.chat().send(content, opts).
agent.chat() → Chat
Returns a new chat handle for multi-turn conversations.
agent.conversation(id) → Chat
Reattach to an existing conversation by id.
chat.send(content, opts?) → Promise<AssistantMessage>
opts.files?: FileAttachment[], attachments.
opts.timeoutMs?: number, override the wait timeout for this call.
opts.clientCapabilities?: { mcpApps?: boolean }, what this client renders. Declare mcpApps: true only from a client that renders MCP App views (tool_call.started.app); the server then mints app sessions and tells the agent a view is showing. <CuratorChat /> does this for you; a server-side caller should leave it out.
Returns the final assistant message. message.metadata holds the agent's structured result (or null).
chat.stream(content, opts?) → AsyncIterable<StreamEvent>
Same send, streamed. Yields events as they arrive:
| event.type | Payload | Terminal |
| --- | --- | --- |
| delta | text, incremental prose tokens | no |
| state | metadata, complete structured snapshot for this turn | no |
| plan_progress | phase, message, completed/total, labels | no |
| tool_call.started / tool_call.completed | tool call id / name | no |
| tool_call.failed | tool call id / name, reason | no |
| done | message (with message.metadata) | yes |
| error | failureClass, message | yes |
| paused_for_approval | approval | yes |
state fires on every turn the agent reports structured state, see Structured results.
Tool calls have a symmetrical lifecycle: every tool_call.started is followed by exactly one tool_call.completed or tool_call.failed carrying the same toolCallId, so you can always close the right pending indicator:
for await (const event of chat.stream("Find the Q3 contract")) {
if (event.type === "tool_call.started") pending.add(event.toolCallId)
else if (event.type === "tool_call.completed") pending.delete(event.toolCallId)
else if (event.type === "tool_call.failed") {
pending.delete(event.toolCallId)
setNotice(event.reason === "policy_denied"
? "This action wasn't allowed."
: "The tool couldn't complete.")
}
}tool_call.failed is not terminal — a tool failing doesn't end the run, and the agent usually keeps working. Its reason is a closed vocabulary (policy_denied for a policy or budget gate, tool_error for a tool that ran and failed); there is deliberately no free-text detail, since the underlying reason can reference internal policies and user identifiers. Treat an unrecognised reason as a generic failure — more may be added.
plan_progress fires for planned agents (ones that decompose a request into a task plan). It reports coarse progress through phase (planning → executing → finalizing) with a user-facing message (e.g. "Working on 3 tasks") and, once tasks are running, completed/total. A planned run is otherwise quiet until the finalizer streams deltas, so handle this to show progress while it works:
for await (const event of chat.stream("Research our top 5 competitors")) {
if (event.type === "delta") appendText(event.text)
else if (event.type === "plan_progress") setStatus(event.message) // "Planning the work…", "Completed 2 of 5"
}Non-planned agents never emit it, so it's always safe to ignore.
chat.history(opts?) → Promise<HistoryPage>
opts.limit?: number, page size (server cap 500).
opts.beforeSequence?: number, cursor; pass page.oldestSequence from the previous page.
chat.id
Conversation id, or null until the first send.
curator.files.upload(input) → Promise<FileAttachment>
One-shot upload. input is { filename, mimeType, data, size? } where data is a Blob, File, ArrayBuffer, Uint8Array, or Buffer. Returns the reference to pass into chat.send(..., { files: [...] }).
curator.files.createUpload(input) → Promise<PresignUploadResult>
Two-step: returns { uploadUrl, method, headers, expiresAt, fileReference }. PUT the bytes to uploadUrl with the returned headers, then attach fileReference to a send.
Errors
| Class | Thrown when |
| --- | --- |
| CuratorAuthError | 401: bad / revoked API key. |
| CuratorForbiddenError | 403: permission denied. |
| CuratorNotFoundError | 404: unknown deployment / conversation. |
| CuratorDeploymentRetiredError | 410: deployment retired. |
| CuratorRateLimitError | 429: back off. |
| CuratorApprovalRequiredError | Run paused for human approval. |
| CuratorAgentError | Terminal agent failure (LLM quota, etc.). failureClass + isPermanent. |
| CuratorTimeoutError | Wait expired. continueWaiting() resumes. |
| CuratorError | Base class; everything above extends this. |
Troubleshooting
CuratorAuthError: Invalid or revoked API key, double-check the key in Settings → Developer. The SDK sends it as Authorization: Bearer <key>.
CuratorNotFoundError: Deployment not found, verify (project slug, environment, deployment slug) all match what the Deployments tab shows. Missing one of these is the #1 cause.
CuratorTimeoutError on every call, your agent is taking longer than defaultTimeoutMs. Raise the timeout (new Curator({ defaultTimeoutMs: 300000 })) or call err.continueWaiting() once.
Browser requests fail with a CORS error, add your origin to the allowlist in Settings → Developer. The SDK isn't doing anything unusual; the request gets blocked before it reaches Curator.
Approval-required pauses on every send, your agent has tools that require human approval. Resolve them once in the dashboard and adjust the deployment's policy if you don't want this gate in your environment.
