Elaras/

Official Node.js SDK for the Elaras Chat developer API.

Server-side TypeScript library for building backends, webhooks, and integrations against the /api/developer/v1/* endpoints.

  • Dual-published (ESM + CJS)
  • First-class TypeScript types
  • Built-in retry with exponential backoff + jitter
  • Typed error hierarchy
  • Timing-safe webhook signature verification

Requires Node 18+ (uses the global fetch and crypto built-ins).


Install

npm install @elaras/sdk-node

Authentication

Create a sk_ secret key from the Elaras dashboard (Settings → Developer → API Keys) and pass it to the client:

import { ElarasClient } from '@elaras/sdk-node';

const elaras = new ElarasClient({
  apiKey: process.env.ELARAS_API_KEY!,
});

Full options:

new ElarasClient({
  apiKey:     'sk_...',                   // required
  baseUrl:    'https://ask-api.elaras.ai',   // default
  timeout:    30_000,                     // default (ms, per-request)
  maxRetries: 3,                          // default (429 + 5xx + network only)
});

Quickstart: build + deploy an agent

The full chain — from empty workspace to an embeddable chatbot — in six calls.

import { ElarasClient } from '@elaras/sdk-node';

const elaras = new ElarasClient({ apiKey: process.env.ELARAS_API_KEY! });

// 1. Create a chatbot (the thing you embed on your site).
const bot = await elaras.chatbots.create({
  name:            'Support bot',
  aiProvider:      'anthropic',
  greetingMessage: 'Hi! Ask me anything about our pricing or returns.',
});

// 2. Ingest some knowledge (workspace-wide, reusable across chatbots).
const pricing = await elaras.knowledge.createFromUrl({ url: 'https://acme.com/pricing' });
const faq     = await elaras.knowledge.createQa({
  question: 'What is your refund window?',
  answer:   '30 days, no questions asked.',
});

// Wait for the URL doc to finish embedding.
await elaras.knowledge.waitForIndex(pricing.id);

// 3. Create a personality — the versioned behaviour layer. Fresh personalities
//    come with a v1 draft already created, so we patch that draft directly.
const personality = await elaras.personalities.create({
  name:        'Support bot personality',
  description: 'Friendly, concise, defers refunds to a human.',
});

// 4. Patch the draft (v1) with the system prompt + attached knowledge.
await elaras.personalities.versions.update(personality.id, 1, {
  snapshot: {
    system_prompt:        'You are a friendly support agent for Acme. Keep answers short.',
    knowledge_source_ids: [pricing.id, faq.id],
    ai_provider:          'anthropic',
    ai_model:             'claude-haiku-4-5-20251001',
  },
});

// 5. Publish the version (freezes it + makes it assignable).
const published = await elaras.personalities.versions.publish(personality.id, 1);

// 6. Activate the published version on the chatbot.
await elaras.chatbots.assignModel(bot.id, { versionId: published.id });

// Done — `bot.public_key` is the X-Chatbot-Key the browser widget uses.
console.log('Embed with key:', bot.public_key);

Account usage

Snapshot of the four metrics on the dashboard billing page for the team the API key is scoped to.

const usage = await elaras.account.usage();
//   {
//     chatbots:         { current: 3,    limit: 10 },
//     credits:          { current: 1500, limit: 2000, used: 500 },
//     team_members:     { current: 4,    limit: 20 },
//     knowledge_chunks: { current: 2187, limit: 10000 },
//   }
  • current is what you're using right now; limit comes from the plan (null means unlimited or no plan).
  • credits.used is lifetime usage this billing period, independent of current (which is credits remaining).
  • Point-in-time only — no history or time-series. For deltas, poll and diff.

Maps to GET /api/developer/v1/account/usage. Requires the read scope.

Sessions

// Create a session. Idempotent on sessionToken — passing the same token
// twice returns the existing session instead of erroring. Omit the token
// to let the server generate a UUID.
const session = await elaras.sessions.create({
  chatbotId:         'cbt_live_xxx',
  sessionToken:      crypto.randomUUID(), // optional
  externalUserId:    'user_42',           // optional identity passthrough
  externalProfileId: 'tenant:acme',       // optional
  externalOrgId:     'org_xyz',           // optional
});
// → { id, chatbot_id, external_user_id, started_at, message_count, ... }

// Fetch session detail (includes messages[]).
const detail     = await elaras.sessions.get(session.id);
const transcript = await elaras.sessions.getTranscript(session.id); // same endpoint, named alias

// Paginated list.
const page = await elaras.sessions.list({
  chatbotId:      'cbt_live_xxx',
  externalUserId: 'user_42',
  limit:          50,
  cursor:         '2',      // Laravel page number
});

stream(sessionId, message)

Sends a message over SSE. Token chunks arrive as delta events as the AI types, followed by a final done event containing the full assembled response.

for await (const event of elaras.sessions.stream(session.id, 'What are your opening hours?')) {
  if (event.type === 'delta') process.stdout.write(event.content); // stream to your frontend
  if (event.type === 'done')  console.log('\nDone:', event.response);
  if (event.type === 'error') console.error(event.message);
}
EventFieldsDescription
deltacontent: stringToken chunk — forward to your frontend to display as the AI types
doneresponse: string, message_id: numberAI response complete — response is the full assembled text
errormessage: string, code?: stringAn error occurred

Knowledge

Knowledge in Elaras is workspace-scoped, not per-chatbot. Attach a doc to a chatbot by adding its id to a personality version's snapshot.knowledge_source_ids and publishing.

Breaking in 0.2.0: chatbotId was removed from createFromUrl, createQa, and list. The field was previously accepted and silently ignored.

// Add a URL — content is fetched and chunked server-side.
const doc = await elaras.knowledge.createFromUrl({
  url:  'https://example.com/pricing',
  name: 'Pricing page',    // optional
});

// Add a Q&A pair.
await elaras.knowledge.createQa({
  question: 'What are your office hours?',
  answer:   'We are open 9am–5pm, Monday to Friday.',
});

// Fetch / list.
const detail = await elaras.knowledge.get(doc.id);
const list   = await elaras.knowledge.list();
// → { data: KnowledgeDocument[], locked: boolean }

// Block until the doc is indexed (useful after createFromUrl which is async).
const indexed = await elaras.knowledge.waitForIndex(doc.id, {
  timeoutMs:      60_000,
  pollIntervalMs: 2_000,
});

// Mutations.
await elaras.knowledge.delete(doc.id);
const refreshed = await elaras.knowledge.resync(doc.id);
const cloned    = await elaras.knowledge.clone(doc.id);
const chunks    = await elaras.knowledge.getChunks(doc.id);
// → { data: KnowledgeChunk[] }

Leads

// A lead in Elaras is a session with `lead_email` captured.
const leads = await elaras.leads.list({
  chatbotId: 'cbt_live_xxx',
  email:     'jane@acme.com',     // optional partial match
  limit:     50,
});

// Leads are sessions with lead_email populated — there's no separate lead ID.
// Fetch by the session the lead was captured on:
const lead = await elaras.leads.getBySession('sess_abc');

Chatbots

// CRUD
const bot     = await elaras.chatbots.create({ name: 'Support bot' });
const list    = await elaras.chatbots.list();
const fetched = await elaras.chatbots.get('cbt_live_xxx');
await elaras.chatbots.update('cbt_live_xxx', { status: 'inactive' });
await elaras.chatbots.delete('cbt_live_xxx');

// Activate a published personality version on this chatbot. `versionId` is the
// numeric `id` returned by elaras.personalities.versions.list(modelId).
await elaras.chatbots.assignModel('cbt_live_xxx', { versionId: 42 });

// Attach a Flow (deterministic conversation branch) to the chatbot.
await elaras.chatbots.attachFlow('cbt_live_xxx', 7, {
  triggerPhrase: 'book a call',   // optional — forces this flow when matched
  enabled:       true,
});

// Analytics
const stats   = await elaras.chatbots.getStats('cbt_live_xxx', { period: '7d' });
const flagged = await elaras.chatbots.listFlaggedSessions('cbt_live_xxx', {
  category: 'cannot_answer',
  limit:    50,
});

// `public_key` is exposed on get() — this is the X-Chatbot-Key the browser
// widget / JS SDK uses. Safe to ship client-side; it's not a secret.
console.log(bot.public_key);

Personalities + Versions

A personality is a versioned behaviour container. Draft versions are mutable; published versions are frozen. Attach knowledge, skills, and flows to a version via its snapshot, then publish and assign the published version to a chatbot.

Identifiers: personality.id is a UUID string (the public identifier). Pass it to every .get/.update/.delete method and as the first arg to versions.*. Integer PKs are internal — passing one to the SDK will 404 against the backend. Versions within a personality are identified by their sequential version_number (1, 2, 3, …), not an ID.

// Personality CRUD
const list       = await elaras.personalities.list();
const created    = await elaras.personalities.create({ name: 'Support bot' });
const personaId  = created.id;                                        // UUID
const detail     = await elaras.personalities.get(personaId);
const renamed    = await elaras.personalities.update(personaId, { name: 'Renamed' });
await elaras.personalities.delete(personaId);

// Versions — nested resource under elaras.personalities.versions
const versions   = await elaras.personalities.versions.list(personaId);
const v2         = await elaras.personalities.versions.get(personaId, 2);   // by version_number
const draft      = await elaras.personalities.versions.create(personaId, {
  changeNotes: 'Tightening the system prompt',
});

// Patch the draft snapshot. Deep-merged server-side.
await elaras.personalities.versions.update(personaId, draft.version_number, {
  snapshot: {
    system_prompt:        'You are helpful.',
    knowledge_source_ids: [7, 9],
    skill_ids:            [1, 2],
    ai_provider:          'anthropic',
    ai_model:             'claude-haiku-4-5-20251001',
    rules:                ['Never discuss competitor pricing.'],
  },
});

// Publish → freezes the version. Returns the published row.
const published  = await elaras.personalities.versions.publish(personaId, draft.version_number);

// Clone a published version into a new editable draft.
const clone      = await elaras.personalities.versions.clone(personaId, 2, {
  changeNotes: 'v2 → v3 iteration',
});

await elaras.personalities.versions.delete(personaId, draft.version_number);

Flows

A Flow is a deterministic conversation branch (step graph) that the LLM can hand off to. Flows are workspace-scoped; attach to a chatbot with elaras.chatbots.attachFlow().

const flows   = await elaras.flows.list();
const flow    = await elaras.flows.get(7);
const created = await elaras.flows.create({
  name:        'Book a slot',
  description: 'Walks the user through calendar availability.',
  definition:  { steps: [/* ... */] },
});

