One definition. Thirteen platforms.
next-vibe turns a single TypeScript definition into thirteen platforms at once - interactive web UI, CLI command, MCP tool, mobile screen, cron job, WebSocket, admin panel, and more. Full type safety, zero drift, zero repetition.
You've built the same thing thirteen times.
Every feature needs a custom web UI, CLI command, MCP tool, mobile screen, cron job, WebSocket handler, admin panel, and more. Same validation, same i18n, same error handling - just dressed differently. Every time.
Your AI builds new tools for itself.
Add an endpoint and it immediately becomes a custom interactive UI, CLI command, AI-callable tool, MCP server tool, and scheduled job. The stack is recursive - Claude Code builds next-vibe using next-vibe.
Two files required. Every platform.
Each feature lives in a folder. Only definition.ts and route.ts are required - everything else is optional.
definition.ts - the contract
Declare your fields, Zod schemas, labels, error types, and examples once. This file is the single source of truth - the framework reads it at runtime on every platform.
const { POST } = createEndpoint({
scopedTranslation,
method: Methods.POST,
path: ["explain", "to-my-boss"],
title: "post.title" as const,
description: "post.description" as const,
icon: "sparkles",
category: "endpointCategories.ai",
allowedRoles: [UserRole.CUSTOMER, UserRole.ADMIN] as const,
fields: customWidgetObject({
render: ExplainContainer,
usage: { request: "data", response: true } as const,
children: {
decision: requestField(scopedTranslation, {
type: WidgetType.FORM_FIELD,
fieldType: FieldDataType.TEXTAREA,
label: "post.decision.label" as const,
columns: 12,
schema: z.string().min(10),
}),
justification: responseField(scopedTranslation, {
type: WidgetType.TEXT,
content: "post.justification.content" as const,
schema: z.string(),
}),
},
}),
errorTypes: {
[EndpointErrorTypes.UNAUTHORIZED]: { title: "post.errors.unauthorized.title" as const, description: "post.errors.unauthorized.description" as const },
[EndpointErrorTypes.SERVER_ERROR]: { title: "post.errors.serverError.title" as const, description: "post.errors.serverError.description" as const },
// ... other error types
},
successTypes: { title: "post.success.title" as const, description: "post.success.description" as const },
examples: { requests: { default: { decision: "Rewrite everything in TypeScript" } }, responses: { default: { justification: "..." } } },
});
export type ExplainResponseOutput = typeof POST.types.ResponseOutput;
const definitions = { POST } as const;
export default definitions;repository.ts - the logic
Business logic lives here - DB queries, auth checks, error handling with success()/fail(). The route.ts is just a thin delegator; validation, logging, and platform registration happen automatically.
import "server-only";
export class ExplainRepository {
static async explainToMyBoss(
data: ExplainRequestOutput,
user: JwtPayloadType,
logger: EndpointLogger,
t: ExplainT,
): Promise<ResponseType<ExplainResponseOutput>> {
if (!user.id) {
return fail({ message: t("post.errors.unauthorized.title"), errorType: ErrorResponseTypes.UNAUTHORIZED });
}
try {
const [saved] = await db
.insert(explainResults)
.values({ userId: user.id, decision: data.decision })
.returning();
logger.info("Saved decision", { userId: user.id, id: saved.id });
return success({ justification: saved.justification ?? "" });
} catch (error) {
logger.error("Failed to save decision", parseError(error));
return fail({ message: t("post.errors.serverError.title"), errorType: ErrorResponseTypes.INTERNAL_ERROR });
}
}
}widget.tsx - the UI (optional)
Without a widget, the framework auto-renders your fields everywhere. Add widget.tsx to ship a fully custom interactive UI - the same component renders in admin panels, embedded widgets, and mobile screens.
"use client";
interface CustomWidgetProps {
field: {
value: ExplainResponseOutput | null | undefined;
} & (typeof definition.POST)["fields"];
}
export function ExplainContainer({ field }: CustomWidgetProps): JSX.Element {
const children = field.children;
const emptyField = useMemo(() => ({}), []);
return (
<Div className="flex flex-col gap-4 p-4">
<TextareaFieldWidget fieldName="decision" field={children.decision} />
<SubmitButtonWidget<typeof definition.POST>
field={{
text: "Explain to my boss",
loadingText: "Explaining...",
icon: "sparkles",
variant: "primary",
}}
/>
<FormAlertWidget field={emptyField} />
<AlertWidget
fieldName="justification"
field={withValue(children.justification, field.value?.justification, null)}
/>
</Div>
);
}Write it once. Run it everywhere.
This is a real endpoint from this repo: read the focused window on a Linux desktop. Three files. No glue code. It shows up as a web page, a CLI command, an MCP tool, an AI tool and a cron job.
definition.ts
Path, alias, roles, Zod schema, widget, errors, examples. Everything every surface needs, in one place.
// definition.ts - the ONE source of truth
const { POST } = createEndpoint({
scopedTranslation,
method: Methods.POST,
path: ["desktop", "get-focused-window"],
aliases: ["desktop-get-focused-window"] as const,
title: "get-focused-window.title",
icon: "eye",
allowedRoles: [UserRole.ADMIN, UserRole.PRODUCTION_OFF],
fields: customWidgetObject({
render: GetFocusedWindowWidget, // one widget, every platform
usage: { request: "data", response: true } as const,
children: {
windowTitle: responseField(scopedTranslation, {
type: WidgetType.TEXT,
label: "get-focused-window.response.windowTitle",
schema: z.string().optional(),
}),
pid: responseField(scopedTranslation, {
type: WidgetType.TEXT,
label: "get-focused-window.response.pid",
schema: z.number().optional(),
}),
},
}),
errorTypes: { /* all 9 EndpointErrorTypes - required */ },
successTypes: { /* title + description */ },
examples: { /* request + response samples */ },
});route.ts
Wires the definition to a repository function. That handler is what every surface calls.
// route.ts - wire definition to repository
export const { POST, tools } = endpointsHandler({
endpoint: getFocusedWindowEndpoints,
[Methods.POST]: {
email: undefined,
handler: ({ t, logger }) =>
DesktopWindowRepository.getFocusedWindow(t, logger),
},
});What you get without writing more code
CLI command
$ vibe desktop-get-focused-window
◆ Focused Window
Mozilla Firefox
pid: 2820 2293×916 monitor: DP-2MCP tool
// .mcp.json - any MCP client (Claude, Cursor, ...)
{
"mcpServers": {
"my-app": { "command": "vibe", "args": ["mcp"] }
}
}
// the client now lists: desktop-get-focused-window
// input and output schemas come from the Zod fields aboveAI-callable tool
// Same definition, exposed as a function-calling tool.
// The model reads the Zod schema + description. No wrapper.
//
// user: "What window am I looking at?"
// model: desktop-get-focused-window({})
// tool: { windowTitle: "Mozilla Firefox", pid: 2820 }
//
// Hide it from the model: add UserRole.AI_TOOL_OFFCron job
// task.ts - the same handler on a schedule
const task = createCronTask(
definition.POST,
tools.POST, // <- the route handler from route.ts
{
id: "credits-expire",
schedule: CRON_SCHEDULES.DAILY_MIDNIGHT,
category: TaskCategory.MAINTENANCE,
priority: CronTaskPriority.MEDIUM,
timeout: TASK_TIMEOUTS.SHORT,
enabled: true,
taskInput: undefined,
},
);
export const tasks: Task[] = [task];Add your own in five lines of shell
Create the folder, write the three files, regenerate, check. If vibe check says 0 errors, it works everywhere.
mkdir -p src/desktop/get-focused-window
# write definition.ts, repository.ts, route.ts (+ widget.tsx)
vibe gen # regenerates route, CLI, MCP and tool registries
vibe check src/desktop/get-focused-window # 0 errors or it's not done
vibe desktop-get-focused-window # CLI, right now
# web: /en-US/tools/desktop-get-focused-window
# MCP: already listed - restart your clientOne definition. 13 platforms. Zero drift.
Write one endpoint definition. Get an interactive web UI, CLI command, AI tool, MCP server, cron job, WebSocket, mobile screen, desktop app, admin panel, agent skill, and more - automatically. Type-safe end-to-end. Role-controlled access. Zero boilerplate.
const { POST } = createEndpoint({
method: Methods.POST,
path: ["greet"],
allowedRoles: [UserRole.PUBLIC, UserRole.CUSTOMER, UserRole.ADMIN],
title: "Greet user",
description: "Returns a personalised greeting",
icon: "hand-wave",
tags: ["greetings"],
fields: objectField({
type: WidgetType.CONTAINER,
title: "Greeting form",
usage: { request: "data", response: true },
children: {
name: requestField({
schema: z.string().min(1).max(100),
type: WidgetType.FORM_FIELD,
fieldType: FieldDataType.TEXT,
label: "Your name",
placeholder: "e.g. Alice",
}),
message: responseField({
schema: z.string(),
type: WidgetType.ALERT,
}),
submitButton: widgetField({
type: WidgetType.SUBMIT_BUTTON,
text: "Say hello",
}),
},
}),
examples: {
requests: { simpleGreeting: { name: "Alice" } },
responses: { simpleGreeting: { message: "Hello, Alice!" } },
},
});Add a path. The REST endpoint exists.
No routing config. No controllers. The moment you define a path and schema, the endpoint is live at /api/[locale]/[path]. Role checks, validation, and error shapes happen before your code runs.
const res = await fetch("/api/en-GLOBAL/greet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
});
const { message } = await res.json();
// message → "Hello, Alice!"🌍REST API
Standard HTTP, always available
Your AI builds new tools for itself
Add an endpoint → it immediately becomes an AI-callable tool, CLI command, live web UI, and scheduled job. The stack is recursive. Claude Code builds next-vibe using next-vibe.
From a car dashboard to an AI platform.
Same codebase, any surface. These are real products running on next-vibe - a car PC, online shops, booking sites, and unbottled.ai itself. One framework, wildly different products.
A full car headunit
Online shops that sell
Practitioner & booking sites
unbottled.ai
No any. No unknown. No throw.
Types must align completely - no exceptions. This isn't a style preference. It's a structural rule enforced at build time by vibe check.
Replace with a real typed interface. If you reach for any, your architecture has a hole.
Same rule. unknown is just any with extra steps. Define the type.
Bare object is meaningless. Write the shape you actually expect.
Type assertions are lies to the compiler. Fix the architecture instead.
Use ResponseType<T> with success(data) or fail({message, errorType}). Errors are data.
Every string needs a translation key. The checker catches untranslated literals.
Fork, ask, ship.
You're at unbottled.ai's level from day one.
Fork & clone
Fork on GitHub, then clone your fork locally.
git clone https://github.com/YOUR_USERNAME/next-vibe
cd next-vibe && bun installStart the server
For local development, vibe dev starts PostgreSQL in Docker, runs migrations, seeds data, and gives you hot reload. For production, vibe build && vibe start does the initial deploy. vibe rebuild is what you use after that to update production with zero downtime.
# development
vibe dev
# production - first start
vibe build && vibe start
# production - after changes
vibe rebuildLogin as Admin
Open the app and click "Login as Admin" - the setup wizard walks you through API keys and admin password.
Ask the AI
Open unbottled.ai chat or Claude Code and describe the feature you want. The AI writes all the files - definition, route, widget, i18n - and runs vibe check automatically.
# In unbottled.ai chat or Claude Code:
"Build me a feature that explains decisions to my boss"
# AI writes all files and runs vibe check automaticallyBuilding something big?
We help teams with setup, custom integrations, architecture review, and ongoing development support. Same codebase, your deployment.