LibrarySearch and metadatav18 · Updated Report

Search and metadata

Two ways to find a document

Oddlynew gives you two retrieval calls, and picking the right one is most of the skill.

  • search finds documents by content or meaning; it was called searchDocuments until 31 August 2026, and the old name now fails with a list of the current methods. It returns a bounded top-N sample of ranked results with the best matching passages attached.

  • listDocuments enumerates what exists by raw catalog facts: type, creator, title, size, dates, metadata. No ranking, exact filters, cursor pagination.

Both run inside the documents namespace through run, which executes your JavaScript statements in an async context. Every example below is a complete script.

organizationId is required on every catalog and search call. search, listDocuments, listMetadataValues, listTypes, searchTypes, getType, createType, and createDocument all reject a call that omits it, before the call reaches the provider. There is no default organization on this surface. Reads addressed by document id (getDocument, getBacklinks) need no organizationId; getDocument accepts one only as a check and refuses the read if the document is not in that organization.

So the first script of a session is usually this:

const mine = await organizations.listMine({})
return mine.organizations.map(o => ({ id: o.organizationId, name: o.name }))

Pass an id you do not belong to and the call fails with Organization not found among your active memberships. Call organizations.listMine and choose one of those organizationId values.

search

const found = await documents.search({
  organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01",
  query: "incident retro rate limiting",
  mode: "hybrid",
  limit: 10,
  matchesPerDoc: 3,
})
return found.results.map(r => ({
  id: r.documentId,
  title: r.title,
  version: r.version,
  score: r.score,
  where: r.matches.map(m => m.section),
}))

Each result row carries documentId, title, typeId, version, createdAt, updatedAt, archivedAt, score, and matches[]. archivedAt appears only on archived rows: on a document that is not archived the key is absent rather than null, so test whether it is present instead of comparing it to null. Each match carries section, excerpt, excerptTruncated, primaryBlockKey, blockKeys, and blockKeyCount. The response also carries limitApplied and truncated, plus documentUrlBase when the deployment has a canonical document web origin (append a documentId to it to build a link).

To read the evidence behind a match, chain into getDocument with the row's version plus a block key from the match. matches is empty on tail rows and primaryBlockKey can be null, so guard before dereferencing:

const found = await documents.search({
  organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01",
  query: "rate limiting",
  limit: 3,
})
const top = found.results[0]
const key = top && top.matches[0] && top.matches[0].primaryBlockKey
if (!key) return { found: false }
return await documents.getDocument({
  documentId: top.documentId,
  version: top.version,
  scope: { type: "slice", blockKey: key, above: 2, below: 2 },
})

version pins the read to the snapshot that was actually searched. Omit it only when you deliberately want current live state instead.

Modes, and when each wins

mode accepts "hybrid" (default), "semantic", "fts", and "exact".

Every mode searches the content of a document. The title is not part of the searched text: a passage never matches because of the title above it, and semantic and exact do not look at titles at all. Titles are found by the title rule of hybrid and fts described below, and by listDocuments with the title filter.

  • hybrid: runs keyword and vector retrieval in parallel and fuses the two ranked lists. Best default recall. In hybrid and fts, a document whose title equals your query (trimmed, case-insensitive) is placed first. Documents whose title contains every query word are placed on top as well when the trimmed query is at least eight characters long and contains at least two distinct lexical words, at least one of them three or more characters long; at most three rows are promoted in total, including exact-title matches, whose promotion does not require these thresholds. When you know a name or a part of it, listDocuments with the title filter is still the direct way.

  • semantic: vector similarity only. Use it when the wording in the corpus is not the wording in your head ("what did we decide about vendor lock-in" against documents that never say "lock-in").

  • fts: keyword full-text only. Use it for names, identifiers, and jargon where embeddings dilute a rare term.

  • exact: deterministic substring matching against canonical stored text. Every result scores 1.0, and rows come back ordered by the document's updatedAt, newest first, rather than by relevance. Use it when the string is the question: an error code, a URL, a function name.

