npm package: @happyrobot-ai/sdk
Description: TypeScript SDK for the HappyRobot Public API
Latest version: 0.1.8

# @happyrobot-ai/sdk

TypeScript SDK for the HappyRobot Public API.

## Installation

```bash
npm install @happyrobot-ai/sdk
# or
pnpm add @happyrobot-ai/sdk
# or
yarn add @happyrobot-ai/sdk
```

**Optional:** If you plan to use `HappyRobotVoiceClient` (imported from `@happyrobot-ai/sdk/voice`) for browser-side voice calls, also install `livekit-client`:

```bash
npm install livekit-client
# or
pnpm add livekit-client
# or
yarn add livekit-client
```

This is only needed for voice — all other SDK features work without it.

## Quick Start

```ts
import { HappyRobotClient } from "@happyrobot-ai/sdk";

const client = new HappyRobotClient({ apiKey: "sk_live_..." });

// List workflows
const { data } = await client.workflows.list();

// Trigger a run
const { run_id } = await client.workflows.triggerRun("my-workflow", {
  payload: { phone: "+1234567890" },
});
```

## Configuration

```ts
const client = new HappyRobotClient({
  apiKey: "sk_live_...",   // Required. API key (sk_live_... or sk_test_...)
  timeout: 30_000,         // Optional. Request timeout in ms. Defaults to 30000
  maxRetries: 2,           // Optional. Retries on 429/5xx with exponential backoff. Defaults to 2
  fetch: customFetch,      // Optional. Custom fetch implementation
});
```

## Helpers

High-level convenience functions available via `@happyrobot-ai/sdk/helpers`.

### `triggerAndWait`

Trigger a workflow run and poll until it reaches a terminal status.

```ts
import { triggerAndWait } from "@happyrobot-ai/sdk/helpers";

const { run, sessions } = await triggerAndWait(client, {
  workflowId: "my-workflow",
  payload: { phone: "+1234567890" },
  timeoutMs: 300_000,      // Optional. Defaults to 5 minutes
  pollIntervalMs: 2_000,   // Optional. Defaults to 2 seconds
  fetchSessions: true,     // Optional. Fetch sessions on completion. Defaults to true
  environment: "production", // Optional. Defaults to "production"
});

console.log(run.status); // "completed" | "failed" | "canceled" | ...
```

### `createVoiceAgent`

Create a voice agent workflow from a template with optional publishing.

```ts
import { createVoiceAgent } from "@happyrobot-ai/sdk/helpers";

const { workflow, publishResult } = await createVoiceAgent(client, {
  name: "Sales Agent",
  template: "voice-agent",   // "voice-agent" (outbound) or "inbound-voice-agent"
  prompt: "You are a helpful sales assistant...",
  initialMessage: "Hi, how can I help you today?",
  publish: true,
  environment: "production",
  folderId: "folder-id",     // Optional
});
```

### `createFromTemplate`

Create any workflow from a template with custom inputs.

```ts
import { createFromTemplate } from "@happyrobot-ai/sdk/helpers";

const { workflow } = await createFromTemplate(client, {
  template: "voice-agent",
  name: "My Agent",
  inputs: { agent_name: "My Agent" },
  publish: false,
});
```

## Resources

| Property | Class | Description |
|---|---|---|
| `client.workflows` | `WorkflowsResource` | Workflow CRUD, publishing, runs, and templates |
| `client.versions` | `VersionsResource` | Version management (fork, publish, lock, test) |
| `client.nodes` | `NodesResource` | Node CRUD, config schema, and available variables |
| `client.runs` | `RunsResource` | Run details, cancellation, and annotations |
| `client.sessions` | `SessionsResource` | Session details, messages, and SSE streaming |
| `client.messages` | `MessagesResource` | Message quality flags |
| `client.variables` | `VariablesResource` | Workflow-scoped variables |
| `client.phoneNumbers` | `PhoneNumbersResource` | Phone number management |
| `client.sipTrunks` | `SipTrunksResource` | SIP trunk management |
| `client.integrations` | `IntegrationsResource` | Integration and event discovery |
| `client.contacts` | `ContactsResource` | Contact lookup and history |
| `client.knowledgeBases` | `KnowledgeBasesResource` | Knowledge base document management |
| `client.workflowFolders` | `WorkflowFoldersResource` | Workflow folder organization |
| `client.mcp` | `MCPResource` | MCP server management |
| `client.billing` | `BillingResource` | Billing usage details and totals |
| `client.apiKey` | `ApiKeyResource` | API key introspection |
| `client.artifacts` | `ArtifactsResource` | Resolve fresh artifact URLs |
| `client.adversarialSuites` | `AdversarialSuitesResource` | Adversarial suite management and execution |
| `client.adversarialTests` | `AdversarialTestsResource` | Adversarial test management and execution |
| `client.northstars` | `NorthstarsResource` | Northstar quality criteria management |
| `client.customEvals` | `CustomEvalsResource` | Custom eval management and execution |
| `client.issues` | `IssuesResource` | Quality issue (flag) status management |
| `client.auditRemarks` | `AuditRemarksResource` | Audit remark feedback management |
| `client.chat` | `ChatResource` | Chat session management, messaging, and file uploads |
| `client.voice` | `VoiceResource` | Voice call token creation |

