omnisearch
Cross-entity full-text search across documents, projects, contacts, drawings, specs, reports, and files.
omnisearch is the rich search service behind the Part3 MCP omnisearch
tool and the in-app global search bar. It runs full-text search across
projects, documents, contacts, drawings, specs, reports, and files in
one call, scoped to one organization (and optionally one project), and
streams results per entity type.
Two surfaces, same backend, same permission model:
- MCP tool — Claude / Cursor / VS Code, etc. (see tools index)
- Streaming HTTP (SSE) via the auth-proxy — used by the Part3 web app's search bar and available to web-app integrators today. It's not a REST endpoint — results stream back as Server-Sent Events, one batch per entity type, so the UI can render hits as they arrive rather than blocking on the slowest entity.
When to use it
Use omnisearch when:
- You don't know which entity type the thing lives under (rather than
juggling
documents_list,projects_list, etc.) - You want to search across content fields that the entity-specific list tools don't cover — RFI question text, change-order reasons, comment bodies, MasterFormat division titles, PDF content of drawings and specs, attachment filenames
- The user gave you a free-form query like "any RFIs about the elevator wiring on Stetson?" or "find a spec about thermal insulation"
For exact-by-name lookups (project picker, organization picker, doc id), prefer the entity-specific list tools — they're cheaper and return richer per-entity fields.
HTTP surface (web app + integrators)
Endpoint
GET {auth-proxy URL}/search?orgId=...&q=...
Accept: text/event-streamResponse is an event stream, not a JSON body — clients consume it with
fetch + ReadableStream. The auth-proxy URL is the same one the
Part3 web app uses; ask the Part3 team for the current production URL
if you don't already have it. (No custom domain mapping today — it's
the auto-assigned Cloud Run URL.)
There are two picker helper endpoints next to /search that are
plain JSON (not SSE):
| Endpoint | Returns |
|---|---|
GET /organizations | { id, name }[] of orgs the user can see |
GET /organizations/:orgId/projects | { id, name }[] of projects in one org |
The web app uses these to populate the search bar's org / project
dropdowns. Same auth as /search.
Auth
Pass a Firebase ID token for a signed-in Part3 user as a bearer header:
Authorization: Bearer <firebase-id-token>The auth-proxy verifies the token, mints a short-lived proxy JWT, and forwards the request to omnisearch with the user's full session resolved (orgs, projects, doc-level access). Permissions are enforced exactly the same as in the web app — there's no separate "search permission".
CORS: when a request's Origin header is on the auth-proxy's allowlist,
the response includes Access-Control-Allow-Origin: <that origin> plus
Access-Control-Allow-Credentials: true. Add your origin via the Part3
team if you're calling from a frontend not already on the list.
Getting a Firebase ID token (Part3 webapp internal)
For Part3 webapp features that already wrap the user's Firebase session,
you can grab the ID token straight off the current firebase/auth user:
import { getAuth } from 'firebase/auth';
const user = getAuth().currentUser;
if (!user) throw new Error('Not signed in');
// no-arg = use cached token, refresh only if expired (the common case).
// `true` = force a refresh (rotates immediately — used for re-auth flows).
const idToken = await user.getIdToken();Tokens are short-lived (~1 hour) but the SDK refreshes them transparently
on getIdToken(). Don't cache the returned string yourself — call
getIdToken() per outbound request. The Firebase SDK already does the
right thing with its own internal cache + refresh.
If you're outside the React tree (worker, service module), use the
onIdTokenChanged listener to mirror the current token into wherever
your fetch helper picks it up:
import { onIdTokenChanged, getAuth } from 'firebase/auth';
let currentToken: string | null = null;
onIdTokenChanged(getAuth(), async (user) => {
currentToken = user ? await user.getIdToken() : null;
});For the Part3 webapp itself, the existing getToken() helper already
does this — pass it straight into the search-widget's
SearchServiceConfig.getToken.
Query params
| Param | Type | Required | Description |
|---|---|---|---|
orgId | string | yes | Organization scope. Web-app users can only pass orgs they have access to. |
q | string | conditional | Search query, 2–500 chars. Required unless type is set (browse mode). |
projectId | string | no | Narrow to one project within the org. Server rejects if the project isn't in the org. |
type | string | no | Single entity type to query — one of projects, documents, contacts, drawings, specs, reports, files (plural — these are the SSE event names). When set, only that one event is emitted. |
format | string | no | For documents: rfi, submittal, bulletin, changeOrder, certificate, instruction, quote, siteInstruction. Other values are ignored. |
limit | integer | no | Per-entity result cap, 1–50, default 5. Values outside the range are clamped. |
HTTP errors (before the SSE stream starts)
The endpoint returns a normal JSON error response (not an SSE event) when the request is rejected upfront:
| Status | When |
|---|---|
400 | Missing orgId; q too short / too long; projectId not in orgId |
401 | No bearer token / token rejected by the proxy |
403 | Session not found in cache; user doesn't have access to the org or project |
Once the SSE stream has started (200 text/event-stream), per-entity
errors are surfaced via the done event (see below) — the connection
itself stays open.
Response — Server-Sent Events stream
The endpoint emits one event per entity type as that type's query
finishes, plus a final done event:
:ok
event: contacts
data: { "results": [ ... ], "cached": false }
event: projects
data: { "results": [ { "type": "project", "id": "...", "title": "...", "meta": { ... } }, ... ], "cached": false }
event: documents
data: { "results": [ ... ], "cached": false }
event: drawings
data: { "results": [ ... ], "cached": false }
event: specs
data: { "results": [ ... ], "cached": false }
event: reports
data: { "results": [ ... ], "cached": false }
event: files
data: { "results": [ ... ], "cached": false }
event: done
data: { "elapsed": 134, "errors": [] }Notes on the stream:
:okis an SSE comment line sent immediately after headers to flush the connection. Skip lines that start with:in your parser.- Each entity emits exactly one event, even on zero hits (you'll
get
{ "results": [], "cached": false }). - The
cachedboolean tells you whether the batch came from omnisearch's per-user Redis cache (true) or was freshly computed (false). - Order is not guaranteed. Lightweight queries (
projects,contacts) fire first, then heavier ones (documents,drawings,specs,reports,files). UIs render hits as each event arrives. done.elapsedis total milliseconds.done.errorsis an array of the entity event names that failed (e.g.["files"]) — the rest of the stream's results are still valid; only those entities are missing.- There is no
errorSSE event. Fatal failures terminate the connection without a final event.
Limits
limitcapped at 50 per entity type per call (default 5)qminimum 2 characters, maximum 500- One organization per call (no cross-org search)
- Per-entity SQL has a 15-second internal timeout — if hit, that
entity name appears in
done.errors
Consumption
Browser EventSource does not support custom request headers, so it
can't pass the Authorization: Bearer ... header the auth-proxy
requires. Use fetch + ReadableStream instead:
const res = await fetch(`${authProxyUrl}/search?orgId=${orgId}&q=${query}`, {
headers: { Authorization: `Bearer ${firebaseIdToken}` },
});
if (!res.ok) throw new Error(`omnisearch ${res.status}`);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let event = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line === '' || line.startsWith(':')) {
// blank line = end of event; ':' line = SSE comment (`:ok`).
event = '';
continue;
}
if (line.startsWith('event: ')) {
event = line.slice(7).trim();
} else if (line.startsWith('data: ') && event) {
const data = JSON.parse(line.slice(6));
handle(event, data); // route by event name
}
}
}The Part3 search-widget at packages/search-widget is a working
reference implementation — copy it if you'd rather not roll your own
parser.
Result shape
Each result has the same top-level fields:
| Field | Type | Description |
|---|---|---|
type | string | One of project, document, contact, drawing, spec, report, file (singular — distinct from the SSE event name) |
id | string | Stable Part3 UID for the hit |
title | string | Short label suitable for display |
subtitle | string | null | Secondary label (e.g. project name) |
projectId | string | null | Project the hit belongs to |
projectName | string | null | Project display name |
meta | object | Type-specific fields (see below) |
meta fields by result type
project
| Field | Type | Description |
|---|---|---|
archived | 'true' | 'false' | Whether the project is archived (string, not boolean) |
createdAt | string | null | ISO 8601 creation timestamp |
document
| Field | Type | Description |
|---|---|---|
documentId | string | null | Human-readable ID (e.g. RFI-001) |
type | string | Document type |
format | string | rfi, submittal, bulletin, changeOrder, etc. |
status | string | Current document status |
matchedField | string | null | Which field matched: title, documentId, description, attachment |
matchedValue | string | null | The matched text or decoded attachment filename |
contact
| Field | Type | Description |
|---|---|---|
email | string | null | Contact email |
phone | string | null | Contact phone |
title | string | null | Job title |
company | string | null | Company name |
drawing
| Field | Type | Description |
|---|---|---|
drawingNumber | string | null | Drawing version ID (e.g. A-716) |
matchedField | string | null | drawingNumber, title, or pdfContent |
matchedValue | string | null | The matched text |
spec
| Field | Type | Description |
|---|---|---|
divisionCode | string | null | MasterFormat division code (e.g. 05) |
divisionTitle | string | null | Division title (e.g. Metals) |
sectionCodeTail | string | null | Section code within division (e.g. 50 00) |
matchedField | string | null | code, title, or pdfContent |
matchedValue | string | null | The matched text or PDF snippet |
report
| Field | Type | Description |
|---|---|---|
reportType | string | null | Report type |
file
| Field | Type | Description |
|---|---|---|
fileClass | string | spec, drawing, or other |
matchedField | string | name or pdfContent |
pageNumber | string | null | Page number for PDF content matches |
specSectionId | string | null | If file is a spec version, parent spec section UID |
drawingRevisionId | string | null | If file is a drawing version, parent drawing UID |
MCP tool
When invoked through MCP, the inputs and result fields are identical. The auth model is different — the MCP server uses an opaque OAuth access token, exchanges it for a proxy JWT, and forwards downstream the same way.
| Parameter | Type | Required | Description |
|---|---|---|---|
orgId | string | yes | Organization scope. If unknown, call organizations_list first. |
q | string | yes (≥2 chars) | Search query |
projectId | string | no | Narrow to a single project within the org |
Defaults: 5 results per entity type, single-org per call. The type,
format, and limit HTTP params aren't exposed by the MCP tool today
— if you need them through MCP, file a request.
OAuth client_credentials
A direct REST endpoint authenticated by OAuth client_credentials (for server-to-server callers without a signed-in user) is not available today. Use MCP for that case, or open a request with the Part3 team.