mode: "exact" matches your query as a literal substring. It is case-insensitive (it compiles to ILIKE), and your %, _, and \ are escaped to literals. There is no regular expression mode; a call that still passes regex is rejected as an unknown parameter.

return await documents.search({
  organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01",
  query: "ERR_QUOTA_[A-Z]+",
  mode: "exact",
  regex: true,
  limit: 25,
})

Scores are comparable only within one mode. Their magnitude means nothing across deployments, and a nonempty response is not a confidence verdict. An empty results array is the only miss signal there is.

Hybrid and semantic need an embedding rollout to be configured. If it is not, those modes fail with a message naming the mode you asked for: hybrid search is unavailable because no embedding rollout policy is configured, or, when a rollout exists but exposes nothing readable, hybrid search is unavailable because the embedding rollout policy has no readable profiles. Retry with mode: "fts" or mode: "exact".

truncated and band

truncated: true means the pass filled its candidate pool, so matching documents may sit outside what you got back. In hybrid, fts, and exact it reports the keyword leg and is a real per-call signal: narrow filters or raise limit and search again before concluding something is not there. In semantic it is expected in any sizable corpus, because nearest-neighbor retrieval always fills its pool; treat it as background noise.

Every mode keeps a response cheap to skim with band. In hybrid and semantic the first five rows are "leading" and carry matches; later rows come back as "tail": title, score and document fields, with matches emptied. Pass excerptRows (0 to 50) for more rows with passages; excerptRowsApplied echoes what was used. In fts and exact the band follows score instead: rows scoring below half the response's top score are "tail", and since every exact row scores 1.0, an exact response is always entirely "leading". Tail rows are still real matches, so read one by id when the leading rows do not answer the question.

Filters

Every filter composes with every other, and all of them AND together.

  • typeIds: an array of document type ids. Pass one id to narrow to one type, or several ids to match any of those types.

  • createdAfter / createdBefore: on createdAt, which never moves. Lower bound inclusive, upper bound exclusive.

  • updatedAfter / updatedBefore: on updatedAt, which moves on every persisted edit.

  • archived: "exclude" (default), "include", or "only".

  • metadataFilters: see the next section.

  • limit: 1 to 50, default 20. matchesPerDoc: 1 to 5, default 1.

Dates take an ISO date ("2026-01-31") or datetime ("2026-01-31T12:00:00Z"). An unparseable value fails immediately with Invalid createdAfter: expected an ISO-8601 timestamp like 2026-07-10T12:00:00Z.

Searching more than one organization

Each search call covers exactly one organization; no parameter takes several. To search across the organizations you belong to, list them with organizations.listMine and run one search per organization, starting with the organization the task belongs to:

const mine = await organizations.listMine({ limit: 20 })
const rows = []
for (const org of mine.organizations) {
  const found = await documents.search({
    organizationId: org.organizationId,
    query: "rate limiting",
    limit: 5,
  })
  rows.push(...found.results.map(r => ({ organization: org.name, id: r.documentId, title: r.title })))
}
return { rows, nextCursor: mine.hasMore ? mine.nextCursor : null }

Every search is metered as its own call, so searching ten organizations costs ten searches; search only the organizations the question concerns. One run makes at most 64 provider calls, so continue with nextCursor in a further run when the list is longer. Scores are comparable only within one call, so read the top rows of each organization rather than sorting the merged list by score.

Organization activity log

Oddlynew records agent activity in each organization's private activity log: the corpus queries agents run, the document changes they make, and the organization's API key events (created, revoked, rotated), as three event kinds in one stream; key events are shown only to members who may manage the organization's API keys. Authorized agent searches are captured by default; signed-in and anonymous human-facing web searches are not. That boundary is intentional: human search text is often shorthand or navigational noise, while an agent can formulate a clearer question from its task and therefore provides the stronger signal for improving the corpus.

