name: crudtable description: >- Build CRUD screens in the AS500 project using the CRUDTable runtime — a declarative config that auto-generates a paginated list, create/edit form, and delete-confirm screen from one TypeScript object plus plain-function services. Use whenever the user asks for a new admin screen, maintenance screen, editable listing, or CRUD for any entity (users, customers, roles, orders, settings, lookup tables, etc.); or when they mention "add/edit/delete", "CRUDTable", a subfile with options, or an AS/400-style list; or when they want to refactor a hand-written screen into a config. Do NOT hand-roll a new list/form screen in AS500 unless the feature is genuinely outside the CRUDTable model (e.g. login, menus, dashboards, wizards).
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.tsas 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 toCRUD_*from a hand-written screen handler. Adding aCrudNodeto 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/.
import { eq } from 'drizzle-orm';
import { db } from '../db/index.js';
import { things } from '../db/schema.js';
export async function listThings(params?: { filter?: string }) {
const rows = await db.select().from(things);
return rows;
}
export async function createThing(input: { name: string; cityId: number }) {
const [row] = await db.insert(things).values(input).returning();
return row;
}
export async function updateThing(input: { id: number; name: string; cityId: number }) {
const [row] = await db.update(things).set({ name: input.name, cityId: input.cityId })
.where(eq(things.id, input.id)).returning();
return row;
}
export async function deleteThing(id: number) {
await db.delete(things).where(eq(things.id, id));
}
Step 2 — Write the config
Create server/src/configs/thingsConfig.ts.
import type { CRUDTableConfig } from '../crudtable/types.js';
import * as thingService from '../services/thingService.js';
import * as cityService from '../services/cityService.js';
export const thingsConfig: CRUDTableConfig = {
id: 'things',
title: 'Things',
requireAuth: true,
requirePermission: 'things:read',
services: {
list: { service: thingService, method: 'listThings' },
create: { service: thingService, method: 'createThing',
requirePermission: 'things:write',
params: ctx => ({ name: ctx.values.name, cityId: Number(ctx.values.cityId) }) },
update: { service: thingService, method: 'updateThing',
requirePermission: 'things:write',
params: ctx => ({ id: ctx.editRecord!.id as number,
name: ctx.values.name,
cityId: Number(ctx.values.cityId) }) },
delete: { service: thingService, method: 'deleteThing',
requirePermission: 'things:write',
params: ctx => ctx.selection[0].id as number },
},
fieldConfigs: {
name: {
field: 'name', label: 'Name', length: 20,
form: { required: true },
column: { width: 20 },
},
city: {
field: 'cityId', label: 'City', length: 4,
datasource: {
service: cityService, method: 'listCities',
valueField: 'id', displayField: 'name',
},
form: { required: true },
column: {
width: 18,
cellRenderer: (r, ds) => ds?.find(c => c.id === r.cityId)?.name ?? '',
},
},
},
columnBuilder: ['name', 'city'],
formBuilder: ['name', 'city'],
};
Step 3 — Register the config
Edit server/src/configs/index.ts:
import { thingsConfig } from './thingsConfig.js';
export function registerCRUDConfigs(): void {
// …existing…
registerConfig(thingsConfig);
}
Step 4 — Add a node to the menu tree
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):
import { PERMISSIONS } from '../services/access.js';
import { thingsConfig } from '../configs/thingsConfig.js';
{
type: 'crudtable',
key: 'things',
name: 'Things',
requirePermission: PERMISSIONS.THINGS_READ,
configId: 'things', // must match CRUDTableConfig.id
// Optional — runs immediately before the CRUD list renders. Use it to seed
// session.context.crud_things_input = {…} or other per-user context.
// initContext: initThingsContext,
}
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. - Pushes the parent menu onto
session.screenStackon 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) withOptcolumn - Option
2=Edit,4=Delete(→ confirmation screen),9=Open(ifopenUI) - Custom record actions auto-numbered from
5(skipping9if openUI exists) F6/ clientNkey → create flowF3/F12/ clientEsc→ 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
staticOptionsor adatasource - Screen-level and per-service access-control gates
- Pre-populated edit form from the record (with optional
formValuemapping) - Dynamic header text via
listHeader(ctx), custom F-keys vialistKeys - 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.
// Inside your CRUDTableConfig
mcp: {
name: 'things', // prefix for the tool names (e.g. things.list)
description: 'Things managed by the AS500 Thing registry.',
operations: {
list: true, // enable individually; omit/false to disable
read: true,
create: true,
update: true,
delete: { requirePermission: PERMISSIONS.THINGS_DELETE },
},
// Declare any caller-supplied context (analog of `session.context.crud_*_input`).
// The runtime surfaces agent-supplied params as required inputs on every tool,
// then threads them into the synthesized `CRUDContext` before calling your services.
//
// SECURITY — user-scoped configs: use `injectFromAuth: 'userId'` instead of
// exposing userId as a tool input. The runtime injects McpCallUser.userId
// (= auth_tokens.user_id for the active OAuth session) and strips the param
// from the Zod schema so agents can never pass a different id.
scope: [
{
name: 'userId',
type: 'number',
required: true,
description: 'Injected from the OAuth token — not a tool input.',
injectFromAuth: 'userId', // ← never appears in the tool schema
},
],
}
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 viamcp.operations[op].requirePermission(admins bypass all three, same as the UI) - OAuth 2.1 + Dynamic Client Registration on
/mcpwith per-token rate limiting and an append-only row inmcp_audit_logfor 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.readis implemented —updateanddeleteuse it to fetch the current row before running validators. If a CRUDTable config previously only hadlist/create/update/delete, addreadbefore turning on MCP updates or deletes. - If an operation should be visible in the UI but not to agents, mark it
falseinmcp.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:
cd server
npx 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.
Step 6 (optional) — Expose the config over the REST API
Every CRUDTable config can also be served as a conventional HTTP REST API with no extra handler code. Add an api block to the config; the runtime at server/src/api/ mounts routes on GET/POST/PUT/DELETE /api/<id>[/<id>] (port 3002) and enforces the same AS500 RBAC that gates the terminal UI and the MCP surface.
// Inside your CRUDTableConfig
api: {
name: 'things', // display name shown by GET /api discovery endpoint
description: 'Things managed by the AS500 Thing registry.',
operations: {
list: true, // enable individually; omit/false to disable
read: true, // requires services.read
create: true,
update: true,
delete: { requirePermission: PERMISSIONS.THINGS_DELETE },
},
// Scope params: injected into ctx.input before services run.
// SECURITY — use injectFromAuth: 'userId' for user-scoped configs; the
// runtime injects the token's userId and strips it from the URL so callers
// can never pass a different id.
scope: [
{
name: 'userId',
type: 'number',
required: true,
description: 'Injected from the Bearer token.',
injectFromAuth: 'userId', // ← never accepted from the caller
},
{
name: 'date',
type: 'string',
required: true,
description: 'Workday in YYYY-MM-DD format — pass as ?date=…',
},
],
}
HTTP shape for a config with id = 'things':
| Method | URL | Purpose |
|---|---|---|
GET | /api/things | List (paginated: ?offset=&limit=, max 100) |
GET | /api/things/:id | Read one record |
POST | /api/things | Create (body = writable fields only) |
PUT | /api/things/:id | Update (body = writable fields only) |
DELETE | /api/things/:id | Delete (returns 204) |
Non-injectFromAuth scope params go in the query string for all methods. The body contains only the resource's own writable fields.
Getting a Bearer token for the REST API:
Option A — First-party app (you own the client):
# Login once
curl -X POST http://localhost:3002/api/auth/token \
-H "Content-Type: application/json" \
-d '{ "username": "FREDRIC", "password": "fredric" }'
# → { "access_token": "...", "refresh_token": "...", "expires_in": 3600 }
# Use on every REST call
curl http://localhost:3002/api/things?date=2026-04-23 \
-H "Authorization: Bearer <access_token>"
# Refresh after 1 hour (old pair revoked, new pair returned)
curl -X POST http://localhost:3002/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{ "refresh_token": "<token>" }'
Option B — Third-party / AI agent: use the full OAuth 2.1 + DCR flow (same as MCP). See CLAUDE.md § REST API and server/scripts/smoke-mcp.mjs.
What you get for free:
- RBAC enforcement:
config.requirePermission,ServiceCall.requirePermission, per-opapi.operations[op].requirePermission— admins bypass all three - Rate limiting: 300 req/min per client in production (600 in dev)
- Audit row in
mcp_audit_logwithsource='api'for every call services.readrequired forread,update, anddeleteoperations (same as MCP)
Things you still own:
services.readmust be implemented if any ofread/update/deleteare enabled- Scope param names must match the keys your service functions read from
ctx.input - Error format is
{ "error": { "code": "…", "message": "…", "fields": […] } }— HTTP 400/401/403/404/405/429/500
Reference: server/src/configs/timeRegV2.ts has a working api block. The APIConfig and APIOperationConfig types in server/src/crudtable/types.ts are the authoritative shape. Full reference in CLAUDE.md § REST API.
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 |
Relations — parent → child navigation from the edit form
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.
// Parent config
relations: [
{
label: 'Mods', // shown in form status bar: 'Esc=Back M=Mods'
actionKey: 'M', // single key, case-insensitive
targetConfigId: 'mods',
mapInput: (rec) => ({
motorcycleId: rec.id,
motorcycleLabel: `${rec.brand} ${rec.model} ${rec.year}`,
}),
},
],
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:
// Child config (e.g. modsConfig.ts)
services: {
list: {
service: modsService,
method: 'listMods',
params: (ctx) => ({ motorcycleId: ctx.input.motorcycleId as number }),
},
create: {
service: modsService,
method: 'createMod',
params: (ctx) => ({
motorcycleId: ctx.input.motorcycleId as number, // echo FK on mutations
name: ctx.values.name?.trim() || '',
// …
}),
},
// update / delete likewise echo motorcycleId
},
listHeader: (ctx) => [
{ row: 5, col: 2, content: `Mods: ${ctx.input.motorcycleLabel ?? ''}` },
],
Rules of thumb.
actionKeymust not collide withEnter,Esc/F3/F12, or field input handling. Uppercase letters (M,S,L) are safe.- The child must be registered in
configs/index.ts. An unknowntargetConfigIdsilently no-ops. - The child's list/create/update/delete
paramsmust all echo the scoping keys fromctx.input— otherwise new child rows can be created orphaned. - Relations don't do their own permission check; the child's
requirePermissiongate still runs on navigation. - Use
listHeader(ctx)on the child to show the parent label —mapInputconventionally provides a*Labelkey 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.tsand rendered byserver/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.tsto add a case for the new screen. The default case handles allCRUD_*andMENU_*IDs. - Do not edit
server/src/screens/mainMenu.ts. It is a thin delegator tomenuRuntime.ts; the main-menu contents are inmenuTree.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; useinitContextfor any pre-navigation seeding. - Do not mutate
CRUDContextoutside a service,listKeys.handler, orinitContexton 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.handlerclosure. - 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 everyFieldConfig; it drives form width and is the column-width fallback. - Do not mix config
idcasing. Use lowercase-snake inid(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. SetrequirePermissionon 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 + mcp block + api block (canonical reference for both remote surfaces) |
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:
-
npm run typecheckpasses from the repo root - Service file lives under
server/src/services/; each function takes a single argument - Any new DB table is in
server/src/db/schema.tsand a migration was generated withnpm run db:generate - Config file lives under
server/src/configs/and is imported + registered inconfigs/index.ts - The config
idis lowercase-snake; screens route onCRUD_{ID_UPPERCASE} - Every
fieldConfigs[*]haslength -
columnBuilderandformBuilderreference only existingfieldConfigskeys - Permissions exist in
server/src/services/access.ts(add them if new) and are seeded for the relevant roles - A
CrudNodefor the config is added toserver/src/menus/menuTree.tswith the right parent menu,requirePermission, andconfigIdmatching the config'sid - If the list needs caller context, it is seeded inside
initContext(session)on the menu node — not from a screen handler and not in the config body
If adding an api block (Step 6):
-
services.readis implemented (required byread,update, anddelete) -
injectFromAuth: 'userId'is used on any scope param that comes from the logged-in user — never accept userId from the caller - Each non-injected scope param is documented with a
description(shown inGET /apidiscovery) - Only operations that should be public-facing are enabled in
api.operations - Smoke-tested with
curl -X POST http://localhost:3002/api/auth/token+ a call toGET /api/<configId>
If adding an mcp block (Step 5):
-
services.readis implemented (required byupdateanddelete) -
mcp.descriptionis set (required) -
injectFromAuth: 'userId'is used for user-scoped scope params
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.