The same document twice, and an answer that doesn't know
Nothing is refused when you upload the same file again. A filename is a label — documents have ids — and re-uploading a revised file under its stable name is a normal, useful thing to do. But both copies stay retrievable, and that quietly doubles a passage's weight in an answer.

This is a small thing that produces a confusing symptom. Retrieval returns the same passage twice. The model sees the same claim from what look like two independent sources. Answers built on that passage come out more confident than the corpus warrants, and there is nothing in the citations that says the two entries are the same file.
Check the hash, not the response
Every document carries content_hash — SHA-256 of the bytes as uploaded — and duplicate_of, the id of the oldest document in the project with the same hash.
const doc = await ai.documents.get(id);
if (doc.duplicate_of) {
// already in the corpus under `doc.duplicate_of` — delete one of them
}Read it off get or list, not off the upload response. It is resolved when you ask, against the corpus as it stands — so an upload that has not finished indexing has no hash to match on and reports null.
That is also what makes a whole folder uploaded at once resolve correctly. Every copy links as soon as the rows have hashes. Nothing to sweep, nothing to backfill. Delete the original later and the copies stop pointing at it, and the link is always to something strictly older, so two uploads racing cannot come back naming each other.
If you want to avoid the duplicate rather than clean it up: hash the bytes yourself and check content_hash before uploading. That is the intended use, and it is cheaper than the alternative.
While we are on fields nobody knows exist
A document carries more than most people ever look at, and two of them answer questions that are otherwise unanswerable.
embedding_model — which model's vectors this document is in. A document embedded by an older model lives in its own collection. This is how you tell which ones a reindex still owes.
pipeline_version — the reader and the embedder that produced the current vectors, as one string. Same use: it tells you what is stale after you change the project's settings.
Those two are the difference between a model migration you can watch and one you can only survive. If you are building this yourself, put both on the row from day one. It costs a column.
status is pending → processing → ready | failed, and only ready is searchable. chunk_count is never 0 on a ready document — a file nothing could read comes back failed with a reason, rather than ready with nothing in it. A ready document with zero chunks is a document that will never appear in an answer and will never appear in an error either.
failure is the same failure as one word for your code to branch on: usage_cap, rate_limit, unsupported_type, unreadable, embedding, vector_store, storage, unknown. Two of those are not about the file at all — usage_cap and rate_limit mean the project hit its own spend cap or per-minute limit. Nothing is wrong with the document, and re-uploading it fails the same way until the cap is raised.
error is the sentence for a person. failure is for the code around them. Keeping those separate is the small discipline that stops error handling turning into string matching.
Retagging without losing identity
Tags scope a curated corpus — both retrieve({tags}) and chat({tags}) key on them — so getting one wrong is ordinary. Fixing it should not cost the document its identity:
await oberik.documents.update(id, { tags: ["policies", "2026"] });
await oberik.documents.reingest(id);tags replaces the list; fields you do not pass are left alone. The id does not change, which is the point — deleting and re-uploading gives you a new id and silently breaks every citation, stored document_ids scope and audit record that pointed at the old one.
Note this is on the project client, not the end-user one. An end-user token is refused a document it does not own, deliberately: your users must not be able to rewrite the corpus you curate. Uploads made with a project key are recorded against the subject service, which is why the project client is the surface that can edit them.
Two deletes that are deliberately not the same
await ai.documents.delete(id); // end-user token — goes
await project.documents.delete(id, { confirm: true }); // project key — requiredThat is not an inconsistency waiting to be tidied up.
An end-user removing their own upload is an ordinary action, and the place to confirm it is your product's UI, where the person actually is. A project key is an operator credential reaching across every end-user in the workspace, usually from a script nobody is watching — and the confirmation is the only thing standing between a stray loop and a corpus.
So the gate is on the operator route, not on the document. And confirm is a required argument rather than an optional one, which means a script that has not thought about it fails to compile instead of failing at run time against a corpus it was halfway through.
list is a page
let offset: number | null = 0;
const all = [];
while (offset !== null) {
const page = await ai.documents.list({ limit: 200, offset });
all.push(...page.items);
offset = page.next_offset; // null on the last page
}limit defaults to 100 and caps at 500; outside that is a 422 naming the bound, rather than a 200 that quietly means something else.
Page with next_offset rather than adding your own limit to your own offset — the server-side cap can make that arithmetic skip rows. has_more is free, because the server reads one spare row. with_total: true costs a second query, so it is for a summary rather than for paging.
A corpus is the one collection that only grows, and a bare array asserts completeness by omission. That was the actual bug: a project with 137 documents answered with 100 rows and nothing saying so — indistinguishable from a project that has exactly 100.