A query event contains the query text (limited to 500 characters), its timestamp, and the connection that ran it. Oddlynew does not attach search results, retrieved passages, or the agent's answer. Queries are not sampled, and duplicates are preserved because repetition is useful evidence of a recurring knowledge gap. A change event is recorded when a document version commits and on the lifecycle actions created, edited, archived, restored, deleted, type_changed, copied, signature_sent and signature_completed, and carries the document id, the committed version, the action, and the connection. Payloads are ids, actions, and query text only; document titles and content never enter the log.

Reading the log requires the org:activity_log:read permission. Team roles carry it; machine-principal API keys deliberately do not, so a narrow key handed to a third party cannot read the organization's query stream or change graph. Who did what is a further privilege: the actor on an event is visible only to holders of the log administration permission, and everyone else reads the same stream actor-free; a null actor on a visible event means it was captured actorless by design. Public readability of a document never exposes the log, and a public-readable document search does not gain capture authority. The configured platform Help agent is the deliberate exception: after its Help authorization succeeds, its search is recorded, actorless, in the organization that owns the configured Help corpus. A human using the public Help website is still outside the capture boundary.

Oddlynew keeps the newest 10,000 events per kind (queries and changes each) for at most 90 days, whichever limit removes a row first. Reads always apply the 90-day window; the per-kind cap is enforced by cleanup, so a few events beyond it can appear briefly until cleanup catches up. Capture is best-effort and preserves the miss signal: an authorized agent query can be recorded even when retrieval finds nothing or fails afterwards, while a capture problem does not put query text into application logs.

The logs namespace reads the log. logs.list returns both kinds in one stream and accepts kind, documentId, and time-bound filters (log administrators can additionally filter by actorUserId); logs.queries and logs.changes are the per-kind projections of the same data. Pagination is newest-first and cursor-based, 100 events per page by default and 1,000 at most:

return await logs.list({
  organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01",
  limit: 100,
})

The response is { events, nextCursor, hasMore, truncatedBefore }. Every event carries id, kind, and createdAt; query events add query, and change events add documentId, documentVersion, and action. The projections answer in their own shape (queries or changes instead of events). truncatedBefore honestly reports the latest boundary imposed by retention, the per-kind cap, or an explicit clear, for the kind being read.

Organization administrators manage the log from the Logs card in Organization → Integrations. Capture starts on by default and is unsampled. One toggle covers the whole log: an administrator can disable future capture without deleting existing history and turn it back on later. One clear irreversibly deletes the retained history, searches and document changes alike. There is no sampling control: a complete bounded signal is easier to reason about, and the fixed per-kind limits bound storage.

Reading this log as editorial evidence, recognizing from the queries when the corpus itself should change, is covered in What Your Agents Search For Tells You What to Write.

listDocuments

listDocuments is a single canonical PostgreSQL query. Its filters always apply exactly, so an empty page means nothing matches, not that something is still indexing.

const page = await documents.listDocuments({
  organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01",
  typeIds: ["0193a0c1-77aa-7bd2-91e6-45f3c0aa1101"],
  minCharCount: 2000,
  createdAfter: "2026-06-01",
  sort: "char_count",
  limit: 25,
})
return {
  rows: page.documents.map(d => ({ id: d.documentId, title: d.title, chars: d.charCount })),
  hasMore: page.hasMore,
  nextCursor: page.nextCursor,
}

Parameters: organizationId (required), typeIds (array; use one element for one type), creatorId, title, minCharCount / maxCharCount, the same four date filters, archived, metadataFilters, sort ("recency" default, which orders by updatedAt newest first, or "char_count"), cursor, and limit (1 to 50, default 20).

The title filter is the fastest known-title lookup in the product, and it never touches the ranked index:

return await documents.listDocuments({
  organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01",
  title: { value: "Incident retro: 2026-06-14", match: "exact" },
})

match is "exact" (default), "prefix", or "contains" for a part of the title. All three match case-insensitively and literally, with no wildcards.

