@lelemon-ai/sdk
TypeScript SDK for the Lelemon API v2. It is generated from the API contract, so every method, parameter and response type matches the API reference.
- Zero runtime dependencies.
- ESM and CommonJS, with types for both.
- Automatic retries, an
Idempotency-Keyon everyPOST, automatic pagination. - Webhook, signed-request and MCP assertion verification built on Web Crypto.
- Typed errors, one class per HTTP family.
Install
npm install @lelemon-ai/sdk
Quickstart
import { Lelemon } from '@lelemon-ai/sdk';
const lelemon = new Lelemon({ apiKey: process.env.LELEMON_API_KEY });
const agent = await lelemon.agents.create({
name: 'Order bot',
instructions: 'You answer questions about orders in one short sentence.',
llmConfig: { model: 'anthropic/claude-sonnet-5.5' },
});
console.log(agent.id);
apiKey defaults to the LELEMON_API_KEY environment variable. The constructor throws if there is no key.
Method names are the operationId of each route in the API reference: agents.create, cases.takeControl, webhookEndpoints.create. The JSDoc of each method comes from the contract, so your editor shows what the route does and the scopes it needs.
Supported runtimes
| Runtime | Support |
|---|---|
| Node.js 20 or later | ESM (import) and CommonJS (require). Tested |
| Bundlers (esbuild, webpack, Vite) | Tested with esbuild for a neutral platform |
| Cloudflare Workers, Deno, Bun, edge runtimes | The code only uses Web APIs (fetch, Web Crypto, URL). The bundle loads and runs in a context with no Node globals. Not run on those runtimes themselves |
| Browsers | The code runs, but never ship an API key to a browser |
Under TypeScript the package resolves with moduleResolution set to node10, node16, nodenext or bundler. A CommonJS project gets index.d.cts and an ESM project gets index.d.ts.
The SDK sends User-Agent: lelemon-sdk-node/<version> on every request. Chrome silently drops that header in fetch, so the API cannot identify the SDK from a Chrome page.
Client options
| Option | Default | |
|---|---|---|
apiKey | LELEMON_API_KEY | The key |
baseUrl | https://api.lelemon.cl | |
subaccount | none | Sends Lelemon-Subaccount on every call |
maxRetries | 2 | Retries on 429, 500, 502, 503 and 504, and on network errors and timeouts |
timeoutMs | 60000 | Per attempt |
retryDelayMs | 500 | Base of the exponential backoff. Retry-After wins when present |
fetch | global fetch | Your own implementation |
Every option accepts undefined, so new Lelemon({ apiKey: process.env.LELEMON_API_KEY }) compiles under exactOptionalPropertyTypes.
Calling the API
Every method has the same shape:
method(...pathParams, body?, options?)
Operations without a body are method(...pathParams, options?). The body is required in the types when the contract says so.
options is the only options object. It holds the query, the idempotency key, the subaccount, the abort signal and extra headers:
const page = await lelemon.agents.list({ query: { limit: 10, status: 'active' } });
const message = await lelemon.whatsapp.messages.create(
{ to: { phone: '+56911112222' }, text: 'Hello' },
{ query: { dryRun: true }, idempotencyKey: 'order-8812-confirmation' },
);
| Option | |
|---|---|
query | The query parameters of the route, typed from the contract |
idempotencyKey | Sent as Idempotency-Key |
subaccount | Sent as Lelemon-Subaccount. Wins over the client option |
signal | An AbortSignal |
headers | Extra headers |
Idempotency-Key and Lelemon-Subaccount are never method arguments, even when the contract documents them as header parameters.
Idempotency
Every POST carries an Idempotency-Key. The SDK generates a UUID when you do not pass idempotencyKey, and a retry reuses the same key, so a retried request is never executed twice.
Pass your own key when the same business operation can be triggered more than once (a queue that redelivers, a user who double clicks):
await lelemon.contacts.create(
{ name: 'Ana Rojas', externalRef: 'cust-88123' },
{ idempotencyKey: 'create-cust-88123' },
);
Pagination
A list returns a promise of the first page that is also an async iterable of every item:
for await (const contact of lelemon.contacts.list({ query: { limit: 50 } })) {
console.log(contact.id);
}
const firstPage = await lelemon.contacts.list();
console.log(firstPage.data, firstPage.hasMore, firstPage.nextCursor);
const firstHundred = await lelemon.contacts.list().toArray({ max: 100 });
The iterator asks for the next page only when you reach the end of the current one, and keeps your query filters.
Errors
Every error extends LelemonError.
| Class | When |
|---|---|
LelemonApiError | Any non-2xx answer. The base of the classes below |
BadRequestError | 400 |
AuthenticationError | 401 |
PermissionError | 403 |
NotFoundError | 404 |
ConflictError | 409 |
UnprocessableError | 422 |
RateLimitError | 429, after the retries ran out |
ApiServerError | 5xx, after the retries ran out |
LelemonConnectionError | The API could not be reached after the retries |
LelemonTimeoutError | A timeout. Extends LelemonConnectionError |
LelemonAbortError | Your signal aborted. cause is the abort reason |
LelemonSignatureVerificationError | A webhook, signed request or MCP assertion did not verify |
An API error carries the problem details of the answer:
import { LelemonApiError, NotFoundError, RateLimitError } from '@lelemon-ai/sdk';
try {
await lelemon.agents.retrieve('agt_...');
} catch (error) {
if (error instanceof NotFoundError) {
console.log(error.status, error.code, error.requestId);
} else if (error instanceof RateLimitError) {
console.log(error.retryAfterSeconds);
} else if (error instanceof LelemonApiError) {
console.log(error.code, error.detail, error.param, error.docUrl, error.fix, error.budget);
} else {
throw error;
}
}
code is typed as the union of error codes in the contract, fix is the next step the error catalog suggests and budget comes with budget_exceeded. error.name is always the literal class name.
Aborting stops everything at once, including the wait before a retry:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
await lelemon.agents.list({ signal: controller.signal });
Webhooks
Verify the signature with the raw body. constructEvent returns a union of the 36 events of the contract, discriminated by type, and throws LelemonSignatureVerificationError when the signature, the timestamp or the secret do not match. The verification is asynchronous because it uses Web Crypto.
import { Lelemon, type LelemonEvent } from '@lelemon-ai/sdk';
app.post('/webhooks/lelemon', express.raw({ type: 'application/json' }), async (req, res) => {
let event: LelemonEvent;
try {
event = await Lelemon.webhooks.constructEvent(
req.body,
req.header('Lelemon-Signature') ?? '',
process.env.LELEMON_WEBHOOK_SECRET!,
);
} catch {
res.sendStatus(400);
return;
}
switch (event.type) {
case 'case.escalated':
console.log(event.data.case.id, event.customerId);
break;
default:
break;
}
res.sendStatus(200);
});
The payload is a string, a Uint8Array (a Node Buffer is one) or an ArrayBuffer. A re-serialized body does not verify. The default tolerance is 300 seconds (toleranceSeconds). During a secret rotation the header carries two v1 signatures and either one is enough.
An event type added after your SDK version still parses. Keep a default branch.
Signed requests to your actions and connectors
verifyRequest(headers, method, url, rawBody, secret, options?) verifies the Lelemon-Signature that Lelemon adds to the calls it makes to your actions and data connectors. deriveToolSecret(organizationSecret, toolId) derives the secret of one tool. Both are asynchronous. createReplayGuard() returns an isReplay function for the jti.
MCP assertions
verifyMcpAssertion(token, secrets, { audience, expectedTool, expectedArgs }) checks the JWT that Lelemon sends to your MCP server when its run identity is on. secrets maps each kid to its secret. It pins HS256, checks the issuer, the audience and the expiry, rejects an assertion that lives longer than 60 seconds, requires it to be for the tool and the arguments of the call you received (expectedTool, expectedArgs), and can reject a repeated jti (isReplay).
Data connectors
connector.manifest, connector.account, connector.field, connector.table, connector.badge, connector.action, connector.viewPage and connector.accountList build the documents of a lelemon-connector/v1 connector with types. validateConnectorDocument(type, document) checks one against the JSON Schemas of GET /v2/data-connectors/format (connector-format.json, kept equal to the running API by pnpm spec:update).
The package installs the lelemon command:
lelemon connector validate <url|file> [--type manifest|account|accountSearch|view] [--account <id>] [--header "Name: value"]
lelemon connector serve --fixture <file> [--port 4300] [--secret <signing secret>]
examples/connector.ts is a complete connector, and examples/fixture.json a fixture for serve.
Subaccounts
const client = lelemon.withSubaccount('sub_...');
await client.contacts.list();
await lelemon.contacts.list({ subaccount: 'sub_...' });
withSubaccount returns a new client and leaves the original untouched. Events that belong to a subaccount carry customerId.
Test mode
A key that starts with lel_test_ works on a separate copy of your data: agents answer, cases open and webhooks fire, but nothing reaches a real person. Use it for the whole integration:
const lelemon = new Lelemon({ apiKey: process.env.LELEMON_TEST_KEY });
Many routes accept query: { dryRun: true } to validate a request and see what would happen without saving, sending or charging anything. The answer to a dry run carries dryRun: true in its type, so you can tell it from the real one.
Types
The generated types of the contract are exported as a namespace:
import type { Types } from '@lelemon-ai/sdk';
function describe(agent: Types.Agent) {
return agent.name;
}
Where the contract says "exactly one of", the types are unions that reject invalid combinations at compile time, for example a recipient with both contactId and externalRef.
Development
pnpm --filter @lelemon-ai/sdk generate
pnpm --filter @lelemon-ai/sdk typecheck
pnpm --filter @lelemon-ai/sdk test
pnpm --filter @lelemon-ai/sdk build
openapi.json is the contract. generate reads it and writes src/generated. The tree of methods comes from the operationId of each route.
License
MIT. Copyright (c) 2026 Lelemon SpA.