Intro
What Commands are, why they exist, and which problems they solve.
Tulip Commands are a typed action system for business UIs. You define command behavior once, then reuse it across inline actions, dropdown menus, context menus, responsive headers, and table bulk actions.
Entry points
Use the shared package entry points depending on where the code runs:
import { commandBuilder, createCommandBuilder, defineCommands } from "@tulip-systems/core/commands";
import { InlineCommandMenu, DeleteCommand } from "@tulip-systems/core/commands/client";@tulip-systems/core/commands: command definitions and registries@tulip-systems/core/commands/client: menu components, command primitives, utilities, and hooks
What it is
At a high level, Commands are:
- Typed definitions: each command has input schema, optional permission, optional visibility/disabled rules, and render function
- Registry-based: commands are grouped with
defineCommands({...}) - UI-agnostic: the same command definition can render in different menu surfaces
- Composable: each screen picks the subset it needs via
.pick({...}).toArray()
Why we need it
Without a command system, action code is usually duplicated across pages:
- a delete button in row actions
- another delete action in context menu
- another delete action in detail header
That leads to drift in permissions, behavior, labels, and mutation side-effects.
Commands solve this by centralizing the action logic and letting UI only decide where to render it.
Problems it solves
Repeated action logic
One command definition can be rendered in many places.
Inconsistent permission checks
Permissions live with the command (.permission(...)) instead of being scattered.
Inconsistent visibility rules
Context-aware visibility belongs in .visibleWhen(...).
Inconsistent disabled behavior
Context-aware disabled state belongs in .disabledWhen(...).
Screen-specific action sets
Pages can pick exactly what they need:
commands={projectCommands.pick({ updateStatus: true, archive: true, delete: true }).toArray()}Different UI, same behavior
The same command works in:
InlineCommandMenuDropdownCommandMenuContextCommandMenuContentFloatingCommandMenuResponsiveCommandMenu
Core building blocks
commandBuilder/createCommandBuilder<TMeta>()defineCommands({...})- menu components from
@tulip-systems/core/commands/client - utility primitives like
DeleteCommand,ArchiveCommand,CommandClick,CommandDialog, andCommandFormDialog
Recommended pattern
- Export one registry per module (
xCommands) - Select commands at usage site with
.pick({...}).toArray() - Avoid creating extra alias constants only for selected subsets
- Never pass registry objects directly to menu components; pass arrays
First command
This is the smallest useful command setup: define a schema, register the command, then render a selected array through a menu.
import { commandBuilder, defineCommands } from "@tulip-systems/core/commands";
import { CommandClick, CommandLabel, InlineCommandMenu } from "@tulip-systems/core/commands/client";
import { ArchiveIcon } from "lucide-react";
import z from "zod";
import { orpc } from "@/server/router/client";
const projectSchema = z.object({ id: z.string() });
export const projectCommands = defineCommands({
archive: commandBuilder
.input(projectSchema)
.permission({ project: ["archive"] })
.render(({ data }) => (
<CommandClick
label="Archive"
variables={{ ids: [data.id] }}
mutation={orpc.projects.archive.mutationOptions()}
>
<ArchiveIcon className="w-4" />
<CommandLabel />
</CommandClick>
)),
});
export function ProjectActions({ project }: { project: { id: string } }) {
return (
<InlineCommandMenu
data={project}
commands={projectCommands.pick({ archive: true }).toArray()}
/>
);
}When a command does not render
Check these in order:
- the menu received an empty
commandsarray - the command was not included in
.pick(...)or was removed by.omit(...) datafailed the command.input(...)schema.visibleWhen(...)returnedfalseor an array containingfalse- the current user does not satisfy
.permission(...)