Skip to main content

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.ts builds:
    • TOOL_CATALOG — categorized object (16 categories).
    • TOOL_DEFINITIONS — flat array of all definitions for buildToolSet().
    • 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

PipelineFileTools loaded
Main chat (/api/chat)pipeline.ts calls buildToolSet() from tools/index.tsAll 51 tools registered in TOOL_CATALOG
Onboarding + MIA Tipsonboarding-pipeline.ts — its own ONBOARDING_TOOL_MAP20-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)

ToolGoes to
web_searchBrave Search HTTP (DuckDuckGo fallback) — API key from env
get_weatherOpen-Meteo geocoding + archive/forecast — public API, no auth
lookup_pivot_helpExternal 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.

ToolWhat goes through HTTPWhat still uses Admin SDKWhy
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_companyPOST /companiesApi/companies, PATCH /companies/{id} (multiple)AIOnboardingSessions/{userId}, Users/{userId}/signupPhase, Companies/{cid}/createdAt, Companies/{cid}/businessProfileThese 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 asWhat's stored
.csvCSVheaders + up to 500 rows in sheets[0]
.xlsx / .xlsExcel via ExcelJSup to 3 sheets, headers + preview rows each
.pdfPDF text via pdf-parseextracted 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/)imagebase64 + 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 (if rowCount ≤ 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_employees and pass the ENTIRE file content as the csvData parameter (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:

EventPayloadWhen
status{ text }Phase boundaries ("Analyzing data…", "Generating widgets…")
content_block_deltaAnthropic deltaStreaming answer tokens
widget_start / widget_completebracketingAround the widget JSON stream
suggestions{ suggestions: string[] }After fan-out
error{ message }Anywhere
donenoneEnd 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.