Docs.

Office agent (/office/agent)

architecture/office-assistant.md

A natural-language operations assistant for staff. It answers questions from the live data ("how much revenue did we earn last month?") AND does desk work ("black out the FX6 next weekend", "cancel RES-0042") — with every write gated behind an in-chat approval card: the model proposes the exact call, the staffer clicks Approve, and only then does it execute.

The chat UI, the tool layer, and the write verbs are built and shipping. The model call runs through the Vercel AI Gateway and is activated by a single env var (AI_GATEWAY_API_KEY); with no key the feature degrades to a clean "not configured" state rather than erroring.

Write model (the part that matters)

  • Per-request, policy-bound tools. buildAgentTools (agent-tools.ts) assembles the tool set around the signed-in staffer from a configurable permission matrix: every tool belongs to a group (insights, lookups, reservation writes, blackout writes, Google read, Google write), each group has a minimum level — a role, "superadmin" (only the earliest-granted administrator), or "none" — and administrators edit the matrix at /office/agent/permissions (stored as the audited agent.permissions settings revision; agent-policy.ts resolves it against safe defaults — junk can fall back but never widen). Twenty groups cover the whole operation: analytics, lookups, the library, the audit log, reservations, payments, claims, the client file, emails, blackouts, inventory + kits, schedules, standing, pricing (read and write), Google (read and write), account merges, archive/restore, and read-only SQL — a single SELECT/WITH/EXPLAIN statement inside a READ ONLY transaction with a 5s timeout and 200-row cap, audited verbatim (super-admin by default; the Claude-replacement workhorse). Deliberately absent at every level: raw WRITE SQL (typed verbs carry the state machine, guards, and audit — raw writes would bypass all three), team/role management, and purging.
  • Google Drive/Docs/Sheets. With a service account configured (GOOGLE_SERVICE_ACCOUNT_EMAIL/KEY, src/lib/google/client.ts — hand-signed JWT, no SDK), the agent can search Drive, read Docs/Sheets, and — admin-only by default, approval-carded always — create Docs and update Sheet ranges. The access boundary is Drive sharing itself: the service account only sees what the studio explicitly shares with it. Google writes audit as entityType: "google", source agent. Without credentials the groups stay dormant.
  • Approval-gated execution. Every write tool sets needsApproval, so the AI SDK pauses before execute; the chat renders the exact arguments as a card and resumes only on Approve (addToolApprovalResponse + lastAssistantMessageIsCompleteWithApprovalResponses).
  • Same write path as the UI. The verbs call the same lib functions the office pages use, so the lifecycle machine, the overlap guard, quantity caps, and the status emails all hold — a confused model cannot skip a stage or double-book any more than a human can.
  • Attribution. Writes audit as actor = the staffer, source = agent — the audit column added for exactly this in migration 0010.

Architecture

graph LR
    U["Staff (browser)<br/>/office/chat"] -->|"useChat → POST"| R["/api/office/chat<br/>(cookie-auth gated)"]
    R -->|"streamText + tools"| GW["Vercel AI Gateway<br/>anthropic/claude-…"]
    GW -->|"tool calls"| T["officeTools<br/>(chat-tools.ts)"]
    T --> I["insights.ts<br/>(read-only queries)"]
    I --> DB[("Supabase<br/>service role")]
    GW -->|"streamed tokens"| U
    style R fill:#1e40af,color:#fff
    style I fill:#065f46,color:#fff
LayerFileRole
UIsrc/app/office/(dashboard)/agent/OfficeChat.tsxuseChat client: input, streamed messages, tool chips, suggestions, no-key banner
Pagesrc/app/office/(dashboard)/agent/page.tsxserver component; passes configured from env; auto-gated by the dashboard layout's requireAdmin()
Routesrc/app/api/office/chat/route.tsstreaming handler; cookie-auth via getAdminUser(); 503 when unconfigured
Toolssrc/lib/office/agent-tools.ts (+ read-only insights wrappers in chat-tools.ts)per-request, role-bound AI SDK tool() registry + system prompt
Datasrc/lib/office/insights.tsthe actual read-only analytics queries

