@caruuto/caruuto-js
v0.9.9
Published
A high-level library for interacting with Caruuto
Downloads
1,632
Readme
Caruuto JS
The official JavaScript SDK for Caruuto — a high-level client for querying content collections, sending AI chat messages, managing media, queueing email, and issuing tokens.
- Installation
- Initialisation
- Content API
- Query filters
- Pagination & sorting (
limitTo,goToPage,afterId) - find
- create
- update
- delete
- getCollections
- syncSchema
- search
- Media API
- AI extension
- Email extension
- Tokens extension
- Error handling
- Custom fetch
Installation
npm install @caruuto/caruuto-jsRequires Node.js 18 or later.
Initialisation
Use createClient for all production use — it returns a client with all extensions (ai, email, tokens) attached.
const CaruutoClient = require('@caruuto/caruuto-js')
const client = CaruutoClient.createClient(
'https://your-instance.caruuto.com',
'your-api-key'
)The bare constructor (new CaruutoClient(url, key)) creates a client without extensions. Use it only when you need the Content API alone and want to avoid loading extension code.
Options
CaruutoClient.createClient(url, apiKey, {
// Override the built-in undici fetch with your own implementation.
fetch: myCustomFetch,
// Properties that are stripped before create/update calls.
// Defaults to ['created_at', 'created_by', 'updated_at', 'updated_by', 'v'].
reservedProperties: ['_id', 'created_at']
})Content API
The Content API follows a chainable builder pattern. Filter methods set state on the client and return this; a terminator (find, create, update, delete) fires the request and returns a Promise. The state is reset automatically after each request.
// Collection → filter → terminator
const result = await client
.in('articles')
.whereFieldIsEqualTo('status', 'published')
.limitTo(10)
.sortBy('published_at', 'desc')
.find()Query filters
All filter methods return this and can be chained freely.
in(collection)
Select the target collection.
client.in('products')where(query)
Pass a raw MongoDB-style query object directly.
client.where({ status: 'active', price: { $gt: 100 } })whereFieldIsEqualTo(field, value, caseInsensitive?)
Exact match. Pass true as the third argument for a case-insensitive match.
client.whereFieldIsEqualTo('slug', 'hello-world')
client.whereFieldIsEqualTo('email', '[email protected]', true)whereFieldIsNotEqualTo(field, value)
Negated match. Strings produce a case-insensitive regex negation; numbers use $ne.
client.whereFieldIsNotEqualTo('status', 'archived')whereFieldIsGreaterThan(field, value)
client.whereFieldIsGreaterThan('price', 50)whereFieldIsGreaterThanOrEqualTo(field, value)
client.whereFieldIsGreaterThanOrEqualTo('stock', 1)whereFieldIsLessThan(field, value)
client.whereFieldIsLessThan('age', 18)whereFieldIsLessThanOrEqualTo(field, value)
client.whereFieldIsLessThanOrEqualTo('score', 100)whereFieldContains(field, value)
Case-insensitive substring match (/value/i).
client.whereFieldContains('title', 'caruuto')whereFieldBeginsWith(field, value)
Case-insensitive prefix match (/^value/i).
client.whereFieldBeginsWith('slug', 'product-')whereFieldEndsWith(field, value)
Case-insensitive suffix match (/value$/i).
client.whereFieldEndsWith('email', '@example.com')whereFieldDoesNotContain(field, value)
Negated substring match.
client.whereFieldDoesNotContain('title', 'draft')whereFieldExists(field)
Match documents where the field is not null.
client.whereFieldExists('publishedAt')whereFieldDoesNotExist(field)
Match documents where the field is null or absent.
client.whereFieldDoesNotExist('deletedAt')whereFieldIsOneOf(field, values)
$in — field value must be one of the provided array.
client.whereFieldIsOneOf('category', ['news', 'opinion'])whereFieldIsNotOneOf(field, values)
$nin — field value must not appear in the provided array.
client.whereFieldIsNotOneOf('status', ['draft', 'archived'])Pagination & sorting
Two pagination modes are available: offset-based (page/limit) and cursor-based (afterId). They are mutually exclusive — when afterId is set the server ignores goToPage.
limitTo(n)
Limit the number of returned documents. Works with both pagination modes.
client.in('posts').limitTo(20).find()goToPage(n)
Select a page (1-indexed, offset-based). Works together with limitTo. Not compatible with afterId — the server ignores page when a cursor is active.
client.in('posts').limitTo(20).goToPage(3).find()afterId(id)
Cursor-based pagination. Pass the id of the last document from the previous page to fetch the next page. This is the required pagination mode for the members collection and will be supported by other content types.
When an afterId cursor is active the response includes both count (documents on this page) and totalCount (total across all pages).
// First page — no cursor
const page1 = await client.in('members').limitTo(50).find()
const lastId = page1.results.at(-1).id
// Next page — pass the last ID as the cursor
const page2 = await client.in('members').limitTo(50).afterId(lastId).find()sortBy(field, order)
Sort on a field. order is 'asc' (default) or 'desc'.
client.in('posts').sortBy('published_at', 'desc').find()Multiple sorts can be chained:
client
.in('posts')
.sortBy('category', 'asc')
.sortBy('published_at', 'desc')
.find()useFields(fields)
Restrict the fields returned. Pass an array of field names.
client.in('articles').useFields(['title', 'slug', 'excerpt']).find()useVersion(version)
Override the API version for this request. Defaults to 'v1'.
client.in('articles').useVersion('v2').find()find
Retrieve documents matching the current query state.
const result = await client
.in('articles')
.whereFieldIsEqualTo('status', 'published')
.find()
// result.results — array of documents
// result.metadata — pagination info (when extractMetadata is true)Pass { extractMetadata: true } to also fetch document count and pagination metadata:
const result = await client
.in('articles')
.limitTo(10)
.find({ extractMetadata: true })
// result.metadata.totalCount
// result.metadata.totalPagescreate
Create one or more documents in a collection. Reserved properties (created_at, updated_at, etc.) are stripped automatically.
// Single document
const doc = await client.in('articles').create({
title: 'Hello World',
status: 'published'
})
// Multiple documents
const docs = await client.in('articles').create([
{ title: 'Post One' },
{ title: 'Post Two' }
])update
Update documents matching the current query. A query must be set before calling update.
await client
.in('articles')
.whereFieldIsEqualTo('_id', '64a1b2c3d4e5f6a7b8c9d0e1')
.update({ status: 'archived' })delete
Delete documents matching the current query. A query must be set before calling delete.
await client
.in('articles')
.whereFieldIsEqualTo('status', 'draft')
.delete()getCollections
Return the list of all content type definitions registered in the Caruuto instance.
const collections = await client.getCollections()syncSchema
Sync a collection's schema definition with the server. Useful for automated schema migrations.
await client.in('articles').syncSchema({
fields: [
{ key: 'title', type: 'String', required: true },
{ key: 'body', type: 'String' }
]
})search
Full-text search across a collection.
const results = await client
.in('articles')
.search({ phrase: 'caruuto headless' })
.find()Media API
uploadFile
Upload a file to the project's media bucket. The storage bucket is resolved server-side from the API key.
const fs = require('fs')
const buffer = fs.readFileSync('/path/to/image.png')
const result = await client.uploadFile(
'images/avatars', // directory within the bucket
'avatar.png', // file name
'image/png', // MIME type
buffer, // Buffer or ArrayBuffer
buffer.byteLength // content length in bytes
)getSignedUrl
Obtain a signed URL for a direct client-side upload (e.g. from a browser).
const { url, fields } = await client.getSignedUrl({
fileName: 'photo.jpg',
mimeType: 'image/jpeg',
directory: 'uploads'
})AI extension
The AI extension is available on clients created with createClient. All methods are on client.ai.
ai.query
Send a message and receive a single blocking response from the AI.
const response = await client.ai.query({
message: 'What are your opening hours?'
})
console.log(response.answer)
console.log(response.conversation_id) // use to continue the conversationOptions
| Parameter | Type | Required | Description |
|---|---|---|---|
| message | string | Yes | The user's message |
| projectId | string | No | Caruuto project UUID. Not required when authenticating with an API key (the key's project is used). |
| conversationId | string | No | Continue an existing conversation |
| clientSessionId | string | No | Anonymous user token for session continuity |
| source | string | No | 'widget', 'api', or 'embed' |
| referrerUrl | string | No | URL where the chat widget is embedded |
| utmSource | string | No | UTM source from the page URL |
| utmMedium | string | No | UTM medium from the page URL |
| utmCampaign | string | No | UTM campaign from the page URL |
| utmTerm | string | No | UTM term from the page URL |
| utmContent | string | No | UTM content from the page URL |
| leadEmail | string | No | Capture a lead email on this turn |
| leadName | string | No | Lead name (used together with leadEmail) |
| type | string | No | Restrict knowledge retrieval to a single type |
| maxItems | number | No | Maximum knowledge items to retrieve (1–10) |
Response
{
answer: string,
conversation_id: string,
context: [], // knowledge items used
entities: [], // entities detected
_meta: {},
from_cache: boolean // present when the answer was served from cache
}ai.stream
Send a message and receive a streaming response. Returns an async generator — iterate with for await.
for await (const chunk of client.ai.stream({ message: 'Tell me about your products' })) {
if (chunk.type === 'text') {
process.stdout.write(chunk.text)
} else if (chunk.type === 'done') {
console.log('\nconversation_id:', chunk.conversation_id)
}
}Yields two event types:
| type | Fields | Description |
|---|---|---|
| 'text' | text: string | A token or token batch from the LLM |
| 'done' | conversation_id, context, entities, _meta, from_cache? | Final event, mirrors query() response fields |
Accepts the same options as ai.query.
ai.context
Retrieve the assembled context for a message without calling the LLM. Use this when you want to drive your own LLM call (e.g. with the AI SDK) and need Caruuto to handle retrieval, entity detection, and prompt assembly.
const ctx = await client.ai.context({
message: 'What are your pricing tiers?',
conversationId: existingConversationId
})
if (ctx.from_cache) {
// serve the cached answer directly
return ctx.cached_answer
}
// ctx.system_prompt — ready to pass to your LLM
// ctx.history — prior conversation turns
// ctx.context — knowledge items
// ctx.entities — detected entities
// ctx.conversation_idAccepts the same options as ai.query (excluding leadEmail/leadName).
ai.saveTurn
Persist a completed turn (user message + assistant response) after your own LLM call. Call this once your stream is complete with the full response text and token usage.
await client.ai.saveTurn({
conversationId: ctx.conversation_id,
userContent: 'What are your pricing tiers?',
assistantContent: fullAssistantText,
modelId: 'claude-sonnet-4-6',
inputTokens: 512,
outputTokens: 128,
cachedTokens: 0,
contextItems: ctx.context,
entitiesReferenced: ctx.entities.map(e => e.id)
})Options
| Parameter | Type | Required | Description |
|---|---|---|---|
| conversationId | string | Yes | From context() or a prior query() response |
| userContent | string | Yes | The user's message text |
| assistantContent | string | Yes | The full assistant response text |
| projectId | string | No | Not required when authenticating with an API key |
| modelId | string | No | Model identifier (e.g. 'claude-sonnet-4-6') |
| inputTokens | number | No | |
| outputTokens | number | No | |
| cachedTokens | number | No | |
| fromCache | boolean | No | Set true when assistantContent came from a context() cache hit |
| contextItems | object[] | No | Knowledge items from context() response |
| entitiesReferenced | string[] | No | Entity IDs from context() response |
| leadEmail | string | No | |
| leadName | string | No | |
Response
{ ok: boolean, turn_count: number }ai.endConversation
Explicitly end a conversation, marking its status as 'ended'. Call this when the user closes the chat widget or navigates away.
const summary = await client.ai.endConversation({
conversationId: 'conv_abc123'
})
// summary.id
// summary.status → 'ended'
// summary.turn_count
// summary.total_tokens
// summary.started_at
// summary.ended_atai.loadConversation
Load a conversation and its messages formatted as AI SDK UIMessage objects. Use this to restore a prior conversation into your chat UI.
const conversation = await client.ai.loadConversation({
conversationId: 'conv_abc123'
})
// conversation.id
// conversation.status
// conversation.turn_count
// conversation.started_at
// conversation.ended_at
// conversation.messages ← UIMessage[]ai.forkConversation
Fork an existing conversation or question-cache entry into a new conversation. Returns the new conversation ID.
const { id } = await client.ai.forkConversation({
sourceId: 'conv_abc123',
source: 'conversation' // 'conversation' (default) | 'question_cache'
})ai.shareConversation
Set a conversation's visibility to 'public' or 'private'. Public conversations are accessible via a share URL without authentication.
const result = await client.ai.shareConversation({
conversationId: 'conv_abc123',
visibility: 'public'
})
// result.id
// result.visibility
// result.share_urlai.getPublicConversation
Fetch a publicly shared conversation without authentication. Returns null if the conversation is private or not found.
const conversation = await client.ai.getPublicConversation({
conversationId: 'conv_abc123'
})
if (!conversation) {
// private or not found
}
// conversation.id
// conversation.turn_count
// conversation.started_at
// conversation.ended_at
// conversation.messagesai.trackLinkClick
Record a click on a link surfaced in an assistant response. Powers the CTA-click and conversion panels in the analytics dashboard.
await client.ai.trackLinkClick({
conversationId: 'conv_abc123',
url: 'https://example.com/pricing',
linkLabel: 'View pricing',
messageIndex: 2
})Options
| Parameter | Type | Required | Description |
|---|---|---|---|
| conversationId | string | Yes | The conversation the link appeared in |
| url | string | Yes | The full clicked URL, exactly as rendered |
| projectId | string | No | Not required when authenticating with an API key |
| linkLabel | string | No | The link's visible text |
| messageIndex | number | No | Zero-based index of the assistant message the link appeared in |
Response
{ ok: boolean }Email extension
Manage email records via client.email. All methods return a Promise.
email.find(query)
Retrieve email records matching the given query.
const result = await client.email.find({ status: 'queued' })email.queue(data)
Queue an email for sending.
await client.email.queue({
to: '[email protected]',
subject: 'Welcome',
templateId: 'welcome-v1'
})email.update(data)
Update an existing email record.
await client.email.update({ id: 'email_123', status: 'cancelled' })email.delete(data)
Delete an email record.
await client.email.delete({ id: 'email_123' })Tokens extension
Issue and verify short-lived tokens via client.tokens. Also accessible as top-level methods client.createToken() and client.verifyToken().
tokens.create(data) / createToken(data)
Create a new token.
const token = await client.tokens.create({ userId: 'user_abc', scope: 'reset' })
// or
const token = await client.createToken({ userId: 'user_abc', scope: 'reset' })tokens.verify(token) / verifyToken(token)
Verify a token string.
const result = await client.tokens.verify('tok_abc123')
// or
const result = await client.verifyToken('tok_abc123')Error handling
All terminator and extension methods return Promises. Failed requests (HTTP 4xx/5xx) reject with an Error object that carries additional properties:
try {
await client.in('articles').whereFieldIsEqualTo('_id', 'bad-id').find()
} catch (err) {
console.error(err.message) // message from the API response body, if present
console.error(err.status) // HTTP status code
console.error(err.errors) // validation errors array, if present
}Custom fetch
By default the SDK uses undici's fetch. Pass your own implementation via the fetch option to use a different HTTP client, add middleware, or enable instrumentation:
const client = CaruutoClient.createClient(url, apiKey, {
fetch: (url, options) => {
console.log('→', options.method, url)
return globalThis.fetch(url, options)
}
})The custom fetch function receives the same (url, init) arguments as the Fetch API and must return a Response-compatible object.
