@lookio/sdk

中文文档
1.0.0发布于 2026/11/02MITTypeScriptESM + CJS零依赖

The official Lookio TypeScript SDK: resources, assistants and questions from Node, Workers, Deno or a server-side app.

原始使用文档
文档目录18 节
  1. Install
  2. Quick start
  3. Ask an assistant
  4. Stream an answer
  5. Upload a file and wait until it is ready
  6. Iterate every resource
  7. Set a site's sync schedule
  8. Methods
  9. Idempotency and retries
  10. Error handling
  11. Credits
  12. Widgets
  13. Conversations and end users
  14. Logs
  15. Types
  16. Development
  17. Security
  18. License

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, TextDecoder and crypto.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.

NamespaceMethods
account, credits, usageaccount.get(), credits.get(), usage.get({ from?, to?, granularity? })
resourceslist, iterate, get, createText, createUrl, createSite, uploadFile, update, delete, content, reindex, waitUntilReady, pages, iteratePages
sitespreview, pages, iteratePages, updateRules, retryFailed
tagslist, rename({ from, to }), delete(tag)
assistantslist, get, create, update, delete, ask, askStream, search, getPublication, publish, unpublish
publicAssistantslist, iterate, get
accesseslist, get, create, update, delete, createKey, updateKey, deleteKey
conversationslist, iterate, get, rename, delete, feedback
logsconversations.{list, iterate, get, stats}, answers.{list, iterate, stats}, indexing.{list, iterate, stats}, activity.{list, iterate}
widgetslist, get, create, update, delete, duplicate, setImage, clearImage, conversations.{list, iterate, get}
memberslist, updatePermissions(userId, { permissions, assistant_ids })
feedbacksend({ 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):

FailureGET, PUT, DELETEPOSTPATCH
429retriedretriedretried (it was refused at the gate)
408, 5xx, network error, timeoutretriedretried (same key)not retried (the change may have landed)
any other 4xxnevernevernever

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.

ClassWhen
AuthenticationError401: no key, a wrong, expired or revoked one
PermissionError403: missing scope, feature_not_available, admin only
NotFoundError404 and 410
ConflictError409
RateLimitError429, after the retries are used up
InsufficientCreditsErrorcode insufficient_credits, has actionUrl
ConnectionErrornetwork failure, request timeout (code: "request_timeout"), abort (code: "aborted")
ResourceFailedError, WaitTimeoutErrorwaitUntilReady
StreamInterruptedErrora stream ended before its final event
LookioUsageErrorthe 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.

LogReadsNeedsKept
logs.conversationsWhat 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 assistants90 days after the last message
logs.answersEvery answer from any channel (chat, lookio_ai, api, mcp, widget), metadata only, no question or answer textusage:readWith the usage history
logs.indexingEvery file, page or text read and indexed, with its status and the reason of a failureresources:readWith the resources
logs.activityWho 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-separated widget, api, mcp), widget_id, api_key_id, assistant_id, rating (up, down, rated, unrated), end_user, q, from, to.
  • answers: channel (comma-separated chat, lookio_ai, api, mcp, widget), assistant_id, widget_id, api_key_id, member_id (admins), mode, from, to.
  • indexing: resource_id, status (comma-separated queued, 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, or assistant for 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.

版本与发布记录

@lookio/sdk 共发布 2 个版本,首次发布于 2026/11/02;过去 12 个月发布了 2 个版本。

版本发布日期距今
1.0.0latest2026/11/021 天前
0.0.0-stage2026/11/021 天前

关于 @lookio/sdk 的常见问题

@lookio/sdk 是什么?

官方的Lookio TypeScript SDK:来自Node、Workers、Deno或服务器端应用的资源、助手和问题。

如何安装 @lookio/sdk?

在项目目录运行 npm install @lookio/sdk,或使用 pnpm add / yarn add / bun add。

@lookio/sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@lookio/sdk 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@lookio/sdk 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@lookio/sdk 可以商用吗?

@lookio/sdk 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@lookio/sdk 还在维护吗?

最新版本 1.0.0 发布于 2026/11/02(1 天前);至今共发布 2 个版本,其中过去 12 个月发布了 2 个;首次发布于 2026/11/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。