Agent SDK (pivotAiAgent)
The AI runtime that powers /api/chat. Lives at functions/pivotAiAgent/, deployed as a Cloud Run service (separate from the Firebase Functions modular monolith).
High-level shape
The dominant flow is the solid line: every tool call goes through an internal HTTP endpoint with the caller's Bearer token. The dotted "Admin-SDK stopgap" branch is not a parallel architectural pattern — it's tech debt for RTDB paths that don't have an endpoint yet (see Exceptions).
Tools are not Temporal activities. The pipelineTrackingWorkflow exists for observability (one signaled-step-per-phase trace in Temporal Web UI) but does not execute tools. See Temporal.
Tools: how they're wired
- All tools live under
functions/pivotAiAgent/tools/<category>/<tool>.ts. - Each tool exports two things:
definition— Anthropic tool schema (name,description,input_schema).execute(companyId, input, token, _reqId?, userId?, personaName?)— async function returning the tool's result.
- The registry at
functions/pivotAiAgent/tools/index.tsbuilds:TOOL_CATALOG— categorized object (16 categories).TOOL_DEFINITIONS— flat array of all definitions forbuildToolSet().TOOL_MAP— name → module map for dispatch.executeTool(name, input, ...)— single dispatcher used by the pipeline.
Dispatcher (the actual code)
// functions/pivotAiAgent/tools/index.ts:192
export async function executeTool(
name: string,
input: any,
companyId: string,
token?: string | null,
_reqId?: string,
userId?: string,
personaName?: string
): Promise<any> {
const tool = TOOL_MAP.get(name)
if (!tool) return { error: `Unknown tool: ${name}` }
return (tool.execute as any)(companyId, input, token, _reqId, userId, personaName)
}
The chat pipeline calls this once per tool_use block emitted by Sonnet — see functions/pivotAiAgent/pipeline.ts:285.
Tool-call architecture (the important part)
Most tools call internal Firebase Function endpoints over HTTP, forwarding the caller's Firebase ID token as Authorization: Bearer <token>. Each endpoint then runs the standard authMiddleware + companyScopeMiddleware (and any role gates) — so tools inherit user permissions automatically. The AI cannot reach data the calling user couldn't.
URL builder: functions/pivotAiAgent/tools/_endpoints.ts
export function getFunctionsUrl(): string {
if (process.env.FUNCTIONS_EMULATOR_URL) return process.env.FUNCTIONS_EMULATOR_URL
const projectId = process.env.GCLOUD_PROJECT || 'pivot-dev-59310'
return `https://us-central1-${projectId}.cloudfunctions.net`
}
A canonical example — get_employees:
const res = await globalThis.fetch(
`${getFunctionsUrl()}/employees/employees?${params.toString()}`,
{ method: 'GET', headers: { Authorization: `Bearer ${token}` } }
)
The browser-style globalThis.fetch is used throughout (not axios / undici). When the target URL is a Firebase RTDB host, a global interceptor in functions/pivotAiAgent/firebase.ts re-routes the call through the Admin SDK if service-account credentials are available — and otherwise falls back to user-token REST.
Tool sets per pipeline
| Pipeline | File | Tools loaded |
|---|---|---|
Main chat (/api/chat) | pipeline.ts calls buildToolSet() from tools/index.ts | All 51 tools registered in TOOL_CATALOG |
| Onboarding + MIA Tips | onboarding-pipeline.ts — its own ONBOARDING_TOOL_MAP | 20-tool subset (8 onboarding-only + 12 shared) |
buildToolSet() ignores its categories argument today — every main-chat call sees all 51 tool definitions. Sonnet picks what it needs. (The comment block at tools/index.ts:2-8 describes an aspirational search_tools meta-tool for on-demand category loading; not implemented yet.)
CORE_TOOL_DEFINITIONS is a 4-tool always-loaded set: find_employee, get_current_user, set_user_rule, update_company_memory. Defined at tools/index.ts:163.
See Tool Catalog for the full enumerated list.
Exceptions to the HTTP-call pattern
There are two categories. External tools are intentional. The Admin-SDK ones are tech debt.
External tools (intentional)
| Tool | Goes to |
|---|---|
web_search | Brave Search HTTP (DuckDuckGo fallback) — API key from env |
get_weather | Open-Meteo geocoding + archive/forecast — public API, no auth |
lookup_pivot_help | External knowledge base (may use Hubspot KB if configured) |
Admin-SDK stopgaps (tech debt)
These tools also call the standard HTTP endpoints for everything they can. They only fall back to direct Admin-SDK RTDB writes for paths that don't have an endpoint yet. The intent is to migrate; see TODOs.
| Tool | What goes through HTTP | What still uses Admin SDK | Why |
|---|---|---|---|
create_shift | (nothing yet) | WeeklySchedule/{cid}/{date} write via Firebase REST ?auth= | The schedule module has no "create published shift" endpoint yet. File has explicit TODO(refacto-endpoint). |
create_company | POST /companiesApi/companies, PATCH /companies/{id} (multiple) | AIOnboardingSessions/{userId}, Users/{userId}/signupPhase, Companies/{cid}/createdAt, Companies/{cid}/businessProfile | These paths aren't on the public API. The file comment explicitly says: "We still use admin SDK for onboarding-only metadata … because those paths have no endpoint." |
complete_onboarding, get_onboarding_status, create_positions, import_competitor_csv, scrape_* | Where endpoints exist (company patches, employee creates) | Onboarding session state (AIOnboardingSessions/, ImportStagingArea/) | Same reason as above. |
Mia uses her own privileged token (getMiaIdToken(), custom claim isMiaAgent: true) for the HTTP portions during onboarding so she can act cross-tenant before the new company exists; the user's identity travels via onBehalfOfUserId in the body so ownership stays correct.
File uploads
A side channel for getting CSVs / images / PDFs / Excel sheets into Mia. The flow is two-call: upload first, then reference the uploadId in a subsequent /api/chat request.
POST /upload (Cloud Run, multipart)
Handler: functions/pivotAiAgent/index.ts (Express + multer). Generates a uuidv4 uploadId, parses by file extension, and persists.
| Extension(s) | Parsed as | What's stored |
|---|---|---|
.csv | CSV | headers + up to 500 rows in sheets[0] |
.xlsx / .xls | Excel via ExcelJS | up to 3 sheets, headers + preview rows each |
.pdf | PDF text via pdf-parse | extracted text + pageCount. Falls back to a metadata-only entry on parse failure (encrypted / scanned PDFs) |
.jpg / .jpeg / .png / .gif / .webp / .bmp / .svg (or mimetype starts with image/) | image | base64 + mediaType for vision |
Storage: in-memory uploadStore Map keyed by uploadId, plus a parallel write to RTDB at pivotAiAgentUploads/{uploadId} so the data survives a service restart.
Response: { success: true, uploadId, filename, type } (plus type-specific extras like pageCount).
How an upload reaches the prompt
When /api/chat is called with an uploadId and that ID resolves to an upload in uploadStore, buildPlannerSystem() injects an UPLOADED FILE block into the system prompt:
- For CSV / Excel:
filename+ row count,Headers: a, b, c, ..., then either all rows (ifrowCount ≤ 100) or first 20 of N (otherwise). Rows are tab-joined. - A directive follows that's specific to CSV ingestion: "When they ask to create/add employees, IMMEDIATELY call
batch_create_employeesand pass the ENTIRE file content as thecsvDataparameter (raw CSV text, headers + all rows). The tool parses it automatically."
The injection happens in pipeline.ts around line 1321; search for UPLOADED FILE to see the exact strings.
For images (and PDFs), the mechanism differs — images go straight to Anthropic's vision input, PDFs ride as extracted text. Tools that consume the upload (the batch_create_employees tool for CSVs, vision-driven tools for images) look it up by uploadId.
What this looks like end-to-end
1. Frontend → POST /upload (multipart, 1 file)
2. Backend → parses by extension, stores in uploadStore + RTDB
3. Backend ← { uploadId, filename, type, ... }
4. Frontend → POST /api/chat { messages, ..., uploadId }
5. Backend's buildPlannerSystem injects UPLOADED FILE block into system prompt
6. Sonnet sees the headers + rows + CRITICAL directive → emits batch_create_employees tool_use
7. executeTool('batch_create_employees', { csvText, ... }) → POST /employees/batch
8. Created employees stream back as tool results, fan-out generates the answer + widgets
The user typically isn't asked anything between step 1 and step 4 — the upload happens in the same chat input box that triggers the next message. From the user's POV they dragged a CSV in and said "create these employees."
Streaming
The Express handler at functions/pivotAiAgent/index.ts writes SSE chunks directly to the response. Event types interleaved on the stream:
| Event | Payload | When |
|---|---|---|
status | { text } | Phase boundaries ("Analyzing data…", "Generating widgets…") |
content_block_delta | Anthropic delta | Streaming answer tokens |
widget_start / widget_complete | bracketing | Around the widget JSON stream |
suggestions | { suggestions: string[] } | After fan-out |
error | { message } | Anywhere |
done | none | End of stream |
Session persistence
After the pipeline completes (chat path or data path), output is written to RTDB at AIZackboardSessions/{companyId}/{userId}/{sessionId}. Schema is in Data: Memory & Conversations. The frontend persists the session ID locally and re-fetches to "resume conversation."
Observability
Every /api/chat request signals a pipelineTrackingWorkflow (one workflow execution per request). Each pipeline phase becomes a named activity on that workflow's timeline in Temporal Web UI, so engineers can inspect a stuck or slow request post-hoc. Signal failures are logged but don't fail the user request. See Temporal: tracking workflow.