---

## `client.workflows`

```ts
// List workflows (paginated)
const { data, pagination } = await client.workflows.list({ page: 1, limit: 20 });

// Iterate all workflows across pages
for await (const wf of client.workflows.listAll()) { ... }

// Create a workflow
const wf = await client.workflows.create({ name: "My Workflow" });

// Get by ID or slug
const wf = await client.workflows.get("my-workflow");

// Update
await client.workflows.update("my-workflow", { name: "New Name" });

// Delete
await client.workflows.delete("my-workflow");

// Duplicate
await client.workflows.duplicate("my-workflow", { name: "Copy" });

// Publish / unpublish
await client.workflows.publish("my-workflow");
await client.workflows.unpublish("my-workflow");

// Cancel all active runs
await client.workflows.cancelRuns("my-workflow");

// List templates
const { data } = await client.workflows.listTemplates();

// List versions
const { data } = await client.workflows.listVersions("my-workflow");

// List runs
const { data } = await client.workflows.listRuns("my-workflow");

// Trigger a run
const { run_id } = await client.workflows.triggerRun("my-workflow", { payload: { phone: "+1..." } });
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list(query?)` | GET | `/workflows` | List workflows, paginated |
| `listAll(query?)` | GET | `/workflows` | Async generator over all pages |
| `create(body)` | POST | `/workflows` | Create a new workflow |
| `get(workflowId)` | GET | `/workflows/:id` | Get workflow by ID or slug |
| `update(workflowId, body)` | PATCH | `/workflows/:id` | Update name, description, etc. |
| `delete(workflowId)` | DELETE | `/workflows/:id` | Delete a workflow |
| `duplicate(workflowId, body?)` | POST | `/workflows/:id/duplicate` | Duplicate a workflow |
| `publish(workflowId, body?)` | POST | `/workflows/:id/publish` | Publish the latest version |
| `unpublish(workflowId)` | POST | `/workflows/:id/unpublish` | Unpublish a workflow |
| `cancelRuns(workflowId)` | POST | `/workflows/:id/cancel-runs` | Cancel all active runs |
| `listTemplates(query?)` | GET | `/workflows/templates` | List workflow templates |
| `listVersions(workflowId, query?)` | GET | `/workflows/:id/versions` | List versions for a workflow |
| `listRuns(workflowId, query?)` | GET | `/workflows/:id/runs` | List runs for a workflow |
| `triggerRun(workflowId, body?)` | POST | `/workflows/:id/runs` | Trigger a new run |

---

## `client.versions`

