Skip to content

Read documents from code

urai:knowledge is the uraijs library for reading the documents in your collections. A tool or a workflow imports it, opens a document by name or by id, and streams the text.

import { knowledge } from "urai:knowledge";
const doc = await knowledge.open("q3-links.md", { collection: "Sales" });
const domains = new Set<string>();
for await (const line of doc.lines()) {
for (const m of line.matchAll(/https?:\/\/([^\/\s")]+)/g)) domains.add(m[1]);
}

The library asks for no API key. It resolves the reserved urai:knowledge secret, and the host answers with a short-lived token that reaches only the collections this execution was granted.

Search finds passages, the library reads documents

Section titled “Search finds passages, the library reads documents”

POST /search and the search_documents tool return the passages that match a query, with a citation for each one. That answers a question. urai:knowledge does a different job. It gives your code the whole text of a document, in windows, so a script can count, group, filter, or total it.

You want Use
The passages that match a question POST /search or the search_documents tool
Every row of a 40 MB export urai:knowledge
A quote for a model to read search_documents, or read_document in agent mode
An arithmetic over a set of documents urai:knowledge inside a script

An agent can already put a document into its own context with read_document, but that caps out at about 40,000 characters and bills the text as tokens. urai:knowledge puts the bytes where the loop is. The model never holds them.

The two work well together. Search first to find the document, then open it by the id the result carries:

const doc = await knowledge.open(result.document.id);
const rows = [];
for await (const line of doc.lines()) {
if (line.startsWith("| ")) rows.push(line);
}
const docs = await knowledge.list({ collection: "Sales", path: "reports/2026" });
Option Effect
nameContains Case-insensitive substring match on the document name.
collection One collection, by its name or its slug.
path A folder and everything under it, for example "policies/2026".
limit At most 200, which is also the default.

Each entry describes one document.

Field Notes
id The document id. Open with it when a name is ambiguous.
name The file name.
collection The collection the document belongs to.
bytes The size of the extracted text. This is what read pages through. Absent for a document ingested before the size was recorded, and filled in once the document is read.
pages Absent for a format that has no pages, such as Markdown, text, and spreadsheets.
paths The folders the document is filed in, /-joined. Empty for a document at the collection root. Two entries mean one document filed in two places, not two copies.
const doc = await knowledge.open("invoice.pdf", { collection: "Finance" });

open takes a name, an id, or a name qualified by its folder, such as "policies/2026/leave.pdf". A qualified name means that folder and not one below it.

Always pass collection when you open by name. A file name is unique only inside its collection, so a name that matches in two collections makes open throw and name both. Opening by id never needs one.

open also throws when there is no such document, and lists what is reachable. Use knowledge.find() instead when an absence is an ordinary outcome. It returns null for a missing document, and still throws on an ambiguous name.

Four methods read from an open document. All of them page, because the library never holds a whole file unless you ask it to.

for await (const line of doc.lines()) {
// one line at a time
}

Line terminators are stripped, and \r\n is handled, so a file written on Windows leaves no stray \r. A last line without a trailing newline is still returned.

lines() throws on a file with no line structure, such as minified JSON or a single-row export. Buffering such a file would look like streaming and would fill the heap. Use chunks() for it.

for await (const chunk of doc.chunks({ bytes: 32 * 1024 })) {
// successive windows, no gaps and no overlap
}

The default window is 64 KiB.

const whole = await doc.text();

text() reads a small document whole. It refuses past 8 MiB rather than fill the heap quietly. Pass { maxBytes } to raise the limit when you mean to, or use lines() or chunks() when one pass over the text is enough. One pass is almost always enough.

read() is the primitive the other three are built on. It returns one window.

let offset = 0;
for (;;) {
const w = await doc.read({ offset, length: 64 * 1024 });
process(w.text);
if (w.eof) break;
offset = w.offset + w.bytes;
}
Field Notes
offset Where the window starts. This is not always what you asked for, because an offset inside a multi-byte character advances to the next character boundary.
bytes The source bytes the window consumed.
eof Whether the window reaches the end of the document.
text The text of the window.

Continue from offset + bytes of the reply, and never from the offset you asked for. Those are the only values that land on a character boundary. length is clamped by the host, currently to 256 KiB.

You get the extracted text, which is the Markdown the knowledge pipeline produced. For a .md or .txt upload that is the original file byte for byte. For a PDF, a DOCX, or an XLSX it is the conversion. Tables become Markdown tables, and the original bytes are not available here.

The library reads only the collections the execution was granted. An execution with no collections gets nothing, and the error says so.

  • An agent. Attach the collections to the assistant. Scripts the agent writes then read them. This is the same grant that gives the agent its search_documents and read_document tools.
  • A workflow. Name the collections in knowledge_collections when you register the workflow over the API, by slug or by id. An empty list grants nothing. See Register and secure it.

A grant is checked when it is spent, not only when it is minted. A collection deleted after the token was minted stops being readable at once.

These are network calls, so they belong inside Step.run. A Workflow.run coordinator body re-executes on every tick and must stay free of side effects.

const total = await Step.run("total-invoices", async () => {
const doc = await knowledge.open("invoices-q3.csv", { collection: "Finance" });
let sum = 0;
for await (const line of doc.lines()) sum += Number(line.split(",")[3] ?? 0);
return sum;
});

urai:knowledge normally exchanges an execution credential for a short-lived token, and that exchange needs a live host. The CLI has none. Put an ordinary API key entitled to the knowledge product in a local secrets file, and the same code runs unchanged:

{
"URAI_KNOWLEDGE_API_KEY": "sk-urai-...",
"URAI_BASE_URL": "https://chat.app.urai.dev"
}

The key has no effect in production, where the host mints the credential.

The scope differs, and that matters. An API key reaches every collection in its organization. A host-minted capability reaches only the collections the execution was granted. Code that finds a document locally can still get a “not found” in production. When that happens, the document is in a collection the agent or the workflow was not given.

Condition What you get
The document does not exist, or it is not in scope The same “not found” for both, so a script cannot probe for documents it was denied.
The document is still being processed An error that names the processing status. Wait and read it again.
The name matches in more than one collection An error that names the collections. Pass { collection }, or open by id.
The execution has no collections An error that says urai:knowledge is not available to this execution.