CRUDTable
AS500's declarative CRUD runtime. One TypeScript config + plain-function services → a fully working list + form + delete-confirm flow with validation, pagination, access control, keyboard/mouse row navigation, and select-field dropdowns.
When to use CRUDTable
Use CRUDTable whenever the task fits this shape:
- "Add a screen to list X with the ability to add / edit / delete."
- "I need a maintenance screen for {users, customers, products, tasks, …}."
- "Build an admin UI for this table."
- "Give each row options 2=Edit, 4=Delete, 9=Open like the other screens."
- "Refactor the hand-written time-reg screen into a CRUDTable config." (see
server/src/configs/timeRegV2.ts as the reference)
Use a manual screen (DSL-only) when the task is:
- Login, signoff, main menu, help screens
- Wizards or multi-step flows that don't match list + form
- Dashboards / pure-display screens with no CRUD
- Screens that need custom layout CRUDTable can't express (e.g. two side-by-side subfiles)
When in doubt, prefer CRUDTable. It is strictly additive — existing manual screens stay as-is.
Read these first
Before writing code for a non-trivial task, read:
DOCS/CRUDTABLE/5. CRUDTable Concept.md — the mental model (10 min)
DOCS/CRUDTABLE/6. CRUDTable Reference.md — every field, every screen behavior (lookup reference)
For a small change (e.g. adding one field to an existing config), skim this SKILL and look at a working config.
Fast path: add a new CRUD screen in 4 steps
Wiring model (important): CRUD screens are exposed to the user through the central menu tree at server/src/menus/menuTree.ts, not by navigating to CRUD_* from a hand-written screen handler. Adding a CrudNode to the tree is the only UI-wiring step. The generic menu runtime (server/src/menus/menuRuntime.ts) handles permission filtering, initContext, stack push, and dispatch to the CRUD runtime.
Step 1 — Write the service
Create server/src/services/thingService.ts. Functions take a single argument (usually an object) and return arrays or records. Use Drizzle via db from ../db/index.js; add any new table to server/src/db/schema.ts first and run npm run db:generate inside server/.
ts
1import { eq } from 'drizzle-orm';
2import { db } from '../db/index.js';
3import { things } from '../db/schema.js';
4
5export async function listThings(params?: { filter?: string }) {
6 const rows = await db.select().from(things);
7 return rows;
8}
9export async function createThing(input: { name: string; cityId: number }) {
10 const [row] = await db.insert(things).values(input).returning();
11 return row;
12}
13export async function updateThing(input: { id: number; name: string; cityId: number }) {
14 const [row] = await db.update(things).set({ name: input.name, cityId: input.cityId })
15 .where(eq(things.id, input.id)).returning();
16 return row;
17}
18export async function deleteThing(id: number) {
19 await db.delete(things).where(eq(things.id, id));
20}
Step 2 — Write the config
Create server/src/configs/thingsConfig.ts.
ts
1import type { CRUDTableConfig } from '../crudtable/types.js';
2import * as thingService from '../services/thingService.js';
3import * as cityService from '../services/cityService.js';
4
5export const thingsConfig: CRUDTableConfig = {
6 id: 'things',
7 title: 'Things',
8 requireAuth: true,
9 requirePermission: 'things:read',
10
11 services: {
12 list: { service: thingService, method: 'listThings' },
13 create: { service: thingService, method: 'createThing',
14 requirePermission: 'things:write',
15 params: ctx => ({ name: ctx.values.name, cityId: Number(ctx.values.cityId) }) },
16 update: { service: thingService, method: 'updateThing',
17 requirePermission: 'things:write',
18 params: ctx => ({ id: ctx.editRecord!.id as number,
19 name: ctx.values.name,
20 cityId: Number(ctx.values.cityId) }) },
21 delete: { service: thingService, method: 'deleteThing',
22 requirePermission: 'things:write',
23 params: ctx => ctx.selection[0].id as number },
24 },
25
26 fieldConfigs: {
27 name: {
28 field: 'name', label: 'Name', length: 20,
29 form: { required: true },
30 column: { width: 20 },
31 },
32 city: {
33 field: 'cityId', label: 'City', length: 4,
34 datasource: {
35 service: cityService, method: 'listCities',
36 valueField: 'id', displayField: 'name',
37 },
38 form: { required: true },
39 column: {
40 width: 18,
41 cellRenderer: (r, ds) => ds?.find(c => c.id === r.cityId)?.name ?? '',
42 },
43 },
44 },
45
46 columnBuilder: ['name', 'city'],
47 formBuilder: ['name', 'city'],
48};
Step 3 — Register the config
Edit server/src/configs/index.ts:
ts
1import { thingsConfig } from './thingsConfig.js';
2
3export function registerCRUDConfigs(): void {
4 // …existing…
5 registerConfig(thingsConfig);
6}
Edit server/src/menus/menuTree.ts and drop a CrudNode under the appropriate parent menu (top-level for user-facing screens; under the admin submenu for admin-only screens):
ts
1import { PERMISSIONS } from '../services/access.js';
2import { thingsConfig } from '../configs/thingsConfig.js';
3
4{
5 type: 'crudtable',
6 key: 'things',
7 name: 'Things',
8 requirePermission: PERMISSIONS.THINGS_READ,
9 configId: 'things', // must match CRUDTableConfig.id
10 // Optional — runs immediately before the CRUD list renders. Use it to seed
11 // session.context.crud_things_input = {…} or other per-user context.
12 // initContext: initThingsContext,
13}
No other files need to change. No edits to server/src/index.ts, server/src/screens/mainMenu.ts, or any manual screen handler. The menu runtime:
- Hides the item if the user lacks
requirePermission / requireAdmin.
- Pushes the parent menu onto
session.screenStack on selection.
- Awaits
initContext(session) if provided.
- Sets
session.currentScreen = 'CRUD_THINGS' (derived as 'CRUD_' + config.id.toUpperCase()) and returns the list screen.
If the list needs caller-supplied filtering or defaults, put that logic in initContext — it is the correct and only place to seed session.context.crud_{id}_input.
What the config gives you for free
- Paginated list (page size 12,
PAGEUP/PAGEDOWN) with Opt column
- Option
2=Edit, 4=Delete (→ confirmation screen), 9=Open (if openUI)
- Custom record actions auto-numbered from
5 (skipping 9 if openUI exists)
F6 / client N key → create flow
F3 / F12 / client Esc → back, with stack + context cleanup
- Client-side arrow-key row focus,
Enter = primary action, d = delete shortcut, mouse click/double-click
- Required-field checks, custom validator pipeline, service error surfacing
- Select dropdowns from
staticOptions or a datasource
- Screen-level and per-service access-control gates
- Pre-populated edit form from the record (with optional
formValue mapping)
- Dynamic header text via
listHeader(ctx), custom F-keys via listKeys
- Cross-config navigation via
openUI.mapContext
Step 5 (optional) — Expose the config over MCP
Every CRUDTable config can be opened up to remote AI agents as a set of MCP tools with no extra handler code. Add an mcp block to the config; the runtime at server/src/mcp/ auto-generates one tool per enabled operation (<id>.list, <id>.read, <id>.create, <id>.update, <id>.delete), with a zod input schema derived from the same field configs, and enforces the same AS500 RBAC that gates the terminal UI.
ts
1// Inside your CRUDTableConfig
2mcp: {
3 name: 'things', // prefix for the tool names (e.g. things.list)
4 description: 'Things managed by the AS500 Thing registry.',
5 operations: {
6 list: true, // enable individually; omit/false to disable
7 read: true,
8 create: true,
9 update: true,
10 delete: { requirePermission: PERMISSIONS.THINGS_DELETE },
11 },
12 // Declare any caller-supplied context (analog of `session.context.crud_*_input`).
13 // The runtime surfaces these as required params on every tool, then threads
14 // them into the synthesized `CRUDContext` before calling your services.
15 scope: [
16 { name: 'ownerId', type: 'number', required: true, description: 'Owner user id' },
17 ],
18}
What the MCP runtime gives you for free:
- JSON-schema/zod input validation derived from each field's
form.type + required
- Per-tool permission enforcement:
config.requirePermission, per-ServiceCall.requirePermission, and per-op override via mcp.operations[op].requirePermission (admins bypass all three, same as the UI)
- OAuth 2.1 + Dynamic Client Registration on
/mcp with per-token rate limiting and an append-only row in mcp_audit_log for every call (ok/error, client_id, user_id, config_id, op, duration, sha256 params hash — values are never logged)
- Identical validators and services to the terminal UI: no duplication, no drift
Things you still own:
- Make sure
services.read is implemented — update and delete use it to fetch the current row before running validators. If a CRUDTable config previously only had list/create/update/delete, add read before turning on MCP updates or deletes.
- If an operation should be visible in the UI but not to agents, mark it
false in mcp.operations.
- If the UI gates a config behind a permission, grant that same permission to any agent role that needs MCP access. Don't widen for agents.
Quickest end-to-end smoke of your new MCP surface:
bash
1cd server
2npx tsc && node scripts/smoke-mcp.mjs # walks DCR → consent → token → tools/list → tools/call
Reference: server/src/configs/timeRegV2.ts has a working mcp block with scope params. The MCPConfig and MCPOperationOverride types in server/src/crudtable/types.ts are the authoritative shape; server/src/mcp/schemaBuilder.ts shows how each field is translated into zod.
Patterns to reach for
| Need | Use |
|---|
| Field only required on create | form.required: ctx => ctx.formMode === 'create' |
| Field read-only when editing | form.disabled: ctx => ctx.formMode === 'edit' |
Map backend boolean to 'Y'/'N' in the form | form.formValue: v => v === true ? 'Y' : 'N', plus a validator on submit |
| Cross-field check (e.g. password == confirm) | Validator on one field reads ctx.values.other |
| Resolve foreign-key id to a label in the list | column.cellRenderer: (r, ds) => ds?.find(...)?.name + matching datasource |
| Filter the list by something the caller provides | services.list.params: ctx => ({ … ctx.input.foo }) + seed the child's ctx.input via a menu node's initContext or a parent's relation mapInput |
Composite primary key (no single id) | Store originals in a hidden field or use editRecord.original_*; see roleDefaultsConfig.ts |
| Day / page / group stepping with F7/F8 | listKeys.F7 + listKeys.F8 mutating ctx.input and ctx.pageOffset = 0 |
| Extra contextual text at the top of the list | listHeader: ctx => [{ row, col, content }, …] |
| "From parent X's edit form, jump to list of child Y's scoped to X" | relations: [{ label, actionKey, targetConfigId, mapInput: rec => ({ parentId: rec.id, parentLabel: … }) }] — see Relations below |
config.relations?: RelationConfig[] adds single-key hotkeys on the edit form (never the create form) that open another registered CRUDTable's list, scoped to the currently edited parent record.
ts
1// Parent config
2relations: [
3 {
4 label: 'Mods', // shown in form status bar: 'Esc=Back M=Mods'
5 actionKey: 'M', // single key, case-insensitive
6 targetConfigId: 'mods',
7 mapInput: (rec) => ({
8 motorcycleId: rec.id,
9 motorcycleLabel: `${rec.brand} ${rec.model} ${rec.year}`,
10 }),
11 },
12],
The runtime seeds session.context['crud_mods_input'] = mapInput(editRecord), pushes the parent form onto screenStack, and navigates to the child's list. The child reads the scoping via ctx.input:
ts
1// Child config (e.g. modsConfig.ts)
2services: {
3 list: {
4 service: modsService,
5 method: 'listMods',
6 params: (ctx) => ({ motorcycleId: ctx.input.motorcycleId as number }),
7 },
8 create: {
9 service: modsService,
10 method: 'createMod',
11 params: (ctx) => ({
12 motorcycleId: ctx.input.motorcycleId as number, // echo FK on mutations
13 name: ctx.values.name?.trim() || '',
14 // …
15 }),
16 },
17 // update / delete likewise echo motorcycleId
18},
19listHeader: (ctx) => [
20 { row: 5, col: 2, content: `Mods: ${ctx.input.motorcycleLabel ?? ''}` },
21],
Rules of thumb.
actionKey must not collide with Enter, Esc/F3/F12, or field input handling. Uppercase letters (M, S, L) are safe.
- The child must be registered in
configs/index.ts. An unknown targetConfigId silently no-ops.
- The child's list/create/update/delete
params must all echo the scoping keys from ctx.input — otherwise new child rows can be created orphaned.
- Relations don't do their own permission check; the child's
requirePermission gate still runs on navigation.
- Use
listHeader(ctx) on the child to show the parent label — mapInput conventionally provides a *Label key for exactly this.
- Esc from the child list (or its own edit form) returns the user to the parent form in its previous state — the runtime handles the stack.
Canonical example: server/src/configs/motorcyclesConfig.ts (parent with two relations) + modsConfig.ts / servicesPerformedConfig.ts (scoped children).
Anti-patterns (do NOT)
- Do not hand-roll a new list/form DSL screen when CRUDTable fits. Configs are ~50–150 lines; hand-rolled screens are ~250+.
- Do not hand-roll a new menu screen. All menu navigation is declared in
server/src/menus/menuTree.ts and rendered by server/src/menus/menuRuntime.ts. New screens are exposed by adding a node there, not by writing a DSL menu.
- Do not edit
server/src/index.ts to add a case for the new screen. The default case handles all CRUD_* and MENU_* IDs.
- Do not edit
server/src/screens/mainMenu.ts. It is a thin delegator to menuRuntime.ts; the main-menu contents are in menuTree.ts.
- Do not navigate into a CRUD screen from a manual screen handler when a menu entry will do. Put the entry in the tree (
type: 'crudtable') and let the runtime dispatch; use initContext for any pre-navigation seeding.
- Do not mutate
CRUDContext outside a service, listKeys.handler, or initContext on the menu node. Config functions (params, validators, cellRenderer, listHeader, getInitialValues, mapContext) are read-only.
- Do not call services from the config body (top level). Anything that needs runtime data goes inside a
params / cellRenderer / listKeys.handler closure.
- Do not use a different screen-ID convention. It must be exactly
CRUD_{config.id.toUpperCase()} — anything else won't route.
- Do not forget
length. It's required on every FieldConfig; it drives form width and is the column-width fallback.
- Do not mix config
id casing. Use lowercase-snake in id (user_mgmt, timereg_v2) — derived IDs will uppercase it.
- Do not bypass
requirePermission. If a CRUD operation is sensitive, gate it per-service, not by commenting it out in the UI. Set requirePermission / requireAdmin on the menu node too, so the entry is hidden for users without access.
Working examples in the repo
| File | What it demonstrates |
|---|
server/src/configs/timeRegV2.ts | listHeader + listKeys (F7/F8 day nav) + input-driven filtering + init helper |
server/src/configs/userMgmtConfig.ts | staticOptions select, context-sensitive required/disabled, password+confirm with validator, formValue for booleans |
server/src/configs/roleDefaultsConfig.ts | Composite primary key, SYS_ADMIN gate, validators using a seeded registry |
server/src/configs/motorcyclesConfig.ts | relations — two edit-form hotkeys (M=Mods, S=Services) jumping to scoped child lists via mapInput |
server/src/configs/modsConfig.ts / servicesPerformedConfig.ts | Child side of a relation: ctx.input.motorcycleId scoping on list + all mutations, parent label in listHeader |
Open one of these before writing a config from scratch — pattern-matching will save time.
Verification checklist
After implementing a new CRUD screen:
When to go beyond the fast path
Only read 6. CRUDTable Reference.md end-to-end when:
- You're adding a feature to the runtime itself (e.g. implementing
action.scope: 'bulk')
- You're porting CRUDTable to a different renderer (React, CLI)
- The config you're writing doesn't fit any of the example patterns
- You're debugging unexpected behavior and need the exact evaluation order
For most CRUD-screen tasks, this SKILL + one example config is enough.