```ts
const version = await client.versions.get("version-id");
await client.versions.update("version-id", { name: "v2" });
await client.versions.fork("version-id");
await client.versions.publish("version-id");
await client.versions.unpublish("version-id");
await client.versions.lock("version-id");
await client.versions.unlock("version-id");
await client.versions.testAll("version-id");
const issues = await client.versions.getPromptIssues("version-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `get(versionId)` | GET | `/versions/:id` | Get version with node summary and changelog |
| `update(versionId, body)` | PATCH | `/versions/:id` | Update name or description |
| `fork(versionId)` | POST | `/versions/:id/fork` | Clone a version |
| `publish(versionId, body?)` | POST | `/versions/:id/publish` | Publish a version |
| `unpublish(versionId)` | POST | `/versions/:id/unpublish` | Unpublish a version |
| `lock(versionId)` | POST | `/versions/:id/lock` | Lock a version (prevent edits) |
| `unlock(versionId)` | POST | `/versions/:id/unlock` | Unlock a version |
| `testAll(versionId)` | POST | `/versions/:id/test-all` | Run test-all validation |
| `getPromptIssues(versionId)` | GET | `/versions/:id/prompt-issues` | Get prompt quality issues for all prompt nodes |

---

## `client.nodes`

Nodes are always scoped to a version.

```ts
const nodes = await client.nodes.list("version-id");
await client.nodes.addBatch("version-id", { nodes: [...] });
const node = await client.nodes.get("version-id", "node-id");
await client.nodes.update("version-id", "node-id", { config: { ... } });
await client.nodes.delete("version-id", "node-id");
const schema = await client.nodes.getConfigSchema("version-id", "node-id");
const vars = await client.nodes.getAvailableVars("version-id", "node-id");
const result = await client.nodes.test("version-id", "node-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list(versionId)` | GET | `/versions/:vId/nodes` | List all nodes in a version |
| `addBatch(versionId, body)` | POST | `/versions/:vId/nodes` | Add nodes in batch (1–50) |
| `get(versionId, nodeId)` | GET | `/versions/:vId/nodes/:nId` | Get a single node |
| `update(versionId, nodeId, body)` | PUT | `/versions/:vId/nodes/:nId` | Update a node |
| `delete(versionId, nodeId)` | DELETE | `/versions/:vId/nodes/:nId` | Delete a node |
| `getConfigSchema(versionId, nodeId)` | GET | `/versions/:vId/nodes/:nId/config-schema` | Get field types and requirements |
| `getAvailableVars(versionId, nodeId)` | GET | `/versions/:vId/nodes/:nId/available-vars` | Get upstream variables available to this node |
| `test(versionId, nodeId, body?)` | POST | `/versions/:vId/nodes/:nId/test` | Test a single node (version must not be published) |

---

## `client.runs`

```ts
const run = await client.runs.get("run-id");
const sessions = await client.runs.getSessions("run-id");
const recordings = await client.runs.getRecordings("run-id");
const flags = await client.runs.getFlags("run-id");
await client.runs.cancel("run-id");
await client.runs.mark("run-id", { annotation: "correct" });
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `get(runId)` | GET | `/runs/:id` | Get run details |
| `getSessions(runId, query?)` | GET | `/runs/:id/sessions` | Get sessions for a run |
| `getRecordings(runId)` | GET | `/runs/:id/recordings` | Get recordings for a run |
| `getFlags(runId, query?)` | GET | `/runs/:id/flags` | Get quality flags for a run |
| `cancel(runId)` | POST | `/runs/:id/cancel` | Cancel a run |
| `mark(runId, body)` | POST | `/runs/:id/mark` | Annotate a run (correct / incorrect / critical) |

---

## `client.sessions`

```ts
const session = await client.sessions.get("session-id");
const { data } = await client.sessions.getMessages("session-id");

// SSE streaming
for await (const event of await client.sessions.stream("session-id")) {
  if (event.event === "message") console.log(event.data.role, event.data.content);
  if (event.event === "session_ended") break;
}
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `get(sessionId)` | GET | `/sessions/:id` | Get session details |
| `getMessages(sessionId, query?)` | GET | `/sessions/:id/messages` | Get paginated messages for a session |
| `stream(sessionId, query?)` | GET | `/sessions/:id/stream` | Open SSE stream of session messages |

---

## `client.messages`

```ts
const { data } = await client.messages.listFlags("message-id");
await client.messages.createFlag("message-id", { type: "incorrect", note: "..." });
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `listFlags(messageId, query?)` | GET | `/messages/:id/flags` | List quality flags for a message |
| `createFlag(messageId, body)` | POST | `/messages/:id/flags` | Create a quality flag on a message |

---

## `client.artifacts`

```ts
const resolved = await client.artifacts.resolve({
  s3_keys: ["artifacts/<media_id>/extracted_text.txt"],
});
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `resolve(body)` | POST | `/artifacts/resolve` | Resolve fresh presigned URLs for artifacts |

---

## `client.variables`

Variables are scoped to a workflow.

```ts
const { data } = await client.variables.list("workflow-id");
await client.variables.create("workflow-id", { name: "MY_VAR", value: "hello" });
await client.variables.update("workflow-id", "variable-id", { value: "world" });
await client.variables.delete("workflow-id", "variable-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list(workflowId, query?)` | GET | `/workflows/:wId/variables` | List variables for a workflow |
| `create(workflowId, body)` | POST | `/workflows/:wId/variables` | Create a variable |
| `update(workflowId, variableId, body)` | PATCH | `/workflows/:wId/variables/:vId` | Update a variable |
| `delete(workflowId, variableId)` | DELETE | `/workflows/:wId/variables/:vId` | Delete a variable |

---

## `client.phoneNumbers`

```ts
const numbers = await client.phoneNumbers.list();
await client.phoneNumbers.buy({ area_code: "415" });
await client.phoneNumbers.update("id", { name: "Main Line" });
await client.phoneNumbers.getUsage("id");
await client.phoneNumbers.freeUp("id");
await client.phoneNumbers.delete("id");
await client.phoneNumbers.removeFromWorkflow("id", { use_case_id: "..." });
await client.phoneNumbers.getTollFreeVerification("id");
await client.phoneNumbers.createTollFreeVerification("id", { ... });
await client.phoneNumbers.deleteTollFreeVerification("verification-sid");
await client.phoneNumbers.createSipTrunk("id");
await client.phoneNumbers.validateTollFreeNumbers({ phone_numbers: [...] });
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list(query?)` | GET | `/phone-numbers` | List all phone numbers |
| `buy(body)` | POST | `/phone-numbers` | Purchase a new phone number |
| `update(id, body)` | PUT | `/phone-numbers/:id` | Update name or caller ID |
| `getUsage(id)` | GET | `/phone-numbers/:id/usage` | Get usage info |
| `getTollFreeVerification(id)` | GET | `/phone-numbers/:id/tollfree-verification` | Get toll-free verification status |
| `createTollFreeVerification(id, body)` | POST | `/phone-numbers/:id/tollfree-verification` | Start toll-free verification |
| `deleteTollFreeVerification(verificationSid)` | DELETE | `/phone-numbers/tollfree-verification/:sid` | Delete a toll-free verification |
| `createSipTrunk(id)` | POST | `/phone-numbers/:id/sip-trunk` | Create a SIP trunk for a number |
| `freeUp(id)` | POST | `/phone-numbers/:id/free-up-number` | Release a number from workflows |
| `delete(id)` | POST | `/phone-numbers/:id/delete-number` | Permanently delete a number |
| `removeFromWorkflow(id, body)` | POST | `/phone-numbers/:id/remove-from-workflow` | Remove from a specific workflow |
| `validateTollFreeNumbers(body)` | POST | `/phone-numbers/validate-toll-free-numbers` | Check for conflicts |

---

## `client.sipTrunks`

```ts
const trunks = await client.sipTrunks.list();
const options = await client.sipTrunks.listOptions();
await client.sipTrunks.create({ name: "Main Trunk", ... });
await client.sipTrunks.createBulk({ trunks: [...] });
const trunk = await client.sipTrunks.get("trunk-id");
await client.sipTrunks.update("trunk-id", { name: "Updated" });
await client.sipTrunks.delete("trunk-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list()` | GET | `/sip-trunks` | List all SIP trunks |
| `listOptions()` | GET | `/sip-trunks/options` | Lightweight list of trunk options |
| `create(body)` | POST | `/sip-trunks` | Create a SIP trunk |
| `createBulk(body)` | POST | `/sip-trunks/bulk` | Create multiple SIP trunks |
| `get(trunkId)` | GET | `/sip-trunks/:id` | Get a SIP trunk |
| `update(trunkId, body)` | PUT | `/sip-trunks/:id` | Update a SIP trunk |
| `delete(trunkId)` | DELETE | `/sip-trunks/:id` | Delete a SIP trunk |

---

## `client.integrations`

```ts
const integrations = await client.integrations.list();
const integration = await client.integrations.get("integration-id");
await client.integrations.createCredential("integration-id", { name: "My Creds", fields: { ... } });

// Integration-specific sub-resources
await client.integrations.googleSheets.spreadsheets();
await client.integrations.googleSheets.worksheets({ spreadsheet_id: "..." });
await client.integrations.googleSheets.columns({ spreadsheet_id: "...", worksheet_id: "..." });
await client.integrations.googleSheets.rows({ spreadsheet_id: "...", worksheet_id: "..." });

await client.integrations.slack.channels();
await client.integrations.slack.users();

await client.integrations.teams.teams();
await client.integrations.teams.channels();
await client.integrations.teams.users();

await client.integrations.twilioSms.phoneNumbers();

await client.integrations.whatsApp.businesses({ credential_id: "..." });
await client.integrations.whatsApp.businessAccounts({ credential_id: "...", business_id: "..." });
await client.integrations.whatsApp.phoneNumbers({ credential_id: "...", business_account_id: "..." });
await client.integrations.whatsApp.messageTemplates({ credential_id: "...", business_account_id: "..." });
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list(query?)` | GET | `/integrations` | List all integrations |
| `get(id)` | GET | `/integrations/:id` | Get integration and its events |
| `createCredential(id, body)` | POST | `/integrations/:id/create-credential` | Create a credential for a form-based integration |
| `googleSheets.spreadsheets(query?)` | GET | `/integrations/google-sheets/spreadsheets` | List spreadsheets |
| `googleSheets.worksheets(query)` | GET | `/integrations/google-sheets/worksheets` | List worksheets in a spreadsheet |
| `googleSheets.columns(query)` | GET | `/integrations/google-sheets/columns` | List columns in a worksheet |
| `googleSheets.rows(query)` | GET | `/integrations/google-sheets/rows` | List rows in a worksheet |
| `slack.channels(query?)` | GET | `/integrations/slack/channels` | List Slack channels |
| `slack.users(query?)` | GET | `/integrations/slack/users` | List Slack users |
| `teams.teams(query?)` | GET | `/integrations/teams/teams` | List Microsoft Teams teams |
| `teams.channels(query?)` | GET | `/integrations/teams/channels` | List Microsoft Teams channels |
| `teams.users(query?)` | GET | `/integrations/teams/users` | List Microsoft Teams users |
| `twilioSms.phoneNumbers(query?)` | GET | `/integrations/twilio-sms/phone-numbers` | List Twilio SMS numbers |
| `whatsApp.businesses(query)` | GET | `/integrations/whatsapp/businesses` | List WhatsApp businesses |
| `whatsApp.businessAccounts(query)` | GET | `/integrations/whatsapp/business-accounts` | List WhatsApp business accounts |
| `whatsApp.phoneNumbers(query)` | GET | `/integrations/whatsapp/phone-numbers` | List WhatsApp phone numbers |
| `whatsApp.messageTemplates(query)` | GET | `/integrations/whatsapp/message-templates` | List WhatsApp message templates |

---

## `client.contacts`

```ts
const { data } = await client.contacts.list({ search: "John" });
const contact = await client.contacts.resolve({ phone_number: "+14155551234" });
const contact = await client.contacts.get("contact-id");
const interactions = await client.contacts.getInteractions("contact-id");
const memories = await client.contacts.getMemories("contact-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list(query?)` | GET | `/contacts` | List contacts (cursor-paginated) |
| `resolve(query)` | GET | `/contacts/resolve` | Look up a contact by phone number or email |
| `get(contactId)` | GET | `/contacts/:id` | Get contact by ID |
| `getInteractions(contactId)` | GET | `/contacts/:id/interactions` | Get call/message history for a contact |
| `getMemories(contactId)` | GET | `/contacts/:id/memories` | Get AI memories for a contact |

---

## `client.knowledgeBases`

```ts
const kbs = await client.knowledgeBases.list();
const files = await client.knowledgeBases.listFiles("kb-id");
const urls = await client.knowledgeBases.getUploadUrls("kb-id", { files: [{ name: "doc.pdf", content_type: "application/pdf" }] });
await client.knowledgeBases.triggerChunking("kb-id");
await client.knowledgeBases.deleteFile("kb-id", "file-id");
await client.knowledgeBases.delete("kb-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list()` | GET | `/knowledge-bases` | List all knowledge bases |
| `listFiles(kbId)` | GET | `/knowledge-bases/:id/files` | List documents in a knowledge base |
| `getUploadUrls(kbId, body)` | POST | `/knowledge-bases/:id/upload-urls` | Get pre-signed S3 upload URLs |
| `triggerChunking(kbId)` | POST | `/knowledge-bases/:id/trigger-chunking` | Start document processing/chunking |
| `deleteFile(kbId, fileId)` | DELETE | `/knowledge-bases/:id/files/:fileId` | Delete a file from a knowledge base |
| `delete(kbId)` | DELETE | `/knowledge-bases/:id` | Delete a knowledge base |

---

## `client.workflowFolders`

```ts
const { data } = await client.workflowFolders.list();
await client.workflowFolders.create({ name: "Production" });
const folder = await client.workflowFolders.get("folder-id");
await client.workflowFolders.update("folder-id", { name: "Updated" });
await client.workflowFolders.delete("folder-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list(query?)` | GET | `/workflow-folders` | List folders (paginated) |
| `create(body)` | POST | `/workflow-folders` | Create a folder |
| `get(folderId)` | GET | `/workflow-folders/:id` | Get a folder |
| `update(folderId, body)` | PUT | `/workflow-folders/:id` | Update a folder |
| `delete(folderId)` | DELETE | `/workflow-folders/:id` | Delete a folder |

---

## `client.mcp`

```ts
const { data } = await client.mcp.list();
await client.mcp.create({ name: "My MCP", url: "https://..." });
await client.mcp.refresh("mcp-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `list(query?)` | GET | `/mcp` | List MCP servers (paginated) |
| `create(body)` | POST | `/mcp` | Register a new MCP server |
| `refresh(mcpId)` | POST | `/mcp/:id/refresh` | Re-discover tools for an MCP server |

---

## `client.billing`

```ts
const details = await client.billing.getDetails({ start: "2024-01-01", end: "2024-01-31" });
const totals = await client.billing.getTotals({ start: "2024-01-01", end: "2024-01-31" });
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `getDetails(query)` | GET | `/billing/usage/details` | Detailed billing line items |
| `getTotals(query)` | GET | `/billing/usage/totals` | Aggregated billing totals |

---

## `client.apiKey`

```ts
const info = await client.apiKey.describe();
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `describe()` | GET | `/api-key/describe` | Introspect the current API key (ID, name, org, etc.) |

---

## `client.adversarialSuites`

Adversarial suites group multiple adversarial tests and can auto-generate them from a prompt.

```ts
const { suite } = await client.adversarialSuites.get("suite-id");
await client.adversarialSuites.update("suite-id", { name: "New Name", generation_count: 10 });
await client.adversarialSuites.delete("suite-id");

// Generate tests from the suite's generation_prompt using AI
await client.adversarialSuites.generate("suite-id", { version_id: "v-id" });

// Generate a mermaid workflow graph for visualization
await client.adversarialSuites.generateGraph("suite-id", { version_id: "v-id" });

// Run all tests in the suite
const { suite_run_id } = await client.adversarialSuites.run("suite-id", { version_id: "v-id" });

// Poll results
const { runs } = await client.adversarialSuites.listRuns("suite-id");
const { run } = await client.adversarialSuites.getRun("suite-run-id");
const { test_runs } = await client.adversarialSuites.getRunTestRuns("suite-run-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `get(suiteId)` | GET | `/adversarial-suites/:id` | Get suite with optional mermaid graph |
| `update(suiteId, body)` | PATCH | `/adversarial-suites/:id` | Update name, model, timeout, generation settings |
| `delete(suiteId)` | DELETE | `/adversarial-suites/:id` | Delete a suite |
| `generate(suiteId, body?)` | POST | `/adversarial-suites/:id/generate` | AI-generate tests from the suite's generation prompt |
| `generateGraph(suiteId, body)` | POST | `/adversarial-suites/:id/generate-graph` | Generate a mermaid workflow visualization |
| `run(suiteId, body)` | POST | `/adversarial-suites/:id/run` | Execute all tests asynchronously, returns `suite_run_id` |
| `listRuns(suiteId, query?)` | GET | `/adversarial-suites/:id/runs` | List suite runs |
| `getRun(suiteRunId)` | GET | `/adversarial-suites/runs/:runId` | Get a suite run with aggregate pass/fail counts |
| `getRunTestRuns(suiteRunId)` | GET | `/adversarial-suites/runs/:runId/test-runs` | List individual test results within a suite run |

---

## `client.adversarialTests`

Individual adversarial tests simulate a user trying to break the agent's behavior.

```ts
const { test } = await client.adversarialTests.get("test-id");
await client.adversarialTests.update("test-id", { adversarial_prompt: "Try to get PII", timeout_seconds: 120 });
await client.adversarialTests.delete("test-id");

// Run a single test
const { test_run_id } = await client.adversarialTests.run("test-id", { version_id: "v-id" });

// Poll results
const { runs } = await client.adversarialTests.listRuns("test-id");
const { run } = await client.adversarialTests.getRun("run-id");
const { messages } = await client.adversarialTests.getRunMessages("run-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `get(testId)` | GET | `/adversarial-tests/:id` | Get test details |
| `update(testId, body)` | PATCH | `/adversarial-tests/:id` | Update prompt, model, variables, timeout |
| `delete(testId)` | DELETE | `/adversarial-tests/:id` | Delete a test |
| `run(testId, body)` | POST | `/adversarial-tests/:id/run` | Execute a test asynchronously, returns `test_run_id` |
| `listRuns(testId, query?)` | GET | `/adversarial-tests/:id/runs` | List test runs with audit remarks |
| `getRun(runId)` | GET | `/adversarial-tests/runs/:runId` | Get a single run with full audit remarks |
| `getRunMessages(runId)` | GET | `/adversarial-tests/runs/:runId/messages` | Get the full conversation from a test run |

---

## `client.northstars`

Northstars are quality criteria that define how the agent should behave. They are used to automatically grade conversations.

```ts
const { northstar } = await client.northstars.get("northstar-id");
await client.northstars.update("northstar-id", { enabled: true, priority: "high" });
await client.northstars.delete("northstar-id");

// Get full regeneration history
const { history } = await client.northstars.getHistory("northstar-id");

// Rate a northstar (-2 = strongly wrong, +2 = strongly correct)
const { feedback } = await client.northstars.submitFeedback("northstar-id", {
  correctness: 2,
  feedback: "This criterion is perfect.",
  trigger_regeneration: false,
});

await client.northstars.deleteFeedback("northstar-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `get(northstarId)` | GET | `/northstars/:id` | Get a northstar |
| `update(northstarId, body)` | PATCH | `/northstars/:id` | Update name, description, examples, category, priority, or enabled state |
| `delete(northstarId)` | DELETE | `/northstars/:id` | Delete a northstar |
| `getHistory(northstarId)` | GET | `/northstars/:id/history` | Get the full regeneration chain, oldest first |
| `submitFeedback(northstarId, body)` | POST | `/northstars/:id/feedback` | Rate a northstar's correctness (−2 to +2), optionally trigger regeneration |
| `deleteFeedback(northstarId)` | DELETE | `/northstars/:id/feedback` | Remove your feedback on a northstar |

---

## `client.customEvals`

Custom evals test a specific prompt node against expected outputs or northstar criteria.

```ts
const { test } = await client.customEvals.get("eval-id");
await client.customEvals.update("eval-id", {
  name: "Greeting check",
  expected_response: "Hello, how can I help you?",
});
await client.customEvals.delete("eval-id");

// Run an eval
const { run_id } = await client.customEvals.run("eval-id", { version_id: "v-id" });

// Poll results
const { runs } = await client.customEvals.listRuns("eval-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `get(evalId)` | GET | `/custom-evals/:id` | Get a custom eval |
| `update(evalId, body)` | PATCH | `/custom-evals/:id` | Update messages, expected outputs, variables, or northstar IDs |
| `delete(evalId)` | DELETE | `/custom-evals/:id` | Delete a custom eval |
| `run(evalId, body)` | POST | `/custom-evals/:id/run` | Execute the eval asynchronously, returns `run_id` |
| `listRuns(evalId, query?)` | GET | `/custom-evals/:id/runs` | List runs with pass/fail and judge reasoning |

---

## `client.issues`

Issues (quality flags) are raised when a conversation fails a northstar or quality check.

```ts
await client.issues.update("issue-id", { status: "approved" });
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `update(issueId, body)` | PATCH | `/issues/:id` | Update issue status (`open` / `approved` / `rejected` / `closed`) |

---

## `client.auditRemarks`

Audit remarks are individual northstar grades attached to a conversation. Feedback on audit remarks improves future grading.

```ts
const feedback = await client.auditRemarks.getFeedback("audit-remark-id");

// Thumbs up — automatically adds the remark as a northstar positive example
await client.auditRemarks.submitFeedback("audit-remark-id", {
  polarity: true,
});

// Thumbs down with attribution
await client.auditRemarks.submitFeedback("audit-remark-id", {
  polarity: false,
  issue_attribution: "northstar",
  comment: "The northstar criteria was wrong here.",
});

await client.auditRemarks.deleteFeedback("audit-remark-id");
```

| Method | HTTP | Path | Description |
|---|---|---|---|
| `getFeedback(auditRemarkId)` | GET | `/audit-remarks/:id/feedback` | Get your feedback for this remark, or `null` |
| `submitFeedback(auditRemarkId, body)` | POST | `/audit-remarks/:id/feedback` | Submit thumbs up/down; a thumbs-up adds the remark as a northstar example |
| `deleteFeedback(auditRemarkId)` | DELETE | `/audit-remarks/:id/feedback` | Delete your feedback |

---

## Chat (`client.chat` + `HappyRobotChatClient`)

Build custom chat UIs powered by your workflows. Uses a two-tier auth model:

1. **Your server** creates a scoped client token using the API key (keeps it secret)
2. **Your browser** uses `HappyRobotChatClient` with that token for all chat operations

### Server-side: Create a client token

```ts
// --- YOUR SERVER (e.g. Next.js API route, Express handler) ---
import { HappyRobotClient } from "@happyrobot-ai/sdk";

const client = new HappyRobotClient({ apiKey: process.env.HAPPYROBOT_API_KEY });

app.post("/api/chat-token", async (req, res) => {
  const { token, expires_at } = await client.chat.createToken({
    workflow_id: "your-workflow-id",
  });
  res.json({ token, expires_at });
});
```

### Browser-side: `HappyRobotChatClient`

```ts
// --- YOUR BROWSER CODE (React, Vue, vanilla JS, etc.) ---
import { HappyRobotChatClient } from "@happyrobot-ai/sdk";

// 1. Get a client token from your server
const { token } = await fetch("/api/chat-token", { method: "POST" }).then(r => r.json());

// 2. Initialize the browser-side client
const chat = new HappyRobotChatClient({ token });

// 3. Create a session
const { session_id } = await chat.createSession();

// 4. Connect bidirectional WebSocket
const connection = chat.connect(session_id, {
  onResponseStart: () => {
    // Show typing indicator
  },
  onResponseChunk: (content) => {
    // Append streamed text to the UI
  },
  onResponseEnd: (content) => {
    // Full response complete — hide typing indicator
  },
  onSessionClosed: (event) => {
    // Session ended: event.status, event.reason, event.duration
  },
  onTokenExpired: () => {
    // Token expired — fetch a new token from your server and reconnect
  },
});

// 5. Send messages through the WebSocket
const ack = await connection.sendMessage({ content: "Hello!" });
console.log(ack.message.id); // server-assigned message ID

// 6. Send messages with file attachments
const artifact = await chat.uploadFile(fileBlob, "photo.png", "image/png");
await connection.sendMessage({ content: "Here's the photo", artifacts: [artifact] });

// 7. Get history (page reload, reconnect)
const { messages } = await chat.getHistory(session_id);

// 8. Clean up
connection.close();
```

### Browser-side: File uploads

```ts
// Option A: Use the convenience method (handles all 3 steps)
const artifact = await chat.uploadFile(fileBlob, "photo.png", "image/png");
await connection.sendMessage({
  content: "Here's the photo",
  artifacts: [artifact],
});

// Option B: Manual 3-step flow for more control
const upload = await chat.getPresignedUpload({ filename: "photo.png", mime_type: "image/png" });
await fetch(upload.upload_url, { method: "PUT", body: fileBlob, headers: { "Content-Type": "image/png" } });
await chat.completeUpload({
  artifact_id: upload.artifact_id,
  s3_uri: upload.s3_uri,
  filename: "photo.png",
  mime_type: "image/png",
  size_bytes: fileBlob.size,
});
await connection.sendMessage({
  content: "Here's the photo",
  artifacts: [{ media_id: upload.artifact_id, mime_type: "image/png", filename: "photo.png", size_bytes: fileBlob.size }],
});
```

### `HappyRobotClient` (server-side)

| Method | Description |
|---|---|
| `client.chat.createToken({ workflow_id, env? })` | Create a scoped client token (1 hour expiry) |

### `HappyRobotChatClient` (browser-side)

| Method | Description |
|---|---|
| `chat.createSession()` | Create a new chat session |
| `chat.connect(sessionId, handlers)` | Open bidirectional WebSocket — returns `ChatConnection` |
| `chat.sendMessage(sessionId, { content, artifacts? })` | Send a user message via HTTP (fallback if WS unavailable) |
| `chat.getHistory(sessionId)` | Get message history |
| `chat.uploadFile(file, filename, mimeType)` | Upload a file (convenience method) |
| `chat.getPresignedUpload({ filename, mime_type })` | Get presigned S3 upload URL |
| `chat.completeUpload({ artifact_id, s3_uri, ... })` | Register uploaded artifact |

### `ChatConnection`

| Method | Description |
|---|---|
| `connection.sendMessage({ content, artifacts? })` | Send a message via WebSocket. Returns a `Promise` that resolves with the server ack |
| `connection.close()` | Close the WebSocket connection |
| `connection.ws` | The underlying `WebSocket` instance |

### WebSocket Events

**Server → Client:**

| Event | Fields | Description |
|---|---|---|
| `connected` | `session_id` | Connection established |
| `response-start` | `content` | AI started generating a response |
| `response-chunk` | `content` | Partial response text |
| `response-end` | `content` | Complete response text |
| `session-closed` | `session_id`, `status`, `reason`, `duration`, `timestamp` | Session ended |
| `token-expired` | — | JWT expired — reconnect with a fresh token |
| `message-ack` | `id`, `message` | Server confirmed the message was sent |
| `message-error` | `id`, `error` | Server failed to send the message |
| `heartbeat` | — | Keep-alive (every 15s) |

**Client → Server:**

| Event | Fields | Description |
|---|---|---|
| `message` | `content`, `artifacts?`, `id?` | Send a user message. `id` is echoed back in ack/error |

---

## Voice (`client.voice` + `HappyRobotVoiceClient`)

Build browser-side voice calls with AI agents powered by your workflows. Uses the same two-tier auth model as chat:

1. **Your server** creates a scoped LiveKit token using the API key (keeps it secret)
2. **Your browser** uses `HappyRobotVoiceClient` with that token to connect via WebRTC

### Server-side: Create a voice token

```ts
// --- YOUR SERVER (e.g. Next.js API route, Express handler) ---
import { HappyRobotClient } from "@happyrobot-ai/sdk";

const client = new HappyRobotClient({ apiKey: process.env.HAPPYROBOT_API_KEY });

app.post("/api/voice-token", async (req, res) => {
  const result = await client.voice.createToken({
    workflow_id: "your-workflow-id",
    data: { customer_name: "John" }, // optional — passed to the agent
  });
  res.json(result); // { url, token, room_name, run_id }
});
```

### Browser-side: `HappyRobotVoiceClient`

```ts
// --- YOUR BROWSER CODE (React, Vue, vanilla JS, etc.) ---
import { HappyRobotVoiceClient } from "@happyrobot-ai/sdk/voice";

// 1. Get a voice token from your server
const { url, token } = await fetch("/api/voice-token", { method: "POST" }).then(r => r.json());

// 2. Create the voice client and connect
const voice = new HappyRobotVoiceClient({ url, token });
const connection = await voice.connect({
  onConnected: () => {
    // Call started — microphone is automatically enabled
  },
  onAgentConnected: (participant) => {
    // AI agent joined the room
    console.log("Agent:", participant.identity);
  },
  onDisconnected: (reason) => {
    // Call ended
  },
  onReconnecting: () => {
    // Connection temporarily lost
  },
  onReconnected: () => {
    // Successfully reconnected
  },
  onError: (error) => {
    console.error("Voice error:", error);
  },
});

// 3. Mute / unmute
await connection.mute();
console.log(connection.isMuted()); // true
await connection.unmute();

// 4. End the call
await connection.disconnect();
```

### `HappyRobotClient` (server-side)

| Method | Description |
|---|---|
| `client.voice.createToken({ workflow_id, data?, env? })` | Create a LiveKit token for voice calls |

### `HappyRobotVoiceClient` (browser-side)

| Method | Description |
|---|---|
| `new HappyRobotVoiceClient({ url, token })` | Create client with LiveKit credentials |
| `voice.connect(handlers?)` | Connect to room, enable microphone — returns `VoiceConnection` |

### `VoiceConnection`

| Method | Description |
|---|---|
| `connection.disconnect()` | End the call and disconnect from the room |
| `connection.mute()` | Mute the local microphone |
| `connection.unmute()` | Unmute the local microphone |
| `connection.isMuted()` | Check mute state |
| `connection.room` | Underlying LiveKit `Room` instance for advanced use cases |

### Room Events

| Handler | Description |
|---|---|
| `onConnected` | Connected to the LiveKit room |
| `onDisconnected` | Disconnected from the room |
| `onAgentConnected` | AI agent participant joined the room |
| `onTrackSubscribed` | Remote audio track subscribed (auto-plays) |
| `onTrackUnsubscribed` | Remote audio track unsubscribed |
| `onReconnecting` | Connection temporarily lost — attempting to reconnect |
| `onReconnected` | Successfully reconnected |
| `onError` | An error occurred |

---

## Pagination

List methods that are paginated return a `PaginatedResponse<T>`:

```ts
interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    page_size: number;
    total_pages: number;
    total_records: number;
    has_next_page: boolean;
    has_previous_page: boolean;
  };
}
```

For resources that support `listAll()`, use the async generator to iterate all pages automatically:

```ts
for await (const workflow of client.workflows.listAll({ folder_id: "..." })) {
  console.log(workflow.name);
}
```

## Error Handling

```ts
import { ApiError, AuthenticationError, NotFoundError } from "@happyrobot-ai/sdk";

try {
  const wf = await client.workflows.get("nonexistent");
} catch (err) {
  if (err instanceof NotFoundError) {
    console.log("Workflow not found");
  } else if (err instanceof AuthenticationError) {
    console.log("Invalid API key");
  } else if (err instanceof ApiError) {
    console.log(err.status, err.message);
  }
}
```