await elaras.flows.update(7, { name: 'Renamed', enabled: false });
await elaras.flows.delete(7);

// Ask the AI to draft a Flow definition from a plain-text description. The
// returned `definition` is a draft only — pass it to flows.create to persist.
const draft = await elaras.flows.generate('book a 30-minute discovery call');
await elaras.flows.create({ name: 'Discovery', definition: draft.definition });

Scenarios

A scenario is a stored multi-turn test case bound to a personality. A scenario run replays one or more scenarios against a specific version of the personality and judges each bot reply against the configured criteria. The typical pattern is to gate publishing a draft version on a passing run.

Identifiers: scenarios are identified by integer within their personality. Runs are identified by UUID — the run id you get back is the public identifier; pass it to getRun, cancelRun, and waitForRun.

// Seed scenarios on the personality
const seeded = await elaras.scenarios.create(personaId, {
  name:            'Return policy — 30 days',
  initialMessage:  'Can I return something after 2 weeks?',
  overallCriteria: { should_resolve: true },
  turns: [
    { bot_criteria: { mentions_30_days: true }, user_reply: 'What about damaged items?' },
  ],
});

// Or let the AI draft a batch
const drafts = await elaras.scenarios.draft(personaId, { intent: 'returns + exchanges', count: 5 });

// Run them against a draft version you want to publish
const run = await elaras.scenarios.run(personaId, { versionNumber: 2 });