Why these boundaries

  • Tools, not text-to-SQL. The model picks which fixed, parameterized question to ask — it cannot author SQL. Every tool is a wrapper over insights.ts, which has no insert/update/delete path. This is the single most important safety property: a prompt-injected or confused model can read aggregates but can never mutate data or run an arbitrary query.
  • Auth: cookie, not Bearer. The other /api/office/* endpoints use Bearer JWT for the iOS app. This one is called by useChat from the authenticated browser session, so it gates on getAdminUser() (cookie) and returns JSON 401 instead of redirecting. Dev-bypass mirrors the rest of /office.
  • Gateway, not a provider SDK. streamText({ model: "anthropic/…" }) resolves through the Vercel AI Gateway, which gives provider fallback, observability, and zero-data-retention without pinning us to one vendor's SDK. The model is chosen in the office (Agent → settings, from the gateway's live catalog) and stored in the DB — there is no code or env default, so an administrator must pick one before the agent will run.
  • Server-derived date context. The system prompt is built per-request with the current America/Bogota time so the model can turn "last month" into a concrete ISO [start, end) window before calling a tool.

The tool catalog

All amounts are USD cents (canonical column) plus a pre-formatted dollar string.

ToolAnswersSource
revenueInPeriodcash recognized in a window (captures + charges − refunds)payments
bookedValueInPeriodcontracted value of reservations confirmed in a windowreservations.confirmed_at
reservationCountsByStatuslive pipeline by statusreservations
topItemsmost-reserved gear (by qty), optionally windowedreservation_itemsitems
assetUtilizationfleet out vs. serviceable, as a %assets
newClientsInPeriodnew clients in a windowclients
upcomingPickupsactive reservations picking up in N daysreservations

insights.ts is plain data-layer code with no AI dependency, covered by tests/e2e/insights.e2e.test.ts (invariants: net = gross − refunds, the status tally sums to the live count, utilization stays in bounds, windowing narrows results). So the numbers the assistant quotes are themselves regression-tested.

Activation

The feature is dark until a gateway key exists:

  1. Set AI_GATEWAY_API_KEY (Vercel project env), or deploy on Vercel where the OIDC token (VERCEL_OIDC_TOKEN) is present automatically.
  2. Redeploy. The "not configured" banner disappears.
  3. In the office, go to Agent → settings and select a model from the gateway's live catalog. There is no default — the agent stays disabled (a "no model available" state) until one is chosen, so a delisted model can never silently route to an arbitrary substitute. /api/office/chat streams once both a model and a system prompt are configured.
flowchart TD
    A["request to /api/office/chat"] --> B{"admin session?"}
    B -->|no| E401["401 unauthorized"]
    B -->|yes| C{"AI_GATEWAY_API_KEY<br/>or VERCEL_OIDC?"}
    C -->|no| E503["503 ai_not_configured<br/>→ UI shows banner"]
    C -->|yes| S["streamText → tools → stream"]

Cost & safety notes for when it's enabled

  • The route caps tool-calling loops at stepCountIs(12) so one request can't fan out into an unbounded tool storm.
  • maxDuration = 60 bounds a single request.
  • Tools are read-only and aggregate-only; no row-level PII (names, emails) is returned by the current catalog except reservation references in upcomingPickups. Revisit the prompt + tool outputs before exposing client PII through the assistant.

Deferred: product-image search & add

The mandate pairs the chat with "search and add product images (like we did in the beginning)." That capability is documented here, not built tonight — the original flow was script-driven and image search needs an external source of truth (retailer pages) plus a human approval step before writing to a catalog the public sees.

What exists today:

  • scripts/normalize-gear-images.ts and scripts/upload-gear-images.ts — the original normalize + upload pipeline.
  • docs/operations/gear-images.md — the sourcing rules (B&H retail style, white bg, bare body, -1/-2 gallery convention). See also the standing guidance to re-probe B&H sizes rather than trusting a cached 500×500 cap.

Intended design (when built)

Add it as another office-assistant tool plus a staff-approval gate, not an autonomous writer:

sequenceDiagram
    participant S as Staff (chat)
    participant A as Assistant
    participant W as Web search (retailer images)
    participant Q as Review queue
    participant DB as items.gallery

    S->>A: "find product images for the Aputure 600x"
    A->>W: search retailer pages for that MPN
    W-->>A: candidate image URLs
    A-->>S: thumbnails + sources (NO write yet)
    S->>Q: approve the ones that match house style
    Q->>DB: normalize + upload approved → items.gallery

Key constraints carried over from the gear-image work: replacements must match the existing gallery style (white background, retail, bare body), not merely be high-resolution; and B&H size caps must be re-probed per image rather than assumed. The write step reuses the existing normalize/upload scripts; only the search + propose front half is new. Public catalog images are a public-facing change, so this stays human-approved — never an autonomous model write.