lookio
The official TypeScript SDK for Lookio. Ask an assistant one question, continue a conversation, or build Lookio into a product from a server, Worker or script.
- Zero runtime dependencies. Only
fetch,Headers,AbortController,ReadableStream,TextDecoderandcrypto.randomUUID. - Typed from the API's OpenAPI document. Field names are the API's own snake_case, so what you read in the REST docs is what you get.
- Safe retries, automatic idempotency keys, typed errors, auto-pagination, streaming answers.
Install
npm install @lookio/sdk
Runs on Node 18+, Cloudflare Workers, Deno, Bun and modern browsers (see the security note before using it in a browser). ESM and CommonJS.
Quick start
Create an API key in Developer tools. For a product that only asks questions, choose the Ask questions preset and limit the key to the assistants it needs. Keep the key on your server, then:
import { Lookio } from "@lookio/sdk";
const lookio = new Lookio({ apiKey: process.env.LOOKIO_API_KEY });
// or just `new Lookio()`: the key is read from LOOKIO_API_KEY when `process` exists
Ask an assistant
const answer = await lookio.assistants.ask("assistant_id", {
question: "How much does the pro plan cost?",
});
console.log(answer.answer); // Markdown, citations appear as [1], [2]
for (const citation of answer.citations) {
console.log(citation.n, citation.title, citation.source);
}
console.log(answer.credit_cost, answer.credits_remaining);
Stream an answer
const stream = lookio.assistants.askStream("assistant_id", {
question: "Summarise our refund policy.",
});
for await (const text of stream.textStream()) {
process.stdout.write(text);
}
const final = await stream.finalAnswer(); // the same fields as `ask`
console.log(final.citations.length, "citations");
The stream is also an async iterable of typed events
({ event: "status" | "delta" | "done" | "error", data }), and it has
abort(). It can be read once. finalAnswer() may be called before, during or
after reading, and called alone it reads the stream to the end. An error event
rejects finalAnswer() and throws from textStream() as a LookioError
(an InsufficientCreditsError when the workspace ran out of credits). A stream
that ends without a done or error event throws StreamInterruptedError.
The request starts when you first read the stream. A stream is never retried, not even after a network error: the server does not record streams, so a repeat would be charged twice.
Upload a file and wait until it is ready
import { readFile } from "node:fs/promises";
// Node 20+: stream from disk without loading the file in memory:
// const file = await fs.openAsBlob("./handbook.pdf");
const file = new Blob([await readFile("./handbook.pdf")]);
const resource = await lookio.resources.uploadFile(file, "handbook.pdf", {
title: "Employee handbook",
tags: ["hr", "2026"],
source: "Employee handbook, 2026 edition",
});
// resource.status is "queued": it is read and indexed in the background
const ready = await lookio.resources.waitUntilReady(resource.id);
console.log(ready.status); // "ready"
uploadFile accepts a Blob, File, ArrayBuffer, Uint8Array (so a Node
Buffer) or a web ReadableStream. The API takes the file as raw bytes, not
multipart, and requires Content-Length: the SDK computes it for everything but
a stream, which needs { contentLength: <bytes> } (a Node stream goes through
Readable.toWeb). A stream upload cannot be re-sent, so it is not retried.
waitUntilReady(id, { timeoutMs = 120000, intervalMs = 1500, signal }) polls with
a gentle backoff. It throws ResourceFailedError (with statusDetail) if the
import fails and WaitTimeoutError if the time runs out. ready_simulated counts
as ready. A resource left in stale keeps being polled until the timeout: call
resources.reindex(id) for it. For short texts, pages or small files, pass
{ wait: true } directly to createText, createUrl, createSite, uploadFile
or get: the server waits up to 15 seconds so most small imports return ready
in a single request.
Iterate every resource
for await (const resource of lookio.resources.iterate({ tag: "hr", status: "ready" })) {
console.log(resource.id, resource.title);
}
// One page at a time, with the cursor you control:
const page = await lookio.resources.list({ limit: 50 });
console.log(page.data.length, page.hasMore, page.nextCursor);
// Or page by page, automatically:
for await (const p of lookio.resources.iterate().pages()) {
console.log(p.data.length);
}
Lists return { data, hasMore, nextCursor, limit?, requestId }. The same
iterate exists on conversations, publicAssistants, logs.*, and
sites.iteratePages(resourceId) walks the pages of a site.
Set a site's sync schedule
resources.createSite and sites.updateRules accept manual, daily, weekly,
monthly or an interval such as every_3_days, every_2_weeks or
every_1_month. The limits are 1-90 days, 1-52 weeks and 1-12 months.
Recurring schedules require a paid plan. If schedule is omitted when creating
a site, Free workspaces use manual and paid plans use weekly. If it is
omitted from sites.updateRules, the existing schedule stays in place.
Methods
Everything returns the API's data (lists return a page). Every method takes
{ idempotencyKey?, timeoutMs?, signal? } as its last argument.
| Namespace | Methods |
|---|---|
account, credits, usage | account.get(), credits.get(), usage.get({ from?, to?, granularity? }) |
resources | list, iterate, get, createText, createUrl, createSite, uploadFile, update, delete, content, reindex, waitUntilReady, pages, iteratePages |
sites | preview, pages, iteratePages, updateRules, retryFailed |
tags | list, rename({ from, to }), delete(tag) |
assistants | list, get, create, update, delete, ask, askStream, search, getPublication, publish, unpublish |
publicAssistants | list, iterate, get |
accesses | list, get, create, update, delete, createKey, updateKey, deleteKey |
conversations | list, iterate, get, rename, delete, feedback |
logs | conversations.{list, iterate, get, stats}, answers.{list, iterate, stats}, indexing.{list, iterate, stats}, activity.{list, iterate} |
widgets | list, get, create, update, delete, duplicate, setImage, clearImage, conversations.{list, iterate, get} |
members | list, updatePermissions(userId, { permissions, assistant_ids }) |
feedback | send({ observed_problem, situation, expected_solution, ... }) |
client.request({ method, path, query?, body?, headers?, idempotencyKey? })
reaches any endpoint, including one added after this version, with the same auth,
retries and idempotency. The path includes /v1 and it resolves with the whole
JSON body (data, has_more, request_id...). client.lastRequestId holds the
id of the last successful response.
Constructor options: apiKey, baseUrl (default https://api-v2.lookio.app),
timeoutMs (60000), maxRetries (2), retryDelayMs (500, the base of the
backoff), fetch (your own implementation), defaultHeaders,
dangerouslyAllowBrowser. Requests carry Authorization: Bearer <key> and
X-Lookio-SDK: lookio-js/<version> (a browser does not let a page set
User-Agent).
Idempotency and retries
Every POST (except a stream) carries an Idempotency-Key header: a UUID chosen
once per call and repeated unchanged by every retry, so the API runs the request
once however many times it arrives, and a retried question is not charged twice.
Pass { idempotencyKey } to a call to choose the key yourself, for instance to
make your own retry loop safe too. The API remembers a key for 24 hours.
The SDK retries up to maxRetries times, with exponential backoff and jitter,
honouring Retry-After (seconds or an HTTP date):
| Failure | GET, PUT, DELETE | POST | PATCH |
|---|---|---|---|
| 429 | retried | retried | retried (it was refused at the gate) |
| 408, 5xx, network error, timeout | retried | retried (same key) | not retried (the change may have landed) |
| any other 4xx | never | never | never |
A stream and a stream upload are never retried. Each attempt has its own timeout
(timeoutMs, per client or per call, 60 seconds by default; for a stream it covers
the wait for the first byte). Pass a signal to cancel a call and its retries.
Error handling
Every failure is a LookioError, so one instanceof covers the API's errors, a
dropped connection and a misuse of the SDK.
import { InsufficientCreditsError, LookioError, RateLimitError } from "@lookio/sdk";
try {
await lookio.assistants.ask("assistant_id", { question: "..." });
} catch (e) {
if (e instanceof InsufficientCreditsError) {
console.log("Out of credits, top up here:", e.actionUrl);
} else if (e instanceof RateLimitError) {
console.log("Slow down, retry in", e.retryAfterSeconds, "seconds");
} else if (e instanceof LookioError) {
console.log(e.status, e.code, e.message, "request:", e.requestId);
} else {
throw e;
}
}
Branch on e.code (for example insufficient_credits, rate_limited,
resource_not_found), never on the message. Fields: status (0 when no response
arrived), code, type, param, requestId (quote it to support), actionUrl,
docsUrl, retryAfterSeconds.
| Class | When |
|---|---|
AuthenticationError | 401: no key, a wrong, expired or revoked one |
PermissionError | 403: missing scope, feature_not_available, admin only |
NotFoundError | 404 and 410 |
ConflictError | 409 |
RateLimitError | 429, after the retries are used up |
InsufficientCreditsError | code insufficient_credits, has actionUrl |
ConnectionError | network failure, request timeout (code: "request_timeout"), abort (code: "aborted") |
ResourceFailedError, WaitTimeoutError | waitUntilReady |
StreamInterruptedError | a stream ended before its final event |
LookioUsageError | the SDK was misused (no key, a stream upload without a size). Nothing was sent |
A body that is not the API's error envelope (a proxy page, an empty body) becomes
a generic LookioError with code http_error and the status text in the message.
Credits
Importing and everything else is free. Only assistants.ask and
assistants.askStream spend credits, on every question, even when the resources hold no answer
(an assistant that reads no resource is refused with assistant_has_no_resources
before any credit is held). The price depends on mode (flash is the
default, eco, deep), whatever the answer's length. The
prices are in account.get():
const account = await lookio.account.get();
console.log(account.credit_balance);
console.log(account.credential.scopes); // a key without the `query` scope cannot ask
for (const mode of account.capabilities.query_modes) {
console.log(mode.id, mode.credits, mode.available);
}
Widgets
A widget is a chat box that shows one assistant to anonymous visitors on a
website. Each widget comes with ready-to-paste snippets (bubble, embed,
hosted, iframe), plus settings, limits and stats.
const widget = await lookio.widgets.create({
assistant_id: "assistant_id",
name: "Docs help",
allowed_domains: ["docs.acme.com"],
settings: { accent: "#4F46E5", greeting: "Ask me anything about the docs." },
limits: { monthly_credits: 5000 },
});
console.log(widget.snippets.bubble); // paste it in the page
await lookio.widgets.update(widget.id, { status: "paused" }); // shows nothing, answers nothing
for await (const chat of lookio.widgets.conversations.iterate(widget.id)) {
console.log(chat.end_user, chat.message_count, chat.credits);
}
const full = await lookio.widgets.conversations.get(widget.id, "conversation_id");
update is a partial PATCH: settings and limits change field by field, while
allowed_domains is replaced as a whole. Scopes: assistants:read to read,
assistants:write to create and update, assistants:delete to delete.
Visitors' answers are charged to the workspace (the price of the widget's mode,
capped by its limits), so set limits.monthly_credits before you publish the
snippet.
Conversations and end users
Every answer is kept in a conversation (90 days). assistants.ask returns its
conversation_id and the answer's message_id; a follow-up sends only the new
question and the conversation_id, Lookio reads the history itself. An API key
continues the conversations it started:
const first = await lookio.assistants.ask("assistant_id", {
question: "How much does the pro plan cost?",
end_user: "user_42", // your own id for the person asking (optional)
});
const followUp = await lookio.assistants.ask("assistant_id", {
question: "And for the annual plan?",
conversationId: first.conversation_id!,
end_user: "user_42", // a conversation can only be continued for the same end user
});
end_user groups one of your users' conversations (and the workspace's logs show
it); store: false keeps nothing (both ids come back null, and you keep the
conversation yourself with history). List, read, rename and delete the key's
conversations with lookio.conversations.* (list({ end_user }),
get(id, { end_user }), delete(id): an API key's conversation also leaves the
logs), and forward your user's thumbs:
await lookio.conversations.feedback(first.conversation_id!, first.message_id!, {
rating: "down", // "up", "down" or null to take it back
comment: "The price is out of date.",
});
end_user is an identifier, not proof of identity. Set it from your product's
authenticated session on your server. Check that a conversation belongs to that
person before continuing it. Do not trust an id sent by browser code without
checking it.
A person's own chats are personal: a person's token lists those, and takes no
thumbs yet (feature_not_available, a PermissionError).
Logs
The workspace has four logs, all free. Each list has list (one page),
iterate (every page) and, except activity, stats (the figures over the
same filters). The cursors of answers, indexing and activity are opaque:
the iterator passes next_cursor back for you.
| Log | Reads | Needs | Kept |
|---|---|---|---|
logs.conversations | What people outside the workspace asked: widget visitors, your product's users through an API key or the MCP. A member's own chats are not in it. | assistants:write, and for a person's token the right to edit assistants | 90 days after the last message |
logs.answers | Every answer from any channel (chat, lookio_ai, api, mcp, widget), metadata only, no question or answer text | usage:read | With the usage history |
logs.indexing | Every file, page or text read and indexed, with its status and the reason of a failure | resources:read | With the resources |
logs.activity | Who changed what, through which door (app, api, cli, mcp, agent, system) | admin, through a personal connection (never an API key) | A year |
// Conversations
for await (const chat of lookio.logs.conversations.iterate({ rating: "down", channel: "widget,api" })) {
console.log(chat.channel, chat.end_user, chat.title, chat.thumbs_down);
}
const full = await lookio.logs.conversations.get("conversation_id"); // messages, citations, cost, thumbs, comments
const stats = await lookio.logs.conversations.stats({ from: "2026-10-01" }); // conversations, questions, people, credits, thumbs, by_channel
// Answers: the questions the resources could not answer, and the conversation of a kept one
for await (const answer of lookio.logs.answers.iterate({ channel: "widget,api" })) {
console.log(answer.channel, answer.mode, answer.credits, answer.conversation_id);
}
const answerStats = await lookio.logs.answers.stats({ from: "2026-10-01" }); // answers, credits, tokens, by_channel
// Indexing: what failed, and why
const failed = await lookio.logs.indexing.list({ status: "failed", limit: 20 });
for (const line of failed.data) console.log(line.resource.title, line.name, line.error);
const indexingStats = await lookio.logs.indexing.stats({ resource_id: "resource_id" }); // total, indexed, failed, in_progress, words_indexed
// Activity (admins)
for await (const change of lookio.logs.activity.iterate({ origin: "cli,mcp", action: "assistant" })) {
console.log(change.created_at, change.actor.name, change.action, change.summary);
}
Filters (all optional):
conversations:channel(comma-separatedwidget,api,mcp),widget_id,api_key_id,assistant_id,rating(up,down,rated,unrated),end_user,q,from,to.answers:channel(comma-separatedchat,lookio_ai,api,mcp,widget),assistant_id,widget_id,api_key_id,member_id(admins),mode,from,to.indexing:resource_id,status(comma-separatedqueued,processing,indexed,replaced,unchanged,skipped,failed),source_type(file,url,site,text),q,include_unchanged("true"or"false", a site sync's unchanged pages are left out by default),from,to.activity:actor_id,origin(comma-separated),target_type(resource,assistant,widget,tag,member),target_id,action(assistant.updated, orassistantfor every action on a kind),q,from,to.
Types
Public types (Resource, Assistant, Answer, Citation, Conversation,
ConversationSummary, LogConversation, LogStats, LogAnswer, LogAnswerStats, LogIndexing, LogIndexingStats, LogActivity, Widget, WidgetSettings, WidgetLimits,
WidgetSnippets, WidgetConversation, CreateWidgetParams, Member, Usage, Account, Credits, TagSummary,
SitePages, SitePreview, StreamEvent...) are derived from the generated
OpenAPI types and exported from lookio, as are components, operations and
paths themselves. They are generated from ../api-worker/openapi.json:
npm run generate # rewrites src/generated/openapi.ts; never edit that file by hand
npm test, npm run typecheck and npm run build run it first, so the committed
file cannot go stale.
Development
npm install
npm test # vitest, with a mocked fetch: no test calls a live API
npm run typecheck
npm run build # dist/index.js (ESM), dist/index.cjs (CJS), dist/index.d.ts
src/coverage.test.ts reads the OpenAPI document and fails when an operation has
no SDK method, when a method sends a request the spec does not describe, or when
this README mentions an endpoint, scope or error code the API does not have.
Security
An API key must never ship to a browser. Anyone who opens the page can read it
and spend your credits. Call Lookio from a server (a Worker, an API route) and
send the result to the page. In a browser the client logs a warning once; pass
dangerouslyAllowBrowser: true to silence it if you know what you are doing (for
instance a key restricted to read-only scopes on an internal tool). Never commit a
key: read it from LOOKIO_API_KEY.
License
MIT, Duv Inc.
Pause a widget's monthly ceiling with client.widgets.update(id, { limits: { monthly_credits_enabled: false } }). Set it to true to restore the saved amount; spending earlier in the same month still counts.