Rows are richer than search rows: typeName, typeTags, creatorId, organizationId, charCount, blockCount, currentVersion, versionCount, metadata, collaborators, plus timestamps and archivedAt. That makes listDocuments the right first call when you have only an organization and need to decide what to read.

Paginate with cursor until hasMore is false. If a cursor returns an empty page after the previous page reported hasMore: true, the response carries warning.code: "empty_page_with_cursor": the catalog changed under you, so restart pagination without a cursor.

Prefer listDocuments when you know the title, want an exhaustive enumeration, are counting or auditing, or are looking for something written moments ago. **Prefer **search when the question is about content you cannot name precisely.

Metadata as a query plane

Document metadata is free-form JSON. What makes a metadata field filterable is a declaration on the document type, not on the document: searchAttributes, a list of at most 10 entries of { field, kind } with kind one of text, number, datetime, boolean. Declared fields are projected into typed storage on every metadata write; undeclared keys stay legal JSON and are simply not filterable. Declaring a field never constrains its values, and there is deliberately no enum kind.

The loop is discover, enumerate, filter.

const type = await documents.getType({ organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01", typeId: "0193a0c1-77aa-7bd2-91e6-45f3c0aa1101", include: ["unprojectedCounts"] })
return type.searchAttributes

Each entry returns field, kind, and ops (the operators legal on it, so the kind-to-operator table never has to be memorized); pass include: ["unprojectedCounts"] to also get unprojectedCount: how many visible documents carry the key but projected no value, because what they wrote did not fit the declared kind. Coercion failures are silent, so a persistently non-zero count means the declaration and what documents actually write disagree.

Then enumerate the vocabulary of an open text field before filtering on it:

return await documents.listMetadataValues({
  organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01",
  field: "collection",
})

You get field, values[] of { value, documentCount }, most-used first with ties broken by value, plus truncated and declaringTypeIds. The list is bounded at 50 distinct values today; when truncated is true, narrow with typeIds rather than treating what came back as the whole vocabulary. Values come from the typed projection, never from a scan of raw metadata. listMetadataValues is text-kind only: a field declared as number, datetime, or boolean fails with the kinds it is declared under. A field no visible type declares at all fails too, rather than returning an empty list, with No document type visible to this organization declares the metadata attribute ... followed by the attributes that are declared. It also defaults to archived: "exclude", because you enumerate in order to write the next call's filter.

Now filter, on either read:

return await documents.search({
  organizationId: "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01",
  query: "retention policy",
  metadataFilters: [
    { field: "collection", op: "in", values: ["engineering-notes", "support-memory"] },
    { field: "sourcePath", op: "prefix", value: "Projects/" },
    { field: "reviewedAt", op: "range", from: "2026-01-01", to: "2026-06-30" },
  ],
})

Rules that bite if you do not know them:

  • Filters AND together. There is no OR grammar. Use in for alternatives on one field.

  • Operators by kind: text takes eq, in, prefix; number and datetime take eq, in, range; boolean takes eq.

  • range needs at least one of from / to and includes both ends.

  • A datetime operand is read at its own granularity. to: "2026-06-30" covers all of June 30 rather than stopping at its midnight. Add a time when the bound is an instant.

  • A field no visible type declares, or an operator or value the declaring kind rejects, fails the call and lists every declared field with its legal operators. A typo never quietly reads as an empty corpus.

  • Inventing a value is different: that returns an empty page, not an error. Hence enumerate first.

Filtered responses echo where filtering ran: metadataFilterMode ("native" for ranked modes, "postgres" for listDocuments and exact), metadataFilterBackend (the concrete engine: turbopuffer, in-memory, or postgres), and metadataFilterTypes (which types took part in each filter). That last one explains thin results honestly: documents of a type that does not declare the field never match it.

Limits on the metadata plane

A type declares at most 10 filterable attributes. The pool of distinct field names is per organization and shared by every type in it: about one thousand names, of which system bookkeeping uses a small fixed share. Two types declaring the same field name share one attribute, so reusing names across types (status, owner, root) is deliberate good practice, not a collision.

One rule follows from sharing: a field name has one kind per organization. If one type declares priority as a number, a second type declaring priority as text is fighting over the same underlying attribute. Keep a small reused vocabulary with one kind per name and the pool is effectively inexhaustible; a filtered query never gets slower because other combinations of filters exist, since filters compose at query time rather than being precomputed.

getBacklinks

Inline {% docref %} citations build a persisted reverse graph. getBacklinks reads it:

const back = await documents.getBacklinks({
  documentId: "0193c2d4-1a55-7e90-8c22-9b7f0e33bb12",
  limit: 50,
  includeSourceContext: true,
  sourceContextAbove: 1,
  sourceContextBelow: 1,
})
return back.entries.map(e => ({
  from: e.sourceTitle,
  anchor: e.anchorText,
  drift: e.targetDrift.status,
  deleted: e.sourceDeleted,
}))

One row per citing occurrence, so a source block citing the same pin twice yields two rows. Rows carry sourceDocumentId, blockKey, anchorText, sourceTitle, sourceTypeFamily, sourceDeleted, targetVersion, targetBlockKey, and targetDrift. targetDrift is an object, { status, targetCurrentVersion }, whose status tells you whether the citation still points at current target content. Optional filters: limit (1 to 50, default 20), cursor, sourceCreatorId. Context slices take sourceContextAbove / sourceContextBelow, each 0 to 10 and each defaulting to 1.

The response states graphBasis: "current_visible_persisted_graph". There is no historical mode: these rows are never facts about a getDocument(version) snapshot. Paginate until hasMore is false.

Freshness: finding something you just wrote

This is the sharp edge, stated plainly.

Ranked retrieval (hybrid, semantic, fts) runs against a search index that mirrors your documents. Indexing lags writes by a few minutes, typically 3 to 5. A document created seconds ago will often not appear in a hybrid search, and a metadata value changed seconds ago may still be settling into the ranked mirror.

Two reads do not depend on that index at all:

  • listDocuments, which is a canonical PostgreSQL query.

  • search with mode: "exact", which matches against canonical stored text in PostgreSQL.

So the reliable recipe for finding a just-created document is either of these:

const org = "0193b1f2-8c44-7a10-9f3b-2d5c1e77aa01"
const since = new Date(Date.now() - 15 * 60 * 1000).toISOString()
const exact = await documents.search({
  organizationId: org,
  query: "Q3 retention policy",
  mode: "exact",
  createdAfter: since,
  limit: 10,
})
if (exact.results.length > 0) return exact.results

const page = await documents.listDocuments({
  organizationId: org,
  createdAfter: since,
  sort: "recency",
  limit: 20,
})
return page.documents

One qualification that keeps this honest: every search mode, exact included, reads the persisted head of a document, not unsaved live edits. A freshly created document has its first version persisted during creation, so it is exact-searchable right away. Edits made afterwards become searchable when the live state flushes to a new version, which is why getDocument (which reads live state) can show text that search cannot yet find. When freshness is decisive, read the document by id.

Widening ladder when a search comes back empty

Work down this list rather than concluding nothing exists:

  1. Re-phrase the query, or switch mode (fts for a rare term, semantic for a concept, exact for a literal string).

  2. Widen the date window, or drop typeIds.

  3. Check truncated. If it is true outside semantic mode, narrow filters or raise limit and search again.

  4. Fall back to listDocuments, which cannot lag.

  5. Search results never include comment threads by default. If what you are looking for was said in a comment, search again with the Comment type id 00000000-0000-8000-8000-000000000006 in typeIds.

  6. Last rung: archived: "include". Archived documents are out of the default set on every read here, and archiving does not move updatedAt, so an archived document is invisible to a recency-shaped query. Rows returned this way carry archivedAt, so you can say where the evidence came from.

Finally, remember that a run script may make at most 64 provider calls, so paginate with intent: fetch a page, decide, and return a concise result rather than sweeping a whole corpus in one run.

Oddlynew PublicSign in

Referenced by