@brownandroot/api
v9.0.0
Published
Unified TypeScript API for Brown & Root APIHub data.
Readme
@brownandroot/api
Unified TypeScript API for Brown & Root APIHub data.
Installation
bun add @brownandroot/apiOne Import Path
Use only:
import { employees, workorders, clearCache } from '@brownandroot/api'No domain subpath imports are required.
Setup
Add to your app .env:
APIHUB_URL=https://your-apihub-url.com
APIHUB_API_KEY=your-api-keyEnable SvelteKit remote functions if needed:
kit: {
experimental: {
remoteFunctions: true
}
}Unified Domain Contract
Each listable domain exposes the same shape:
getAll(filters?)getDropdown(filters?)get(id)-> returns item, ornullwhen APIHub answers 404
Every one of these throws ApiHubReadError when the read fails (see below).
dropdown(filters?) remains available as a backward-compatible alias.
Domains:
employeesworkorderscostcodespaytypesbusinessUnitsjobtypejobstepschatllmrag
Caching Behavior
- Browser/client: IndexedDB stale-while-revalidate cache for
getAllanddropdown - Server/SSR: in-memory TTL cache for
getAllanddropdown - Browser reads go through the package's remote queries. Server reads call APIHub directly (8.0.0),
because SvelteKit copies every remote query result a request runs into the response. So what your
server code reads through these domain objects stays on the server unless you return it.
llm.getLogsandrag.listare still remote queries on both sides. - Single-record
get(id)reads are fresh and returnnullwhen not found - A failed read is never cached. The next call asks APIHub again, in the browser too (8.0.0).
- A stale cached list is still served while a background refresh fails. A cached EMPTY list is
re-checked after 30 seconds, and if that re-check fails the read throws rather than serve the
[].
Failed reads throw (7.0.0)
Every domain read throws ApiHubReadError when it fails:
getAll,getDropdown/dropdownandgeton every domainworkorders.byBu,workorders.search,businessUnits.jtjsemployees.getAlternateBadges,getSupervisorChain,getJdeFromEmail,getJdeAccess
"Fails" means any non-2xx answer except a 404 on a single lookup, plus a timeout or a dropped
connection. That includes a 400: an unscoped cost code or work order getDropdown is refused by
APIHub and throws.
A 404 on a single lookup is an answer, not a failure. get(id) returns null and
getJdeFromEmail returns { jde: null, employee: null }.
employees.get(id) is stricter (8.0.0). It returns null only when APIHub answers its own
{"error":"Employee not found"}. Any other 404 (an unmatched route, an APIHub without the route, a
proxy, a stopped App Service) throws ApiHubReadError with status 404, in the browser and on the
server. The other get(id) routes answer the same {"error":"Not found"} body as an unmatched route,
so they still return null on any 404.
Before 7.0.0 most of these returned [], null or a default on failure, so an outage looked like
"there are none". Now an empty list means the data really is empty.
import { paytypes, isApiHubReadError } from '@brownandroot/api'
try {
options = await paytypes.getDropdown()
} catch (error) {
if (!isApiHubReadError(error)) throw error
loadError = "Couldn't load pay types. Try again." // error.operation, error.status, error.cause
}ApiHubReadError has operation (e.g. paytypes.getAll), status (HTTP status, or null for a
timeout or a dropped connection) and cause (the original error). Use isApiHubReadError rather than
instanceof if your app could end up with two copies of the package.
Retry
Call the read again. That is the whole retry (8.0.0): a failed read is not cached anywhere, so the next call asks APIHub again.
In the browser this used to need help. SvelteKit keeps a failed remote query cached, rejected, per
arguments, so the same call got the same failure back without a request. The package now refreshes a
query it finds holding a failure before it reads it. It does this once per call and only after a
failure: a healthy read is not fetched twice, nothing retries on its own, and a failure that persists
throws again. Drop any refreshAll() you added before a retry.
During an outage, each call to a failing read is one request. The background refresh of a stale cached list is the exception: one at a time per list, and not again within 30 seconds of a failure.
<script lang="ts">
import { paytypes } from '@brownandroot/api'
let attempt = $state(0)
</script>
{#key attempt}
{#await paytypes.getDropdown()}
Loading...
{:then list}
<!-- ... -->
{:catch}
Couldn't load pay types. <button onclick={() => attempt++}>Retry</button>
{/await}
{/key}Upgrading from 6.x: find every call that relied on the fallback (?? [], an empty list read as
"none", getJdeAccess read as "no access") and decide what the screen should show when the read
fails. A caller that still wants the old fail-closed getJdeAccess catches and returns
{ canViewRates: false, securityLevel: 0 } itself.
Error Diagnostics
The package now throws structured ApiHubError instances for request-layer failures.
ApiHubError includes:
statuspathurlresponseBodyretriableservice
Request layer behavior:
- timeout with
AbortController(default60000ms) - retries for idempotent GETs on
429/502/503/504(defaultretryCount: 2) - optional
onErrorcallback viaApiHubClientoptions
Dropdown defaults:
- Domains with
isActiveautomatically defaultisActive: trueforgetDropdown(filters?) - Caller can override by passing
isActiveexplicitly
Examples
Workorders
import { workorders } from '@brownandroot/api'
const rows = await workorders.getAll({ businessUnitId: 'BU001' })
const options = await workorders.getDropdown({ businessUnitId: 'BU001' })
const one = await workorders.get('WO001') // Workorder | nullEmployees
import { employees } from '@brownandroot/api'
const crew = await employees.getAll({ hbu: 'TX01', q: 'john' })
const list = await employees.getDropdown({ businessUnitId: 'BU001' })
const emp = await employees.get('12345')
const chain = await employees.getSupervisorChain('12345')
const jde = await employees.getJdeFromEmail('[email protected]')Since 8.0.0 these return Employee, which leaves out dob and identityHash. Server code that
needs either one reads through the client, which returns the full EmployeeRecord:
import { getApiHubClientFromEnv } from '@brownandroot/api'
const record = await getApiHubClientFromEnv().getEmployee('12345') // EmployeeRecordServer-only calls (@brownandroot/api/privileged)
Pay rates, PTO balances, the JT/JS override, and every write that changes an employee or the
knowledge store. They are plain functions, not remote functions, so a browser cannot call them.
Call them from server code after your own permission check. (6.0.0 moved updateEmployee and the
RAG upload and delete here: as remote commands, any signed-in user could change anyone's roles.)
import { getEmployeePrivileged, updateEmployee, uploadDocument } from '@brownandroot/api/privileged'
const rates = await getEmployeePrivileged('12345')
await updateEmployee('12345', { alternateBadge: 'X-77' })Identity check (9.0.0)
verifyIdentity takes the first three letters of each name, the birth date and the last four of
the SSN, and answers the employee or null. It is server-only because those last four are only
10,000 guesses: as a root remote command (before 9.0.0) any signed-in user could find anyone's.
Limit attempts per address or session in your own code before calling it. APIHub also limits the
route (5 attempts per person, 1,000 per caller address, in 15 minutes) and answers 429 past that,
which throws IdentityRateLimitedError. APIHub's limit is shared by every user of your app, so it
is a backstop, not your limit.
import { verifyIdentity, isIdentityRateLimitedError } from '@brownandroot/api/privileged'
try {
const employee = await verifyIdentity({ first3FirstName: 'joh', first3LastName: 'doe', dob: '1985-03-15', ssn4: '4321' })
if (!employee) return fail(400, { message: 'Those details do not match.' })
} catch (error) {
if (isIdentityRateLimitedError(error)) return fail(429, { message: 'Too many attempts. Try again later.' })
throw error
}Chat
import { chat, rag } from '@brownandroot/api'
const result = await chat.complete({
messages: [{ role: 'user', content: 'What tools do you have?' }],
grounded: true,
})
console.log(result.message.content)
console.log(result.trace.toolCalls)
for await (const event of chat.stream({
messages: [{ role: 'user', content: 'Summarize the overtime policy.' }],
})) {
if (event.type === 'delta') {
process.stdout.write(event.chunk)
}
}chat.complete and chat.stream are server-side APIs. They read API Hub credentials from APIHUB_URL and APIHUB_API_KEY by default, so consuming apps do not need to manually construct an ApiHubClient for the common case.
Chat request options:
grounded— defaulttrue; enables Brown & Root grounded chat behavior, including knowledge retrieval and the default tool bundleinstructions— appended to the default system promptsystemPrompt— fully replaces the default system promptuserContext— optional personalization contextsource— optional application identifier for logging
Default grounded tool bundle includes:
searchDocssearchDocsByTypelistKnowledgeDocumentssearchBusinessUnitssearchWorkOrderssearchAppCatalogsearchAppCatalogRecordsgetAppCatalogOverview
When the user asks broad app catalog questions (for example, "what is the app catalog" or "show everything in the app catalog"), the stream agent now prefers getAppCatalogOverview before generating the final response.
If systemPrompt is provided, it takes precedence over instructions.
Legacy LLM and RAG
import { llm, rag } from '@brownandroot/api'
const logs = await llm.getLogs()
const chat = await llm.chat({
messages: [{ role: 'user', content: 'Summarize this document.' }],
source: 'my-app',
user: 'jane.doe',
enableRag: true,
})
const docs = await rag.list()
const results = await rag.search({ query: 'overtime policy', topK: 5 })llm.chat remains available for lower-level request/response usage.
llm.chat RAG flags:
enableRag: truetells the backend to enrich the chat prompt with retrieved document context before generating a response.enableRag: false(or omitted) sends a normal chat request without forcing retrieval augmentation.enableRagis the single supported parameter for toggling default tools/RAG retrieval.
Cache Management
import { clearCache } from '@brownandroot/api'
clearCache()
clearCache('workorders')
clearCache('employees')Call clearCache(entity) after mutations so next reads fetch fresh data.
Advanced Direct Client
ApiHubClient is still available for advanced usage (streaming/proxy control/custom behavior):
import { ApiHubClient } from '@brownandroot/api'
const client = new ApiHubClient({
baseUrl: process.env.APIHUB_URL!,
apiKey: process.env.APIHUB_API_KEY!,
})You can also let the package resolve those env vars for you:
import { getApiHubClientFromEnv } from '@brownandroot/api'
const client = getApiHubClientFromEnv()