// Wait for it to finish, then gate publish on the result
const final = await elaras.scenarios.waitForRun(personaId, run.id);
if (final.status === 'completed' && (final.failed ?? 0) === 0) {
  await elaras.personalities.versions.publish(personaId, 2);
} else {
  console.warn('Scenario run did not pass — not publishing', final);
}

// Other operations
const all = await elaras.scenarios.list(personaId);   // Scenario[]
await elaras.scenarios.get(personaId, seeded.id);
await elaras.scenarios.update(personaId, seeded.id, { name: 'Returns — 30 days (v2)' });
await elaras.scenarios.delete(personaId, seeded.id);
await elaras.scenarios.listRuns(personaId, { limit: 10 });
await elaras.scenarios.cancelRun(personaId, run.id);

Run identifiers (run.id) are UUID strings — feed them straight back into getRun, cancelRun, and waitForRun. The SDK normalises every run shape it returns, even though the backend sometimes serves a raw Eloquent row with a separate uuid field.

waitForRun polls getRun on an interval (default: 2s) and resolves when the run's status leaves queued/running. The default timeout is 3 minutes — override with waitForRun(…, { timeoutMs, pollIntervalMs }).


Error handling

Every failure thrown by the SDK extends ElarasError. Match on the specific subclass for ergonomic handling:

