Spotter Agent — Behavior
Surface
| Endpoint | Purpose |
|---|---|
GET /organizations/:orgId/agent/usage | Org daily AI spend snapshot (tier, cap, spent, percent, reset). |
GET /organizations/:orgId/agent/conversations | List caller’s conversations in this org. |
GET /organizations/:orgId/agent/conversations/:conversationId | Full conversation detail with reconstructed messages + tool calls. |
POST /organizations/:orgId/agent/messages | Send a user turn. Returns an SSE stream. |
POST /organizations/:orgId/agent/conversations/:conversationId/confirm/:toolUseId | Approve / reject a pending destructive tool. Returns an SSE stream. |
POST /organizations/:orgId/agent/conversations/:conversationId/confirm-batch | Approve / 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/:toolUseId | Resolve a disambiguation_pending candidate pick. Returns an SSE stream. |
POST /organizations/:orgId/agent/conversations/:conversationId/undo/:toolUseId | Invoke 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 + doneSuspended runs
When a user_picker tool is invoked, or when a destructive write is pending, the orchestrator stops the loop and emits:
disambiguation_pendingwithkind,prompt,candidates[].confirmation_pendingwithrouter,action, sanitized executableinput,confirm: 'destructive' | 'always', and a server-resolvedconfirmationpayload. The orchestrator parses tool input before persistence, strips the reserved__agentConfirmationPreviewkey, 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:
| Sibling | Persisted | Announced | Loader pendingReason |
|---|---|---|---|
confirm: 'destructive' | 'always' | pending + server-derived preview | confirmation_pending | approval |
kind: 'user_picker' | pending | disambiguation_pending | pick |
anything else (read, confirm: 'never' write, unknown tool) | pending, input marked held (__agentHeld, stripped before execution and before any client sees it) | nothing | queued |
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 → doneClaim 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_useis dropped, the turn is stored, every remaining call gets an errortool_result(the first one carriesTRUNCATED_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 onlymessage_donewithstopReason: 'max_tokens'for that turn — notool_started/tool_completed/error. The discarded calls are persisted asfailedrows marked discarded, and the loader returns them withdiscarded: trueso a reload hides them too. - Second time in the same run, or a prose-only overflow: terminal
provider_output_truncatederror, 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:
| Run | Call |
|---|---|
| New user message | MODEL_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 viameta.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 toaudit_logspost-success.inverse?—{ router, action, buildInput(output) }enables undo.kind?—'normal' | 'user_picker'.user_pickerhandlers are never invoked; the orchestrator suspends and waits for apick.mcp?—{ summary }: the description the MCP server serves instead ofsummary.
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
| Action | Effect | Example prompt |
|---|---|---|
get_current_context | Returns 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_list | List active programs. | ”what programs do we run?” |
members_search | Search members by name/email/role/status. | ”find Saar” |
members_get | Full member detail by membershipId. | ”show me Dani’s plan + payment status” |
exercises_search | Hybrid (lexical + semantic) exercise library search. | ”find a squat variation” |
exercises_resolve_batch | Batch-resolve movement names → exercise ids in one round-trip; flags ambiguous. | (used internally by workout build) |
ask_user_to_pick | user_picker kind. Suspends; user picks one candidate. | ”which Saar?” |
search_anything | Cross-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_id | Detail by type+id. | (post-search) |
workout_comments_in_program | List recent comments on workouts in a program (unreadOnly filter). | “any new comments in coaching?” |
workout_comments_for_assignment | Full comment thread on a single assignment. | ”what did Dani say on Monday’s WOD?” |
workouts — 6 actions
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
create | Insert a workout (freeform or structured). | never | workouts.delete |
update | Patch a workout. | never | — |
set_sections | Replace all sections + movements wholesale. | never | — |
delete | Soft-delete. | destructive | — |
parse | Coach text for one day → proposal (title, brief, parts, blocks, matched movements), unresolved, warnings; saves with save, confirming suggestions via save.resolutions. | never | — |
upsert | One 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.parseandworkouts.upsertare in theworkoutsrouter. - 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 thedescription; a part’s note, clock window and “optional” belong to the part; coach text, pasted or typed, always goes verbatim throughworkouts.parse(withdate, andsave: { programId }when the user wants it written) — never rebuilt withworkouts.create+set_sections;saved: falsewithunresolvedmovements → one moreworkouts.parsewith the same text andsave.resolutions: [{ movementKey, mention, exerciseId }](ask withread.ask_user_to_pickwhen several candidates fit); every workout has a title and the brief never is one; every warning andnotSavedReasonsentry is told to the coach (auto_confirmed_movement= matches a model confirmed, for review);workouts.upsertonly for a day assembled without coach text; publish once withprograms.session_create. - Guard —
sourceTextandscoresstay MCP-only onworkouts.set_sectionsandworkouts.upsert(refuseHandAuthoredText: “Source text and score slots require the MCP channel”). The coach’s verbatim words reach a Spotter-written workout only throughworkouts.parse, which stores what the parser read.
programs — 4 actions
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
create | New training program. | never | programs.delete |
update | Patch. | never | — |
delete | Soft-delete. | destructive | — |
enroll_member | Enroll a member in a coaching program. | never | — |
assignments — 20 actions
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list | Assignments for a week by userId or programId (incl. drafts). | never (read) | — |
get | One assignment + full snapshot workout. | never (read) | — |
my_week | Caller’s published week. | never (read) | — |
my_today | Caller’s published assignments for a date. | never (read) | — |
coach_overview | Coaching-book compliance KPIs + per-athlete roster. | never (read) | — |
create | Program-feed assignment for one athlete (draft by default). | never | assignments.delete |
assign_personal | 1-on-1 PT assignment for a date. | never | — |
replace_program_day | Atomically replace one coaching-program date for all active enrollees. | destructive | — |
remove_program_day | Soft-delete one coaching-program date for every member. | destructive | — |
assign_schedule | One PUBLISHED class session per slot with the workout attached. | always | — |
update | Patch assignment. | never | — |
set_published | Toggle published flag. | never | — |
complete | Mark completed. | never | — |
uncomplete | Reset to assigned. | never | — |
skip | Mark skipped. | never | — |
delete | Single assignment. | destructive | — |
mark_comments_read | Mark comments as read for a coach. | never | — |
bulk_preview | Dry-run for bulk ops. | never (read) | — |
bulk_delete | Bulk delete N assignments. | destructive | — |
bulk_publish | Bulk publish N assignments. | always | — |
bookings — 8 actions
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list_mine | Caller’s bookings. | never (read) | — |
attendance_summary | Summary stats. | never (read) | — |
attendance_trend | 7-day series. | never (read) | — |
attendance_daily | Per-day attended / no-show over N days (owner/admin). | never (read) | — |
book | Book the CALLER’s own seat (staff bypass gates). Never books another member. | always | — |
cancel | Cancel the CALLER’s own booking. | always | — |
admin_cancel | Cancel 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
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list_for_date | Sessions on a date. | never (read) | — |
roster | Bookings on a session (bookingId, member, status, check-in). | never (read) | — |
attendance_history | One member’s booking history. | never (read) | — |
qr_token | Check-in QR URL + expiry (the raw token is not returned separately). | never (read) | — |
create | New class session. | never | class_sessions.cancel |
update | Patch session. | never | — |
publish | Publish. | never | class_sessions.unpublish |
unpublish | Unpublish. | never | — |
cancel | Cancel (row kept). | destructive | — |
delete | Hard-remove; cancels bookings first (owner/admin). | destructive | — |
set_workouts | Replace THIS session’s workout override (whole set). | always | — |
set_day_workouts | Write the day’s programming + clear sibling overrides (whole set). | always | — |
create_inline_workout | Quick freeform workout attached to the session. | never | — |
create_many | Every 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_in | Staff check-in of a booking. | never | class_sessions.undo_check_in |
undo_check_in | Booking back to confirmed. | never | — |
bulk_preview | Dry-run for bulk ops. | never (read) | — |
bulk_delete | Bulk delete. | destructive | — |
bulk_publish | Bulk publish. | always | — |
copy_week | Copy 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.
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list_for_week | Slots (class type × date) with status + workouts for 7 days. | never (read) | — |
list_for_date | Slots for one date (optional classTypeId). | never (read) | — |
set | Replace a slot’s workout list (whole set); draft unless status=published. | always | — |
set_status | Publish / unpublish a slot. | always | — |
remove | Delete 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
| Action | Effect |
|---|---|
list_for_program | Class types under a program. |
analytics — 12 actions, all read
| Action | Wraps |
|---|---|
revenue_summary | AnalyticsService.getRevenueSummary |
revenue_trend | getRevenueTrend |
members_summary | getMembersSummary |
members_growth | getMembersGrowth |
at_risk_members | getAtRiskMembers |
popular_classes | getPopularClasses |
class_utilization | getClassUtilization |
coach_overview | getCoachOverview |
org_insights | InsightsService.getOrgInsights |
plan_distribution | getPlanDistribution |
members_activation | getMembersActivation |
workouts_summary | getWorkoutsSummary |
tasks — 5 actions
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list | List tasks. | never (read) | — |
create | New task. | never | tasks.delete |
update | Patch. | never | — |
complete | Quick-complete. | never | — |
delete | Soft delete. | destructive | — |
program_templates — 10 actions
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list | List templates. | never (read) | — |
get | Header + cells / slots. | never (read) | — |
preview | Dry-run of apply (conflicts included). | never (read) | — |
create | New template. | never | program_templates.delete |
update | Header fields; slots replaces the whole slot set on schedule templates. | always | — |
duplicate | Copy as ” | never | program_templates.delete |
upsert_cells | Replace ALL coaching cells (whole set). | always | — |
apply | Materialize a template into the org. | always | — |
from_history | Derive a template from historical rows. | always | — |
delete | Soft-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.
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list_pending_for_org | Pending compliance instances (roster-scoped). | never (read) | — |
compliance_status_for_member | Per-typeKey compliance status (FIT-158). | never (read) | — |
templates_list | Templates (compliance + check-in), archived hidden by default. | never (read) | — |
template_get | Full template incl. fields/body. | never (read) | — |
coverage | Active members missing a valid signature for a family. | never (read) | — |
template_create | New DRAFT template (publish is deliberately not accepted). | never | — |
template_update | Patch an unpublished draft. | never | — |
template_publish | Publish a draft. | always | — |
template_bump_version | Publish version+1 of a published template. | always | — |
template_set_auto_issue | Toggle auto-issue-on-join. | never | — |
template_set_required_for_booking | Toggle the org-wide booking gate. | always | — |
template_widen_to_plan_group | Re-target a plan-linked family to its plan group (irreversible). | always | — |
template_archive | Archive the family; outstanding instances retired. | destructive | — |
template_delete | Hard-delete (409 once anyone submitted). | destructive | — |
assign | Issue a compliance instance to one member (signing token never returned). | never | — |
assign_all_missing | Bulk-issue to the coverage set. | always | — |
remind | In-app push to the assignee. | always | — |
email_link | Email a 7-day signing link to the address on file (link not returned). | always | — |
instance_archive | Revoke 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.
| Action | Effect | Confirm | Notes |
|---|---|---|---|
list | Filter/search leads. | never (read) | |
get | One lead + status history + unified timeline. | never (read) | |
create | New lead (dedup by normalized email/phone). | never | Conflict error carries existingLeadId/existingLeadName/matchedBy in details so the model acts on the existing lead instead of re-searching. |
update | Patch fields; bare status maps to the primary pipeline’s matching stage. | never | email/phone/note accept null to clear (prod regression: “delete her email” used to fail validation). |
convert | Lead → member. | always | Owner/admin only. |
import | Bulk rows (pasted CSV). | always | |
analytics | Funnel KPIs incl. per-hook win rates (byCampaign). | never (read) | |
pipelines_list | Org pipelines + stages (+ a lead’s memberships via leadId). | never (read) | Quick action. |
campaigns_list | Campaign hooks (granular acquisition sources) with lead counts. | never (read) | Quick action. |
add_to_pipeline | Add lead to another pipeline (idempotent). | never | |
move_stage | Move within one of the lead’s pipelines; primary move syncs status + fires automations. | never | |
tags_list | Org tags + usage counts. | never (read) | Quick action. |
set_tags | Attach/detach tags by id. | never | |
archive | Soft-archive (board/list hide, history kept). | never | Inverse: restore (15s undo). |
restore | Un-archive. | never | |
campaign_create | Manual campaign hook. | never | Owner/admin. |
bulk_move_stage_preview | Dry-run of bulk_move_stage with skip reasons. | never (read) | Owner/admin. |
bulk_move_stage | Move leadIds / filter audience to a stage. | always | Owner/admin. |
bulk_add_to_pipeline | Add leadIds / filter audience to a pipeline. | always | Owner/admin. |
bulk_set_tags | Attach/detach tags on leadIds / filter audience. | always | Owner/admin. |
bulk_archive | Soft-archive leadIds / filter audience. | always | Owner/admin. |
bulk_email | Queue an email to ids / pipeline+stages / tags / filter, minus excludeTagIds. | always | Owner/admin. Recipients are consent-filtered server-side (ADR-0013). |
pipeline_create | New pipeline (default funnel unless stages given). | never | Owner/admin. |
pipeline_update | Rename / reposition / archive / make default. | never | Owner/admin. |
stage_add | Add a stage. | never | Owner/admin. |
stage_update | Rename / recolour / recategorise / archive / move a stage. | never | Owner/admin. |
stage_remove | Delete an EMPTY stage. | destructive | Owner/admin. |
stages_reorder | Full reorder (every stage id). | always | Owner/admin. |
memberships — 12 actions (TKN-305)
Wraps MembershipsService + MemberErasureService. invite is quick-action eligible; every other invitation send confirms.
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list | Paginated roster with billing / memberStatus / rosterBucket filters. | never (read) | — |
stats | A member’s attendance stats. | never (read) | — |
roster_summary | Headcount per roster bucket + trained this week. | never (read) | — |
invitations_list | Pending invitations. | never (read) | — |
invite | Clerk invitation by email + role (grant matrix). | never | — |
create | Desk registration — active immediately; may email an invitation (owner/admin). | always | — |
update | Role / status / email / profile (owner/admin). | always | — |
bulk_invite | Invite imported members without an account (owner/admin). | always | — |
send_invitation | Invite one imported member (owner/admin). | always | — |
resend_invitation | Fresh 7-day invitation email. | always | — |
revoke_invitation | Revoke a pending invitation. | destructive | — |
erase | Permanent 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.
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list | Automations with status / trigger / step count. | never (read) | — |
get | Full step graph + counters. | never (read) | — |
preview | Render a message against sample data. | never (read) | — |
create | DRAFT automation. | never | automations.remove |
update | Header or (paused-only) structural edits; step replacement cancels active enrollments. | always | — |
set_status | active / paused / archived. | always | — |
run | Manual broadcast to an explicit in-org audience. | always | — |
remove | Archive + 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.
| Action | Effect | Confirm | Inverse |
|---|---|---|---|
list | Threads with leads/members, filterable. | never (read) | — |
get | Thread + last 30 messages (bodies wrapped in <message_data>). | never (read) | — |
start | New/reused thread, optional first message. | always | — |
send | Outbound message; service enforces lead consent (LEAD_CHANNEL_BLOCKED). | always | — |
assign | Assign to a teammate / unassign. | never | — |
set_status | open / snoozed / closed. | never | — |
mark_read | Unread → 0. | never | — |
Confirmation flow
- Model emits
tool_useblock for a tool withconfirm: 'destructive'or'always'. - 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 andtool_result; the mutation is never run and no broken pending confirmation is left behind. - Orchestrator stores the preview beside the executable input in the pending
ai_tool_executionspayload and emitsconfirmation_pendingwith{ toolUseId, router, action, input, confirm, confirmation? }. - Any subsequent
tool_useblocks in the same assistant message are also persisted pending (they can’t be partially fed back). - The frontend renders localized consequence labels plus the resolved target, affected count, and message excerpt. Raw ids remain hidden.
- Loop suspends; controller emits
donewith usage 0. - User clicks Approve or Reject in the frontend →
POST /confirm/:toolUseId { approved: boolean }, or decides several at once withPOST /confirm-batch. - If approved, runner validates the original input and executes the tool; status transitions
pending → succeeded | failed.tool_completedSSE event emitted. - If rejected, status →
rejected_by_userwith synthetic error{ code: 'rejected_by_user' }. - Once all pending tools for the message are resolved, the orchestrator appends a unified
tool_resultuser 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:
- Load
ai_tool_executionsby(conversationId, toolUseId). - Reject if not
status: 'succeeded'(422). - Lookup the registry entry; require
meta.inverseis defined. - Build the inverse input via
inverse.buildInput(original.output)— typically{ id: output.id }. - Execute via
ToolRunnerService.execute— bypasses confirmation entirely. - 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:
- Model emits
read.ask_user_to_pick { kind, prompt, candidates: [{ id, label, sublabel?, detail? }, ...] }. - Orchestrator sees
meta.kind === 'user_picker', persists pending, emitsdisambiguation_pending. - Frontend renders a “Which Saar?” card with 2-8 buttons.
- User clicks →
POST .../pick/:toolUseId { id: <pickedId> }. - Orchestrator validates the id is in
candidates, persists{ pickedId, pickedLabel, kind }as the tool output, emitstool_completed, and resumes the model loop.
Audit logging
ToolRunnerService.execute writes an audit_logs row when:
meta.auditis defined, ANDmeta.sideEffects === 'write', AND- The tool succeeded.
Row carries:
actor_clerk_id= caller.action=meta.audit.actionLabel.resource=meta.audit.resource.resource_id= extracted from outputidfield if present.metadata.agent: trueso 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):
| Tier | Daily 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):
| Model | input | output | cache read | cache write 5m | cache write 1h |
|---|---|---|---|---|---|
claude-sonnet-4-5 | 3.00 | 15.00 | 0.30 | 3.75 | 6.00 |
claude-sonnet-5 | 2.00 | 10.00 | 0.20 | 2.50 | 4.00 |
claude-sonnet-5-5 | 2.00 | 10.00 | 0.20 | 2.50 | 4.00 |
claude-haiku-4-5 | 1.00 | 5.00 | 0.10 | 1.25 | 2.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 / capfor capped tiers.- A subtle upgrade CTA when
percentUsed >= 0.8. - A blocking modal with localized copy when
agent_budget_exceededflows 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/resumeAfterPickreplay 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/hasTurnContextinturn-context.tsare the only place that rule lives; the conversation-load mapper (AgentController.getConversation), the compaction summarizer and the T1 router’srecentTranscriptall 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+callerBecause 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.
groupByRoutersorts 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_NOTEtells 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).
| Role | before (chars) | after (chars) | after (tokens, count_tokens) |
|---|---|---|---|
| owner | 234,385 | 146,502 | 48,154 |
| admin | 233,029 | 145,810 | — |
| coach | 171,031 | 109,723 | 36,210 |
| owner, quick route | 26,243 | 18,081 | 5,945 |
| coach, quick route | 22,749 | 15,752 | 5,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):
| Event | Stage |
|---|---|
agent.turn.started | First emit at the controller. Carries endpoint, promptLength. |
agent.turn.completed | finalize. Carries inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsdMicros, cacheHitRatio. |
agent.turn.failed | logTurnFailure. Carries the classified errorCode. |
agent.tool.executed | Per-tool. Carries router, action, durationMs, outcome, optional errorCode. |
agent.tool.confirmation_pending | Per pending destructive tool. |
agent.tool.disambiguation_pending | Per pending picker tool. |
agent.replay.violation | On replay-integrity violation. Carries violations count, reasons[], repaired. |
agent.storage.snapshot | Daily 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_limitedreplay_integrity_violation,tool_execution_not_found,tool_already_resolved,invalid_pickaborted(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
ilikefor lexical + Voyagevoyage-multilingual-2embedding 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:
>= 24messages past the latestsystem_noteanchor (or from start). - Keep last 6 messages verbatim.
- Run the Haiku summarizer (
MODEL_POLICY.spotterCompaction, 3s budget). - Persist the summary as a
system_noterow withpageContext.summarizedThroughMessageId. listMessagesForReplayreads from the latestsystem_noteforward.- 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.cleanTitlekeeps 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 runfloor((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_resultandsystem_noterows 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_resultblock whose content is over 2,000 chars becomes a digest: top-level scalars kept, each array cut to its first 10 items reduced toid/*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 atMAX_TOOL_RESULT_CHARS= 50,000) is head-truncated behind the same note. - Never slimmed:
is_errorresults, disambiguation picks, anything ≤ 2,000 chars,tool_useinputs, 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_idpairing is untouched, sovalidateReplayis 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 errortool_resultblocks for the orphanedtool_useids so the model can reason about the redirect.AgentObservabilityService.logReplayViolationrecords 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\nThe 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 fromagent-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 atsend()time; null/empty values are dropped. - Current contributors: the leads surface registers
leads:pipeline(pipelineId+pipelineNamewhile the board is shown) andleads:lead(leadIdwhile 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 + globalCmd-Khotkey.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 wheninverseAvailable.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
localeis read fromx-localeheader (set by the web client) oraccept-language, defaulting toen.- 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.*inapps/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
| Failure | Surface | Recovery |
|---|---|---|
ANTHROPIC_API_KEY unset | agent_disabled SSE error | Set env. |
| Org over daily $ cap | agent_budget_exceeded SSE error | Wait for UTC midnight or upgrade tier. |
Picker / confirm sent twice for the same toolUseId | tool_already_resolved | UI disables the action card after first submit. |
| Free-text reply instead of clicking | replay-integrity guard repairs synthetically | User retries the prompt; agent re-asks. |
| Tool throws | tool-runner converts to typed { code, message, hint } tool_result | Model retries or apologizes; no run abort. |
| Anthropic 529 overloaded | provider_overloaded SSE error | Retry in a moment. |
| Anthropic 429 | provider_rate_limited | Retry. |
| User closes the dashboard mid-stream | AbortController cancels the Anthropic stream | No partial state — assistant row carries whatever streamed before abort. |