Part3 Developers

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-stream

Response 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):

EndpointReturns
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

ParamTypeRequiredDescription
orgIdstringyesOrganization scope. Web-app users can only pass orgs they have access to.
qstringconditionalSearch query, 2–500 chars. Required unless type is set (browse mode).
projectIdstringnoNarrow to one project within the org. Server rejects if the project isn't in the org.
typestringnoSingle 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.
formatstringnoFor documents: rfi, submittal, bulletin, changeOrder, certificate, instruction, quote, siteInstruction. Other values are ignored.
limitintegernoPer-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:

StatusWhen
400Missing orgId; q too short / too long; projectId not in orgId
401No bearer token / token rejected by the proxy
403Session 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:

  • :ok is 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 cached boolean 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.elapsed is total milliseconds. done.errors is 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 error SSE event. Fatal failures terminate the connection without a final event.

Limits

  • limit capped at 50 per entity type per call (default 5)
  • q minimum 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:

FieldTypeDescription
typestringOne of project, document, contact, drawing, spec, report, file (singular — distinct from the SSE event name)
idstringStable Part3 UID for the hit
titlestringShort label suitable for display
subtitlestring | nullSecondary label (e.g. project name)
projectIdstring | nullProject the hit belongs to
projectNamestring | nullProject display name
metaobjectType-specific fields (see below)

meta fields by result type

project

FieldTypeDescription
archived'true' | 'false'Whether the project is archived (string, not boolean)
createdAtstring | nullISO 8601 creation timestamp

document

FieldTypeDescription
documentIdstring | nullHuman-readable ID (e.g. RFI-001)
typestringDocument type
formatstringrfi, submittal, bulletin, changeOrder, etc.
statusstringCurrent document status
matchedFieldstring | nullWhich field matched: title, documentId, description, attachment
matchedValuestring | nullThe matched text or decoded attachment filename

contact

FieldTypeDescription
emailstring | nullContact email
phonestring | nullContact phone
titlestring | nullJob title
companystring | nullCompany name

drawing

FieldTypeDescription
drawingNumberstring | nullDrawing version ID (e.g. A-716)
matchedFieldstring | nulldrawingNumber, title, or pdfContent
matchedValuestring | nullThe matched text

spec

FieldTypeDescription
divisionCodestring | nullMasterFormat division code (e.g. 05)
divisionTitlestring | nullDivision title (e.g. Metals)
sectionCodeTailstring | nullSection code within division (e.g. 50 00)
matchedFieldstring | nullcode, title, or pdfContent
matchedValuestring | nullThe matched text or PDF snippet

report

FieldTypeDescription
reportTypestring | nullReport type

file

FieldTypeDescription
fileClassstringspec, drawing, or other
matchedFieldstringname or pdfContent
pageNumberstring | nullPage number for PDF content matches
specSectionIdstring | nullIf file is a spec version, parent spec section UID
drawingRevisionIdstring | nullIf 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.

ParameterTypeRequiredDescription
orgIdstringyesOrganization scope. If unknown, call organizations_list first.
qstringyes (≥2 chars)Search query
projectIdstringnoNarrow 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.

On this page