ObjectStackObjectStack

Anatomy of an ObjectStack App

A tour of the metadata an AI agent writes for you — objects, actions, views, apps — and how to read it so you can verify the result in the Console.

An ObjectStack app is its metadata: a few hundred lines of typed definitions for objects, actions, views, apps, automation, and permissions. In the AI-first workflow, an agent writes these files for you — you rarely type them by hand.

But you do need to read them. In AI development the human's job shifts from writing code to verifying what the agent produced: is the object modelled right, is that predicate scoped correctly, does the app match what you asked for? This page is a tour of the pieces so you can review an agent's work with confidence — and catch the things the validation gate can't.

Want to build something, not just read about the pieces? Follow Build with Claude Code — it runs the whole loop (describe → agent authors → validate → verify) on a live app. New here? The Example Apps are working code you can run today.

What you build

Everything an agent writes falls into one of a few areas — the same way the work divides when you build an app. This is the map to hold in your head when you read a project:

AreaThe metadata you author (src/…)What ObjectStack derives from it
DataObjects, fields, relationships, validation (src/objects)Database schema, CRUD APIs, type safety
AutomationFlows, workflows, triggers, approvals (src/flows)Event handlers, approval chains, scheduled jobs
InterfaceViews, apps, dashboards, actions (src/views, src/apps)The Console UI, navigation, responsive layout
AccessRoles, permissions, sharing, row-level securityMiddleware, RLS policies, field masking
AISkills, tools, RAG, MCP exposure (src/skills)Chat, search indexes, an MCP server

The through-line: you author intent once as metadata, and the runtime derives the database, the API, the UI, and the AI tool surface from it. That's why an agent can build across all of these in one coherent change — and why reading the metadata tells you everything about how the app behaves.

Reading an object

The object is the heart of every app. Here's a representative one, in the same ObjectSchema.create + Field.* form the scaffolder and the agent use:

src/objects/ticket.object.ts
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Ticket = ObjectSchema.create({
  name: 'support_desk_ticket',        // ← snake_case, namespace-prefixed
  label: 'Ticket',
  pluralLabel: 'Tickets',
  icon: 'life-buoy',
  description: 'A customer support request.',

  fields: {
    subject: Field.text({ label: 'Subject', required: true, searchable: true, maxLength: 200 }),
    description: Field.textarea({ label: 'Description' }),

    status: Field.select({
      label: 'Status',
      required: true,
      options: [
        { label: 'Open', value: 'open', color: '#3B82F6', default: true },
        { label: 'Pending', value: 'pending', color: '#F59E0B' },
        { label: 'Resolved', value: 'resolved', color: '#10B981' },
        { label: 'Closed', value: 'closed', color: '#6B7280' },
      ],
    }),
  },

  sharingModel: 'private',      // org-wide default (OWD) — required by the security gate
  enable: { apiEnabled: true, searchable: true },
});

What to check when you read one:

  • Names are snake_case and namespace-prefixed (support_desk_ticket) — the scaffolder derives the prefix from your project name.
  • Field types are Field.* builders (text, textarea, select, date, lookup, …), not raw strings. A select's options carry the exact labels, values, and colors you'll see in the UI — verify these match your intent.
  • required, default, searchable encode behavior the UI and API inherit automatically.
  • sharingModel is the org-wide default for records the user doesn't own — an explicit, authored security decision (private unless you have a reason otherwise). The validation gate rejects custom objects that omit it.

This one definition powers the REST API, the Console UI, and the MCP tools exposed to AI agents. Define it once, and ObjectStack derives the rest — see Data Modeling for the full field catalog and relationships.

Reading the other pieces

The remaining domains follow the same shape — a typed factory call per file. When reviewing an agent's work, these are the two things most worth a close read:

  • Actions (defineAction) — buttons and bulk operations. Their visible / disabled predicates are CEL, record-scoped: record.status, never a bare status. A bare reference silently hides the action on every record — the single most common AI mistake, which is exactly why os validate rejects it. See it in action in Build with Claude Code → the gate. On a row action, also guard the field with has()has(record.status) && record.status != 'sent' — because a list row carries only the columns that view projects, and reading an absent one faults and hides the button. See Actions for when the guard needs && record.x != null on top.
  • Views & Apps (defineView, App.create) — the list/form lenses and the navigation. Reading these tells you what the user will actually see and click.

For the full authoring surface of each, load the matching skill or read UI Metadata and Automation.

Reading a project's layout

Metadata lives under src/, one directory per collection. This map lets you navigate any ObjectStack project — including one an agent just generated:

