Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesSpotter AgentSpotter Agent — Behavior

Spotter Agent — Behavior

Surface

EndpointPurpose
GET /organizations/:orgId/agent/usageOrg daily AI spend snapshot (tier, cap, spent, percent, reset).
GET /organizations/:orgId/agent/conversationsList caller’s conversations in this org.
GET /organizations/:orgId/agent/conversations/:conversationIdFull conversation detail with reconstructed messages + tool calls.
POST /organizations/:orgId/agent/messagesSend a user turn. Returns an SSE stream.
POST /organizations/:orgId/agent/conversations/:conversationId/confirm/:toolUseIdApprove / reject a pending destructive tool. Returns an SSE stream.
POST /organizations/:orgId/agent/conversations/:conversationId/confirm-batchApprove / reject several pending approvals of ONE assistant message in one round. Body { decisions: [{ toolUseId, approved }] } (1–20, unique; agentConfirmBatchRequestSchema). Returns an SSE stream; the single-confirm endpoint is the one-decision case of the same code path.
POST /organizations/:orgId/agent/conversations/:conversationId/pick/:toolUseIdResolve a disambiguation_pending candidate pick. Returns an SSE stream.
POST /organizations/:orgId/agent/conversations/:conversationId/undo/:toolUseIdInvoke the declared inverse of a previously-executed write. Bypasses confirmation; logs both events.

All endpoints require a Clerk session and active org membership. The send/confirm/pick endpoints additionally reject member role (v1 staff-only).

State machine

new user message │ ▼ ┌─────────────────────────────────┐ │ conversation_started (if new) │ └─────────────────────────────────┘ │ preCheck org daily $ cap │ ┌──────┴──────┐ │ │ over cap ok │ │ ▼ ▼ error+done persist user turn │ ▼ ambient RAG (800ms budget) │ ▼ compactIfNeeded (3s budget) │ ▼ ┌─────── driveModelLoop (≤ 6 turns) ───────┐ │ │ │ validateReplay → repair if needed │ │ withCachedTail (tail cache breakpoint) │ │ anthropic.messages.stream │ │ emit text_delta │ │ persist assistant message │ │ │ │ stop_reason == end_turn? │ │ yes → exit loop │ │ no → processToolUses │ │ ↓ │ │ ┌────────┼────────┐ │ │ │ │ │ │ │ normal user_picker destructive │ │ (run (suspend) (suspend) │ │ inline) │ │ │ │ │ │ │ └────────┴────────┘ │ │ ↓ all normal: tool_result blocks → next turn │ ↓ any picker/destructive: suspend → done └──────────────────────────────────────────┘ │ ▼ finalize: usage + cost + recordTurn + observability + done

Suspended runs

When a user_picker tool is invoked, or when a destructive write is pending, the orchestrator stops the loop and emits:

  • disambiguation_pending with kind, prompt, candidates[].
  • confirmation_pending with router, action, sanitized executable input, confirm: 'destructive' | 'always', and a server-resolved confirmation payload. The orchestrator parses tool input before persistence, strips the reserved __agentConfirmationPreview key, and derives the preview from validated arguments; model-provided preview metadata is never trusted.

The confirmation payload always tries to name what the call acts on. A tool either has a bespoke confirmationPreviewMethod or declares confirmationTarget on @AgentTool ({ entity, idField }, { nameFields }, { countField }), which ConfirmationTargetService resolves with one org-scoped primary-key lookup. target is interpolated into translated sentences, so it is only ever a bare name or title (a workout/program/template/form/automation/task title, a person’s full name, a stage or pipeline name, <class> · <local date & time> for a session in the caller’s locale and the org’s timezone) — never an id or an English phrase; when the thing has no name it is left undefined. A missing or foreign-org entity fails the preview, which returns not_found to the model instead of suspending, so nobody is asked to approve a call that cannot succeed. tools/confirmation-target.unit.spec.ts enforces that every confirmable tool has one or the other.

The frontend renders a card; user click triggers POST .../pick/:toolUseId, POST .../confirm/:toolUseId or POST .../confirm-batch.

Siblings of a suspended tool

Every tool_use after the suspending one (same assistant turn) is persisted pending, but only calls that need the user are announced:

SiblingPersistedAnnouncedLoader pendingReason
confirm: 'destructive' | 'always'pending + server-derived previewconfirmation_pendingapproval
kind: 'user_picker'pendingdisambiguation_pendingpick
anything else (read, confirm: 'never' write, unknown tool)pending, input marked held (__agentHeld, stripped before execution and before any client sees it)nothingqueued

Resolving a message

confirm / confirm-batch / pick │ validate ALL decisions first (atomic): exists → pending → awaiting approval │ (not held, not a picker) → same assistant message ▼ apply decisions in the MESSAGE's tool order (not the request's) approved → run → tool_completed rejected → rejected_by_user → tool_completed(ok:false) │ ▼ anything in the message still waiting for the user? ── yes ──► done (zero usage), stream ends │ no ▼ run held siblings in tool order: tool_started → tool_completed each a held call sequenced AFTER a declined step is not run: status failed, error code `skipped_after_rejection`, and the model is told why │ ▼ ONE merged tool_result turn (tool_use order) → driveModelLoop once → done

Claim before execute. Validation only reads. Before anything runs, each decision is claimed with one conditional update (claimToolExecution: pending → running … WHERE status = 'pending' RETURNING), all up front; a lost claim releases the ones already won and the batch ends with tool_already_resolved. Picks and held siblings are claimed the same way (a held sibling whose claim is lost is left to the winner). So a double tap, a retry after a dropped connection or a second tab can never run the same write twice. While any row of the message is running under a live claim, a stream ends with done instead of resuming the model.

Abandoned claims. The claim time is stored with the row’s input (__agentClaimedAt; the table has no updated-at, and createdAt is when the call was proposed). A running row whose claim is older than 2 minutes has no live stream behind it: it is closed as failed / interrupted_outcome_unknown — never re-run, since the write may have landed — when its message is next resolved or before the next user message is stored (which also appends the merged tool_result turn if the message is thereby complete). The loader presents such a row as failed rather than as a spinner.