import {
  ElarasAuthError,
  ElarasValidationError,
  ElarasRateLimitError,
  ElarasServerError,
  ElarasNetworkError,
} from '@elaras/sdk-node';

try {
  await elaras.knowledge.createFromUrl({ url: 'nope' });
} catch (err) {
  if (err instanceof ElarasValidationError) {
    console.log(err.errors);           // { url: ['The url field is required.'] }
  } else if (err instanceof ElarasAuthError) {
    // 401 / 403 — bad key or missing scope
  } else if (err instanceof ElarasRateLimitError) {
    console.log(err.retryAfterSeconds); // honour this before retrying
  } else if (err instanceof ElarasServerError) {
    // 5xx — SDK already retried per `maxRetries`
  } else if (err instanceof ElarasNetworkError) {
    // DNS, TCP, timeout — SDK already retried
  }
}

Retry policy

  • 429 and 5xx responses are retried.
  • Backoff: 500ms * 2^attempt + jitter(0..100ms).
  • 429 responses honour the Retry-After header when present.
  • 4xx responses other than 429 are never retried.
  • Default retries: 3. Set maxRetries: 0 to opt out.

Webhook signature verification

When Elaras calls your skill endpoint it signs the request body with HMAC-SHA256 using the skill's API key. Verify every incoming request before acting on it:

import { verifyWebhookSignature } from '@elaras/sdk-node';
import type { SkillPayload } from '@elaras/sdk-node';
import express from 'express';

const app = express();

// IMPORTANT: use the raw body — re-serialising via JSON.stringify can reorder
// keys and break the signature.
app.post(
  '/webhooks/elaras/book-slot',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.get('X-Elaras-Signature');
    const ok  = verifyWebhookSignature(req.body, sig, process.env.ELARAS_SKILL_SECRET!);
    if (!ok) return res.status(401).send('invalid signature');

    const payload = JSON.parse(req.body.toString('utf8')) as SkillPayload;
    // payload.skill, payload.params, payload.session.{id, lead_name, lead_email}

    res.json({ booked: true });
  },
);

The signature header format is X-Elaras-Signature: sha256=<hex>. Comparison is done with crypto.timingSafeEqual.


Partial coverage in 0.2.0

A handful of surface-level helpers from the planned 0.2.0 API are not shipped because the matching developer-API routes do not exist yet. Call out explicitly:

  • chatbots.attachSkill(chatbotId, skillId) / detachSkill — the developer namespace exposes CREATE/UPDATE/DELETE for chatbot-scoped skills, not attach/detach of existing workspace skills. Use elaras.personalities.versions.update(modelId, n, { snapshot: { skill_ids: [...] } }) and publish to attach a workspace skill to a chatbot via its personality.
  • chatbots.detachFlow(chatbotId, flowId) — only POST /chatbots/{chatbot}/flows/{flow}/attach is routed in developer scope; no DELETE pair exists.
  • knowledge.update(id, {...}) — PATCH /knowledge/{id} is only exposed on the dashboard route, not the developer namespace.
  • personalities.versions.get(modelId, versionNumber) is implemented as a list(...).find(...) client-side because the server has no single-version GET route.

These will land as soon as the backend adds the missing routes.


Development

npm install
npm test            # vitest run
npm run typecheck   # tsc --noEmit
npm run build       # tsup — emits dist/index.{js,cjs,d.ts}

License

MIT © Elaras