The engine does work automatically and puts finished proposals (with
evidence) in the founder's queue; he approves/edits/rejects with one tap.
Every action + decision is recorded in engine_actions — that record is the
training data for earning autonomy. Module builders: import from these two
files only; only lib/engine/ai.ts may import @anthropic-ai/sdk, and
it is server-side only (never import in a client component).
lib/engine/ai.ts — the only door to the AI
Model: process.env.ENGINE_MODEL (falls back to claude-opus-4-8). Key:
process.env.ANTHROPIC_API_KEY (root .env). No temperature/top_p —
this model family rejects them.
aiAvailable(): boolean
Key present? Check this FIRST; when false, run your deterministic fallback
(keyword match, price book) and record confidence: "low" — never throw a
missing-key error at a caller that could have degraded gracefully.
extractJson<T>(opts: {
task: string; // short name for logs/rows — never PHI
system: string;
prompt: string; // PHI-minimized: care-need text/category/CPT/age-band/sex/urgency ONLY
schema: object; // JSON Schema: additionalProperties:false + required on every object
maxTokens?: number; // default 4000
}): Promise<{ data: T; modelUsed: string; latencyMs: number }>
Structured output via output_config.format (json_schema); parses the
first text block with JSON.parse. Store modelUsed/latencyMs on the
engine_actions row via propose().
readDocument<T>(opts: extractJson opts & {
media: { mediaType: string; base64: string }; // no "data:" prefix
}): Promise<{ data: T; modelUsed: string; latencyMs: number }>
Same, but the model also sees a file: application/pdf → document block;
image/jpeg|png|gif|webp → image block. File first, instructions second.
class EngineAiError extends Error {
kind: "no_api_key" | "rate_limited" | "connection" | "api_rejected" | "bad_response";
retryable: boolean; // true → try again later unchanged
}
Every failure path throws this, with a plain-language message safe to show
the founder. SDK errors are caught most-specific-first (RateLimitError →
APIConnectionError → APIError). Logs carry task/latency/tokens only — never
prompt contents (no PHI in logs). Callers must NOT log prompts either.
PHI rule (hard): never send name, date of birth, member code, access
code, or address to the API. Ranking rule (hard): the AI drafts prose
and extracts data; it never ranks options and never decides emergencies
(triage is a hard-coded rule list, lib/engine/triage.ts, not built yet).
lib/engine/actions.ts — the proposal lifecycle
type EngineActionType = "triage" | "intake" | "price_research" | "options_draft"
| "order_confirmation_draft" | "bill_parse" | "bill_response_draft"
| "member_message_draft";
type AutonomyTier = "auto" | "approve_each" | "draft_only";
type EngineConfidence = "high" | "medium" | "low";
getTier(actionType): Promise<AutonomyTier>
Reads autonomy_settings; falls back to DEFAULT_TIERS (same values as the
seed) if the row is missing.
propose(actionType, {
careRequestId?, billCaseId?, // link to what the work is about
proposal, evidence, // any JSON-able values; stored as text
confidence, // "low" for no-AI fallbacks
modelUsed?, latencyMs?, errorNote?,
}): Promise<EngineAction>
Records finished engine work. Tier auto → row status auto_done (acted +
recorded, visible); otherwise proposed (founder's queue). First marks any
older still-proposed rows of the same action type on the same care
request / bill case as superseded — the founder only sees the latest.
decide(id, "approved" | "rejected", finalJson?): Promise<EngineAction>
Founder's one-tap. Approve as-is → approved (final_json = proposal);
approve with a changed finalJson → edited (final_json = what was
used); reject → rejected. Atomic claim: only a proposed row can be
decided; repeats return the row unchanged.
listProposed(): Promise<EngineAction[]> // founder queue, oldest first
getForRequest(careRequestId): Promise<EngineAction[]> // audit trail, newest first
Autonomy tiers (seeded defaults — npm run seed)
| action_type | tier | why |
|---|---|---|
| triage | auto | pure analysis; rules decide, never the LLM, never cost-aware |
| intake | auto | parsing the member's words — reversible |
| price_research | auto | lookups only — reversible |
| bill_parse | auto | reading an uploaded bill — reversible |
| options_draft | approve_each | publishing options commits the plan |
| order_confirmation_draft | approve_each | script for a call the founder makes |
| bill_response_draft | approve_each | letter that leaves the company |
| member_message_draft | approve_each | reaches a member |
Never automated, no tier can grant it: money movement, anything that narrows a member's options, anything sent outside the company (the founder fires all outward communications), and there is no negotiation capability anywhere. If uncertain, uncertainty is priced to the plan, never the member.
Tables (prisma/schema.prisma)
-
engine_actions— one row per engine action:action_type,proposal_json,evidence_json,confidence,status(proposed | approved | edited | rejected | auto_done | superseded),final_json,decided_at,model_used,latency_ms,error_note, nullable FKscare_request_id/bill_defense_case_id. -
autonomy_settings—action_type(unique) →tier.