Batch-level error codes (SSE error, nothing executed): tool_execution_not_found, tool_already_resolved, tool_not_awaiting_approval (a held sibling or a picker), confirm_batch_mixed_messages. A malformed body (empty, > 20, duplicate ids) is a 400 from the shared schema.

Output cap and truncated tool batches

The cap is the loop call’s maxTokens in the model policy (apps/api/src/ai/model-policy.ts): 16,384, shared with adaptive thinking (8,192 for a run resumed on Sonnet 4.5). A capped reply’s unfinished last block — a tool_use, or a thinking block, which has no signature yet — is dropped before the turn is stored; a reply that was only thinking is stored as a (reply cut off) text block. A reply that ends with stop_reason: 'max_tokens' is never executed — a cut-off batch is not a complete plan. What happens next:

  • First time in a run, with tool calls in the reply: the unfinished trailing tool_use is dropped, the turn is stored, every remaining call gets an error tool_result (the first one carries TRUNCATED_BATCH_INSTRUCTION: nothing ran, redo it with a bulk tool — class_sessions.create_many, replicate_day, copy_week, program_templates.apply, the *.bulk_* tools — or at most 8 calls per reply), and the loop continues. The client receives only message_done with stopReason: 'max_tokens' for that turn — no tool_started / tool_completed / error. The discarded calls are persisted as failed rows marked discarded, and the loader returns them with discarded: true so a reload hides them too.
  • Second time in the same run, or a prose-only overflow: terminal provider_output_truncated error, as before.

The static prompt carries the matching rule: smallest number of calls, bulk tools for repetition, never more than 8 tool calls per reply.

Soft terminal errors (provider_output_truncated, max_turns_reached) do not throw, so AgentObservabilityService.logTerminalError reports them to Sentry explicitly (tags agent.code, agent.trace_id, agent.conversation_id, agent.org_id, agent.model, agent.turn_count; ids only). Thrown failures already reach Sentry through logTurnFailure. Both SSE error events carry traceId.

MAX_TURNS

MAX_TURNS = 6 per controller call. After six round trips the loop exits; the assistant’s last message stands.

Model selection

Every Spotter model call is a role in the model policy (MODEL_POLICY, apps/api/src/ai/model-policy.ts, ADR-0022): spotterLoop, spotterQuickRoute, spotterQuickAnswer, spotterTitle, spotterCompaction. The main loop’s call is chosen once per run by AgentOrchestrator.loopCall:

RunCall
New user messageMODEL_POLICY.spotterLoop: claude-sonnet-5-5, thinking: { type: 'adaptive' }, output_config: { effort: 'low' }, 16,384-token cap
Resumes a pending tool call (confirmation, picker)spotterLoopCall(model of the last assistant message): that model’s loop call — the policy’s, or one of EARLIER_SPOTTER_LOOP_CALLS (claude-sonnet-5 with adaptive thinking and a 16,384 cap; claude-sonnet-4-5 with no thinking field and an 8,192 cap); the policy’s loop call for a model the loop never ran on

AgentOrchestrator.loopHistory picks the call and prepares the replayed history together. Thinking blocks are stored with the assistant turn and are signed by the model that wrote it, so a replayed assistant turn keeps them only when the run’s call thinks (thinks(call)) and that turn was written by the same model (snapshot date ignored); every other turn is replayed without them. The stored rows keep them. Switching the loop’s model starts a new prompt-cache prefix: the first turn of each conversation after the switch writes it. The quick route, title and compaction run Haiku (claude-haiku-4-5-20251001).

Routers and actions

