Quick map of every HTTP endpoint under app/api/. For exact request/response shapes, status codes, and validation rules, read the route handler directly — the file path is the truth.
This doc exists so you can find your way around the API surface without grepping. Update it whenever a route is added, removed, or repurposed.
Better Auth catch-all at app/api/auth/[...all]/route.ts. All paths below are proxied through Better Auth's auth.handler. See lib/auth/config.ts for provider config and lib/auth/queries.ts for server-side session lookup.
| Endpoint | Purpose | Auth required |
|---|---|---|
POST /api/auth/sign-up/email |
Email + password registration. Triggers sendVerificationEmail. |
No |
POST /api/auth/sign-in/email |
Email + password sign-in (requires emailVerified=true). |
No |
POST /api/auth/sign-out |
End the current session. | Yes |
GET /api/auth/get-session |
Returns { user, session } or { user: null, session: null }. |
No (returns null) |
GET /api/auth/verify-email?token=... |
Verify email via a one-time token from the verification email. | No |
POST /api/auth/send-verification-email |
Re-send the verification email (invalidates the previous token). | No |
GET /api/auth/sign-in/social?provider=github|google&callbackURL=/ |
OAuth entry — 302 to provider. | No |
GET /api/auth/callback/:provider |
OAuth callback — creates/links account and session, 302 to callbackURL. |
No |
EMAIL_INVALID, PASSWORD_TOO_WEAK, EMAIL_TAKEN, INVALID_CREDENTIALS, EMAIL_NOT_VERIFIED, RATE_LIMITED, EMAIL_QUOTA_EXCEEDED, OAUTH_FAILED, OAUTH_DENIED, TOKEN_INVALID, TOKEN_EXPIRED.
{
user: { id: string; email: string; emailVerified: boolean; name?: string; image?: string };
session: { id: string; userId: string; token: string; expiresAt: string };
}Thread metadata, backing the assistant-ui sidebar. Implementation: lib/threads/{queries,validators}.ts. Adapter: lib/threads/adapter.ts.
Auth + isolation contract (Stage 1): every endpoint below requires a session cookie. GET lists only the calling user's threads. GET / PATCH / DELETE on [id] return 404 if the thread exists but belongs to another user (no existence leak). Deleting a user cascades through ON DELETE CASCADE and removes their threads.
Response shape (single row, same for list / fetch / create / update):
{
id: string; // LangGraph thread_id
status: "regular" | "archived";
title: string;
lastMessageAt: string; // ISO timestamp
}lastMessageAt mirrors the most recent activity for the thread (creation time until a run-end sync lands; see lib/threads/queries.ts). The frontend adapter translates this object into assistant-ui's RemoteThreadMetadata (remoteId + externalId are both set to id).
| Endpoint | Purpose | Status codes |
|---|---|---|
GET /api/threads |
List regular (non-archived) threads owned by the current user. | 200 / 401 |
POST /api/threads |
Create a new thread bound to the current user; registers the id with the LangGraph dev STORE. | 201 / 400 / 401 |
GET /api/threads/[id] |
Fetch one thread's metadata (owner-only). | 200 / 401 / 404 |
PATCH /api/threads/[id] |
Rename, archive, unarchive, or replace custom jsonb (owner-only). |
200 / 400 / 401 / 404 |
DELETE /api/threads/[id] |
Remove the thread metadata row plus its LangGraph checkpointer rows and PostgresStore thread summaries (owner-only; observability_spans ride on the threads FK cascade). Best-effort — a checkpointer/store failure leaves orphans on disk but still returns 204. |
204 / 401 / 404 |
Per-thread React Flow snapshot (issue: chat-first app that expands to a split-view canvas where the user can paste / regenerate images). One row per thread; FK to threads cascades on delete. Design doc: docs/CANVAS.md (auto-save cadence, layout, image-gen tool).
Auth + isolation contract: every endpoint is wrapped in withAuth (rule #9). Reads JOIN against threads.userId so cross-user access returns 404 (no enumeration of other users' thread ids — same shape as /api/threads/[id]). upsertCanvasSnapshot pre-checks ownership inside the query; the route maps the thread not owned by user error back to a 404.
The document field is the full React Flow { nodes, edges } payload. We don't re-validate it server-side: React Flow's loader is the single source of truth and would re-parse anything we stored here anyway (see lib/canvas/snapshot.ts comment). The route only rejects a non-object body.
| Endpoint | Purpose | Status codes |
|---|---|---|
GET /api/canvas/[threadId] |
Fetch the saved snapshot for a thread (owner-only). 404 covers both "no row" and "not yours". | 200 / 401 / 404 |
PUT /api/canvas/[threadId] |
Upsert the snapshot — first save creates the row, subsequent saves overwrite (onConflictDoUpdate on thread_id). 2s debounce on the client; navigator.sendBeacon flush on beforeunload / pagehide. |
200 / 400 / 401 / 404 |
DELETE /api/canvas/[threadId] |
Best-effort removal (owner-only; idempotent — silent no-op on non-owned threads). | 204 / 401 / 404 |
GET / PUT response (200): { threadId, document, createdAt, updatedAt }. PUT 400 is { code: "BAD_REQUEST", error: ZodIssues } — fires when the body isn't an object or is missing document.
Chat attachments backed by Cloudflare R2 (issue #12). The browser uploads directly to R2 via a presigned PUT — bytes never touch the Next.js server. Design doc: docs/ATTACHMENTS.md (key convention, lazy-register, Content-Disposition per-object).
Auth + isolation contract: every endpoint below is wrapped in withAuth (rule #9). Reads scope by user_id; cross-user access returns 404 (no existence leak). When any R2_* server env is missing the routes return 503 ATTACHMENTS_NOT_CONFIGURED rather than 500 — mirrors the DENO / ALCHEMY lazy-register pattern.
Reserve an attachment row and a 5-minute presigned PUT URL. Browser immediately PUTs the file to R2 with the returned uploadHeaders. Only key + Content-Length are part of the signature; Content-Type and Content-Disposition ride as plain headers (a browser fetch(file) doesn't set Content-Disposition, so signing it would surface as an opaque CORS failure). The R2 key is u/<userId>/upload/<sha256>.<ext> (content-addressed — same bytes → same key → automatic dedup).
| Request body | { name: string (1..256), contentType: string (1..127), sizeBytes: number (>0), sha256: string (64-char hex) }. contentType must appear in R2_ALLOWED_CONTENT_TYPES; sizeBytes ≤ R2_MAX_BYTES. sha256 is REQUIRED — clients compute it via crypto.subtle.digest("SHA-256") before sending; the adapter throws if crypto.subtle isn't available (modern browser, secure context). |
| 201 response | { id, key, uploadUrl, publicUrl, contentType, sizeBytes, uploadHeaders: { "Content-Type": contentType, "Content-Disposition": "inline" | "attachment" }, skipUpload?: true }. The presigned URL expires in 300s. publicUrl is ${R2_PUBLIC_BASE_URL}/${key}. When a matching (user_id, sha256, status='uploaded') row exists, skipUpload: true is set and the adapter jumps straight to confirm (no PUT to R2). |
| Failure codes | 400 BAD_REQUEST (invalid JSON / schema, or missing sha256). 400 CONTENT_TYPE_NOT_ALLOWED. 400 FILE_TOO_LARGE ({ maxBytes, sizeBytes }). 401 UNAUTHORIZED. 503 ATTACHMENTS_NOT_CONFIGURED. |
Finalize after the browser PUT. Server does a HeadObject to verify the upload landed with the claimed Content-Length; row flips from pending to uploaded and confirmedAt is stamped.
| Request body | empty |
| 200 response | { id, publicUrl, contentType, sizeBytes, status: "uploaded" } |
| Failure codes | 401 UNAUTHORIZED. 404 NOT_FOUND (id missing or owned by another user). 409 SIZE_MISMATCH ({ expected, actual } — partial upload). 409 UPLOAD_MISSING (HeadObject 404 — PUT never landed). 503 ATTACHMENTS_NOT_CONFIGURED. |
Best-effort cleanup of a composer chip. Removes the row + the R2 object. Idempotent: R2 404 on delete is swallowed (the row is gone either way).
| Response | 204 No Content |
| Failure codes | 401 UNAUTHORIZED. 404 NOT_FOUND. 503 ATTACHMENTS_NOT_CONFIGURED. |
Presign a 5-minute PUT for a user avatar (issue #28). Same R2 backing as attachments (withAuth, 503 when R2_* unset), but no DB row — avatars aren't chat attachments. Key is a fixed slot per user: u/<userId>/avatar.png (better-auth-ui's client resize hook transcodes any input format to PNG via canvas before upload, so the extension is constant — the server never sees jpg/webp). Re-upload overwrites the same slot in place. The browser PUTs the file, then calls Better Auth updateUser({ image: publicUrl }) — we store only the URL, never base64 (a base64 data URL in user.image flowed through the memory auth-overlay into every <memory> block uncapped; that was the 372K-token blow-up). Client gate: the Settings avatar upload is disabled unless window.__CONFIG__.ATTACHMENTS_ENABLED === "true".
contentType must start with image/ and is not image/svg+xml — unlike attachments (which uses R2_ALLOWED_CONTENT_TYPES), this route hardcodes the image check, and SVG is rejected because it can carry inline script and renders inline from a public bucket (XSS in the bucket origin).
| Request body | { name: string (1..256), contentType: string (1..127), sizeBytes: number (>0) }. contentType must start with image/ and not be image/svg+xml; sizeBytes ≤ R2_MAX_BYTES. |
| 201 response | { key, uploadUrl, publicUrl, uploadHeaders: { "Content-Type": contentType, "Content-Disposition": "inline" } }. Presigned URL expires in 300s. |
| Failure codes | 400 BAD_REQUEST. 400 CONTENT_TYPE_NOT_ALLOWED (non-image or svg). 400 FILE_TOO_LARGE ({ maxBytes, sizeBytes }). 401 UNAUTHORIZED. 503 AVATAR_UPLOADS_NOT_CONFIGURED. |
Delete the R2 object behind a previous avatar URL — avatars have no DB row, so nothing else sweeps the old object when the user deletes or replaces one. Called by the Settings UI on delete and after a successful replace. Owner-scoped: the key must equal the user's own avatar slot (u/<userId>/avatar.png, see lib/r2/keys.ts → r2Keys().avatar).
| Request body | { url: string } — the avatar's public URL. |
| Response | 204 No Content. Idempotent (R2 404 swallowed). An external / non-R2 URL is a no-op 204. |
| Failure codes | 400 BAD_REQUEST (invalid JSON / missing url). 401 UNAUTHORIZED. 403 FORBIDDEN (key outside the caller's avatar path). 503 AVATAR_UPLOADS_NOT_CONFIGURED. |
Per-thread captured LLM / Tool / Chain / Node / Human spans — written at every End hook by the callback handler attached to the compiled graph in backend/agent.ts via compile({ checkpointer }).withConfig({ callbacks: [capturingHandler] }). Attaching at the compile layer (not per-model) ensures ToolNode spans are captured too. Design doc: docs/OBSERVABILITY.md (storage, retention, FORBIDDEN regex, trade-offs).
Auth + isolation contract: every endpoint below is wrapped in withAuth (rule #9). Path id is a LangGraph thread_id — ownership is checked against the calling user; cross-user access returns 404 (no existence leak).
DB rows are cleared automatically by ON DELETE CASCADE when the parent threads row is removed — the observability endpoints don't need to manage thread lifecycle themselves.
Returns the thread's waterfall data + pre-computed aggregate for the panel header. The handler is app/api/threads/[id]/observability/route.ts. Side effect: preflight markRunningAsFailed(id) flips any still-running rows to failed so the client doesn't see stale running states when the chain crashed mid-flight. This is the un-filtered variant — for spans scoped to a single turn, see GET /api/threads/[id]/observability/[parentMessageId] below.
Wire shape (200) — server-transformed:
{
thread_id: string;
retention_days: number; // obs: from OBSERVABILITY_RETENTION_DAYS, default 30
// ponytail: SpanData[] (not raw CapturedSpan[]) — the route runs
// transformCapturedToSpanData server-side so the panel never sees the
// collector's internal payload (input/output/usage/meta are stripped).
// To inspect those on a single row, hit /spans/[spanId] below.
spans: SpanData[]; // ordered by startedAt ASC
aggregate: AggregateDTO | null; // pre-computed stat-card row; null when no spans
in_flight_runs: InFlightRun[]; // always present, empty for the un-filtered route
step_id_to_raw_span_id: Record<string, string>; // synthetic step-wrapper id → raw span_id
}SpanDatais the@assistant-ui/react-o11ywaterfall input —{ id, parentSpanId, name, type, status, startedAt, endedAt, latencyMs }. Status is"running" | "completed" | "failed" | "skipped". Extended on the wire withparentMessageId?: string(the turn this row belongs to) so the panel can build the per-turn detail URL without re-deriving from the waterfall tree.AggregateDTOmirrorsRootAggregateinlib/observability/aggregate.ts— token totals (totalInput/totalOutput/totalTokens/totalCacheRead/totalReasoning), TTFT (ttftAvgMs/ttftMaxMs, both nullable), span counts by kind (llmSpanCount/toolSpanCount/humanCount/failedCount), andtotalDurationMs.
Status codes:
| Status | Trigger | Body |
|---|---|---|
| 200 | owner query | the payload above |
| 401 | no session | { code: "UNAUTHORIZED" } |
| 404 | thread missing or owned by another | { code: "NOT_FOUND" } |
Filtered variant of the GET above — returns only the spans tagged with meta.parent_message_id === <parentMessageId>. The id is the assistant-ui human-message id (message.parentId) that triggered the turn; the backend tags every span for that turn with the same value via CapturingHandler.currentParentMessageId. Implementation lives at app/api/threads/[id]/observability/[parentMessageId]/route.ts. Served by the btree index observability_spans_thread_parent_started_idx (thread_id, parent_message_id, started_at) so the planner can satisfy WHERE thread_id = ? AND parent_message_id = ? ORDER BY started_at from the index alone.
Same wire shape as the un-filtered route, with two additions:
{
// ...same as above, plus:
parent_message_id: string;
in_flight_runs: Array<{
run_id: string;
thread_id: string;
assistant_id: string;
status: "pending" | "running";
created_at: string; // ISO timestamp
updated_at: string; // ISO timestamp
metadata: {
parent_message_id: string | null;
[extra: string]: unknown; // passthrough — additional keys preserved
};
}>;
}in_flight_runs is filtered by metadata.parent_message_id === <parentMessageId> to scope it to the current turn. The two SDK calls (status: "running" + status: "pending") are because list({status}) is single-valued; status values are documented in @langchain/langgraph-sdk's RunStatus type. Main-agent runs are NOT in this list today — only triggerBackgroundAgentNode stamps metadata.parent_message_id on its runs.create payload. Main-agent in-flight state is observable via the spans array (CapturingHandler now persists on handleChainStart).
step_id_to_raw_span_id maps synthetic step-wrapper ids (e.g. step-3-routerAgent-...) to their representative raw span_id. The panel reads this to translate a clicked wrapper row into the raw span id the detail endpoint expects. Empty when the thread has no step wrappers.
Status codes:
| Status | Trigger | Body |
|---|---|---|
| 200 | owner query | the payload above |
| 401 | no session | { code: "UNAUTHORIZED" } |
| 404 | thread missing or owned by another | { code: "NOT_FOUND" } |
Clears all spans for the thread. Returns { cleared: number } (the row count). Same auth + ownership contract as GET.
Status codes: 200 / 401 / 404 (same triggers as GET).
Returns the full CapturedSpan for a single span. Called from the panel when the user clicks a waterfall row — the waterfall renders from the server-transformed SpanData[], but SpanDetails (and the row's hover tooltip fields like model name / TTFT / tokens) need the raw payload.
spanId is the raw span_id, NOT a synthetic step-wrapper id. The panel translates wrapper ids via the step_id_to_raw_span_id map from the parent route before issuing this fetch.
parentMessageId is the turn this row belongs to. The transform layer stamps it on every SpanData (root + step wrapper + leaf), so the panel can pluck it from the clicked row without re-deriving from the waterfall tree. The DB lookup is (thread_id, parent_message_id, span_id) — uses the existing observability_spans_thread_parent_started_idx btree. If the row's span isn't found in DB (e.g. retention evicted it between the parent fetch and the click), the route falls back to langGraphClient.runs.list(threadId, { status: "running"|"pending" }) filtered by metadata.parent_message_id === parentMessageId, so an active bg-agent run still surfaces a details card. Missing in both → 404.
Response (200):
{
thread_id: string;
span: CapturedSpan; // full collector payload — input / output / usage / meta / error
}Status codes:
| Status | Trigger | Body |
|---|---|---|
| 200 | span exists for (parent_message_id, span_id) in DB or SDK |
the payload above |
| 401 | no session | { code: "UNAUTHORIZED" } |
| 404 | thread missing or owned by another, OR span not in this turn, OR span not found at all | { code: "NOT_FOUND" } |
Routes for online user feedback, prompt versioning, A/B performance metrics, and LLM-as-a-Judge orchestration. All routes are withAuth-wrapped and execute on runtime = "nodejs".
threads.kind separates where a run originates so Online Executions can show judge work without leaking benchmark noise. Both lib/eval/queries.ts:getRunsByAgentPage and GET /api/eval/runs/compare filter kind != 'eval-benchmark':
kind |
source | Online Executions |
|---|---|---|
chat |
user chat turn | ✅ |
kb |
standalone kbAgent ingestion |
✅ |
eval-judge |
POST /api/eval/judge — LLM-as-a-Judge scoring |
✅ |
eval-benchmark |
POST /api/eval/benchmarks/run — synthetic run |
❌ (Benchmark Datasets surface only) |
Submits online user feedback or admin rating (1-5 integer). The chat UI sends the assistant message id (resp_...) as runId; the route resolves it via eval_run.id = runId OR eval_run.parent_message_id = runId. When no match exists (e.g. the eval callback never recorded this turn), the handler returns 200 with zero rows inserted — never a dangling FK violation on eval_feedback.run_id. See lib/eval/queries.ts:submitFeedback.
- Body:
{ runId: string, rating: number, source?: "user_online" | "admin_manual", reason?: string } - Status Codes: 200 / 400 (missing
runId/ non-numericrating) / 401 / 500
Returns all prompt_template + prompt_variant rows ordered by id.
- Output:
{ templates: PromptTemplate[], variants: PromptVariant[] } - Status Codes: 200 / 401 / 500
Admin action surface for prompt lifecycle. action discriminates:
| action | required body fields | semantics |
|---|---|---|
create_template |
agent, content (+ optional notes) |
Insert a new prompt_template row; group resolved from GRAPH_MAPPING or "Group:<name>" notes |
create_cohort_variant |
label, bindings: Record<agent,tmplId> |
Upsert a cohort — for each (label, agent) pair, update the matching variant or insert a new one |
delete_cohort_variant |
label (cannot be "default") |
Delete every prompt_variant row with the given label |
create_variant |
templateId, label, trafficWeight |
Insert a single variant; enabled defaults to true |
update_variant_weight |
variantId, trafficWeight (+ enabled) |
Update weight + enabled flag on one variant |
batch_update_weights |
updates: [{ variantId, trafficWeight }] |
Bulk version of the above — one round-trip for the cohort weight sliders |
- Status Codes: 200 / 400 (missing fields / invalid action) / 401 / 500
Updates a prompt_template's content and/or notes.
- Body:
{ id: string, content?: string, notes?: string } - Status Codes: 200 / 400 (missing
id) / 401 / 500
Deletes an admin-authored prompt_template. System templates (those with userId = null, i.e. seeded by seedInitialPrompts) are 403 — the seeder is the only way to remove them.
- Status Codes: 200 / 400 (missing
id) / 401 / 403 (system template) / 404 (not found) / 500
Returns every eval_rubric plus recent eval_judgment rows joined to their eval_run.agent / variant_id. The Admin Eval Studio renders both lists from one fetch.
- Output:
{ rubrics: EvalRubric[], judgments: Array<{ id, runId, rubricId, scores, reasoning, totalCostTokens, judgeThreadId, judgeParentMessageId, createdAt, agent, variantId }> } - Status Codes: 200 / 401 / 500
Inserts or updates a rubric by id (default rubric_${Date.now()}), criteria shape { key, description, weight }.
- Body:
{ id?: string, name: string, criteria: Array<{ key | name: string, description: string, weight: number }> } - Status Codes: 200 / 400 / 401 / 500
Returns every sticky prompt_variant_assignment row joined to the assigned user (name, email, image), the variant label, and the variant's template.
- Output:
{ assignments: Array<{ userId, userName, userEmail, userImage, variantId, variantLabel, templateId, agent, assignedAt }> } - Status Codes: 200 / 401 / 500
Reassigns a user (or whole cohort) to a different variant. Two modes:
| body | semantics |
|---|---|
{ userId, cohortLabel } |
For every agent, set the user's prompt_variant_assignment to the variant with label = cohortLabel |
{ userId, targetVariantId, agent? = "chatAgent" } |
Set a single (userId, agent) row to targetVariantId |
Existing rows are updated in place; new rows are inserted. default variants are merged with the cohort label so a partial cohort binding still leaves non-listed agents on default.
- Status Codes: 200 / 400 / 401 / 500
Returns every benchmark row with its latest judgment denormalized onto the row. The Latest-Join lives on eval_benchmark.latest_judgment_id, not on a query-time LEFT JOIN — see lib/eval/queries.ts:getAllBenchmarksWithLatestJudgment.
- Output:
{ benchmarks: Array<{ ...EvalBenchmarkRow, judgment: { id, scores, reasoning, judgeThreadId, judgeParentMessageId } | null }> } - Status Codes: 200 / 401 / 500
Three action values:
| action | body fields | semantics |
|---|---|---|
create |
agent, title, inputPrompt, expectedOutput? |
Insert a new eval_benchmark row |
delete |
id |
Delete the benchmark (cascade kills its judgments + denorm pointer) |
update_rubric |
rubricId, name?, criteria: Array<{ key, description, weight }> |
Replace the rubric row — rubricId stays, criteria + name change in place |
- Status Codes: 200 / 400 (missing fields / unknown action) / 401 / 500
Triggers a stored Benchmark Test Case end-to-end. Resolves the benchmark row server-side (targetAgent / inputPrompt / expectedOutput), then dispatches evalAgent in mode="benchmark" which owns the full pipeline (target-agent invocation → record eval_run + paired observability span → LLM-judge → denormalize latest_* onto eval_benchmark). The benchmark thread (kind="eval-benchmark") is hidden from the chat sidebar and from Online Executions; the judge thread (kind="eval-judge") is registered so the AI Judge trace stays linked from Online Executions.
- Body:
{ benchmarkId: string } - Output:
{ runId: string | null, judgeThreadId, result: { status, errorMessage: string | null } } - Status Codes: 200 / 400 / 401 / 404 (unknown benchmark) / 500 (judge run returned no result)
- Side effects:
eval_benchmark.latest_judgment_id/latest_run_at/latest_run_status/latest_scoreupdated to point at the new judgment so the nextGET /api/eval/benchmarksreturns the fresh assessment inline.
Triggers an evalAgent LLM-as-a-Judge run against a specific eval_run execution record using domain-specific Rubric Criteria, linking judge_thread_id for Observability trace tracking. Dispatched as mode="judge" — the judge thread is created with kind="eval-judge" and DOES surface in Online Executions for that run's agent.
- Body:
{ runId: string, rubricId?: string } - Output:
{ result, judgeThreadId } - Status Codes: 200 / 400 / 401 / 404 (unknown
eval_run) / 500
Returns aggregated A/B variant metrics (total runs, average latency, ratings) and a first page of recent run logs per agent. Runs are grouped by agent so the UI can render per-agent Online Executions tables. The window is 5 rows per agent; older history is fetched on demand via /api/eval/runs/page. Excludes kind = 'eval-benchmark'.
- Output:
{ "stats": [{ "variantId", "label", "totalRuns", "avgTotalMs", "avgRating" }], "runs": { "<agentId>": [RecentRun, ...] }, "pageSize": 5, "hasMore": { "<agentId>": boolean }, "nextCursor": { "<agentId>": string | null } } - Status Codes: 200 / 401 / 500
Fetches the next page of eval_run rows for a single agent, ordered createdAt DESC, id DESC (cursor-based, stable). The previous page's nextCursor is the id of its last kept row; passing it back returns strictly older runs. Excludes kind = 'eval-benchmark'.
- Query Params:
agent(required),cursor(id from previous response),limit(default5, clamped to1..50). - Output:
{ agent, runs, hasMore, nextCursor } - Status Codes: 200 / 400 (missing agent) / 401 / 500
Single-run drilldown: returns the eval_run row, its first eval_feedback (or null), its first eval_judgment (or null), and the first 20 observability spans for the run's threadId so the Admin Eval Studio can render the full trace inline.
- Output:
{ run, feedback, judgment, spans } - Status Codes: 200 / 401 / 404 (unknown run) / 500
evalAgent accepts a mode field in its input, dispatching via conditional edges inside the graph:
| mode | flow |
|---|---|
"judge" (default) |
judge an existing eval_run (id runId) against its rubric; writes eval_judgment |
"benchmark" |
run the resolved target agent (targetAgent) on inputPrompt, write eval_run + paired span, judge against rubric_<targetAgent> (or rubricId override), denormalize latest judgment + score onto eval_benchmark. The hidden benchmark thread (kind="eval-benchmark") is retained for the AI Judge trace link — no graph-level cleanup node |
Adding a new target agent requires registering an invoke<Name>Agent node and a dispatch entry in routeFromInput — same diff, no separate mapping table.
Backed by the LangGraph PostgresStore (per-user, cross-thread long-term memory). Every route is withAuth-wrapped and isolation is by namespace prefix [userId, ...]. Storage detail: lib/memory/queries.ts; schema (RFC 6902 patches, store): lib/memory/validators.ts; size guard: backend/memory/profile-size.ts. Read counterpart of save_memory (see "Graph tools").
Returns the user's profile doc plus read-only better-auth fields that the agent already sees via the recall middleware, plus a flat list of thread summaries enriched with the corresponding chat title. The store + auth fields are kept separate on purpose so the UI can classify each merged field as "summarized by AI" (store) vs "from account" (auth overlay) using store-keys membership — the same classification the model's <memory> block applies.
| Request body | (none) |
| Output | { store: Record<string, unknown>, auth: { name, email, avatar, socials: Array<{ provider: string; email?: string }> }, threads: Array<{ key, value: SummaryEntry, threadTitle: string | null }> } |
| Status codes | 200 / 401 / 500 (store throws). 401 is returned by the shared withAuth wrapper, not the handler body. |
| Field notes | auth.avatar is the user's profile image — an https://... URL once Settings → Account uploads to R2, null otherwise. Legacy data: base64 rows in user.image are filtered out on read so the model never sees them in the memory block (issue #28 — the 372K-token blow-up was a base64 avatar flowing through the auth overlay uncapped). auth.socials[].provider is the better-auth providerId for each linked OAuth account (github, google, ...). auth.socials[].email is the per-provider verified email when available: Google via OIDC idToken email claim (only when email_verified === true), GitHub via GET /user/emails with the stored accessToken (primary+verified preferred). Unverified / undecodable / fetch-failed → email is absent from that entry. accountId / tokens are deliberately NOT exposed (FR-020). The "credential" provider is filtered out so email+password accounts don't show as a misleading "linked account". threads[] is createdAt-asc (oldest-first); the Memory tab groups by threadId client-side using insertion order — do not sort on the client (FR-passthrough). threadTitle is the row from the threads table set by renameThreadAgent; null when the rename path hasn't run, in which case the UI falls back to the raw threadId. Storage detail: lib/memory/queries.ts; Zod shape: lib/memory/validators.ts MemoryResponseSchema. |
Removes a single profile key. Modeled as a one-shot RFC 6902 remove patch against [userId,"profile"] main. There is no separate "forget" tool — the Memory tab is the only path to forgetting.
| Request body | (none) |
| Output | { ok: true, deletedKey } on success |
| Status codes | 200 / 400 / 401 / 404 |
| Path regex | :key MUST match ^[A-Za-z0-9_-]{1,64}$. Decoded via decodeURIComponent first — %2F becomes / and is rejected. Empty string after decode also → 400. |
| Failure modes | 404 when the profile doc does not exist OR when key is not a top-level property of the doc. 400 when the regex fails. |
Lists thread summaries the user has generated. Grouped by threadId, sorted by sequence desc within a group and by updatedAt desc across groups. Corrupt Zod-failing summaries are skipped server-side.
| Request body | (none) |
| Output | { threads: Array<{ threadId: string, summaries: Summary[] }> }. Summary shape: { threadId, sequence, name, description, startMessageIndex, endMessageIndex, messageCount, updatedAt }. |
| Status codes | 200 / 401 / 500 |
| Field notes | startMessageIndex / endMessageIndex are inclusive bounds on the user-message window (FR-010 closed interval); messageCount === end - start + 1. |
Collapses all summary docs whose key starts with ${threadId}: under the user's [userId,"threads"] namespace.
| Request body | (none) |
| Output | { ok: true, deletedCount } on success |
| Status codes | 200 / 401 / 404 |
| Failure modes | 404 when no summary doc exists for threadId for the current user (a no-op for an empty thread). |
User-facing KB v2 (Settings → Knowledge Base tab). Per-user scoped at the query layer (every helper filters by userId from withAuth). Schema lives in lib/kb/schema.ts; the LangGraph ingestion graph that flips kb_document.status pending → parsing → success/failed is backend/kb-agent.ts. See docs/DB.md for table shapes and FK cascades.
Auth + isolation contract: withAuth (rule #9), no role gate — any signed-in user reads + writes their OWN folders/docs. The WHERE user_id = ? predicate inside every query in lib/kb/queries.ts is the only thing keeping one user's KB from leaking to another.
Create a folder. Subject to the UNIQUE (user_id, name) constraint on kb_folder — duplicate names return 409 with the existing row so the UI can highlight the conflict.
| Request body | { name: string /* 1..64, trimmed */ } |
| 201 response | { folder: { id, userId, name, createdAt, updatedAt } } |
| Failure codes | 400 INVALID_NAME (zod failure). 401 UNAUTHORIZED. 409 DUPLICATE (returns existing folder). 500 INTERNAL. |
Rename a folder. Race-safe against concurrent renames — if two tabs race to the same name, the loser's UPDATE fails with 23505 and the endpoint returns 409 instead of a 500. The attachmentId / contentHash linkage on the folder's child documents is preserved; only the folder's name changes.
| Request body | { name: string /* 1..64, trimmed */ } |
| 200 response | { folder: { id, userId, name, createdAt, updatedAt } } |
| Failure codes | 400 INVALID_NAME. 401 UNAUTHORIZED. 404 NOT_FOUND (folder missing or owned by another user). 409 DUPLICATE (name taken). 500 INTERNAL. |
Delete a folder. By default, Postgres refuses via ON DELETE RESTRICT on kb_document.folder_id if any docs still belong to the folder; we surface that as 409 NON_EMPTY with a docCount so the UI can prompt "delete its N docs first".
With ?deleteAll=true, the server cascades through every doc in the folder (each doc's kb_chunk rows, via ON DELETE CASCADE) before deleting the folder itself; the attachment rows / R2 objects backing those docs are NOT deleted (same retention as the single-doc DELETE /api/kb/documents/[id] semantics). The confirmation lives in the Settings → KB folder-delete dialog: a switch + a typed-confirmation of delete all is required before the request fires.
| Query params | ?deleteAll=true → cascade-delete every doc in the folder (each doc's chunks, embeddings, attachments) before removing the folder. Omit for the empty-only default. |
| Request body | (none) |
| 204 response | (empty body) |
| Failure codes | 401 UNAUTHORIZED. 404 NOT_FOUND. 409 NON_EMPTY ({ docCount }) — only when ?deleteAll=true is not set. |
List all of the caller's folders + their docs in one round-trip. attachmentUrl is the public R2 URL for the source file (used by the "View source" button on each row). No pagination — KB volume per user is O(tens of docs).
When ?mention=1 is set, returns a flat list of status='success' docs only — powers the @-mention composer popover (issue #13 v3). No folder grouping, no pagination, slim payload.
When ?folderId=<id> is set, the sidebar still lists every folder the user owns, but only the targeted folder's documents array is populated. The other folders return documents: [] so the Settings UI's "Live poll indicator" + scoped anyInflight only sees the folder the user is looking at. The ?folderId= scope is ignored when ?mention=1 is set (the composer popover is always cross-folder).
| Query params | ?mention=1 → flat success-only list for composer popover (omit for the default grouped response used by Settings). ?folderId=<id> → scope the doc payload to a single folder (Settings tab only). |
| 200 response | default: { groups: Array<{ folder: { id, name, docCount }, documents: Array<{ id, title, status, errorMessage, contentType, attachmentId, attachmentUrl, createdAt, updatedAt, totalPages, failedPages, pendingPages, parsingPages, totalChunks, successChunks, embeddingPendingChunks, failedChunks, pendingChunks, parsingChunks, entityCount, relationshipCount }> }> } ?mention=1: { folders: Array<{ id, name, docCount, docs: Array<{ id, title, status }> }> } |
| Failure codes | 401 UNAUTHORIZED. 500 INTERNAL. |
Indexing counts in the doc payload —
successChunkscounts chunks withstatus='success' AND embedding IS NOT NULL.embeddingPendingChunksis chunks withstatus='success' AND embedding IS NULL(OCR done, vector pending).entityCount/relationshipCountare graph rows for the doc (hybrid-search third leg). The Settings → KB "Indexed" badge requiressuccessChunks === totalChunks AND (entityCount > 0 OR relationshipCount > 0); the chat-sideChunksStatusBadgeenforces the same gate.
Single-doc detail + slim chunk preview (text content only — no 1024-dim embedding, no generated tsv column). The status field drives whether chunks are included: only success docs return their parsed content; other statuses return chunks: [].
Internal change, same contract. Post issue #45 (audit §8 / migrations
0012_blue_steve_rogers.sql,0013_lowly_whirlwind.sql,0014_bright_norrin_radd.sql), per-chunkentities/themesare no longer stored onkb_chunk.entitiesare JOINed server-side fromkb_entityviasource_chunk_ids && ARRAY[chunkId];themesare JOINed from the flatkb_themetable onchunk_id;relationshipsfromkb_relationship. Wire shape (chunks[i].entities/relationships/themes) is unchanged so the Settings → KB doc-detail UI reads them transparently.
| 200 response | { doc: { id, title, status, errorMessage, contentType, attachmentId, folderId, contentHash, pages, createdAt, updatedAt, entityCount, relationshipCount }, chunks: Array<{ ordinal: number, content: string, status, errorMessage, hasEmbedding, entities: Array<{name,type,description}>, relationships: Array<{source,target,relation,description}>, themes: string[] }> } |
| Failure codes | 401 UNAUTHORIZED. 404 NOT_FOUND. |
hasEmbedding—trueiffkb_chunk.embedding IS NOT NULL(pgvector vector stored). A chunk withstatus='success'andhasEmbedding=falseis in the "Embedding" intermediate state — OCR text was written but the vector hasn't been produced yet. The doc-level "Indexed" badge ignores chunks wherehasEmbedding=falsefor its gate.
Delete a single doc. Cascades to kb_chunk via ON DELETE CASCADE on kb_chunk.document_id. R2 objects (u/<userId>/kb/<sha>.<ext> derived images + u/<userId>/upload/<sha>.<ext> source uploads) are NOT deleted — they live in R2 forever; same-content future ingests dedup at the storage layer (sha-keyed), so orphans are reference-counted rather than wasted bytes. A future retention sweep can prune by sha-refcount == 0.
| 204 response | (empty body) |
| Failure codes | 401 UNAUTHORIZED. 404 NOT_FOUND. |
Add a doc to a folder. Two entry shapes — file OR URL — both end up at the same kbAgent dispatch:
- File path — frontend uploads via
/api/attachments/presign→ PUT →confirmfirst, then POSTs{ folderId, attachmentId, title? }. Theattachment.contentTypecarries the routing decision:application/pdf,text/markdown,text/plain,image/png,image/jpeg,image/webp, plus Office Open XML —application/vnd.openxmlformats-officedocument.wordprocessingml.document(DOCX),...spreadsheetml.sheet(XLSX),...presentationml.presentation(PPTX) — all perR2_ALLOWED_CONTENT_TYPES. PDFs render every page to PNG and extract the native text layer + every embedded raster image with its bbox; markdown / plain text pass through as-is; images upload to R2 and wait for vision OCR; Office formats go throughofficeparserto produce one markdown page per slide (PPTX) / sheet (XLSX), or a single page for DOCX — images land inline as R2refs (no separate vision OCR pass), and PPTX layout / group-shape images missed by officeparser's AST walker are recovered via a self-extracted rels scan. - URL path — frontend POSTs
{ folderId, url, title? }. Server fetches vialib/kb/url.ts(thin wrapper aroundr.jina.ai), uploads the resulting markdown as atext/markdownattachments row, and dispatches the same kbAgent run.
Backend creates a kb_document row (status=pending) and fires a LangGraph run on the kbAgent graph (fire-and-forget — the run mutates the row's status as it progresses; the UI polls GET /api/kb/documents and watches the row flip pending → parsing → success/failed). The kbAgent graph is registered as a top-level assistant in langgraph.json so this dispatch skips the mainAgent router + renameThreadAgent LLM calls. splitFileToPageNode dispatches via getIngestHandler(mimeType) from lib/kb/ingest-handlers.ts (PDF keeps the mupdf render + text + image-extract pipeline and produces textBlocks + imageRefs hints for the OCR prompt; markdown / plain text pre-bake a single markdown page; images upload to R2 and wait for vision OCR; Office formats flow through officeparser and skip OCR entirely because the parser already extracts structural markdown).
Primary dedup: if a doc with the same contentHash already exists for the user, the endpoint returns the existing row instead of creating a duplicate, and re-fires ingestion if the previous attempt failed/stalled.
| Request body | { folderId: string, attachmentId?: string, url?: string /* absolute http(s) URL */, title?: string /* 1..256 */ } — exactly one of attachmentId / url is required |
| 200 response | { doc: { ... kb_document row ... }, deduped: true } (existing doc — re-firing ingestion) |
| 202 response | { doc: { ... kb_document row ... } } (new doc, ingestion run dispatched) |
| Failure codes | 400 INVALID (zod failure — including missing both attachmentId and url). 401 UNAUTHORIZED. 404 ATTACHMENT_NOT_FOUND / FOLDER_NOT_FOUND. 409 ATTACHMENT_NOT_UPLOADED. 500 INTERNAL (URL fetch failed, R2 put failed, jina upstream failure, etc.). |
Re-runs the kbAgent pipeline for an existing kb_document. The Settings UI exposes this via the per-row "Reprocess" button (lucide RefreshCw), with a mode picker for four modes. The legacy ?chunksOnly=true query string is honoured as an alias for ?mode=chunksOnly.
| Mode | What it does | Doc status during | When disabled (409 NOT_READY) |
|---|---|---|---|
?mode=full (default) |
Wipe chunks + flip doc to pending; re-run PDF render + OCR + chunk + embed + extract |
parsing → success / failed |
n/a |
?mode=chunksOnly |
Wipe chunks; keep doc row + cached pages[].markdown; re-run chunk + embed + extract on the cached pages |
stays success |
doc has no pages[] with non-empty markdown |
?mode=retryFailed |
Wipe chunks + flip doc to parsing; re-OCR the failed pages only, then re-chunk + re-embed the whole doc |
flips to parsing |
doc has no pages[] at all |
?mode=retryFailedChunks |
In-place UPDATE failed chunks to status='parsing' (id / ordinal / embedding / content preserved); keep doc row + good chunks; re-run chunk-extract on the failed chunks only |
stays success |
doc status !== 'success' OR no failed chunks |
retryFailedChunks updates in place rather than DELETE+INSERT: under chunksOnly / retryFailed modes pageToMarkdownNode skips, fullMarkdown is empty, and the IIFE inside generateChunkEmbedNode would throw AFTER the DELETE had already committed, leaving the doc with N−K chunks and no recovery path. In-place UPDATE keeps the row indestructible.
| Query params | ?mode=full (default) | ?mode=chunksOnly | ?mode=retryFailed | ?mode=retryFailedChunks. ?chunksOnly=true is a legacy alias for ?mode=chunksOnly. |
| Request body | (none) |
| 202 response | { docId: string, mode: "full" | "chunksOnly" | "retryFailed" | "retryFailedChunks" } (chunksOnly?: true is also returned for the legacy alias) |
| Failure codes | 401 UNAUTHORIZED. 404 NOT_FOUND (no doc, or doc owned by a different user — no existence leak). 409 PROCESSING (doc is already pending / parsing). 409 ATTACHMENT_MISSING (full mode only — R2 source missing; re-upload instead). 409 NOT_READY (chunksOnly needs cached pages; retryFailedChunks needs status='success' + at least one failed chunk; run a full reprocess first). 500 INTERNAL. |
List every kbAgent re-run for one doc. Powers the Settings → KB doc row Activity icon → Observability List popover. Reads the kb_observability table directly (no SDK call); prepareKBDataNode inserts one row per invocation with (docId, threadId, parentMessageId, runId?, source, mode). Initial full-mode uploads don't write rows — the kb_documents row IS the event for those. Only chunksOnly / retryFailed / retryFailedChunks reprocesses land here, so the popover is a re-run history.
threadId is per-row, not top-level: standalone reprocess runs land on docId.replace(/^d-/, ""); chat-path uploads (chat subgraph) land on the chat thread. Per-row threadId lets the popover click handler open the sheet against the correct thread. parentMessageId is the synthetic HumanMessage id (standalone) or the user's chat msg id (chat path) — the same value CapturingHandler stamps onto every span via meta.parent_message_id, so the per-turn sheet route (/api/threads/<threadId>/observability/<parentMessageId>) scopes spans correctly.
| 200 response | { doc_id: string, runs: Array<{ runId: string | null, threadId: string, parentMessageId: string, source: "kb-upload" | "kb-reprocess" | "chat", mode: "full" | "chunksOnly" | "retryFailed" | "retryFailedChunks", createdAt: string }> } (newest first, limit 50) |
| Failure codes | 401 UNAUTHORIZED. 404 NOT_FOUND (no doc, or doc owned by a different user). |
Every endpoint is withAuth({ role: "admin" }, ...) (rule #9). The wire shape for providers strips encryptedKey + iv from apiKeys[] — see docs/ADMIN.md § Secrets handling for the AES-256-GCM contract. Roles are returned in full (no secrets on that table). All providers endpoints live in app/api/admin/providers/**; all roles endpoints in app/api/admin/roles/**.
List every row in provider, ordered by id. The encrypted key material is stripped server-side — only name (the "sk-…xyz9" derived first-3 + last-4) + optional baseUrl are exposed.
| Request body | (none) |
| 200 response | { providers: PublicProvider[] } |
| Failure codes | 401 UNAUTHORIZED, 403 FORBIDDEN |
Create a new provider row. apiKeys / models default to [].
| Request body | { id?: string (^[a-z0-9_-]+$, 1..64; defaults to a slug derived from name), name: string (1..128), baseUrl: url (REQUIRED — OpenAI-compatible endpoint), enabled?: boolean (default true), apiKeys?: ProviderApiKey[] (default []), models?: ModelConfig[] (default []) }. |
| 201 response | PublicProvider |
| Failure codes | 400 BAD_REQUEST, 401, 403, 409 DUPLICATE (PK collision on the derived or supplied id). |
Partial update. Whole-array replacement is used for models when present — no merge semantics. apiKeys is not accepted on this endpoint — encrypted material must go through POST /api/admin/providers/[id]/keys so it routes through encryptApiKey. A raw apiKeys[] on PATCH would store caller-supplied bytes unencrypted in the jsonb.
| Request body | Any subset of { id?, name?, enabled?, baseUrl?, models? }. Empty body returns 400 BAD_REQUEST. apiKeys is silently dropped by the schema. |
| 200 response | PublicProvider |
| Failure codes | 400 BAD_REQUEST, 401, 403, 404 NOT_FOUND |
Hard-delete. Historical credit_usage_log rows keep their provider_id (text, not FK) even after the provider is gone. The seeded default provider is protected — see DELETE row in the Admin table below.
| Response | 204 No Content |
| Failure codes | 401, 403, 404 NOT_FOUND, 409 PROTECTED (default id) |
Encrypt + append a key. Plaintext is encrypted with AES-256-GCM (lib/auth/encryption.ts); the response only carries the derived name + optional baseUrl.
| Request body | { plaintext: string (1..2048), baseUrl?: url } |
| 201 response | PublicProvider (full row, secrets stripped) |
| Failure codes | 400 BAD_REQUEST, 401, 403, 404 NOT_FOUND (provider missing), 409 DUPLICATE_KEY (same-tail collision). |
Remove a key by its name (the derived tail).
| Request body | { name: string (1..64) } |
| 200 response | PublicProvider |
| Failure codes | 400 BAD_REQUEST, 401, 403, 404 NOT_FOUND (provider missing OR no key matches name). |
Rotate (new plaintext) and/or rename an existing key. Path is keyed on the original keyName so existing admin UI links keep working. plaintext re-derives name from the new plaintext via deriveKeyName() so the displayed tail stays in sync with what's encrypted. An explicit name in the body overrides the derived one (rename wins over rotate). Either field can be sent independently — sending only plaintext is the legacy rotate flow; sending only name is a pure rename. Collision check excludes the entry being patched — a rotate-to-same-tail is a no-op rename with a fresh ciphertext.
| Request body | Any subset of { plaintext?: string (1..2048), name?: string (1..64, ^[a-zA-Z0-9_-…]+$) }. Empty body returns 400 BAD_REQUEST. |
| 200 response | PublicProvider |
| Failure codes | 400 BAD_REQUEST, 401, 403, 404 NOT_FOUND (provider or key missing), 409 DUPLICATE (renamed / derived name matches another). |
Append a new model + rate config. inputPer1k / outputPer1k are credits-per-1k-tokens — see docs/CREDIT.md.
| Request body | { name: string (1..128), enabled: boolean, inputPer1k: number (≥0), outputPer1k: number (≥0), kind?: ("chat"|"ocr"|"embed"|"extract"|"rerank"|"eval")[] (min 1, default ["chat"]) }. |
| 201 response | PublicProvider |
| Failure codes | 400 BAD_REQUEST, 401, 403, 404 NOT_FOUND (provider missing), 409 DUPLICATE_MODEL. |
kind is the set of pools this model serves — chat for general inference, ocr for vision-based PDF text extraction, embed for KB chunk vectors, extract for structured-output extraction, rerank for document reranking, and eval for LLM-as-a-Judge. A single upstream model can hold multiple (e.g. ["chat","ocr"]). When omitted, the backend writes ["chat"]. See docs/PROVIDERS.md for how the registry routes traffic across (provider, model, key, kind) tuples.
Partial update. Rate changes after a call are NOT retroactively applied to historical credit_usage_log rows — see docs/CREDIT.md.
| Request body | Any subset of { name?, enabled?, inputPer1k?, outputPer1k?, kind?: ("chat"|"ocr"|"embed"|"extract"|"rerank"|"eval")[] (min 1) }. Empty body returns 400 BAD_REQUEST. |
| 200 response | PublicProvider |
| Failure codes | 400 BAD_REQUEST, 401, 403, 404 NOT_FOUND (provider or model missing). |
When kind is omitted from the PATCH body, the existing model keeps whatever it had (no implicit default applied — only POST auto-fills ["chat"]).
Remove a model from the provider. Historical call rows keep their model_name.
| Response | 204 No Content |
| Failure codes | 401, 403, 404 NOT_FOUND |
List every role row.
| Request body | (none) |
| 200 response | { roles: RoleRow[] } |
| Failure codes | 401, 403 |
Create a new role. The seeded trio (guest, user, admin) is inserted by the migration — POST is for adding extra tiers (e.g. pro, vip).
| Request body | { id: string (^[a-z0-9_-]+$, 1..64), name: string (1..128), creditLimit: number (≥0) | null (null = unlimited), windowHours: number (1..720, default 24) }. |
| 201 response | RoleRow |
| Failure codes | 400 BAD_REQUEST, 401, 403 |
Partial update. creditLimit / windowHours changes take effect on the next LLM call — there is no caching layer.
| Request body | Any subset of { name?, creditLimit?, windowHours? }. Empty body returns 400 BAD_REQUEST. |
| 200 response | RoleRow |
| Failure codes | 400 BAD_REQUEST, 401, 403, 404 NOT_FOUND |
Hard-delete. Refuses with 409 ROLE_IN_USE if any user still references the role — the handler counts user.role_id = <id> and rejects rather than dropping the FK.
| Response | 204 No Content |
| Failure codes | 401, 403, 404 NOT_FOUND, 409 ROLE_IN_USE (body carries message: "role is referenced by <N> user(s)"). |
Admin user management. Backing table is user in lib/auth/schema.ts (FK on role_id → role.id). The banned column was added in migration 0004 alongside a databaseHooks.session.create.before gate in lib/auth/config.ts — banned users cannot mint new sessions, and PATCH ... { banned: true } DELETEs every existing session row so the cutoff is immediate on the next request.
Better Auth's built-in admin plugin was considered and skipped: it brings a parallel role text column that conflicts with our role_id FK to the role table. A boolean on our schema is half the surface and stays consistent with the default-provider / last-admin guards elsewhere.
List every user with a left-joined role snapshot. The admin /admin → Users tab renders this as the table source.
| Request body | (none) |
| 200 response | { users: { id, name, email, image, emailVerified, roleId, roleName, banned, createdAt, updatedAt, todayCredits, todayInputTokens, todayOutputTokens, todayTokens, totalCredits, totalInputTokens, totalOutputTokens, totalTokens }[] }. roleName is null if the FK target is missing (defensive — FK prevents it on a healthy DB; a leftJoin can still null it on race). The today* fields scope to UTC-midnight; the total* fields are lifetime. All token counts come from credit_usage_log rows where status='success'. |
| Failure codes | 401, 403. |
Partial update. roleId / banned flip the same-name columns. The last-admin guard mirrors the default-provider protection — demoting or banning the only remaining admin returns 409 LAST_ADMIN.
When banned: true is sent, every row in session where userId matches is DELETEd in the same handler so the ban takes effect immediately on the next request (the client loses its session cookie → 401 → redirect to /login). Unban does NOT touch sessions — the user signs in fresh.
| Request body | Any subset of { roleId?: string, banned?: boolean }. Empty body returns 400 BAD_REQUEST. |
| 200 response | The updated user row. |
| Failure codes | 400 BAD_REQUEST, 401, 403, 404 NOT_FOUND (user missing OR ROLE_NOT_FOUND for unknown roleId), 409 LAST_ADMIN (would leave no admin). |
Hard-delete. FK cascade removes session + account rows automatically; threads cascade further drops the user's observability_spans. attachments and credit_usage_log cascade off the user row directly. The last-admin guard fires with 409 LAST_ADMIN if the target is admin and no other admin exists.
Before the FK cascade fires the handler also best-effort sweeps per-thread state (LangGraph checkpointer rows + PostgresStore thread summaries) via purgeUserState and the cross-thread memory profile key, since neither has an FK back to user.
| Response | 204 No Content |
| Failure codes | 401, 403, 404 NOT_FOUND, 409 LAST_ADMIN. |
Per-LLM-call log surfaced to the end user. Source of truth is credit_usage_log in lib/credit/schema.ts (see docs/CREDIT.md for the cap-enforcement side, docs/DB.md for the schema). The user-facing Settings → Credits tab renders this; the panel is a react-query useInfiniteQuery on top of this endpoint.
Auth + isolation contract: withAuth (rule #9), no role gate — any signed-in user reads their OWN history. The WHERE user_id = ? predicate in app/api/credit/history/route.ts is the only thing keeping one user's call log from leaking to another.
Returns the signed-in user's credit_usage_log rows, ordered by createdAt DESC, paginated. Backs the Settings → Credits tab. Source rows: every handleLLMEnd (status='success', with token counts + computed credits) and handleLLMError (status='error', with the error message) row from lib/credit/callback.ts.
Wire shape (200):
{
calls: Array<{
id: string; // uuid
providerId: string; // "openai" | "anthropic" | ...
modelName: string; // "gpt-4o-mini" | ...
agentName: string; // "router" | "crypto" | "summarize" | ...
inputTokens: number;
outputTokens: number;
credits: number; // numeric(12,4), exposed as JS number (4dp)
status: "success" | "error";
errorMessage: string | null; // populated when status === "error"
createdAt: string; // ISO timestamp
}>;
total: number; // total row count for the user (used by the panel's "Load more" affordance)
}| Query params | limit (1..200, default 50), offset (>= 0, default 0). Coerced from strings. |
| 200 response | the payload above |
| Failure codes | 400 BAD_REQUEST (invalid limit/offset). 401 UNAUTHORIZED. |
Returns the signed-in user's current cap state. Backs the user-button dropdown's CreditUsageSlot and the Settings → Credits summary card. The client (lib/credit/status.ts) caches for 1s and collapses in-flight requests — see the module header for why that cadence matches the realistic "I want to see fresh data" need.
| Request body | (none) |
| 200 response | { used, limit | null, windowHours | null, resetAt, unlimited, roleName, todayCredits, todayInputTokens, todayOutputTokens, todayTokens, totalCredits, totalInputTokens, totalOutputTokens, totalTokens } — limit and windowHours are null when unlimited. The today* fields scope to UTC-midnight; the total* fields are lifetime. Sourced from credit_usage_log rows where status='success'. |
| Failure codes | 401 UNAUTHORIZED |
| Endpoint | Purpose |
|---|---|
ANY /api/[...path] |
Node catch-all (runtime = "nodejs") that forwards to LANGGRAPH_API_URL (the LangGraph dev server / production endpoint). withAuth-wrapped (rule #9) — anonymous traffic is rejected with 401 before any upstream hop. Strips hop-by-hop headers, adds CORS, sends x-api-key. Also injects the session's userId into the proxied body so withMemoryRecall and CreditTrackingHandler can attribute the run. See docs/CREDIT.md § Where the cap is enforced for the per-turn credit gate. |
Behavior:
- Auth gate —
withAuthruns before any upstream fetch. No session → 401UNAUTHORIZED. (The previous build accepted anonymous traffic; any site's JS could create / list / delete threads through this proxy.) - Credit gate —
POST / PUT / PATCHtriggerscheckCredit(user.id)before the upstreamfetch. If the rolling-window cap is reached, the proxy short-circuits with a synthetic SSE stream (text/event-stream) carrying one AI message with ashow_credit_cardtool_call — no LangGraph call, nocredit_usage_logwrite. The assistant-ui SDK consumes the stream andcomponents/tool-ui/credit/credit-card.tsxmounts inline in the thread.GETstays unchecked (reads only). userIdinjection — forPOST / PUT / PATCHwith a JSON body, the proxy injectsconfig.configurable.userIdandconfig.metadata.userId(=ctx.user.id) so the LangGraph SDK propagates the user through everychatModel.invokecall. Without this,withMemoryRecallwould skip memory loading andCreditTrackingHandlerwould skip recording.- Header forwarding — only
cookieandauthorizationare forwarded (the two LangGraph consults).x-api-keyis added fromLANGCHAIN_API_KEYserver-side. Everything else is dropped. OPTIONS— short-circuits to 204 with CORS headers (Better Auth / browser preflight).- Streaming passthrough —
res.bodyis forwarded as aReadableStream; SSE forruns.stream/runs.stream-logkeeps working because we never buffer it.
Server-side proxy so the Alchemy API key never reaches the browser. Implementation: app/api/alchemy/[...path]/route.ts + app/api/alchemy/status/route.ts. Allowlist source of truth: lib/alchemy/networks.ts (static catalog of every Alchemy-supported network). ALCHEMY_DISABLED_NETWORKS is an optional turn-off filter on top of the catalog.
| Endpoint | Purpose | Response |
|---|---|---|
GET /api/alchemy/status |
Reports whether ALCHEMY_API_KEY is set on the server. The actual key value is never included in the response — it only returns { configured: boolean }. |
{ configured: true | false } |
POST /api/alchemy/<network> |
Forwards a JSON-RPC body to https://<network>.g.alchemy.com/v2/<ALCHEMY_API_KEY>. The <network> slug must be in the catalog AND not in ALCHEMY_DISABLED_NETWORKS, otherwise 400. |
Forwards upstream status + body. CORS headers attached. |
GET /api/alchemy/<network> |
Same proxy path, used for Alchemy endpoints that take query params (e.g. eth_getBlockByNumber over GET). Same allowlist rules. |
Forwards upstream status + body. |
OPTIONS /api/alchemy/<network> |
CORS preflight. | 204 with Access-Control-Allow-* headers. |
400 { error }— network slug missing, not in the static catalog, or listed inALCHEMY_DISABLED_NETWORKS.500 { error }—ALCHEMY_API_KEYis not configured on the server, or the upstream call threw.- Upstream statuses (401 / 429 / 5xx) are passed through to the caller as-is, with the upstream body and
Retry-Afterheader preserved.
ALCHEMY_API_KEY— server-only. The proxy reads it. Never reachable from the browser bundle.ALCHEMY_DISABLED_NETWORKS— server-only, optional, comma-separated Alchemy slugs the proxy will REJECT. Empty = every catalog network is enabled. The admin page (/alchemy) always shows the full catalog; a disabled network's Test button returns 400 so you can see at a glance what's turned off.
/alchemy renders the full catalog grouped by L1 / L2 / testnets, with a status badge from /api/alchemy/status and a per-network Test button that runs eth_blockNumber through the proxy.
The LangGraph agent graph exposes the following tools to the chat model. Both are read-only and run unconditionally — there is no per-call human approval prompt. Write tools added later should hang off their own node and pass interruptBefore: ["<that-node>"] to compile() so only the write path pauses for approval.
Implementation: backend/tool/{web-fetch,web-search}.ts. Shared key pool: lib/jina.ts.
Keyword / natural-language web search via Jina Search (s.jina.ai).
| Input | { query: string } — non-empty |
| Output | { query, results: Array<{ title, url, description }> } (JSON string) |
| Auth | Uses one key from JINA_API_KEYS (comma-separated in .env.example) |
| Failure modes | 500 from upstream → tool throws and the model reports the error; all keys exhausted → tool throws "All N Jina keys exhausted" |
Read a public web page and return it as markdown via Jina Reader (r.jina.ai).
| Input | { url: string } — must be a valid absolute URL with scheme |
| Output | { title, content, url } (JSON string; content is markdown) |
| Auth | Same JINA_API_KEYS pool as search_web |
| Failure modes | Non-2xx from upstream → tool throws with status code; URL validation failure → schema rejection before the request |
JINA_API_KEYS is parsed once at module load into an in-memory pool. Each request picks a key at random. On 401 or 403, the key is removed from the pool and the request retries with another random key. Up to N retries are attempted where N is the pool size at call start; once every key has rejected the same request, the tool throws. The pool is process-local and resets on LangGraph dev-server restart.
Persist structured facts to the user's long-term profile via RFC 6902 JSON Patch operations against [userId,"profile"] main. Implementation: backend/tool/memory/save-memory-tool.ts. Read counterpart (better-auth session / social accounts / thread summaries that the model already sees) is prepended to every model call by withMemoryRecall at backend/middleware/with-memory-recall.ts.
| Input | { patches: Array<MemoryPatch> }. MemoryPatch discriminated union of {op:"add", path, value}, {op:"replace", path, value}, {op:"remove", path}. move/copy/test are rejected at the schema layer. |
| Path shape | RFC 6901. The profile is a flat k-v bag, so paths are constrained to /[A-Za-z_][A-Za-z0-9_-]* (object property names, NOT array indices). |
| Output | { ok: true, bytes, keyCount } (JSON string) |
| Auth | The config.configurable.userId arg passed to LangGraph — injected by the Next.js /api/[..._path] proxy after withAuth. Missing / empty userId fails-fast: throws MissingUserIdError (code: "MISSING_USER_ID"). |
| Size guard | MEMORY_PROFILE_MAX_BYTES (default 8192) — measured post-patch against the serialized JSON. Failure throws MemorySizeError (code: "MEMORY_SIZE_EXCEEDED") before any store.put. |
| Patch validity | replace and remove ops on a path not present in the current profile throw MemoryPatchError (code: "PATCH_FAILED") — silent no-op on a non-existent path is the failure mode this rule exists to avoid. |
| Deletion UX | No separate forget_memory tool — to forget a fact, the user removes it from the Memory settings tab (which calls DELETE /api/memory/profile/[key]). |
The crypto sub-agent (CRYPTO_AGENT_PROMPT in backend/prompt/system.ts) drives the swap flow. The wallet is only used to identify the user — place_crypto_order does not read on-chain balances. The simulated flow auto-funds every user with 10,000 Mock Coin (MC, pegged 1:1 to USD) on the first trade, and the receive-side token is priced via live CoinGecko USD. There is no real signing and no on-chain broadcast in the current default path.
Read-only price lookup via CoinGecko's public /coins/markets. Used to render the price card. Has a 60s in-memory cache to stay under the free-tier rate limit.
| Input | { ids: string[], vs_currency?: string } — CoinGecko coin ids (e.g. ["bitcoin", "ethereum"]); defaults to "usd" |
| Output | { success: true, coins: [...] } (JSON string) — normalized list with price, 24h change, 7d sparkline, market cap |
| Auth | None (CoinGecko public endpoint) |
| Failure modes | Non-2xx → { success: false, error } carrying the upstream status. No retries. |
Read-only FX lookup via frankfurter.app (ECB-sourced). Has a 60s in-memory cache. Currently unused by the wallet-token flow (fiat amounts are rejected) but kept for the chat agent's general knowledge lookups.
| Input | { from: string, to: string } — 3-letter ISO codes, case-insensitive |
| Output | { success: true, from, to, rate, date } |
| Failure modes | Non-2xx → { success: false, error } with the upstream status. |
Not currently exposed to the LLM. Listed here for reference only — the file and tests are kept in backend/tool/crypto/get-token-balances.ts for direct programmatic use, but CRYPTO_TOOLS does not register it. The wallet's address is not in the LLM's context (it lives in wagmi/RainbowKit on the frontend), so the agent has no way to call this tool without inventing an address. The simulated place_crypto_order flow does not consult on-chain balances — every user is auto-funded with Mock Coin. If you want to re-enable this tool, register it in backend/tool/index.ts and update CRYPTO_AGENT_PROMPT step 2.
Read-only. Lists the NFT holdings of an EVM wallet across Ethereum, Arbitrum, Optimism, Base, and Polygon. Calls Alchemy's Portfolio API nfts/by-address directly from the server (uses ALCHEMY_API_KEY).
| Input | { address: string } — 0x-prefixed 40-hex chars. Case-insensitive. The LLM pulls this from the user's message or the most recent connect_wallet ToolMessage — never invents. |
| Output | { success: true, address, totalCount, nfts: Array<{ contractAddress, contractName, collectionName, collectionSlug, contractImageUrl, network, tokenId, tokenType, name, thumbnailUrl, cachedUrl, contentType, balance, totalSupply, floorPriceEth }> } (JSON string). |
| Auth | None from the LLM's perspective. Server reads ALCHEMY_API_KEY. |
| Failure modes | Missing / malformed address → zod-style rejection. Missing ALCHEMY_API_KEY → { success: false, error }. Alchemy 4xx/5xx → { success: false, error: "alchemy N: ..." }. Empty list → { success: true, nfts: [] }, not an error. |
The tool always sends excludeSpam: true plus an in-house name filter (claim | airdrop | visit | gift | giveaway | voucher | reward | drop | bonus) — Alchemy's own spam classifier leaves a lot of airdrop-bait through, and this regex catches the obvious patterns (yield-eth.net, USDC vouchers, etc.). Items whose contract name OR per-token name matches the regex are dropped from the response. Pagination via pageKey is handled internally (capped at 20 pages so a pathological wallet can't spin).
Wallet-authorization interrupt. Pauses via interrupt(); the frontend card opens RainbowKit, then resumes with {address, chainId} from wagmi. That becomes the ToolMessage content the LLM reads. Subsequent tools (place_crypto_order, get_order_status) auto-infer the address from wagmi state — the LLM does not need to thread an address through the schema.
| Input | message? (string) — short prompt shown above the Connect button. Defaults to "Connect your wallet to continue." if omitted. |
| Output | { address: 0x...string, chainId: number } (success) or { error: string } (user cancelled / wallet not installed). Becomes the ToolMessage content. |
| Failure modes | User closes RainbowKit modal without connecting → no resume → turn does not progress. The card stays in the "Connect" state until the user reconnects. |
Simulated swap interrupt. Pauses via interrupt(); the frontend card reads the wallet from wagmi (auto-inferred from the most recent connect_wallet ToolMessage — never pass an address), spends from the auto-funded Mock Coin balance (10,000 MC, pegged 1:1 to USD), prices the receive-side token via live CoinGecko USD (with a hardcoded fallback table in lib/prices/coingecko.ts when CoinGecko is unreachable), polls the price every 30s with a visible countdown, lets the user pick slippage + simulated gas tier (gas is converted to MC at the live ETH/USD price the quote already loaded), and exposes one Place simulated order button. On click, the card synthesizes an order — no real signing, no real DEX POST. The closing ToolMessage is what the LLM uses to write the final sentence.
| Input | side (required, "buy" or "sell") — "sell my X" / "swap X for Y" → sell; "buy Y with X" → buy. source_coin_id? (CoinGecko id) when the user named a source. amount? (positive number) when the user named a quantity. target_coin_id? when the user named what to receive. |
| Output | { status: "simulated_filled", order: { id, coin, symbol, side, amount_human, qty, status, timestamp, note, slippage_bps } } (success) or { status: "cancelled" } (Cancel clicked) or { status: "error", error: string } (price fetch failed and no fallback matched). |
| Failure modes | Malformed CoinGecko id → zod rejection. Id not in lib/tokens/catalog.ts → zod rejection. Non-positive or NaN amount → zod rejection. CoinGecko 4xx/5xx surfaces inside the card as a fallback-priced quote; the user can still click Cancel. |
| Missing source | The LLM does not pass a source — the card always spends from Mock Coin regardless of what the user names. The LLM does not need to verify wallet holdings before calling. |
Order-status interrupt. Pauses via interrupt(); the frontend card shows the order uid + chain and exposes one Check button. This is a simulated-order demo — the synthetic uid from place_crypto_order isn't a real on-chain order, so on click the card synthesizes a status (filled for the demo path) and returns it via the resume.
| Input | order_uid (required, non-empty string) — the order uid returned by place_crypto_order. chain_id (required, integer) — EVM chain id where the order was placed: 1, 42161, 8453, or 11155111. |
| Output | { status: "filled" | "open" | "partially_filled" | "cancelled" | "expired" | "not_found", order_uid, chain_id, filled_buy_amount?, executed_at? }. |
| Failure modes | Empty order_uid or non-numeric chain_id → zod rejection. |