support-desk/
├── objectstack.config.ts     # defineStack() — the single entry point, wires it all
├── src/
│   ├── objects/              # Data models (required)
│   ├── datasources/          # External database / API connections
│   ├── hooks/                # Record lifecycle logic
│   ├── data/                 # Seed records
│   ├── views/                # List / form / kanban lenses
│   ├── pages/                # Standalone custom pages
│   ├── apps/                 # Navigation shells
│   ├── actions/              # Buttons, bulk operations
│   ├── dashboards/           # Analytics boards
│   ├── reports/              # Saved analytical queries
│   ├── datasets/             # Semantic-layer datasets
│   ├── flows/                # Automation logic
│   ├── functions/            # Named handler callables (code, not metadata)
│   ├── translations/         # i18n bundles
│   ├── security/             # Permission sets, positions, sharing rules, capabilities
│   ├── docs/                 # In-app documentation (Markdown)
│   └── agents/               # AI agents
└── test/                     # Tests

No app ships every one of those: it is the union of what examples/app-todo and examples/app-crm lay out flat, plus the directories os g <type> writes into. A larger app groups the same collections by domain instead — examples/app-showcase is the reference for that variant:

app-showcase/
├── objectstack.config.ts
├── src/
│   ├── data/                 # objects, extensions, hooks, mappings, analytics, seed
│   ├── ui/                   # views, pages, apps, actions, dashboards, reports, datasets
│   ├── automation/           # flows, jobs, webhooks
│   ├── security/             # permission sets, positions, sharing rules, capabilities
│   ├── system/               # apis, books, connectors, datasources, emails, server, translations
│   └── docs/                 # In-app documentation (Markdown)
└── test/

Each folder has an index.ts barrel that re-exports its metadata; those barrels are imported into objectstack.config.ts. There is no filename-suffix magic — metadata is wired in through those explicit imports, so objectstack.config.ts is the one place that tells you what's actually in the app.

Which is why the directory is a convention and the defineStack() key is the contract: the two trees above disagree about paths and agree exactly about keys. So when you are holding a piece of metadata and want to know where it goes, start from the key — Where each piece of metadata goes lists every one of them.

Where each piece of metadata goes

Every piece of metadata in an app arrives through one defineStack() key. This is the full authorable set, ordered data → interface → automation → integration → access → AI — one clause each, and the page to read next:

defineStack() keyDeclaresGuide
objectsBusiness objects — tables, fields, validationObjects
objectExtensionsFields and config merged into an object another package ownsReference
datasourcesConnections to external databases and APIsExternal Datasources
datasourceMappingRules routing a package, namespace or object pattern to a datasourceExternal Datasources
dataSeed records loaded at bootstrapSeed Data
hooksRecord lifecycle logic on insert / update / deleteHooks
mappingsField mappings for data import and exportReference
analyticsCubesSemantic-layer cubes over the object graphReference
datasetsQuery-shaped datasets that charts and reports read fromAnalytics
appsNavigation shells — which tabs a user seesApps
viewsList / form / kanban lenses over an objectViews
pagesStandalone custom pagesPages
dashboardsChart and metric boardsDashboards
reportsSaved analytical queries with grouping and totalsReference
actionsButtons and bulk operations, with CEL visibilityActions
translationsi18n bundles for labels and messagesTranslations
docsIn-app Markdown documentation itemsDoc Pages
booksOrdered navigation spines over those doc itemsReference
flowsAutomation and approval graphsFlows
jobsScheduled and background jobsReference
emailTemplatesTemplates the email service resolves by name and localeReference
webhooksOutbound HTTP notificationsWebhooks
connectorsExternal system connectors a flow can dispatchConnectors
apisDeclarative REST endpoints under your own namespaceReference
positionsCapability-distribution groupsPositions
permissionsPermission sets — object, field and system grantsPermission Sets
capabilitiesAuthorization capabilities this package definesReference
sharingRulesRecord-level sharing beyond the org-wide defaultSharing Rules
agentsAI agents — platform-internal; third parties extend through skillsAgents
skillsReusable AI capability bundles — the extension primitiveSkills
toolsOptional AI-presentation refinement over an action or flowReference

Rows linked to a Reference have no hand-written guide yet — the generated schema page is the authority until one lands.

Not in the table, and why. viewItems and runtimeModule are not authorable at all (viewItems is z.never(), the machine-assembled channel for runtime-assembled manifests; runtimeModule is written by objectstack build); manifest, i18n, api, server, requires, tiers, plugins and devPlugins configure the stack rather than declare metadata items; and functions and onEnable are code the runtime calls, not metadata it stores.

How you verify

Reading the metadata is half of verification; running the app is the other half. Two commands, both of which the agent runs for you but you can run yourself:

npx os validate     # the gate — schema + CEL predicates + widget bindings (no artifact)
npx os dev --ui     # boot the app, then open http://localhost:3000/_console/

os validate proves the metadata is well-formed; the Console at /_console/ proves it does what you meant — create a record, watch an action show/hide, confirm the nav and filters. That visual check is the human's half of every build loop. See Validating Metadata for what the gate catches, and Build with Claude Code for the full describe → author → validate → verify cycle.

Go deeper

Next steps

On this page