The tool surface lives in apps/api/src/ai/agent/tools/leaves/*.tools.ts. Each @AgentTool decorator declares:

  • router, action, summary (model-visible).
  • inputSchema (Zod) — re-validated server-side via meta.inputSchema.parse(rawInput).
  • scopes: { roles: AgentRole[] } — RBAC filter at registry-build time + defence-in-depth check in tool-runner.
  • sideEffects: 'read' | 'write' — drives audit and inverse semantics.
  • confirm: 'never' | 'destructive' | 'always' — gates suspension.
  • confirmationPreviewMethod? — provider method that resolves the human-readable target, affected count, full outbound message, old/new field values, and consequence keys before a confirmed tool is persisted. Every confirmed tool also receives an expandable exact-arguments review generated from its validated input, including tools without a custom resolver.
  • audit? — { resource, actionLabel } writes to audit_logs post-success.
  • inverse? — { router, action, buildInput(output) } enables undo.
  • kind? — 'normal' | 'user_picker'. user_picker handlers are never invoked; the orchestrator suspends and waits for a pick.
  • mcp? — { summary }: the description the MCP server serves instead of summary.

Routers (apps/api/src/ai/agent/tools/leaves/<file>.tools.ts). messages, announcements, class_types, help carry one action each; the full per-file list is in code-map.md. Confirmation policy (TKN-305): external messages / invitations / broadcasts, booking or credit changes, activation / publication and whole-set replacement always confirm; irreversible deletion, erasure, and archival that cancels active work are destructive. High-impact tools resolve their concrete target and effects on the server; confirmation cards never rely on model prose for impact. Outbound message bodies and consequential field changes are complete, not excerpted; long bodies and validated exact arguments are user-expandable before approval.

read — 11 actions, all read, never confirm

ActionEffectExample prompt
get_current_contextReturns org + caller identity + locale + now. Not offered to the model (MODEL_HIDDEN_TOOLS): <org_context> states all of it, including the caller’s user id. Registered only so stored executions still resolve.—
programs_listList active programs.”what programs do we run?”
members_searchSearch members by name/email/role/status.”find Saar”
members_getFull member detail by membershipId.”show me Dani’s plan + payment status”
exercises_searchHybrid (lexical + semantic) exercise library search.”find a squat variation”
exercises_resolve_batchBatch-resolve movement names → exercise ids in one round-trip; flags ambiguous.(used internally by workout build)
ask_user_to_pickuser_picker kind. Suspends; user picks one candidate.”which Saar?”
search_anythingCross-entity (program/workout/exercise/member/lead). Scope all matches exercises lexically only — semantic exercise search needs scope exercises (person-name queries used to drown in nearest-embedding movements).“find Murph”
lookup_by_idDetail by type+id.(post-search)
workout_comments_in_programList recent comments on workouts in a program (unreadOnly filter).“any new comments in coaching?”
workout_comments_for_assignmentFull comment thread on a single assignment.”what did Dani say on Monday’s WOD?”

workouts — 6 actions

ActionEffectConfirmInverse
createInsert a workout (freeform or structured).neverworkouts.delete
updatePatch a workout.never—
set_sectionsReplace all sections + movements wholesale.never—
deleteSoft-delete.destructive—
parseCoach text for one day → proposal (title, brief, parts, blocks, matched movements), unresolved, warnings; saves with save, confirming suggestions via save.resolutions.never—
upsertOne day as one structured workout with its parts, keyed by externalRef; untitled → named from date and the program. Spotter may not send sourceText/scores.never—

Example prompt: “build a workout: 3 rounds — 200m run, 30 double-unders”.

Day-grain programming

As over the MCP server, a coach day is ONE workout whose parts (“Warm-up”, “Lift”, “Conditioning”, “Optional Successory”) are section parts:

  • Tools — workouts.parse and workouts.upsert are in the workouts router.
  • Prompt — the static system block gains DAY_GRAIN_PROMPT (context-builder.service.ts): one workout per program and date, never one per part; the brief is the description; a part’s note, clock window and “optional” belong to the part; coach text, pasted or typed, always goes verbatim through workouts.parse (with date, and save: { programId } when the user wants it written) — never rebuilt with workouts.create + set_sections; saved: false with unresolved movements → one more workouts.parse with the same text and save.resolutions: [{ movementKey, mention, exerciseId }] (ask with read.ask_user_to_pick when several candidates fit); every workout has a title and the brief never is one; every warning and notSavedReasons entry is told to the coach (auto_confirmed_movement = matches a model confirmed, for review); workouts.upsert only for a day assembled without coach text; publish once with programs.session_create.
  • Guard — sourceText and scores stay MCP-only on workouts.set_sections and workouts.upsert (refuseHandAuthoredText: “Source text and score slots require the MCP channel”). The coach’s verbatim words reach a Spotter-written workout only through workouts.parse, which stores what the parser read.

programs — 4 actions

ActionEffectConfirmInverse
createNew training program.neverprograms.delete
updatePatch.never—
deleteSoft-delete.destructive—
enroll_memberEnroll a member in a coaching program.never—

assignments — 20 actions

ActionEffectConfirmInverse
listAssignments for a week by userId or programId (incl. drafts).never (read)—
getOne assignment + full snapshot workout.never (read)—
my_weekCaller’s published week.never (read)—
my_todayCaller’s published assignments for a date.never (read)—
coach_overviewCoaching-book compliance KPIs + per-athlete roster.never (read)—
createProgram-feed assignment for one athlete (draft by default).neverassignments.delete
assign_personal1-on-1 PT assignment for a date.never—
replace_program_dayAtomically replace one coaching-program date for all active enrollees.destructive—
remove_program_daySoft-delete one coaching-program date for every member.destructive—
assign_scheduleOne PUBLISHED class session per slot with the workout attached.always—
updatePatch assignment.never—
set_publishedToggle published flag.never—
completeMark completed.never—
uncompleteReset to assigned.never—
skipMark skipped.never—
deleteSingle assignment.destructive—
mark_comments_readMark comments as read for a coach.never—
bulk_previewDry-run for bulk ops.never (read)—
bulk_deleteBulk delete N assignments.destructive—
bulk_publishBulk publish N assignments.always—

bookings — 8 actions

ActionEffectConfirmInverse
list_mineCaller’s bookings.never (read)—
attendance_summarySummary stats.never (read)—
attendance_trend7-day series.never (read)—
attendance_dailyPer-day attended / no-show over N days (owner/admin).never (read)—
bookBook the CALLER’s own seat (staff bypass gates). Never books another member.always—
cancelCancel the CALLER’s own booking.always—
admin_cancelCancel any member’s booking (credit refunded, member emailed, waitlist promoted).always—
refund_credit+1 credit on a member’s active credit subscription (bookings.manage gate).always—

class_sessions — 21 actions

ActionEffectConfirmInverse
list_for_dateSessions on a date.never (read)—
rosterBookings on a session (bookingId, member, status, check-in).never (read)—
attendance_historyOne member’s booking history.never (read)—
qr_tokenCheck-in QR URL + expiry (the raw token is not returned separately).never (read)—
createNew class session.neverclass_sessions.cancel
updatePatch session.never—
publishPublish.neverclass_sessions.unpublish
unpublishUnpublish.never—
cancelCancel (row kept).destructive—
deleteHard-remove; cancels bookings first (owner/admin).destructive—
set_workoutsReplace THIS session’s workout override (whole set).always—
set_day_workoutsWrite the day’s programming + clear sibling overrides (whole set).always—
create_inline_workoutQuick freeform workout attached to the session.never—
create_manyEvery entry of slots (classTypeId, startTime HH:MM org-local, durationMinutes, optional location / coach / guest coach / title / capacity / waitlist / notes; 1–80 — sized for a full gym day, never a reason to drop slots) on every date of dates (YYYY-MM-DD; 1–31), slots × dates ≤ 300, status draft (default) or published. One transaction + one audit row. A session overlapping a live session of the SAME class type (or another slot of the batch) is skipped and reported; parallel classes of different types never conflict. Returns { createdCount, skipped[{date,startTime,classTypeName,reason}], sessionIds }. The tool for anything beyond ~3 sessions.always (affectedCount = slots × dates; consequence creates_schedule_drafts or creates_live_sessions)—
check_inStaff check-in of a booking.neverclass_sessions.undo_check_in
undo_check_inBooking back to confirmed.never—
bulk_previewDry-run for bulk ops.never (read)—
bulk_deleteBulk delete.destructive—
bulk_publishBulk publish.always—
copy_weekCopy a program’s live sessions (draft/published, not cancelled) from one week to the same weekdays of another, as new DRAFT sessions — same time/location/coach/duration as the source. Skips a target day that already has a session; never overwrites.always—

Example prompt: “schedule open gym tomorrow 13:00–14:00 published”.

daily_programming — 5 actions (TKN-305)

Per-(class type, date) programming every session of that class type inherits unless it carries a class_sessions.set_workouts override. Wraps DailyProgrammingService.

ActionEffectConfirmInverse
list_for_weekSlots (class type × date) with status + workouts for 7 days.never (read)—
list_for_dateSlots for one date (optional classTypeId).never (read)—
setReplace a slot’s workout list (whole set); draft unless status=published.always—
set_statusPublish / unpublish a slot.always—
removeDelete a slot’s programming.destructive—

Spotter never composes a workout through set or any other tool. The only path from text to a posted WOD is the coach handing Spotter the exact words — workouts.create (freeform, saved verbatim) then daily_programming.set (or class_sessions.set_day_workouts) for the class day named. The Schedule “Build the week with Spotter” button states the week’s status (sessions, WOD coverage, gaps, saved timetables) so the model’s first move is to list what’s missing and wait — see daily-programming.

class_types — 1 action

ActionEffect
list_for_programClass types under a program.

analytics — 12 actions, all read

ActionWraps
revenue_summaryAnalyticsService.getRevenueSummary
revenue_trendgetRevenueTrend
members_summarygetMembersSummary
members_growthgetMembersGrowth
at_risk_membersgetAtRiskMembers
popular_classesgetPopularClasses
class_utilizationgetClassUtilization
coach_overviewgetCoachOverview
org_insightsInsightsService.getOrgInsights
plan_distributiongetPlanDistribution
members_activationgetMembersActivation
workouts_summarygetWorkoutsSummary

tasks — 5 actions

ActionEffectConfirmInverse
listList tasks.never (read)—
createNew task.nevertasks.delete
updatePatch.never—
completeQuick-complete.never—
deleteSoft delete.destructive—

program_templates — 10 actions

ActionEffectConfirmInverse
listList templates.never (read)—
getHeader + cells / slots.never (read)—
previewDry-run of apply (conflicts included).never (read)—
createNew template.neverprogram_templates.delete
updateHeader fields; slots replaces the whole slot set on schedule templates.always—
duplicateCopy as ” (copy)“.neverprogram_templates.delete
upsert_cellsReplace ALL coaching cells (whole set).always—
applyMaterialize a template into the org.always—
from_historyDerive a template from historical rows.always—
deleteSoft-delete.destructive—

forms — 19 actions (FIT-176 / FIT-184 reads; TKN-305 writes)

Wraps FormsService — the compliance org-type gate, publish lint, family-wide archive and form_has_submissions delete guard apply unchanged. Member signing and the raw signing-link mint are deliberately absent: staff Spotter never signs for a member and no live signing URL enters model context.

ActionEffectConfirmInverse
list_pending_for_orgPending compliance instances (roster-scoped).never (read)—
compliance_status_for_memberPer-typeKey compliance status (FIT-158).never (read)—
templates_listTemplates (compliance + check-in), archived hidden by default.never (read)—
template_getFull template incl. fields/body.never (read)—
coverageActive members missing a valid signature for a family.never (read)—
template_createNew DRAFT template (publish is deliberately not accepted).never—
template_updatePatch an unpublished draft.never—
template_publishPublish a draft.always—
template_bump_versionPublish version+1 of a published template.always—
template_set_auto_issueToggle auto-issue-on-join.never—
template_set_required_for_bookingToggle the org-wide booking gate.always—
template_widen_to_plan_groupRe-target a plan-linked family to its plan group (irreversible).always—
template_archiveArchive the family; outstanding instances retired.destructive—
template_deleteHard-delete (409 once anyone submitted).destructive—
assignIssue a compliance instance to one member (signing token never returned).never—
assign_all_missingBulk-issue to the coverage set.always—
remindIn-app push to the assignee.always—
email_linkEmail a 7-day signing link to the address on file (link not returned).always—
instance_archiveRevoke an instance.destructive—

leads — 28 actions (CRM surface)

The original 15 are staff-scoped except convert (owner/admin); the TKN-305 bulk + pipeline-administration tools are owner/admin (the leads / leadPipelines permission-matrix rows) and re-apply the lead_management tier gate. Wraps OrganizationLeadsService, LeadPipelinesService, LeadTagsService, LeadBulkEmailService.

ActionEffectConfirmNotes
listFilter/search leads.never (read)
getOne lead + status history + unified timeline.never (read)
createNew lead (dedup by normalized email/phone).neverConflict error carries existingLeadId/existingLeadName/matchedBy in details so the model acts on the existing lead instead of re-searching.
updatePatch fields; bare status maps to the primary pipeline’s matching stage.neveremail/phone/note accept null to clear (prod regression: “delete her email” used to fail validation).
convertLead → member.alwaysOwner/admin only.
importBulk rows (pasted CSV).always
analyticsFunnel KPIs incl. per-hook win rates (byCampaign).never (read)
pipelines_listOrg pipelines + stages (+ a lead’s memberships via leadId).never (read)Quick action.
campaigns_listCampaign hooks (granular acquisition sources) with lead counts.never (read)Quick action.
add_to_pipelineAdd lead to another pipeline (idempotent).never
move_stageMove within one of the lead’s pipelines; primary move syncs status + fires automations.never
tags_listOrg tags + usage counts.never (read)Quick action.
set_tagsAttach/detach tags by id.never
archiveSoft-archive (board/list hide, history kept).neverInverse: restore (15s undo).
restoreUn-archive.never
campaign_createManual campaign hook.neverOwner/admin.
bulk_move_stage_previewDry-run of bulk_move_stage with skip reasons.never (read)Owner/admin.
bulk_move_stageMove leadIds / filter audience to a stage.alwaysOwner/admin.
bulk_add_to_pipelineAdd leadIds / filter audience to a pipeline.alwaysOwner/admin.
bulk_set_tagsAttach/detach tags on leadIds / filter audience.alwaysOwner/admin.
bulk_archiveSoft-archive leadIds / filter audience.alwaysOwner/admin.
bulk_emailQueue an email to ids / pipeline+stages / tags / filter, minus excludeTagIds.alwaysOwner/admin. Recipients are consent-filtered server-side (ADR-0013).
pipeline_createNew pipeline (default funnel unless stages given).neverOwner/admin.
pipeline_updateRename / reposition / archive / make default.neverOwner/admin.
stage_addAdd a stage.neverOwner/admin.
stage_updateRename / recolour / recategorise / archive / move a stage.neverOwner/admin.
stage_removeDelete an EMPTY stage.destructiveOwner/admin.
stages_reorderFull reorder (every stage id).alwaysOwner/admin.

memberships — 12 actions (TKN-305)

Wraps MembershipsService + MemberErasureService. invite is quick-action eligible; every other invitation send confirms.

ActionEffectConfirmInverse
listPaginated roster with billing / memberStatus / rosterBucket filters.never (read)—
statsA member’s attendance stats.never (read)—
roster_summaryHeadcount per roster bucket + trained this week.never (read)—
invitations_listPending invitations.never (read)—
inviteClerk invitation by email + role (grant matrix).never—
createDesk registration — active immediately; may email an invitation (owner/admin).always—
updateRole / status / email / profile (owner/admin).always—
bulk_inviteInvite imported members without an account (owner/admin).always—
send_invitationInvite one imported member (owner/admin).always—
resend_invitationFresh 7-day invitation email.always—
revoke_invitationRevoke a pending invitation.destructive—
erasePermanent right-to-be-forgotten erasure of a cancelled member (owner only).destructive—

automations — 8 actions (TKN-305)

Wraps AutomationsService; owner/admin (automations.manage); every action re-applies the email_automations tier gate via ToolFeatureGate.

ActionEffectConfirmInverse
listAutomations with status / trigger / step count.never (read)—
getFull step graph + counters.never (read)—
previewRender a message against sample data.never (read)—
createDRAFT automation.neverautomations.remove
updateHeader or (paused-only) structural edits; step replacement cancels active enrollments.always—
set_statusactive / paused / archived.always—
runManual broadcast to an explicit in-org audience.always—
removeArchive + soft-delete.destructive—

inbox — 7 actions (TKN-305)

Staff CRM unified inbox (ConversationsService, ADR-0012). All staff; lead_management tier gate re-applied. Named inbox so it never blurs with the agent’s own conversations.

ActionEffectConfirmInverse
listThreads with leads/members, filterable.never (read)—
getThread + last 30 messages (bodies wrapped in <message_data>).never (read)—
startNew/reused thread, optional first message.always—
sendOutbound message; service enforces lead consent (LEAD_CHANNEL_BLOCKED).always—
assignAssign to a teammate / unassign.never—
set_statusopen / snoozed / closed.never—
mark_readUnread → 0.never—

Confirmation flow

  1. Model emits tool_use block for a tool with confirm: 'destructive' or 'always'.
  2. When the decorator names a confirmationPreviewMethod, the registry invokes that provider method with validated input. It resolves the target and impact through org-scoped domain services. Validation or lookup failures are normalized into a persisted failed execution and tool_result; the mutation is never run and no broken pending confirmation is left behind.
  3. Orchestrator stores the preview beside the executable input in the pending ai_tool_executions payload and emits confirmation_pending with { toolUseId, router, action, input, confirm, confirmation? }.
  4. Any subsequent tool_use blocks in the same assistant message are also persisted pending (they can’t be partially fed back).
  5. The frontend renders localized consequence labels plus the resolved target, affected count, and message excerpt. Raw ids remain hidden.
  6. Loop suspends; controller emits done with usage 0.
  7. User clicks Approve or Reject in the frontend → POST /confirm/:toolUseId { approved: boolean }, or decides several at once with POST /confirm-batch.
  8. If approved, runner validates the original input and executes the tool; status transitions pending → succeeded | failed. tool_completed SSE event emitted.
  9. If rejected, status → rejected_by_user with synthetic error { code: 'rejected_by_user' }.
  10. Once all pending tools for the message are resolved, the orchestrator appends a unified tool_result user message and resumes the model loop.

The same merge-and-resume path handles disambiguation_pending picks.

Undo flow

POST .../undo/:toolUseId resolves the inverse tool of a previously-succeeded write:

  1. Load ai_tool_executions by (conversationId, toolUseId).
  2. Reject if not status: 'succeeded' (422).
  3. Lookup the registry entry; require meta.inverse is defined.
  4. Build the inverse input via inverse.buildInput(original.output) — typically { id: output.id }.
  5. Execute via ToolRunnerService.execute — bypasses confirmation entirely.
  6. Audit log captures both events: the original and the inverse, linked by their audit rows.

Example: workouts.create returns { id, ... }; the declared inverse is workouts.delete with buildInput: (out) => ({ id: out.id }). A user toast “Workout created — Undo (15s)” calls the undo endpoint; the workout is soft-deleted.

Disambiguation flow (read.ask_user_to_pick)

Triggered by the system prompt when ambient context lists multiple plausible candidates for the same entity:

  1. Model emits read.ask_user_to_pick { kind, prompt, candidates: [{ id, label, sublabel?, detail? }, ...] }.
  2. Orchestrator sees meta.kind === 'user_picker', persists pending, emits disambiguation_pending.
  3. Frontend renders a “Which Saar?” card with 2-8 buttons.
  4. User clicks → POST .../pick/:toolUseId { id: <pickedId> }.
  5. Orchestrator validates the id is in candidates, persists { pickedId, pickedLabel, kind } as the tool output, emits tool_completed, and resumes the model loop.

Audit logging

ToolRunnerService.execute writes an audit_logs row when:

  • meta.audit is defined, AND
  • meta.sideEffects === 'write', AND
  • The tool succeeded.

Row carries:

  • actor_clerk_id = caller.
  • action = meta.audit.actionLabel.
  • resource = meta.audit.resource.
  • resource_id = extracted from output id field if present.
  • metadata.agent: true so admin views filter agent vs human.

ai_tool_executions.audit_log_id links the tool run back to its audit row.

Per-org daily $ cap

Lives in apps/api/src/ai/agent/rate-limit.service.ts and is sourced from PLATFORM_TIER_MAP (libs/shared/src/lib/constants/platform-tiers.ts):

TierDaily budget (USD)
Lite$1.00 (1_000_000 micros)
Pro$5.00
Elite$25.00

AgentRateLimitService.preCheck(orgId):

  • Read org → tier → aiDailyBudgetUsdMicros.
  • Budget -1 → unmetered, allow.
  • Else compare to costTracker.getOrgSpendTodayMicros(orgId) (UTC day bucket).
  • If spent >= budget, return { ok: false, code: 'agent_budget_exceeded', message: 'Your org has reached its AI daily spending limit ($X.XX). It resets at 00:00 UTC. Upgrade your plan for a higher limit.', remainingUsdMicros: 0 }.

AgentController.sendMessage invokes preCheck after SSE headers flush so the client can render the upgrade prompt inline.

AgentRateLimitService.getUsageSnapshot(orgId) returns the same payload the dashboard composer footer reads — used to display “0.40 / $5.00” inline counters.

Per-org/user/day cost tracking

AgentCostTracker.recordTurn upserts into ai_usage_daily keyed on (organization_id, user_id, day) with SQL increments so concurrent turns don’t lose updates. Day key is UTC YYYY-MM-DD.

Cost is accumulated per model call, not per turn: AgentOrchestrator.tallyUsage prices each response with computeCostUsdMicros (apps/api/src/ai/cost.util.ts) at the rate of the model the request was sent with, and finalize records the sum. A turn mixes models (Haiku router → Sonnet loop) and cache-write TTLs, so pricing the summed tokens at one rate would misstate it. Token columns are still plain sums.

Rates per 1M tokens (MODEL_RATES):

Modelinputoutputcache readcache write 5mcache write 1h
claude-sonnet-4-53.0015.000.303.756.00
claude-sonnet-52.0010.000.202.504.00
claude-sonnet-5-52.0010.000.202.504.00
claude-haiku-4-51.005.000.101.252.00

Rows are keyed by base model id: a dated snapshot id (claude-sonnet-5-20260801) is priced as its model, and an id with no row at the dearest rates (claude-sonnet-4-5). Every model in the policy has a row (model-policy.unit.spec.ts).

The 1-hour share of a cache write comes from usage.cache_creation.ephemeral_1h_input_tokens. Title generation and compaction calls are not tallied.

Role-aware upsell

The composer footer (apps/web/src/components/agent/usage-footer.tsx) reads GET /agent/usage and shows:

  • spent / cap for capped tiers.
  • A subtle upgrade CTA when percentUsed >= 0.8.
  • A blocking modal with localized copy when agent_budget_exceeded flows over SSE.

Member role never sees the composer; staff-only gate at the controller.

Prompt caching architecture

AgentContextBuilder.buildSystem(runtime) returns two system blocks, both fully stable for a given org + caller:

[ { type: 'text', text: `${STATIC_SYSTEM_PROMPT}\n\n${DAY_GRAIN_PROMPT}`, cache_control: { type: 'ephemeral', ttl: '5m' } }, { type: 'text', text: orgContextBlock, cache_control: { type: 'ephemeral', ttl: '5m' } }, ]

Nothing per-turn lives in system. The clock, <page_context>, <insight_playbook> and <ambient_context> are a <turn_context> text block attached to the user message of that run and persisted with it (turn-context.ts, AgentContextBuilder.buildTurnContext):

ai_messages.content (role: user) = [ { type: 'text', text: "<what the user typed>" }, { type: 'text', text: "<turn_context>\nCurrent date & time in org timezone (…): …\n<page_context>…</page_context>\n…</turn_context>" }, ]
  • Built once at the top of run(), before T0 seeding and the T1 router, so every lane shares one clock. Ambient RAG only runs after the cheap lanes decline, so its candidates are folded into the same stored block (updateMessageContent) — still before any model call has seen the message.
  • resume / resumeAfterPick replay what was stored and never build another. One exception: a run that began before turn contexts existed gets one backfilled onto its user message on resume, once, so it does not resume without a clock.
  • The static prompt tells the model that “now” is the MOST RECENT <turn_context> and that earlier ones are historical.
  • Nothing a person reads shows it. The first text block of a message is always the user’s words; a later text block starting with <turn_context> is the context. visibleText / stripTurnContext / hasTurnContext in turn-context.ts are the only place that rule lives; the conversation-load mapper (AgentController.getConversation), the compaction summarizer and the T1 router’s recentTranscript all go through them. preview, title generation and the RAG query are fed the raw typed string and never see the block.

AgentOrchestrator.driveModelLoop calls withCachedTail(messages) before every request — adds a cache_control: { type: 'ephemeral' } marker to the trailing message’s last content block. That is the 3rd breakpoint of the 4 Anthropic allows. The provider renders tools → system → messages:

[ tools ][ static system ][ org ][ user + <turn_context> ][ … history … ][ tail ] ↑ 5m, shared by ↑ 5m, per ↑ 5m role × flags org+caller

Because everything ahead of messages is stable and every message replays as stored, the prefix written at one run’s tail is read back by the next run’s first call — the whole conversation hits the cache across runs, not just within one, as long as the next message arrives within 5 minutes of the last model call.

Why two system markers rather than one at the end of system: the first entry (tool list + static prompt, ~83k tokens for an owner on Sonnet 5.5 — ~74k of it tools) holds no org data and is shared by every org with the same tool surface — role × org flags, so day-grain orgs share one entry and every other org another; with a single marker after the org block each new caller would re-write all of it. The org marker is also the fallback hit when a long run pushed the previous tail entry out of the 20-block lookback.

Three more things keep the prefix byte-stable:

  • Tool order is fixed. groupByRouter sorts routers and actions by name; discovery order never reaches the request.
  • The tool list is the same for every turn of a role. Nothing per-page or per-turn is filtered out of it — including after a T0 seed, where hiding the seeded reads would rewrite the ~74k-token tools prefix to save one unlikely call (SEEDED_RESULTS_NOTE tells the model not to repeat them instead).
  • Replay slimming is deterministic, monotonic and infrequent (below).

T1 router caching

tryQuickRoute puts the clock line in the router’s USER content (relative-date lookups need it; its system prompt is a shared cached prefix) and sends system as one text block marked { type: 'ephemeral', ttl: '5m' }, which caches the role’s quick-route tool list + router prompt (~5.3k tokens coach, ~5.9k owner — above Haiku 4.5’s 4,096-token minimum). The prefix holds no org or user data, so all orgs share it per role. QUICK_ANSWER_SYSTEM, the title prompt and the summarizer prompt are a few hundred tokens — below the minimum, where a marker is silently ignored — so they carry none.

Tool definition size

groupByRouter states each action summary once, in the router description (what drives action choice); the per-action arg schemas carry title only. slimJsonSchema drops $schema and the pattern regex next to format: uuid|email. Field descriptions, enums, formats, bounds, additionalProperties and format-less patterns are untouched, and runtime validation still uses the original Zod schema. toolDefinitionStats (tools/tool-definition-stats.ts) measures a list; tool-registry.definitions.unit.spec.ts enforces a ceiling per role, and a separate one per role for day-grain orgs (workouts.parse + workouts.upsert add ~26.5k chars, nearly all of it upsert’s part/section/movement tree).

Rolebefore (chars)after (chars)after (tokens, count_tokens)
owner234,385146,50248,154
admin233,029145,810—
coach171,031109,72336,210
owner, quick route26,24318,0815,945
coach, quick route22,74915,7525,263

Why the default 5-minute TTL rather than ttl: '1h': a cache write costs 125% of input at 5m and 200% at 1h, and Spotter traffic is sparse. Over the 30 days to 2026-10-08, 33 of 82 completed turns came more than an hour after the previous one — a full ~77k-token re-write whatever the TTL — and those turns were 75% of Spotter spend; only 4 turns landed in the 5–60 minute window a 1h entry would have saved. At those numbers 1h cost ~$3/month more than 5m. Reads within a turn and across back-to-back turns refresh a 5m entry, so they still hit. A cold owner turn on Sonnet 5.5 now writes the prefix for ~$0.21 instead of ~$0.34. Revisit if 5–60 minute gaps become common.

Steady-state cache-hit ratio is computed in finalize:

cacheHitRatio = cacheReadTokens / (cacheReadTokens + inputTokens)

Healthy target: 0.85–0.95.

Observability

AgentObservabilityService emits typed events to Pino + Sentry breadcrumbs + PostHog (production only):

EventStage
agent.turn.startedFirst emit at the controller. Carries endpoint, promptLength.
agent.turn.completedfinalize. Carries inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsdMicros, cacheHitRatio.
agent.turn.failedlogTurnFailure. Carries the classified errorCode.
agent.tool.executedPer-tool. Carries router, action, durationMs, outcome, optional errorCode.
agent.tool.confirmation_pendingPer pending destructive tool.
agent.tool.disambiguation_pendingPer pending picker tool.
agent.replay.violationOn replay-integrity violation. Carries violations count, reasons[], repaired.
agent.storage.snapshotDaily cron at 03:00. Per-table row counts + bytes.

scrub() drops content, text, prompt, message, output, input, token, apiKey, secret, password before fan-out. Tokens and durations stay; user text never leaves the server.

Sentry tags: agent.trace_id, agent.org_id, agent.code, agent.endpoint. The trace id is server-generated UUID and surfaces back to the client on errors so QA can paste it into bug reports.

Error classification (error-classifier.ts)

Coarse codes for stable SSE / i18n surface:

  • agent_disabled, agent_budget_exceeded (from rate-limit), rate_limited
  • replay_integrity_violation, tool_execution_not_found, tool_already_resolved, invalid_pick
  • aborted (AbortError)
  • provider_invalid_request (4xx), provider_unauthorized (401/403), provider_overloaded (529 / type), provider_rate_limited (429), provider_unavailable (5xx)
  • internal (catch-all)

Each maps to a localized client string under agent.errors.<code>.

Ambient RAG (rag.service.ts)

On every user turn (skipped on resume), findAmbient({ orgId, query }):

  • 800ms timeout, never throws.
  • 30-character minimum query length (short prompts skip RAG).
  • Up to 3 hits per scope, 5 total across scopes.
  • Scopes: program, workout, exercise, member.
  • Hybrid: Postgres ilike for lexical + Voyage voyage-multilingual-2 embedding for semantic; results blended with an RRF-ish score.
  • Token extraction strips stopwords (articles, verbs, calendar tokens, entity nouns) before lexical search.

Hits are written as <ambient_context> inside the user message’s stored <turn_context> block.

Compaction (compaction.service.ts)

  • Trigger threshold: >= 24 messages past the latest system_note anchor (or from start).
  • Keep last 6 messages verbatim.
  • Run the Haiku summarizer (MODEL_POLICY.spotterCompaction, 3s budget).
  • Persist the summary as a system_note row with pageContext.summarizedThroughMessageId.
  • listMessagesForReplay reads from the latest system_note forward.
  • Title generation also uses Haiku, 1.5s budget, best-effort. It sees only the visible part of the first message (visibleUserText — the text before --- context for Spotter ---), wrapped in <message> tags and framed as data, so a dashboard-seeded opener full of instructions is named, not answered. cleanTitle keeps the first non-empty line with markdown stripped, ≤ 64 chars.
  • The summarizer is given prose and tool names only — tool RESULTS are reduced to “n succeeded, m errored”. Ids that lived only in a result do not survive compaction, which is why the trigger stays at 24 and cost is cut by replay slimming instead.

Replay slimming (replay-slimming.ts)

Applied in replayMessageHistory, never at storage time — ai_messages rows stay full-fidelity for the UI and audit.

  • Verbatim window: always at least the current run and the one before it. Its start moves in strides of 4 runs (WINDOW_STRIDE_RUNS): with R runs so far it opens at run floor((R-2)/4)*4, so nothing is slimmed before the 6th run and the window then holds 2–5 runs. Every rewrite invalidates the cached prefix from that block to the end of the conversation, so sliding one run at a time would throw away the cross-run cache on almost every message. tool_result and system_note rows replay as user turns but never open a run.
  • Old <turn_context> blocks outside the window lose their <ambient_context> section (guesses about one message); the clock and page lines stay.
  • Outside the window, a tool_result block whose content is over 2,000 chars becomes a digest: top-level scalars kept, each array cut to its first 10 items reduced to id / *Id / name-ish fields, _<key>Shown: "10 of N", _omittedKeys, and _note: "[earlier result shortened — call the tool again if you need the full data]". Content that no longer parses (storage caps results at MAX_TOOL_RESULT_CHARS = 50,000) is head-truncated behind the same note.
  • Never slimmed: is_error results, disambiguation picks, anything ≤ 2,000 chars, tool_use inputs, text blocks.
  • Cache-safe: the digest is a pure function of the stored content, and only a new user message moves the window — so a result is rewritten once, when its run ages out, and model turns / confirmation resumes / picker resumes never reshape the prefix. tool_use_id pairing is untouched, so validateReplay is unaffected.

Parallel reads (processToolUses)

When a batch has ≥ 2 tool_use blocks and every one resolves to a registered tool with sideEffects: 'read', confirm: 'never', kind: 'normal', the handlers run concurrently (max 4 in flight). Only the handler is concurrent: each result is settled — execution row, agent.tool.executed, tool_completed, tool_result block, retry-breaker count — strictly in tool_use order, as soon as everything before it has settled. tool_started fires when a tool actually starts. A runner throw propagates as before, after in-flight reads finish. Any other batch (a write, a confirmable tool, a picker, an unknown tool, a malformed call, or a single call) takes the sequential path unchanged.

Replay-integrity guard (replay-validator.ts)

Catches the dominant failure mode: a pending picker / confirmation tool_use whose tool_result was never paired (user typed a free-text reply instead of clicking).

validateReplay(messages) returns { ok, violations[] }. On violation:

  • validateAndRepairReplay(messages) synthesizes error tool_result blocks for the orphaned tool_use ids so the model can reason about the redirect.
  • AgentObservabilityService.logReplayViolation records the violation (PII-scrubbed snapshot of role + block types only).
  • Loop continues with repaired messages.

SSE protocol (agent-schemas/sse-events.ts)

event: conversation_started\ndata: {"type":"conversation_started","conversationId":"<uuid>"}\n\n event: text_delta\ndata: {"type":"text_delta","delta":"..."}\n\n event: tool_started\ndata: {"type":"tool_started","toolUseId":"...","router":"...","action":"...","input":{...}}\n\n event: tool_completed\ndata: {"type":"tool_completed",..."ok":true,"output":{...},"inverseAvailable":true}\n\n event: confirmation_pending\ndata: {"type":"confirmation_pending","toolUseId":"...","router":"memberships","action":"erase","input":{"membershipId":"..."},"confirm":"destructive","confirmation":{"target":"Dana Levi","affectedCount":1,"consequences":["permanent_member_erasure"]}}\n\n event: disambiguation_pending\ndata: {"type":"disambiguation_pending","toolUseId":"...","kind":"member","prompt":"Which Saar?","candidates":[...]}\n\n event: message_done\ndata: {"type":"message_done","messageId":"<uuid>","stopReason":"end_turn"}\n\n event: done\ndata: {"type":"done","conversationId":"<uuid>","usage":{...}}\n\n event: error\ndata: {"type":"error","code":"...","message":"...","traceId":"..."}\n\n

The web reducer in apps/web/src/providers/agent-provider.tsx (and apps/web/src/components/agent/message-list.tsx) reconstructs the conversation from these events.

Page context & selection

Every user message carries pageContext: { pathname, selection? } (agent-schemas/common.ts), rendered as <page_context> inside that message’s stored <turn_context> block by AgentContextBuilder.buildTurnContext.

  • pathname comes from usePathname(). Route-driven surfaces put entity ids in the path (e.g. /dashboard/leads/<pipelineId>), so navigation alone gives the model orientation.
  • selection is populated by pages via useAgentPageSelection(key, entries) (exported from agent-provider.tsx): a keyed registry held in a ref — components register client-side state that is not in the URL (the open lead sheet, the active pipeline’s display name) and unregister on unmount/null. All contributors are merged at send() time; null/empty values are dropped.
  • Current contributors: the leads surface registers leads:pipeline (pipelineId + pipelineName while the board is shown) and leads:lead (leadId while the detail sheet is open).
  • The static prompt tells the model to treat a Selection pipelineId/leadId (or a /dashboard/leads/<uuid> pathname) as the referent of “this pipeline” / “this lead” and to scope pipeline actions accordingly instead of asking.

Adding a new contributor is one hook call in the page component; keep entries small (ids + a display name), since they ride on every message in that conversation turn.

Frontend integration

  • agent-launcher.tsx — floating button + global Cmd-K hotkey.
  • agent-sheet.tsx — slide-in drawer (<Sheet>).
  • composer.tsx — input box + page-context capture + usage footer.
  • message-list.tsx — streaming text + tool-call cards + suspension cards.
  • tool-call-card.tsx — per-tool result card with undo affordance when inverseAvailable.
  • suggested-prompts.tsx — page-aware starter prompts.
  • conversation-list.tsx — sidebar with past conversations.
  • streaming-text.tsx — handles SSE delta buffering.
  • usage-footer.tsx — daily $ counter + upsell.
  • agent-sheet-mount.tsx — keeps the sheet mounted across route changes so streams survive navigation.

Locale

  • locale is read from x-locale header (set by the web client) or accept-language, defaulting to en.
  • The agent’s static prompt instructs “Reply in the caller’s preferred locale unless they switch.”
  • All i18n keys for agent UI live under agent.* in apps/web/src/i18n/{en,he,ru}.json.

Localization of system prompt instructions

  • Intent signals embedded for Hebrew: סשן, מפגש, שיעור, לו״ז, כל החברים — so prompts like “כל החברים בתכנית” route correctly to the schedule path.

Failure modes — agent-specific

FailureSurfaceRecovery
ANTHROPIC_API_KEY unsetagent_disabled SSE errorSet env.
Org over daily $ capagent_budget_exceeded SSE errorWait for UTC midnight or upgrade tier.
Picker / confirm sent twice for the same toolUseIdtool_already_resolvedUI disables the action card after first submit.
Free-text reply instead of clickingreplay-integrity guard repairs syntheticallyUser retries the prompt; agent re-asks.
Tool throwstool-runner converts to typed { code, message, hint } tool_resultModel retries or apologizes; no run abort.
Anthropic 529 overloadedprovider_overloaded SSE errorRetry in a moment.
Anthropic 429provider_rate_limitedRetry.
User closes the dashboard mid-streamAbortController cancels the Anthropic streamNo partial state — assistant row carries whatever streamed before abort.