Custom apps

How your organization will add its own record types, Actions, and views to Continual.

A custom app adds what your business needs and Continual's apps do not have: your own record types, Actions, and views. You define it the same way Continual's own apps are defined, and it runs beside them.

What a custom app is

Here is a small app for a trades business. It keeps jobs, shows them on a board and a calendar, and adds one Action that finishes a job.

TypeScript
import {
  defineAction,
  defineApp,
  defineObject,
  schema,
} from "@company/model/definition/index.ts"
import { Party } from "@company/model/platform/directory/parties.ts"

export const Job = defineObject({
  type: "acme.trades.job",
  collection: "acme.trades.jobs",
  name: "Job",
  pluralName: "Jobs",
  description: "Work done at a customer's site, from booking to done.",
  roles: { "platform.organizationMember": "all" },
  properties: {
    number: schema.counter({ label: "Number", prefix: "JOB" }),
    title: schema.string({ label: "Title", minLength: 1, maxLength: 200 }),
    customer: schema.reference.one(Party, {
      label: "Customer",
      inverse: { key: "jobs", label: "Jobs" },
    }),
    stage: schema.state(
      [
        {
          value: "booked",
          label: "Booked",
          color: "gray",
          category: "pending",
        },
        {
          value: "onSite",
          label: "On site",
          color: "blue",
          category: "active",
        },
        { value: "done", label: "Done", color: "green", category: "succeeded" },
      ],
      { label: "Stage", default: "booked" }
    ),
    visitDate: schema.date({ label: "Visit", nullable: true }),
    quote: schema.money({ label: "Quote", nullable: true }),
  },
  indexes: {
    stage: { fields: ["stage", "visitDate"] },
  },
  defaultViews: [
    { name: "Board", layout: "board", group: "stage" },
    { name: "Schedule", layout: "calendar", start: "visitDate" },
  ],
  display: { title: ["title"], summary: ["customer", "visitDate", "quote"] },
})

export const FinishJob = defineAction({
  key: "finish",
  record: Job,
  name: "Finish job",
  description: "Marks the job done once the work is complete.",
  transition: { from: ["booked", "onSite"], to: "done" },
})

export const TradesApp = defineApp({
  id: "acme.trades",
  publisher: "acme",
  name: "Trades",
  version: "1.0.0",
  objects: [Job],
  actions: [FinishJob],
})

The parts:

  • defineObject declares a record type: its fields, its stages, who can work on it, the views it starts with, and the indexes its views filter and sort by, here jobs by stage and visit date. A job's customer is any company or person in your records.
  • defineAction declares an Action. FinishJob only moves a job between stages, so it needs no code.
  • defineApp packages them. Your app's names start with your organization's namespace, here acme, so they never clash with Continual's.

Name records clearly

Every record shows its type, its number or handle, its title, and its status. Your definition declares each one, and Continual shows them the same way everywhere:

  • Type: name and pluralName, such as "Job" and "Jobs".
  • Number: a schema.counter field, such as JOB-14. It is the record's handle, so it works wherever the record's ID does. Records that are named, not numbered, set handle to a field such as a domain or a code instead.
  • Title: display.title, the fields that say what the record is about. A title never names the counter, because the number already shows beside it and a draft has none. A title field may be a reference, such as a customer: the title then shows that record's own title.
  • Status: a schema.state field. Its stages' categories decide how lists and filters treat it.

For a record that is final once committed, such as an invoice or a work order, use defineDocument instead of defineObject. A Document is a draft until its commit Action runs. Committing numbers it and makes it final, and corrections are new Documents. See Documents and numbers.

How it runs beside Continual's apps

From the definition alone, each record type gets what Continual's own record types get:

  • Its standard operations, such as acme.trades.jobs.list, in the REST API and as MCP tools.
  • Its pages in the web app, with the views you declared.
  • Search, activity, and the record's history.

Your app's records follow the same rules as every other record. Members' roles decide who can read and change them. Policies can hold its Actions for a person's yes. Every change records who made it.

What works today

Installing your own app works through the API today. organizations.install takes the app's manifest, the JSON form of its definition, and adds it to the organization the request works in. Installing it again with changes updates it. Continual refuses a change that would drop a record type that has records, or a field that holds values.

The SDK that writes manifests is not published yet. So this is not self-serve today.

Coming soon: ask Continual to build your app in your thread, and install it from the conversation.