Note: inline code samples are documented with comments, but it's not necessary to include those comments in the output.
We use Interval for our internal tools. Interval lets you build read/write internal tools just by writing async code in your backend codebase, no frontend dev.
Interval apps are defined as actions in your codebase and can be accessed through your team's Interval web dashboard.
Here's an app with a single "Hello, world" action:
// apps/interval/src/interval/routes/hello-world.ts
import { io, ctx } from "@interval/sdk";
import { SecureAction } from "@/lib/secure-constructors";
export default new SecureAction({
// Always use sentence case in titles
name: "Hello world",
// include a brief description of what the action does
description: "Prints the name of the user",
handler: async () => {
// when run in the browser, this script will pause execution until the user submits the form
const name = await io.input.text("Your name");
return `Hello, ${name}`;
}
});Interval has two core building blocks:
- Actions are "write" tools that can collect input and display output (think: 'create' and 'update' forms in a CRUD app).
- Pages are "read" tools that load data and display output (think: an 'index' view in a CRUD app).
- You CANNOT pause and ask for input in pages; these MUST be read-only tools. If input is needed, use an Action.
When the Interval server comes online, Interval constructs your Actions and Pages into routes and displays a hierarchical UI in the browser.
Interval uses file-based routing. Example file structure: (these are not real actions in our codebase)
// assume root app directory: apps/interval
- src
- interval
- routes
- users
- index.ts (page - route is "users")
- create.ts (action - route is "users/create")
- update.ts (action - route is "users/update")
- toggle-flag.ts (action - route is "users/toggle-flag")
- review-charts.ts (action; step-by-step form - route is "review-charts")
- waitlist.ts (page; table of pending users - route is "waitlist")
- users
- routes
- interval
Our Interval routes are located at apps/interval/src/interval/routes. Look at files in this directory for prior art when writing actions and pages. Unless specified, new actions should go in the root "routes" directory.
Route paths are separated by slashes, as described above. When linking to actions you can pass query params in using a 'params' key/value object. Params are accessible via the ctx import from @interval/sdk, e.g. ctx.params.id.
Actions that receive params should validate them using zod schemas for type safety and runtime validation:
import { ctx, io } from "@interval/sdk";
import { SecureAction } from "@/lib/secure-constructors";
import z from "zod";
const InputSchema = z.object({
id: z.string(),
});
export default new SecureAction({
name: "View Record",
unlisted: true,
handler: async () => {
const { id } = InputSchema.parse(ctx.params);
// Use the validated id...
},
});Interval has the following io methods:
- io.input.text
- io.input.boolean
- io.input.number
- io.input.slider
- io.input.email
- io.input.richText
- io.input.url
- io.input.date
- io.input.time
- io.input.datetime
- io.input.file
- io.confirm (displays a confirmation step)
- io.search (searches records w/ an async handler)
- io.select.single (single-select)
- io.select.multiple (multi-select)
- io.select.table (select 1+ tabular data)
- io.display.heading (section header)
- io.display.link (a button)
- io.display.markdown (displays text in markdown)
- io.display.metadata (displays a table of key/value info)
Use io.group to group multiple inputs/outputs together:
const { name, email } = await io.group({
intro: io.display.markdown("..."),
name: io.input.text("Name", {
defaultValue: user.name, // null/undefined are supported, no need to || ""
}),
email: io.input.text("Email", {
defaultValue: user.email,
disabled: true,
})
})Chain .withChoices to customize the "next" button:
const { choice, returnValue: { name, email } } = await io.group({
intro: io.display.markdown("..."),
name: io.input.text("Name", {
defaultValue: user.name,
}),
email: io.input.text("Email", {
defaultValue: user.email,
disabled: true,
})
}).withChoices([
{
label: "Save",
value: "save", // this is 'choice'
theme: "primary",
},
{
label: "Cancel",
value: "cancel", // this is 'choice'
theme: "secondary",
},
])Here is the syntax for creating a page: (note the Page export and how a Layout is returned)
import { Layout, io } from "@interval/sdk";
import { prisma } from "@/lib/db";
import { SecurePage } from "@/lib/secure-constructors";
export default new SecurePage({
// always use title case for Page titles
name: "Record Review",
handler: async () => {
const records = await prisma.records.findMany();
return new Layout({
title: "Record Review",
menuItems: [
{
label: "Create",
action: "records/create",
},
],
children: [
io.display.markdown(
"Showing records that have been flagged for review.",
),
io.display.table("Record Review", {
data: records,
rowMenuItems: row => ({
label: "Edit",
action: "records/edit",
params: { id: row.id }, // will be accessible as ctx.params.id in the "edit" action
})
}),
],
});
},
});You will often display data in tables. Here is how to use tables:
await io.display.table("Record Review", {
data: records,
columns: [
{
label: "Flags",
// use renderCell to output something different than the raw value.
// Strings, numbers, booleans, dates, and null/undefined are all supported.
// Do not transform values unless necessary (e.g. number -> currency)
renderCell: (row) => {
if (row.flags.length === 0) {
return "";
}
return "⚠️";
},
},
{
label: "Note",
// include accessorKey if the value exists in the dataset; this is used for sorting
accessorKey: "note",
// `note` is used for sorting, but we also change the way the value is printed in the UI
renderCell: (row) => {
return formatFlags(row.flags);
},
},
{
label: "Name",
accessorKey: "name", // one of accessorKey or renderCell is required
}
],
// these are shown in a ••• menu at the end of each row
rowMenuItems: (row) => [
{
label: "Fix attribution",
// links to src/interval/routes/records/fix-attribution
action: "records/fix-attribution",
// optionally pass query params to actions
params: {
recordId: row.id,
},
},
],
});Information can also be displayed in a list using io.display.metadata. It requires a data array of key/value pairs:
await io.display.metadata("Record details", {
layout: "list", // 'grid' and 'card' are also supported, but default to 'list'
data: [
{ label: "Record ID", value: record.id },
],
});io.select.single is also a frequently used input:
const { reason, note, amount } = await io.group({
reason: io.select.single("Select a reason", {
// this can also be an array of strings if they aren't key/value pairs
options: [
{
value: "duplicate",
label: "Duplicate",
},
{
value: "invalid",
label: "Invalid",
}
],
}),
note: io.input.text("Note", {
helpText: "Why we're updating this",
// chain optional if the value is not required, otherwise it's required
}).optional(),
// use currency + decimals to format currency
amount: io.input.number("Amount", {
currency: "USD",
decimals: 2,
}),
});Confirmation steps look like this:
const confirmed = await io.confirm(
`Proceed with ${dryRun ? "dry run of " : ""}syncing ${users.length} users?`,
{
helpText: "This will create or update the user and skip any invalid records.",
},
);
if (!confirmed) {
return "Did not confirm"
}You can perform an async search on a remote resource like this:
const user = await io.search("Search for a user", {
renderResult: user => ({
label: user.name,
description: user.email,
}),
onSearch: async query => {
// filter either on the server or on the client
return await myApiClient.query({ name: query })
},
});In our system, it's common to start an action by selecting an organization:
import { selectOrganization } from "@/utils";
// displays an Interval selection input, returning an `Organization` model
const org = await selectOrganization();Interval has APIs for displaying loading indicators:
// Start a loading indicator
await ctx.loading.start({
label: "Processing uploads...",
description: "Reticulating splines"
itemsInQueue: 10, // (optional) - include number of items being processed
});
// Update existing loading spinner
await ctx.loading.update({
title: "Processing uploads...",
description: "Adjusting hidden agendas",
});
// If using itemsinQueue, you can do this after each:
for (const user of users) {
await migrateUser(user);
await ctx.loading.completeOne();
}Instructions for creating a complete CRUD interface for a database model in the Interval app.
For full Interval documentation, see @.cursor/rules/interval.mdc.
- Folder name: Use hyphenated, plural form of model name
- Model
Location→ folderlocations - Model
FeatureFlag→ folderfeature-flags - Model
AppointmentStatusChange→ folderappointment-status-changes
- Model
- Page titles: Use title case with plural form
"Locations","Feature Flags","Appointment Status Changes"
- Action titles: Use title case
"Create Location","Edit Feature Flag","Delete Appointment Status Change"
- Menu labels: Use title case
"Add Location","Edit","Delete"
- File names: Use singular form matching model name
index.ts,create.ts,edit.ts,delete.ts
- Variable names: Use camelCase singular form
location,featureFlag,appointmentStatusChange
- Parameter names: Use camelCase with
IdsuffixlocationId,featureFlagId,appointmentStatusChangeId
- Model must be defined in
packages/db/prisma/schema.prisma - Model must be exported from
@shared/dbpackage - TypeScript types must be available from the database package
For a model named {ModelName} (e.g., Location), create these files in apps/interval/src/interval/routes/{folder-name}/:
routes/
{folder-name}/ # hyphenated, plural (e.g., locations, feature-flags)
index.ts # List page with table view
create.ts # Create new record action
edit.ts # Edit existing record action
delete.ts # Delete record action (optional)
import { prisma } from "@/lib/db";
import type { {ModelName} } from "@shared/db";
import { Layout, io } from "@interval/sdk";
import { SecurePage } from "@/lib/secure-constructors";
export default new SecurePage({
name: "{Page title}", // e.g., "Locations", "Feature Flags"
handler: async () => {
const {modelName}s = await prisma.{modelName}.findMany({
// Judgment call: order by a "name" or "title" field if appropriate and such a field is available, otherwise created_at
orderBy: { created_at: "desc" },
});
return new Layout({
title: "{Page title}", // e.g., "Locations", "Feature Flags"
menuItems: [
{
label: "Add {modelName}", // e.g., "Add Location", "Add Feature Flag"
action: "{folder-name}/create",
},
],
children: [
io.display.table("", {
data: {modelName}s,
columns: [
// REQUIRED: Add columns for each field you want to display
{ label: "Name", accessorKey: "name" },
// For string fields:
{ label: "Field Name", accessorKey: "field_name" },
// For boolean fields:
{
label: "Boolean Field",
renderCell: (row: {ModelName}) => row.boolean_field ? "Yes" : "No",
},
// For array fields:
{
label: "Array Field",
renderCell: (row: {ModelName}) => row.array_field.join(", "),
},
// For date fields:
{
label: "Created",
renderCell: (row: {ModelName}) => row.created_at.toLocaleDateString(),
},
],
rowMenuItems: (row: {ModelName}) => [
{
label: "Edit",
action: "{folder-name}/edit",
params: { id: row.id },
},
{
label: "Delete",
action: "{folder-name}/delete",
theme: "danger",
params: { id: row.id },
},
],
}),
],
});
},
});import { prisma } from "@/lib/db";
import { io } from "@interval/sdk";
import { SecureAction } from "@/lib/secure-constructors";
export default new SecureAction({
name: "Create {modelName}", // e.g., "Create Location", "Create Feature Flag"
description: "Create a new {modelName} in the database",
unlisted: true,
handler: async () => {
// REQUIRED: Define all form fields based on your model
const {
// Add all required fields from your model
name,
// string_field,
// boolean_field,
// array_field,
} = await io.group({
name: io.input.text("{ModelName} Name", {
// Help text should add context if necessary, otherwise omit
helpText: "Name must be in sentence case and pluralized",
}),
// For string fields:
// string_field: io.input.text("Field Label", {
// helpText: "Use help text to add context",
// placeholder: "Include placeholder if appropriate",
// }),
// For optional string fields:
// optional_field: io.input.text("Optional Field").optional(),
// For boolean fields:
// boolean_field: io.input.boolean("Boolean Field", {
// defaultValue: false,
// }),
// For select fields (single):
// select_field: io.select.single("Select Field", {
// options: [
// { label: "Option 1", value: "option1" },
// { label: "Option 2", value: "option2" },
// ],
// helpText: "Select an option",
// }),
// For multi-select fields:
// multi_select_field: io.select.multiple("Multi Select Field", {
// options: [
// { label: "Option 1", value: "option1" },
// { label: "Option 2", value: "option2" },
// ],
// helpText: "Select multiple options",
// defaultValue: [],
// }),
});
const {modelName} = await prisma.{modelName}.create({
data: {
name,
// Map form fields to database fields
// string_field,
// boolean_field,
// array_field: multi_select_field.map(item => item.value),
},
});
return io.display.object("✅ {ModelName} created successfully!", {
data: {modelName},
});
},
});import { prisma } from "@/lib/db";
import { ctx, io } from "@interval/sdk";
import { SecureAction } from "@/lib/secure-constructors";
import z from "zod";
const InputSchema = z.object({
id: z.string(),
});
export default new SecureAction({
name: "Edit {modelName}", // e.g., "Edit Location", "Edit Feature Flag"
unlisted: true,
handler: async () => {
const { id } = InputSchema.parse(ctx.params);
const {modelName} = await prisma.{modelName}.findUniqueOrThrow({
where: { id },
});
// REQUIRED: Define all form fields with defaultValue from existing record
// Use the same field definitions as in the Create action, but add defaultValue
const {
name,
// Add all editable fields - reference the Create action for field definitions
} = await io.group({
name: io.input.text("{ModelName} Name", {
defaultValue: {modelName}.name,
}),
// Add other fields here with defaultValue set from the existing record
});
const updated{ModelName} = await prisma.{modelName}.update({
where: { id: {modelName}.id },
data: {
name,
// Map form fields to database fields
// string_field,
// boolean_field,
// array_field: multi_select_field.map(item => item.value),
},
});
return io.display.object("✅ {ModelName} updated successfully!", {
data: updated{ModelName},
});
},
});import { prisma } from "@/lib/db";
import { ctx, io } from "@interval/sdk";
import { SecureAction } from "@/lib/secure-constructors";
import z from "zod";
const InputSchema = z.object({
id: z.string(),
});
export default new SecureAction({
name: "Delete {modelName}", // e.g., "Delete Location", "Delete Feature Flag"
description: "Delete a {modelName} from the database",
unlisted: true,
handler: async () => {
const { id } = InputSchema.parse(ctx.params);
const {modelName} = await prisma.{modelName}.findUniqueOrThrow({
where: { id },
});
const shouldDelete = await io.confirm(
`Are you sure you want to delete '${{{modelName}.name}}'?`,
{
helpText: "This action cannot be undone.",
},
);
if (!shouldDelete) {
await io.display.markdown("❌ {ModelName} was not deleted");
return;
}
await prisma.{modelName}.delete({
where: { id: {modelName}.id },
});
await io.display.markdown(`✅ {ModelName} ${{{modelName}.name}} was deleted`);
},
});-
Analyze the Prisma model - Examine the model definition in
schema.prismato understand:- Required vs optional fields
- Field types (string, boolean, array, etc.)
- Relationships to other models
- Default values
-
Create the directory structure:
mkdir -p apps/interval/src/interval/routes/{folder-name} -
Create all four files using the templates above, replacing:
{ModelName}with PascalCase model name (e.g.,Location){modelName}with camelCase model name (e.g.,location){folder-name}with hyphenated, plural folder name (e.g.,locations,feature-flags){Page title}with sentence case plural title (e.g.,"Locations","Feature flags")- Form fields with actual model fields
- Column definitions with actual model properties
-
Customize form fields based on field types:
- String:
io.input.text(),io.input.url(),io.input.email() - Boolean:
io.input.boolean() - Array:
io.select.multiple() - Number:
io.input.number() - Date:
io.input.datetime() - Enum:
io.select.single()with enum values - Optional: Add
.optional()to field definition
- String:
-
Update table columns in
index.tsto display relevant fields -
Handle special cases:
- Arrays: Map to/from
{ label, value }format for selects - Relationships: Include related data in queries if needed
- Arrays: Map to/from
For array fields, always use this pattern:
// In create.ts
array_field: io.select.multiple("Field Name", {
options: OPTIONS_ARRAY.map(item => ({ label: item, value: item })),
defaultValue: [],
}),
// In database operation
data: {
array_field: array_field.map(item => item.value),
}
// In edit.ts defaultValue
defaultValue: Array.isArray(model.array_field)
? model.array_field.map(item => ({ label: item, value: item }))
: [],When using io.select.single() or io.select.multiple(), the form returns { label, value } objects. You need to extract the value and find the original object:
// In form definition
my_select_field: io.select.single("Select Field", {
options: AVAILABLE_OPTIONS.map(item => ({ label: item.name, value: item.id })),
defaultValue: currentSelection, // { label: "...", value: "..." }
}),
// In database operation - extract the value
data: {
my_field_id: my_select_field?.value, // Use optional chaining for optional selects
}
// For edit forms - find current selection from options array
const currentSelection = AVAILABLE_OPTIONS.find(
item => item.id === existingRecord.my_field_id
);
// Then use as defaultValue: currentSelection ? { label: currentSelection.name, value: currentSelection.id } : undefined// Import enum from shared package
import { ENUM_VALUES } from "@shared/db";
// Use in form
enum_field: io.select.single("Enum Field", {
options: ENUM_VALUES.map(value => ({ label: value, value })),
helpText: "Select an option",
}),When a new field is added to an existing database model, update the corresponding CRUD interface files:
- Identify the field type from the Prisma schema to determine the appropriate form input
- Update the Create action (
create.ts):- Add the new field to the
io.group()form definition - Add the new field to the
prisma.create()data object
- Add the new field to the
- Update the Edit action (
edit.ts):- Add the new field to the
io.group()form definition withdefaultValue - Add the new field to the
prisma.update()data object
- Add the new field to the
- Update the List page (
index.ts) if the field should be displayed in the table - Test the changes to ensure the new field works correctly
Given this instruction: "Update the Location management tool in Interval tool with the new is_open column"
Update locations/create.ts:
// Add to io.group():
is_open: io.input.boolean("Location is currently open", {
defaultValue: true,
}),
// Add to prisma.location.create():
data: {
// ... existing fields
is_open,
},Update locations/edit.ts:
// Add to io.group():
is_open: io.input.boolean("Location is currently open", {
defaultValue: location.is_open ?? true,
}),
// Add to prisma.location.update():
data: {
// ... existing fields
is_open,
},Update locations/index.ts (optional):
// Add to table columns if the field should be displayed:
{
label: "Status",
renderCell: (row: Location) => row.is_open ? "Open" : "Closed",
},- ALWAYS use
unlisted: truefor create, edit, and delete actions - NEVER hardcode options arrays - import from shared packages when available
- ALWAYS handle optional fields with null coalescing (
??) - NEVER commit with TypeScript errors - run
pnpm typecheckbefore committing