| name | carte |
| description | Authors Carte carte entries — typed, RBAC-aware named DB queries the LLM can invoke. Use when the user asks to add a query/entry to a Carte carte, design a carte from a Drizzle schema, or audit one for sprawl. Two-phase flow — propose carte shape, then generate and verify entries against the project's check-queries script. |
| allowed-tools | Read, Grep, Glob, Bash(pnpm:*), Bash(npx:*), Bash(node:*), Bash(tsx:*), Bash(bun:*), Bash(rg:*), Edit, Write |
Carte carte author
A Carte carte is a small, opinionated set of named queries the LLM is allowed to invoke — not a generated wrapper around every table. The single worst failure mode for an authoring agent is producing 50 entries when 8 well-shaped ones absorb the same questions through allowedFilters and allowedSorts. This skill enforces a two-phase flow that prevents that.
When to use
- "Add a
top_failing_jobs entry to my Carte carte."
- "Design a carte for this Drizzle schema."
- "Audit my carte — is it the right shape?"
- "Fix the carte to match the schema after the rename."
Skip when the user wants raw SQL, a new UI component built from scratch, or framework-level changes inside @usecarte/core.
Mental model: what a carte entry is
A carte entry is a complete contract for one named question:
id — stable identifier the LLM emits in its plan.
description — the LLM's primary disambiguation signal. Be explicit about when to pick this entry vs. siblings ("for time series prefer queue_depth_over_time").
exampleQuestions — natural-language phrasings users actually ask. The LLM matches against these.
params — Zod schema. May include filters: filtersParamSchema.optional() and sorts: sortsParamSchema.optional().
returns — Zod schema for the row shape. Source of truth for $bind resolution at validation time.
allowedFilters? — per-field filter contract (operators, type, optional caseSensitive, optional description).
allowedSorts? — per-field sort contract.
maxLimit? — runtime cap pinned to the Zod .max().
access?(ctx) — RBAC predicate. Filtered entries are stripped from the prompt entirely.
staticHints? — small object surfaced in the prompt to teach the LLM about ranges and enums. Authoring-time-shaped, deterministic, no DB queries inside.
query — the function. Drizzle is the natural fit; anything (params) => Promise<rows> works.
The framework ships applyDrizzleFilters(columns, filters, allowed) and applyDrizzleSort(columns, sorts, allowed) (from @usecarte/core/drizzle). The columns map is keyed on keyof <AllowedX> so missing keys are TypeScript errors at the call site.
Two-phase workflow
This is the structural defense against hallucination and sprawl. Do not skip Phase 1.
Phase 1 — propose, don't generate
- Read the Drizzle schema first. Hallucinated columns are the #1 failure of one-shot generators. Find the schema (typically
src/schema.ts or wherever the user's tables live) and load it before reasoning about entries.
- Ask the user about question shape. Examples: what questions should the LLM answer? Are there role-gated views (admin/operator)? Are there single-number metrics (count, total) vs. row-level lists vs. time series?
- Propose a carte shape, in chat, before any file writes. For each proposed entry, list:
- id and one-line description
- archetype (snapshot / windowed-aggregate / row-level-filtered / mode-switched / timeseries / rbac-detail / single-metric)
- questions absorbed by this entry (so 1:1 question-to-entry ratios are visible — that's the sprawl smell)
allowedFilters proposed: each filter field with the exact Drizzle column it maps to
allowedSorts proposed if applicable
- whether RBAC is required and why
- whether
staticHints is required and why
- Get explicit user approval. Phase 2 cannot start without it.
If the user says "just add this one entry" and skips Phase 1: still do a 30-second mini-proposal — confirm archetype, columns map, RBAC, filter set — then proceed.
Phase 2 — generate + verify
- Pick the matching archetype template from
examples/*.ts.
- Fill placeholders. Every filter field key must equal a key in the columns map and (where applicable) a key in
returns.
- Run the verification script (see below).
- On failure: parse the error, fix one cause at a time, re-run. Budget: 3 retries per entry. After that, surface to the user with the error, the diff attempted, and a suspected cause.
- On zero-row success (script exits clean but the entry returned no rows): soft-pass — flag it, don't hide it. The seed may not cover this entry yet.
Decision rules
These rules are testable. Apply them in order; the first one that fires wins.
New entry vs. extend allowedFilters
- Extend an existing entry's filters if the new question reuses the entry's
returns shape unchanged.
- New entry if either: (a) the rows shape changes (aggregate vs. row-level — different columns), or (b) the answer requires a different join or grouping.
Concrete test: "Would the existing returns keys still describe the answer to this question?" Yes → filter. No → entry.
Aggregated/row-level twin pattern
For a domain with both "which category is unhealthy?" and "which specific row is failing?", produce two entries (mirror the failure_rate_by_type + top_failing_jobs pair from the worked example). Don't fold them into one entry with a mode: "aggregate" | "rows" param. The LLM picks better between two clearly-described siblings than within one entry's mode flag.
staticHints required when
params includes an enum or constrained range, OR returns includes an enum, OR a limit has a non-trivial cap, OR the entry has siblings the LLM might confuse it with.
Security contract (see references/security-model.md — Invariant 3): staticHints output is rendered verbatim into the LLM's prompt. Three structural rules:
- No
ctx parameter — the function takes no arguments. The same value is computed for every user, every role, every tenant. If you need per-role variation, split into separate entries with different access predicates.
- Deterministic — two calls within the same process must return equal values. No DB queries inside. Set
CARTE_VERIFY_STATIC_HINTS=1 in dev/CI to catch determinism violations.
- Shape (
StaticHints) — flat record of primitives and primitive arrays. The runtime guard rejects nested objects, arrays of objects, anything row-shaped.
Counts, ranges, defaults, enum lists, and author-declared scope notes are the safe vocabulary. Never include row data, sample values, runtime counts, or anything derived from query execution at request time.
RBAC required when
returns exposes any of: stack traces, error messages, raw user-supplied strings, non-UUID internal IDs, PII-shaped fields (email, phone, address), full admin-table rows. access: (ctx) => ctx.role === "operator" is the typical shape. Filtered entries disappear from the prompt entirely (defense in depth).
returns must be statically declared
This is a security invariant, enforced at defineEntry time. The returns schema must be a Zod schema known when the carte is authored — never derived from a query result, never built dynamically from runtime values. The model can safely see the shape of what each query returns precisely because the shape is declared by you, not inferred from data. See references/security-model.md for the full rationale.
maxLimit required when
params.limit exists. Pin it to whatever value the Zod .max() would have used; the runtime cap surfaces a clearer message than a Zod parse error and the prompt advertises the cap as a top-level "Max limit" hint.
New UI component vs. reuse
Only suggest a new component if no existing one accepts the cardinality + axis-types combination the new entry produces. Default = bind to existing. Authoring new UI components is not in this skill's scope.
Filter operator selection
Pick the smallest operator set that covers reasonable questions:
- string identifiers:
eq, neq, in, nin, startsWith. Add contains only if substring-match is a real user need.
- numeric:
eq, gt, gte, lt, lte. Add in/nin only for enum-shaped numbers.
- datetime:
gt, gte, lt, lte. Add in/nin only when the user filters on specific timestamps.
- boolean:
eq only.
- nullable columns: add
isNull / isNotNull to the relevant field's operators.
caseSensitive: true for string fields representing identifiers (id columns, type tokens). Default insensitive otherwise.
Verification loop
Discovery order
package.json script named check-queries, check:queries, or verify:carte.
scripts/check-queries.ts (the convention from the worked example) — run via pnpm tsx / npx tsx / bun depending on lockfile.
- Ask the user for the path.
Do not invent a verification script. Do not modify package.json to add one.
What you fix on failure
Parse the script's console.error line, identify the failing entry by id (the worked example logs === <id> === and rethrows on first failure). Likely causes, in order of frequency:
- Column name typo. Re-read the schema; correct against the canonical column.
- Missing
Number() coercion on count() / aggregate results — Postgres returns these as strings.
- Missing
toIsoString coercion on timestamp columns — node-postgres returns timestamptz as Date, timestamp without time zone as ISO-ish strings; normalize to ISO 8601.
- Mismatched filter type in
allowedFilters (e.g. declared string for an integer column).
- Missing key in the
applyDrizzleFilters / applyDrizzleSort columns map.
coalesce order for "last attempted" / "duration since" expressions.
Retry budget
3 attempts per entry. After that, surface to the user with: the error verbatim, the diff(s) attempted, the suspected cause. Do not silently move on.
Success criteria
- Verification script exits 0.
- Every new/changed entry logs a non-error block with row count ≥ 0.
- Zero-row results are soft-passes (flag for the user).
Boundaries — what this skill will NOT do
- Will not decide which columns are safe to expose. Always asks before adding fields like
errorMessage, userId, email, raw text/JSON columns to returns.
- Will not write or modify tests, the runner, the prompt builder, or anything in
packages/core.
- Will not author UI components from scratch. Can suggest an existing component, can stub a
bindable() skeleton with a manual-review marker, but new components are a separate manual task.
- Will not introspect a live database. Drizzle schema files only.
- Will not modify migrations or the Drizzle schema.
- Will not generate >5 entries in a single batch without explicit user re-confirmation between batches.
- Will not modify
package.json to add a check-queries script — discovery falls back to asking.
Reference
Deep references for specific topics live in references/ — load when relevant:
references/operators.md — full operator vocabulary, per-type validity matrix, caseSensitive semantics.
references/allowed-filters.md — keying rules, FilterColumns<A> type-safe column map, applyDrizzleFilters mechanics.
references/bind-rules.md — bindable(z.X()) prop typing, { $bind: "field" } and { $bind: "*" } rules, validator constraints against returns shape.
references/rbac.md — access(ctx) patterns, common gates, why filtered entries disappear from the prompt.
references/static-hints.md — staticHints contract, what's safe to surface, migration from summaryStats.
references/prompt-formats.md — how markdown / xml / plain prompt formats consume entries, what the LLM actually sees.
Examples for each archetype live in examples/:
examples/snapshot.ts — zero-param aggregate, enum return.
examples/windowed-aggregate.ts — time-window param, static staticHints for range bounds.
examples/row-level-filtered.ts — limit + filters + sorts + maxLimit.
examples/mode-switched.ts — enum mode param + filters + sorts.
examples/timeseries.ts — interval + window, date_bin-style bucketing.
examples/rbac-detail.ts — access(ctx) + truncated text columns.
examples/single-metric.ts — single-row { count } shape for the metric component.
examples/ui-component.ts — bindable() skeleton, marked manual-review-required.
Lift these verbatim, replace placeholder names, fill the returns shape and the query body. The framework idioms are baked into the templates.