Chatbots have moved beyond text-based Q&A to become execution environments. Today, developers use them as runtime hosts where tools execute, UI widgets render inline, and user interactions feed back to the model in real time.
However, the developer experience is fragmented. OpenAI uses a proprietary Apps SDK for ChatGPT, while Anthropic Claude follows the Model Context Protocol (MCP). Building and maintaining separate integrations for each platform increases maintenance costs.
Skybridge addresses these challenges. It is an open-source React framework that abstracts host-specific differences. You write one application and deploy it to ChatGPT, Claude, or any MCP-compatible client.
This article demonstrates how to build a unified AI app from scratch, moving from basic tool calls to an interactive, state-synchronized interface.
If you are wondering about Skybridge, check out the troubles it takes away from developers.
Each major AI platform comes with its own unique runtime behaviors, protocol specifications, and widget embedding rules. Skybridge acts as a write-once, run-anywhere abstraction layer that automatically manages protocol bridging and host-specific nuances, allowing a single React app to deploy effortlessly across ChatGPT, Claude, and VS Code.
Testing an MCP app usually involves a tedious loop: deploying your app, opening a manual tunnel, connecting to a live chatbot, and constantly triggering model responses to check your changes. Skybridge eliminates this friction with a dedicated developer dashboard, which we’ll explore during our testing phase.
Ensure you have these tools and accounts ready:
We will start by scaffolding a new project.
Open your terminal and run the following command:
npm create skybridge@latest hotel-app cd hotel-app npm install
This command creates an Express-based MCP server and a Vite-powered React application.
The above command sets up a boilerplate with an Express-based MCP server and a Vite-powered React application.
Skybridge offers direct integration with the Vite ecosystem.
You can check out vite.config.ts file for further configuration if you need to.
Skybridge provides a local developer dashboard at http://localhost:3000
This comes with the following tools:
For this project, we will build a hotel booking app that works inside AI agents.
We will start by setting up the MCP server and registering a tool to search for hotels.
Create a server.ts file and register search-hotels tool like so:
import { McpServer } from "skybridge/server";
import { z } from "zod";
const server = new McpServer({
name: "hotel-booking-app",
version: "1.0.0"
}, {});
server.registerTool({
name: "search-hotels",
description: "Searches for available hotels in a city.",
inputSchema: {
location: z.string().describe("The city to search in")
},
view: {
component: "hotel-results",
description: "Displays a list of available hotels."
}
}, async ({ location }) => {
const hotels = [
{ id: "h1", name: "Grand Plaza", price: 200, rating: 4.5, location },
{ id: "h2", name: "Seaside Resort", price: 150, rating: 4.2, location },
];
return {
structuredContent: { hotels }, // Data passed to React
content: [{ type: "text", text: `Found ${hotels.length} hotels in ${location}.` }],
isError: false,
};
});
export type AppType = typeof server;
We have just defined a tool contract. The view property tells the Skybridge runtime to render the hotel-results component when the model calls search-hotels
You can further refine the tool’s behavior with the annotations property:
server.registerTool({
name: "search-hotels",
annotations: {
title: "Available Hotels",
},
// rest of config
});
Annotations help the AI host display readable titles in chat logs. Skybridge Beacon Audit Tool also checks this metadata as part of quality validation. For this demo, hotel data is hardcoded.
Next, build the React component that renders search results for the discovery view.
Before we set up the discovery view, create a helpers.ts file.
This file will provide the view with the exact tool input and output types from the server contract.
This is the content of the file:
import { generateHelpers } from "skybridge/web";
import type { AppType } from "./server.js";
export const { useToolInfo, useCallTool } = generateHelpers<AppType>();
generateHelpers uses your McpServer TypeScript type to provide autocomplete and validation for tool names, inputs, and outputs in the views.
The discovery view is the first interface the user sees after the model calls search-hotels tool.
Create a hotel.tsx file with the following code:
import { useState } from "react";
import { useLayout } from "skybridge/web";
import { useToolInfo } from "../helpers.js";
import Booking from "./booking.js";
import { Star, MapPin, Hotel, ArrowRight, Sparkles, Compass } from "lucide-react";
export default function HotelResults() {
const { theme } = useLayout();
const { output } = useToolInfo<"search-hotels">();
const hotels = output?.hotels || [];
const [selectedHotel, setSelectedHotel] = useState<{ id: string; name: string } | null>(null);
if (selectedHotel) {
return (
<Booking
initialHotelId={selectedHotel.id}
initialHotelName={selectedHotel.name}
onBack={() => setSelectedHotel(null)}
/>
);
}
return (
<div className={`app-shell font-sans ${theme === "dark" ? "dark" : ""}`}>
<div className="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6 sm:py-8">
<section className="glass-panel overflow-hidden p-5 sm:p-7">
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-2">
<p className="pill bg-sky-100 text-sky-700 dark:bg-sky-950/60 dark:text-sky-300">
<Sparkles className="h-3.5 w-3.5" /> Curated Results
</p>
<h2 className="font-mozilla text-3xl leading-tight text-deep dark:text-sky-100 sm:text-4xl">
Stays near your destination
</h2>
<p className="max-w-2xl text-sm text-zinc-600 dark:text-zinc-300">
We found {hotels.length} options with instant booking support and synchronized steps.
</p>
</div>
<div className="step-chip w-fit">
<Compass className="mr-1.5 h-3.5 w-3.5" />
Swipe or browse cards
</div>
</div>
</section>
</div>
<section className="mx-auto mt-5 grid w-full max-w-5xl grid-cols-1 gap-4 px-4 pb-8 sm:grid-cols-2 sm:px-6 lg:grid-cols-3">
{hotels.map((hotel) => (
<article key={hotel.id} className="hotel-card">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold leading-tight">{hotel.name}</h3>
<div className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-zinc-500">
<MapPin className="h-3.5 w-3.5" />
{hotel.location}
</div>
</div>
<div className="pill border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300">
<Star className="mr-1 h-3.5 w-3.5 fill-current" />
{hotel.rating}
</div>
</div>
<div className="mt-auto space-y-3">
<div className="flex items-end justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.08em] text-zinc-500">From</p>
<p className="text-3xl font-black text-deep dark:text-sky-100">${hotel.price}</p>
</div>
<p className="text-xs font-medium text-zinc-500">per night</p>
</div>
<button
type="button"
className="primary-action w-full"
onClick={() => setSelectedHotel({ id: hotel.id, name: hotel.name })}
>
<Hotel className="h-4 w-4" />
Start Booking
<ArrowRight className="h-4 w-4" />
</button>
</div>
</article>
))}
</section>
</div>
);
}
This view reads output.hotels with useToolInfo, renders each hotel card, and transitions to the booking experience when the user clicks Start Booking.
At this stage, the app can search and display hotels. Next, we will add tools to collect booking details and finalize reservations.
To make the app interactive, we will add a Book Now and confirm booking tools.
Update the server.ts file to register the book-hotel tool like so:
server.registerTool({
name: "book-hotel",
description: "Initiates the booking process for a selected hotel.",
inputSchema: {
hotelId: z.string().describe("The ID of the hotel to book."),
hotelName: z.string().describe("The name of the hotel."),
},
view: {
component: "booking",
description: "Multi-step booking form."
}
}, async ({ hotelId, hotelName }) => {
return {
structuredContent: { hotelId, hotelName },
content: [{ type: "text", text: `Starting booking for ${hotelName}...` }],
isError: false,
};
});
The book-hotel tool defines the required schema and links to the booking view component.
It runs when the user starts the booking flow.
To finalize a booking after form completion, we will add a confirm-booking tool for this.
Go ahead and add this to the server.ts file:
server.registerTool({
name: "confirm-booking",
description: "Finalize the hotel booking",
inputSchema: {
hotelId: z.string(),
guestName: z.string(),
},
}, async (details) => {
const confirmationNumber = `BK-${Math.random().toString(36).substring(2, 9).toUpperCase()}`;
return {
structuredContent: { ...details, confirmationNumber },
content: [{ type: "text", text: `Booking confirmed! #:${confirmationNumber}` }],
isError: false,
};
});
By omitting the view property, you create an execution tool. The AI can call it in the background after it gathers required arguments from view state or chat context.
Create booking.tsx file and add the following code to it:
import { useState } from "react";
import { useLayout } from "skybridge/web";
import { useToolInfo, useCallTool } from "../helpers.js";
import { Calendar, Users, BedDouble, Ticket, ArrowRight, Waves } from "lucide-react";
export default function Booking() {
const { theme } = useLayout();
const { input, output } = useToolInfo<"book-hotel">();
const hotelName = output?.hotelName || input?.hotelName;
const [state, setState] = useState({
step: 0,
checkIn: "",
checkOut: "",
llmStatus: "Dates pending",
});
const { callTool, isPending } = useCallTool<"confirm-booking">("confirm-booking");
if (!hotelName) return null;
const nextStep = () => setState(s => ({ ...s, step: s.step + 1 }));
const progressSteps = ["Dates", "Guests", "Room", "Review"];
return (
<div className={`app-shell ${theme === "dark" ? "dark" : ""}`}>
<div className="mx-auto w-full max-w-2xl px-4 py-6 sm:px-6 sm:py-8">
<div className="glass-panel space-y-6 p-5 sm:p-7">
<header className="space-y-3">
<p className="pill bg-teal-100 text-teal-700 dark:bg-teal-900/50 dark:text-teal-200">
<Waves className="h-3.5 w-3.5" /> Booking Flow
</p>
<h2 className="font-mozilla text-3xl leading-tight text-deep dark:text-sky-100">
Complete your stay at {hotelName}
</h2>
<div className="flex flex-wrap gap-2">
{progressSteps.map((label, idx) => (
<span key={label} className={`step-chip ${idx === state.step ? "step-chip-active" : ""}`}>
{idx + 1}. {label}
</span>
))}
</div>
</header>
{state.step === 0 && (
<section className="space-y-4 rounded-2xl border border-sky-100 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-900/55">
<div className="flex items-center gap-2 text-deep dark:text-sky-200">
<Calendar className="w-5 h-5" />
<span className="font-semibold">Select Dates</span>
</div>
{/* form fields */}
</section>
)}
{state.step === 1 && (
<section className="space-y-4 rounded-2xl border border-sky-100 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-900/55">
<div className="flex items-center gap-2 text-deep dark:text-sky-200">
<Users className="w-5 h-5" />
<span className="font-semibold">Guest Details</span>
</div>
</section>
)}
{state.step === 2 && (
<section className="space-y-4 rounded-2xl border border-sky-100 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-900/55">
<div className="flex items-center gap-2 text-deep dark:text-sky-200">
<BedDouble className="w-5 h-5" />
<span className="font-semibold">Select Room Type</span>
</div>
</section>
)}
{state.step === 3 && (
<section className="space-y-4 rounded-2xl border border-sky-100 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-900/55">
<div className="flex items-center gap-2 text-deep dark:text-sky-200">
<Ticket className="w-5 h-5" />
<span className="font-semibold">Review and Confirm</span>
</div>
<button
className="primary-action"
onClick={() => callTool({
hotelId: output?.hotelId || input?.hotelId || "",
hotelName,
checkIn: state.checkIn,
checkOut: state.checkOut,
adults: 2,
children: 0,
guestNames: ["Primary Guest"],
roomType: "Standard",
})}
disabled={isPending}
>
Confirm Booking <ArrowRight className="h-4 w-4" />
</button>
</section>
)}
</div>
</div>
<div
className="hidden"
data-llm={`Booking status: ${state.llmStatus}. User is at step ${state.step + 1} for ${hotelName}. Current data: ${JSON.stringify(state)}`}
/>
</div>
);
}
The hidden data-llm block keeps the model aware of current form progress and entered values, which helps it decide when to call confirm-booking
With both views and tools wired together, let’s go ahead and test it.
I will first go ahead and test it out in the developer dashboard:

This is the result of my test in Claude Desktop:

To test it in Claude, do the following:
Thereafter, copy the MCP URL and add it to the connectors in Claude Desktop
The final implementation is available in the hotel booking GitHub repository.
Skybridge simplifies MCP development, but a few constraints still matter:
Developing for the agentic web should not require duplicate integrations or deep protocol expertise. Skybridge gives you one path for building interactive, state-synchronized apps that work across major chatbot platforms. By centering development on shared tools and views, you can ship richer AI experiences with lower maintenance costs.

Learn how to set up Meilisearch, index documents, and build keyword, semantic, and hybrid search with AI-powered retrieval.

Compare pnpm and npm across security defaults, disk usage, dependency strictness, and workspace policy to decide which package manager fits your project.

I migrated 20 production-style components from Tailwind to StyleX. Here’s what the data showed about LOC, CSS bundle size, build time, and type safety.

A guide for using JWT authentication to prevent basic security issues while understanding the shortcomings of JWTs.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now