# auth0Provider Source: https://docs.skybridge.tech/api-reference/auth0-provider Wire OAuth from an Auth0 tenant `auth0Provider` wires authentication through [Auth0](https://auth0.com/). Auth0 carries the audience in the authorize request rather than as a resource indicator, so it also requires this server's public URL as `serverUrl`. ## Example ```ts server.ts highlight={1,6-10} theme={null} import { auth0Provider, Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", oauth: auth0Provider({ domain: process.env.AUTH0_DOMAIN, audience: process.env.AUTH0_API_IDENTIFIER, serverUrl: process.env.SERVER_URL, }), handler, }); ``` ## Signature ```ts theme={null} auth0Provider(opts: Auth0ProviderOptions): Promise; ``` ## Parameters ### `opts` * **`domain`** is the tenant domain, for example `acme.us.auth0.com`. * **`audience`** is the API Identifier from the Auth0 dashboard, bound into the token's `aud` claim. * **`serverUrl`** is this server's public URL, required for Auth0. It also accepts the shared [`CustomProviderOptions`](/api-reference/custom-provider#parameters) options: `scopes`, `requiredScopes`, and `metadataOverrides`. Requires Dynamic Client Registration enabled on the tenant. ## Returns A `Promise` for the [`OAuthConfig`](/api-reference/custom-provider#returns) you pass to the [`oauth`](/api-reference/skybridge#oauth) field, as a value or from a function. Set up sign-in with a hosted provider Add sign-in to your app end to end Wire OAuth from any IdP's discovery document # authplaneProvider Source: https://docs.skybridge.tech/api-reference/authplane-provider Wire OAuth from an Authplane authorization server `authplaneProvider` wires authentication through [Authplane](https://authplane.ai), so your tools receive a signed-in user. ## Example ```ts server.ts highlight={1,6-9} theme={null} import { authplaneProvider, Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", oauth: authplaneProvider({ issuer: process.env.AUTHPLANE_ISSUER, resource: process.env.SERVER_URL, }), handler, }); ``` ## Signature ```ts theme={null} authplaneProvider(opts: AuthplaneProviderOptions): Promise; ``` ## Parameters ### `opts` * **`issuer`** is the authorization server's issuer identifier, for example `https://auth.acme.com`. * **`resource`** is this server's resource identifier: the public URL clients reach, advertised in its protected-resource metadata. Required, unlike the other providers — see below. * **`audience`** overrides the expected `aud`, which defaults to `resource`. Set it only when the resource is configured in Authplane with an explicit audience override. It also accepts the shared [`CustomProviderOptions`](/api-reference/custom-provider#parameters) options: `serverUrl`, `scopes`, `requiredScopes`, and `metadataOverrides`. Dynamic Client Registration is supported natively, so clients register directly with Authplane and this server stays out of the authorization path. ## Why `resource` is required Authplane binds the access token's `aud` to the RFC 8707 resource indicator the client sends, and the client reads that value from the `resource` field of this server's protected-resource metadata. Setting `resource` gives the deployment one fixed identifier for both, so it is required rather than optional. Three values must therefore be identical, and OAuth compares identifiers by exact string match: 1. the value this server advertises as its `resource` metadata; 2. the resource registered in Authplane; 3. the `aud` Authplane mints, which it takes from (2). A mismatch between 1 and 2 fails the authorization request with `invalid_target`, before any token exists; between 1 and 3, token verification fails. Register `resource` in Authplane character for character and all three agree. ### Pathless origins The advertised resource is the URL-normalised form of `resource`, so a bare origin is advertised with a root path: `https://acme.example.com` is advertised as `https://acme.example.com/`. The provider asks for the advertised form up front, and names it if the two differ: ``` authplaneProvider: `resource` must be given in the form it will be advertised. "https://acme.example.com" is advertised as "https://acme.example.com/". Use "https://acme.example.com/", or a path-qualified URL such as "https://acme.example.com/mcp", and register the same value in Authplane. ``` So if your resource is a bare origin, register it in Authplane **with** the trailing slash. Uppercase hosts and explicit default ports normalise the same way. Path-qualified URLs are unchanged by normalisation, and are the most specific identifier available — which is what [RFC 8707 §2](https://www.rfc-editor.org/rfc/rfc8707#section-2) asks clients to send. ## Returns A `Promise` for the [`OAuthConfig`](/api-reference/custom-provider#returns) you pass to the [`oauth`](/api-reference/skybridge#oauth) field, as a value or from a function. Set up sign-in with a hosted provider Add sign-in to your app end to end Wire OAuth from any IdP's discovery document # clerkProvider Source: https://docs.skybridge.tech/api-reference/clerk-provider Wire OAuth from a Clerk instance `clerkProvider` wires authentication through [Clerk](https://clerk.com/). Clerk access tokens carry no `aud` claim, so there is no `audience` option. ## Example ```ts server.ts highlight={1,6-8} theme={null} import { clerkProvider, Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", oauth: clerkProvider({ domain: process.env.CLERK_FRONTEND_API, }), handler, }); ``` ## Signature ```ts theme={null} clerkProvider(opts: ClerkProviderOptions): Promise; ``` ## Parameters ### `opts` **`domain`** is the Clerk Frontend API URL, for example `acme.clerk.accounts.dev` or a production custom domain. There is no `audience` option: Clerk binds no audience to its tokens. It also accepts the shared [`CustomProviderOptions`](/api-reference/custom-provider#parameters) options: `baseUrl`, `serverUrl`, `scopes`, `requiredScopes`, and `metadataOverrides`. Requires Dynamic Client Registration enabled on the instance, and the OAuth application set to issue JWT access tokens (opaque tokens can't be verified). ## Returns A `Promise` for the [`OAuthConfig`](/api-reference/custom-provider#returns) you pass to the [`oauth`](/api-reference/skybridge#oauth) field, as a value or from a function. Set up sign-in with a hosted provider Add sign-in to your app end to end Wire OAuth from any IdP's discovery document # CLI Source: https://docs.skybridge.tech/api-reference/cli Test, build, and run your app from the terminal Skybridge ships one CLI, available as both `skybridge` and `sb` once it's installed in your project. It's a project dependency, not a global install, so run it through your package manager, or the [package scripts](#package-scripts) a scaffold sets up. ## `create` Scaffold a new project. Run it before the package is installed with `npx`: ```bash theme={null} npx skybridge create ``` See [Quickstart](/get-started/quickstart) to scaffold and run a project. ## `dev` Start the development server with hot module reloading and DevTools. ```bash npm theme={null} npx skybridge dev ``` ```bash pnpm theme={null} pnpm skybridge dev ``` ```bash yarn theme={null} yarn skybridge dev ``` ```bash bun theme={null} bun skybridge dev ``` ```bash deno theme={null} deno run -A npm:skybridge/skybridge dev ``` * Serves the MCP endpoint at `http://localhost:3000/mcp` * Opens DevTools for local testing at `http://localhost:3000/` * Watches files and restarts the server, with HMR for views | Flag | Description | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `-p, --port ` | Port to run on. Defaults to `3000`, or the next free port if it's taken. | | `--tunnel` | Open an Alpic tunnel for remote testing and Playground access. | | `--no-open` | Don't open DevTools when the server is ready. | | `-v, --verbose` | Show tunnel logs. | | `--plain` | Disable the interactive UI and stream the server's stdout verbatim, so you can pipe it through a formatter (e.g. `skybridge dev --plain \| bunyan`). | The [scaffolded](/get-started/quickstart#scaffold-your-project) `package.json` adds a `dev:tunnel` script, shorthand for `skybridge dev --tunnel`. The port also reads from the `PORT` environment variable. Set `SKYBRIDGE_OPEN=false` in your shell profile to skip opening DevTools on every run, the equivalent of always passing `--no-open`. ### Formatting structured logs If your server uses a structured JSON logger like [bunyan](https://github.com/trentm/node-bunyan) or [pino](https://github.com/pinojs/pino), pass `--plain` and pipe through the logger's formatter: ```bash theme={null} skybridge dev --plain | bunyan skybridge dev --plain | pino-pretty ``` ## `build` Compile your views and MCP server for production. ```bash npm theme={null} npx skybridge build ``` ```bash pnpm theme={null} pnpm skybridge build ``` ```bash yarn theme={null} yarn skybridge build ``` ```bash bun theme={null} bun skybridge build ``` ```bash deno theme={null} deno run -A npm:skybridge/skybridge build ``` The output lands in `dist/`, ready for the `deploy` script or `skybridge start`. ### Excluding packages from the server bundle The build's last step bundles your server into a single deployable function with esbuild. Bundling means every reachable dependency has to be resolved, including ones your code never runs. Some packages break that step. A common case is a logger with an optional native dependency, like [bunyan](https://github.com/trentm/node-bunyan), which does `require('dtrace-provider')` inside a `try/catch`. You never use DTrace, but esbuild still tries to resolve it and fails on the native binding. List those packages in the Vite plugin's `serverExternal` to leave them out of the bundle: ```ts vite.config.ts theme={null} import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { skybridge } from "@skybridge/vite-plugin"; export default defineConfig({ plugins: [react(), skybridge({ serverExternal: ["dtrace-provider"] })], }); ``` An external package stays a plain `import` in the output, resolved from `node_modules` at runtime. Only list packages your server doesn't need at runtime, or that your deployment target installs itself. ## `start` Run the production server from the build output. Run `skybridge build` first. ```bash npm theme={null} npx skybridge start ``` ```bash pnpm theme={null} pnpm skybridge start ``` ```bash yarn theme={null} yarn skybridge start ``` ```bash bun theme={null} bun skybridge start ``` ```bash deno theme={null} deno run -A npm:skybridge/skybridge start ``` * Serves the MCP endpoint at `http://localhost:3000/mcp` * Runs the compiled server and pre-built view assets from `dist/` | Flag | Description | | --------------------- | ------------------------------------------------------------------------------------------- | | `-p, --port ` | Port to run on. Defaults to `3000`, or the next free port if it's taken. Also reads `PORT`. | ## `telemetry` The CLI reports anonymous usage, on by default. Toggle it with `skybridge telemetry disable`, `enable`, or `status`. See [Telemetry](/resources/telemetry) for what's collected and every way to opt out. ## Package scripts A scaffolded project wires the commands into `package.json`: | Script | Command | Description | | ------------ | ------------------------ | -------------------------------------------------------------------------------- | | `dev` | `skybridge dev` | Start the development server. | | `dev:tunnel` | `skybridge dev --tunnel` | Start the dev server behind an Alpic tunnel. | | `build` | `skybridge build` | Build for production. | | `start` | `skybridge start` | Serve the production build. | | `deploy` | `alpic deploy` | Deploy through the Alpic CLI. See [Deploy](/ship/deploy) for per-platform setup. | Run them with your package manager: ```bash npm theme={null} npm run dev npm run build npm start ``` ```bash pnpm theme={null} pnpm dev pnpm build pnpm start ``` ```bash yarn theme={null} yarn dev yarn build yarn start ``` ```bash bun theme={null} bun dev bun build bun start ``` ```bash deno theme={null} deno task dev deno task build deno task start ``` Scaffold and run your first app Deploy to your platform of choice The server the CLI builds and runs # createStore Source: https://docs.skybridge.tech/api-reference/create-store Persist view state in a Zustand store `createStore` creates a [Zustand](https://github.com/pmndrs/zustand) store synced with the [view](/build/view)'s persisted [state](/build/state), on the same [lifecycle](/api-reference/use-view-state#lifecycle) as [`useViewState`](/api-reference/use-view-state). Reach for it when state is complex or shared across components; otherwise use `useViewState`. ## Example A counter store persists its `count` across remounts, and the view reads and updates it through the store hook. ```tsx highlight={8-11} theme={null} import { createStore } from "skybridge/web"; type CounterState = { count: number; increment: () => void; }; const useCounter = createStore((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), })); function Counter() { const { count, increment } = useCounter(); return ; } ``` ## Signature ```tsx theme={null} createStore( storeCreator: StateCreator, defaultState?: State | (() => State), ): UseBoundStore>; ``` ## Parameters ### `storeCreator` ```tsx theme={null} storeCreator: StateCreator; ``` The Zustand state creator, the standard `(set, get) => ({ ... })` returning the store's initial state and actions. ### `defaultState` ```tsx theme={null} defaultState?: State | (() => State); ``` Initial state, used only when the host holds no persisted state for this view. Pass a value or a lazy initializer that runs once. When the host already has persisted state, that value wins. ## Returns ```tsx theme={null} UseBoundStore>; ``` A Zustand store. Call it as a React hook with a selector (`useCounter((s) => s.count)`), or use `getState`, `setState`, and `subscribe` outside React. Store updates persist to the view's state, and external state changes rehydrate the store. The simpler hook this builds on, and its lifecycle Decide what to persist and share with the model Narrate the on-screen state to the model # customProvider Source: https://docs.skybridge.tech/api-reference/custom-provider Wire OAuth from any IdP's discovery document `customProvider` builds a complete [`OAuthConfig`](#returns) from an identity provider's OAuth discovery document: it reads the provider's metadata at boot and verifies access tokens against its JWKS. Reach for it when no [branded provider](/api-reference/workos-provider) fits, for any IdP that publishes [discovery metadata](https://datatracker.ietf.org/doc/html/rfc8414) and signs JWT access tokens. ## Example ```ts server.ts highlight={1,6-11} theme={null} import { Skybridge, customProvider } from "skybridge/server"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", oauth: customProvider({ issuer: "https://auth.myshop.com", audience: process.env.SERVER_URL, scopes: ["openid", "profile", "checkout"], requiredScopes: ["openid"], }), handler, }); ``` `customProvider` fetches `https://auth.myshop.com`'s discovery document when the app starts, then the [`oauth`](/api-reference/skybridge#oauth) field mounts the well-known metadata and JWKS bearer verification on `/mcp`. `audience` is the value the IdP binds into the token's `aud` claim, here this server's public URL. ## Signature ```ts theme={null} customProvider(opts: CustomProviderOptions): Promise; ``` ## Parameters ### `opts` ```ts theme={null} type CustomProviderOptions = { issuer: string; audience?: string; baseUrl?: string; serverUrl?: string; scopes?: string[]; requiredScopes?: string[]; metadataOverrides?: Omit, "issuer">; }; ``` | Field | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `issuer` | The only required option: the IdP base URL whose discovery document is fetched at boot. It must serve a `jwks_uri`, or the call throws. | | `audience` | Checked against each token's `aud` claim. Omit it only for an IdP that binds no audience (Clerk): the `aud` check is then skipped. | | `baseUrl` | This server's public URL. Set it and the resource URLs are baked once at boot; omit it and they resolve per request from the `x-forwarded-host` / `x-forwarded-proto` / `host` headers. | | `serverUrl` | Advertises this server as the authorization server: the served AS metadata `issuer` and the PRM `authorization_servers` use this URL instead of the IdP's, while verification still trusts the IdP's real `iss`. Needed when this server must sit in the auth path, as [`auth0Provider`](/api-reference/auth0-provider) does, or behind the Alpic DCR proxy. | | `scopes` | Scopes advertised in the served metadata; defaults to the IdP's. | | `requiredScopes` | Server-wide scope floor enforced before any handler, layered under each tool's [`securitySchemes`](/api-reference/register-tool#securityschemes); a token missing one gets a 403. | | `metadataOverrides` | Adjusts advertised metadata only. | ## Returns An `OAuthProvider`: a deferred `OAuthConfig` you pass to the [`oauth`](/api-reference/skybridge#oauth) field. Discovery is a network call, so it runs in `resolve()` at `run()`, not when your module is imported. Call `resolve()` yourself when wiring [`requireBearerAuth`](/api-reference/require-bearer-auth) by hand. ```ts theme={null} type OAuthProvider> = { resolve: () => Promise>; }; ``` ```ts theme={null} type OAuthConfig> = { baseUrl?: string; oauthMetadata: OAuthMetadata; scopesSupported?: string[]; requiredScopes?: string[]; verifier: TokenVerifier; }; ``` | Field | Description | | ----------------- | ---------------------------------------------------------------------- | | `baseUrl` | Echoes the `baseUrl` option. | | `oauthMetadata` | AS metadata served at `/.well-known/oauth-authorization-server`. | | `verifier` | Checks each bearer token, and carries the claim type handlers receive. | | `scopesSupported` | Scopes advertised in protected-resource metadata. | | `requiredScopes` | Server-wide required-scope floor. | `TExtra` is the claim shape the verifier resolves with, and the server reads it from here, so handlers get `extra.http?.authInfo?.extra` typed without declaring anything. Pass it as a type argument to name claims your IdP sends: `customProvider<{ subject?: string; email?: string }>({ ... })`. Build this object by hand only to wire an IdP whose metadata `customProvider` can't discover. Supply a `verifier`, from `createJwksVerifier({ issuer, jwksUri })` for JWTs or your own [`TokenVerifier`](/api-reference/verifier) for opaque tokens. Either way the [`oauth`](/api-reference/skybridge#oauth) field mounts the same endpoints. Set up sign-in with a hosted provider Add sign-in to your app end to end Pass the config to the oauth field # data-llm Source: https://docs.skybridge.tech/api-reference/data-llm Narrate the view's state to the model Between turns the model can't see what the user does in your [view](/build/view). The `data-llm` attribute narrates the view's current [state](/build/state) to the model, so on the next turn it can answer about what is on screen. ## Example The carousel tells the model whether the shopper is browsing or looking at one product, so a follow-up like "is this one in stock?" resolves to the right item. ```tsx views/carousel.tsx highlight={5} theme={null} function Carousel({ products }: { products: Product[] }) { const [selected, setSelected] = useState(null); return (
{selected ? ( ) : ( )}
); } ``` Put it on any element, with a static string or an expression recomputed on render. ## Behavior * Only the content currently on screen is sent. It is recomputed on every render and dropped when the element unmounts. * The text is model-visible, but not live: the host surfaces it to the model on the next turn, not mid-interaction. * Nested attributes form an indented outline. This: ```tsx theme={null}
{/* ... */}
``` reaches the model as: ``` - Reviewing the cart - 3 items, $240 ``` `data-llm` rides the same view state as [`useViewState`](/api-reference/use-view-state), so its [lifecycle](/api-reference/use-view-state#lifecycle) governs when and where the model sees it. What to narrate, and what to leave out Persist structured state alongside the narration Read the tool result the view mounted with # descopeProvider Source: https://docs.skybridge.tech/api-reference/descope-provider Wire OAuth from a Descope MCP Server `descopeProvider` wires authentication through a [Descope](https://www.descope.com/) MCP Server, so your tools receive a signed-in Descope user. ## Example ```ts server.ts highlight={1,6-8} theme={null} import { Skybridge, descopeProvider } from "skybridge/server"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", oauth: descopeProvider({ url: process.env.DESCOPE_DISCOVERY_URL, }), handler, }); ``` ## Signature ```ts theme={null} descopeProvider(opts: DescopeProviderOptions): Promise; ``` ## Parameters ### `opts` * **`url`** is the MCP Server's Discovery URL (its Issuer) from the console's Connection Information, for example `https://api.descope.com/v1/apps/agentic//`, or your custom domain. * **`audience`** defaults to the Project ID parsed from the `/agentic//` segment of `url` (Descope binds the token's `aud` to `[DCR client id, project id]`). If `url` lacks that segment, such as a custom domain, pass `audience` explicitly or the server throws at startup. It also accepts the shared [`CustomProviderOptions`](/api-reference/custom-provider#parameters) options: `baseUrl`, `serverUrl`, `scopes`, `requiredScopes`, and `metadataOverrides`. Requires Dynamic Client Registration enabled on the MCP Server. With DCR disabled and the Alpic DCR proxy, use [`customProvider`](/api-reference/custom-provider) with `serverUrl` instead. ## Returns A `Promise` for the [`OAuthConfig`](/api-reference/custom-provider#returns) you pass to the [`oauth`](/api-reference/skybridge#oauth) field, as a value or from a function. Set up sign-in with a hosted provider Add sign-in to your app end to end Wire OAuth from any IdP's discovery document # FileRef Source: https://docs.skybridge.tech/api-reference/file-ref Reference a host-managed file in a tool's schema `FileRef` is a reference to a file, an id and a URL to fetch its bytes. Use it in a [tool's `inputSchema` or `outputSchema`](/api-reference/register-tool#inputschema-outputschema) to pass files in and out. File params are a **ChatGPT** feature. Other hosts don't pass files through [tool](/build/tools) calls, so a `FileRef` field is populated only on ChatGPT. ## Example `summarize-document` takes a file in, fetches its bytes from `download_url`, and returns a summary file back. ```ts server.ts highlight={10-11} theme={null} import { FileRef, Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "docs", version: "1.0", handler: (server) => server.registerTool( { name: "summarize-document", inputSchema: { document: FileRef }, outputSchema: { summary: FileRef }, _meta: { "openai/fileParams": ["document"] }, }, async ({ document }) => { const bytes = await fetch(document.download_url).then((r) => r.blob()); const summary = await summarize(bytes); // returns a FileRef return { structuredContent: { summary } }; }, ), }); ``` ## Shape ```ts theme={null} type FileRef = { file_id: string; download_url: string; mime_type?: string; file_name?: string; }; ``` | Field | Purpose | | -------------- | ----------------------------------- | | `file_id` | The host's identifier for the file. | | `download_url` | A URL to fetch the file's bytes. | | `mime_type` | The file's MIME type, when known. | | `file_name` | The original file name, when known. | `download_url` is required in both positions: on input the host fills it, on output your handler produces a URL the host can fetch. Move files in and out of your app across hosts Upload, pick, and resolve files from the view Declare file params with `openai/fileParams` # generateHelpers Source: https://docs.skybridge.tech/api-reference/generate-helpers Generate typed hooks inferred from your server Without it, [`useCallTool`](/api-reference/use-call-tool) and [`useToolInfo`](/api-reference/use-tool-info) need their [tool](/build/tools) types written by hand, duplicating your [server](/api-reference/mcp-server). `generateHelpers` infers them from your server type, so you import already-typed hooks. Set it up once and use the hooks across your views. ## Example `helpers.ts` wires your server type to the hooks; views import the typed hooks from it. ```ts helpers.ts theme={null} import type { AppType } from "./server"; // type-only import import { generateHelpers } from "skybridge/web"; export const { useCallTool, useToolInfo } = generateHelpers(); ``` ```tsx views/carousel.tsx theme={null} import { useCallTool, useToolInfo } from "../helpers.js"; function Carousel() { const { output } = useToolInfo<"search-products">(); // output typed from the tool const { callTool } = useCallTool("create-checkout"); // name autocompleted, input typed return ( ); } ``` ## Signature ```tsx theme={null} const { useCallTool, useToolInfo } = generateHelpers(); ``` ## Type Parameters ### `ServerType` ```tsx theme={null} ServerType extends McpServer; ``` Your app's type, `typeof app`. Export it from `src/server.ts` with `export type AppType = typeof app`, then pass it here. Inference works only when `handler` chains `.registerTool()` calls on the server it receives and returns the result, so the tool types accumulate on `typeof app`. ## Returns Two hooks. Both autocomplete tool names and infer types from `ServerType`, so you write no generics; their surface is otherwise identical to the untyped versions. ### `useCallTool` The typed [`useCallTool`](/api-reference/use-call-tool): the `name` argument autocompletes, and the input and output types follow the named tool. ### `useToolInfo` The typed [`useToolInfo`](/api-reference/use-tool-info): the tool name (its type argument) autocompletes, and `input`, `output`, and `responseMetadata` follow it. Call a tool from a view Read the tool result the view mounted with The server whose type you infer from # mcpAuthMetadataRouter Source: https://docs.skybridge.tech/api-reference/mcp-auth-metadata-router Advertise your authorization server for client discovery When a client reaches your server without a token, it needs to know where the user signs in. `mcpAuthMetadataRouter` advertises that, so clients can discover your authorization server on their own. ## Example The server publishes where to authorize, so a client hitting a 401 can find the authorization server on its own. ```ts server.ts highlight={4-13} theme={null} import { Skybridge, mcpAuthMetadataRouter } from "skybridge/server"; export const app = new Skybridge({ name: "shop", version: "1.0", handler }).use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: "https://auth.example.com", authorization_endpoint: "https://auth.example.com/authorize", token_endpoint: "https://auth.example.com/token", response_types_supported: ["code"], }, resourceServerUrl: new URL("https://api.example.com/mcp"), scopesSupported: ["shop.read"], }), ); ``` ## Signature ```ts theme={null} mcpAuthMetadataRouter(options: AuthMetadataOptions): Router; ``` ## Parameters ### `options` ```ts theme={null} type AuthMetadataOptions = { oauthMetadata: OAuthMetadata; resourceServerUrl: URL; scopesSupported?: string[]; serviceDocumentationUrl?: URL; resourceName?: string; }; ``` | Field | Purpose | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `oauthMetadata` | Your authorization server's [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata. At minimum the `issuer`, `authorization_endpoint`, `token_endpoint`, and `response_types_supported`. | | `resourceServerUrl` | This MCP server's URL. Published in the protected-resource metadata so clients map this server to its authorization server. | | `scopesSupported` | The scopes this server recognizes. | | `serviceDocumentationUrl` | Link to human-readable docs for this server. | | `resourceName` | Display name for this resource in the metadata. | Check your OAuth provider's docs for the metadata values it expects. ## Returns An [Express `Router`](https://expressjs.com/en/5x/api/router/) to pass to [`server.use`](/api-reference/mcp-server#use). It serves your OAuth 2.0 [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) at `/.well-known/oauth-protected-resource`. Require a token on every request Accept a token when present, allow anonymous otherwise Add sign-in to your app end to end # McpServer Source: https://docs.skybridge.tech/api-reference/mcp-server The server your handler registers tools on `McpServer` is the server instance a [`Skybridge`](/api-reference/skybridge) app hands to its `handler`, once per request. Its registration methods return the server itself, so you chain them and return the chain: that return value is what carries your tool types into `typeof app`. ## Example The handler registers two tools and a middleware, and returns the chain. ```ts src/server.ts theme={null} import { Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "shop", version: "1.0", handler: (server) => server .registerTool(/* search-products */) .registerTool(/* create-checkout */) .mcpMiddleware("request", (request, extra, next) => { console.log(`[MCP] ${request.method}`); return next(); }), }); ``` Define the handler inline, as above: that is what lets TypeScript infer the claims your `oauth` verifier produces into `extra.http.authInfo.extra`, and the tool registry into `typeof app`. Splitting the tool definitions across files is still easy, since each `registerTool` callback is an ordinary function you can import. The handler runs for every request, so anything in it other than registration runs per request too. Move one-time work (a pool, a client, a file read) into [`setup`](/api-reference/skybridge#setup), whose result is the handler's second argument. ## Methods Every method returns the server, so calls chain. ### `registerTool` ```ts theme={null} server.registerTool(config, handler): this; ``` Registers a tool, optionally bound to a view. See [registerTool](/api-reference/register-tool) for the full config and handler. ### `registerResource`, `registerPrompt` Inherited from the MCP SDK's [`McpServer`](https://github.com/modelcontextprotocol/typescript-sdk), with the same signatures. Views register their own resources, so you only need `registerResource` for data you expose directly. ### `mcpMiddleware` ```ts theme={null} server.mcpMiddleware(handler: McpMiddlewareFn): this; server.mcpMiddleware(filter: McpMiddlewareFilter, handler: McpMiddlewareFn): this; ``` Wraps MCP requests and notifications: each middleware runs `(request, extra, next)`, can inspect or short-circuit the call, and invokes `next()` to continue. Middleware runs in registration order, outermost first. An optional `filter` scopes which methods it runs for. | Filter | Matches | | ---------------------------------- | ------------------------- | | `"tools/call"` | that exact method | | `"tools/*"` | any method under `tools/` | | `"request"` | all requests | | `"notification"` | all notifications | | `["tools/call", "resources/read"]` | any pattern in the list | ```ts theme={null} server.mcpMiddleware("request", (request, extra, next) => { console.log(`[MCP] ${request.method}`, request.params); return next(); }); ``` ### `getToolError` ```ts theme={null} import { getToolError } from "skybridge/server"; getToolError(extra: McpExtra | undefined): unknown; ``` The MCP SDK catches whatever a tool handler throws and turns it into an `isError` tool result, so `await next()` resolves normally and the middleware never sees the failure. `getToolError` returns the error the tool handler threw during the current request, with its stack and `cause` intact. It lives on `extra`, which is never serialized to the client. ```ts theme={null} server.mcpMiddleware("tools/call", async (request, extra, next) => { const result = await next(); const error = getToolError(extra); if (error) { Sentry.captureException(error); } return result; }); ``` A tool failure is a valid MCP response, so [`useOnError`](/api-reference/skybridge#useonerror) never runs for it: report tool errors from `mcpMiddleware`, and keep `useOnError` for transport-level failures. Errors raised by the SDK itself (unknown tool, input or output schema validation) never reach your handler and are not reported here. Configure and run the app that builds this server Define the tools and views the server exposes Turn `AppType` into typed client hooks # optionalBearerAuth Source: https://docs.skybridge.tech/api-reference/optional-bearer-auth Accept a signed-in user when present, allow anonymous otherwise When some [tools](/build/tools) are public and others need sign-in, `optionalBearerAuth` validates a token if one is sent but lets anonymous requests through. Each tool then enforces its own [`securitySchemes`](/api-reference/register-tool#securityschemes) against `extra.http?.authInfo`. ## Example The server accepts a token when one is sent, so public tools run for anyone while gated tools check `extra.http?.authInfo` themselves. ```ts server.ts highlight={6} theme={null} import { Skybridge, optionalBearerAuth } from "skybridge/server"; import { verifyAccessToken } from "./verify-access-token.js"; // the verifier you implement export const app = new Skybridge({ name: "shop", version: "1.0", handler }).use( "/mcp", optionalBearerAuth({ verifier: { verifyAccessToken } }), ); ``` ## Signature ```ts theme={null} optionalBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler; ``` ## Parameters ### `options` ```ts theme={null} type BearerAuthMiddlewareOptions = { verifier: OAuthTokenVerifier; requiredScopes?: string[]; resourceMetadataUrl?: string; }; ``` | Field | Purpose | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verifier` | The provider-specific token check you write. See [Verifier](/api-reference/verifier). | | `requiredScopes` | A scope floor, enforced only when a token is sent: the token must carry all of these, or the request gets a 403. | | `resourceMetadataUrl` | Absolute URL appended to the `WWW-Authenticate` header on a 401, pointing at your OAuth 2.0 [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) so clients can discover the authorization server. | ## Returns An [Express `RequestHandler`](https://expressjs.com/en/5x/guide/routing/) to pass to [`server.use`](/api-reference/mcp-server#use), typically on `/mcp`. * No `Authorization` header: the request proceeds with no `authInfo`. * A valid token: the request proceeds, and [handlers](/api-reference/register-tool#handler) read it from `extra.http?.authInfo`. * A token that is present but invalid or expired: the same 401 / 403 as [`requireBearerAuth`](/api-reference/require-bearer-auth). Sending a bad token is still a client error. Require a token on every request, with the verifier contract Gate individual tools with `securitySchemes` Add sign-in to your app end to end # Overview Source: https://docs.skybridge.tech/api-reference/overview Every server API, hook, and utility, with host compatibility. Every Skybridge export. Server APIs run in your MCP server and work with any host. [View](/build/view) hooks run inside the rendered view, where a host that lacks a capability degrades gracefully, either a no-op or a thrown error documented on the hook's page. ## Server | API | Description | | ------------------------------------------------------------------ | ----------------------------------------------------------------- | | [`mcpAuthMetadataRouter`](/api-reference/mcp-auth-metadata-router) | Serve OAuth metadata for client discovery. | | [`McpServer`](/api-reference/mcp-server) | The per-request server your handler registers tools on. | | [`optionalBearerAuth`](/api-reference/optional-bearer-auth) | Attach a verified identity when a token is present. | | [`registerTool`](/api-reference/register-tool) | Register a tool, its schemas, and the view it renders. | | [`requireBearerAuth`](/api-reference/require-bearer-auth) | Gate a server behind a verified bearer token. | | [`Skybridge`](/api-reference/skybridge) | The app: its config, OAuth, and the handler that registers tools. | ## Hooks | Hook | Description | Supported Hosts | | --------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------- | | [`useCallTool`](/api-reference/use-call-tool) | Call server tools from the view and track call state. | | | [`useDisplayMode`](/api-reference/use-display-mode) | Read and request inline, pip, or fullscreen. | | | [`useDownload`](/api-reference/use-download) | Save files to the user's device. | | | [`useFiles`](/api-reference/use-files) | Upload and pick host-managed files. | | | [`useHost`](/api-reference/use-host) | Identify which host is rendering the view. | | | [`useOpenExternal`](/api-reference/use-open-external) | Open a URL outside the view iframe. | | | [`useRegisterViewTool`](/api-reference/use-register-view-tool) | Expose a tool that runs inside the view. | | | [`useRequestClose`](/api-reference/use-request-close) | Ask the host to dismiss the view. | | | [`useRequestModal`](/api-reference/use-request-modal) | Open the view as a host modal. | | | [`useRequestSize`](/api-reference/use-request-size) | Ask the host to resize the view iframe. | | | [`useSendFollowUpMessage`](/api-reference/use-send-follow-up-message) | Send a follow-up turn to the model. | | | [`useSetOpenInAppUrl`](/api-reference/use-set-open-in-app-url) | Override the fullscreen open-in-app URL. | | | [`useToolInfo`](/api-reference/use-tool-info) | Read the tool input, output, and metadata that rendered the view. | | | [`useUser`](/api-reference/use-user) | Read locale, theme, and device capabilities. | | | [`useViewState`](/api-reference/use-view-state) | Persist UI state on the host across renders. | | | [`useViewport`](/api-reference/use-viewport) | Read the max height and safe-area insets. | | ## Utilities | Utility | Description | | ---------------------------------------------------- | ----------------------------------------------- | | [`createStore`](/api-reference/create-store) | A Zustand store synced with host view state. | | [`data-llm`](/api-reference/data-llm) | Describe on-screen state to the model in words. | | [`generateHelpers`](/api-reference/generate-helpers) | Generate typed hooks inferred from your server. | ## Types | Type | Description | | ---------------------------------------------- | --------------------------------------------------------- | | [`FileRef`](/api-reference/file-ref) | Reference a host-managed file in a tool schema. | | [Type Utilities](/api-reference/utility-types) | Pull tool input, output, and name types from your server. | ## Low-level | Hook | Description | | ---------------------------------------------------------- | --------------------------------------------------- | | [`useAppsSdkContext`](/api-reference/use-apps-sdk-context) | Read a raw ChatGPT (Apps SDK) context value by key. | | [`useMcpAppContext`](/api-reference/use-mcp-app-context) | Read a raw MCP Apps context value by key. | Scaffold and run your first app Register the tools these APIs call How the two runtimes differ and what's unified # registerTool Source: https://docs.skybridge.tech/api-reference/register-tool Register a tool, with or without a view `registerTool` adds an action the model can call to your server, optionally rendering its result through a [view](#view). ## Example `search-products` searches the store catalog for the signed-in user and renders the matches in the `carousel` view. ```ts server.ts theme={null} import { Skybridge } from "skybridge/server"; import { z } from "zod"; export const app = new Skybridge({ name: "shop", version: "1.0", handler: (server) => server.registerTool( { name: "search-products", title: "Search products", description: "Search the store catalog by keyword and optional price ceiling.", inputSchema: { query: z.string().describe("What the shopper is looking for"), }, outputSchema: { productIds: z.array(z.string()).describe("The IDs of products matching the query."), }, annotations: { readOnlyHint: true }, securitySchemes: [{ type: "oauth2", scopes: ["catalog.read"] }], view: { component: "carousel", csp: { connectDomains: ["https://api.myshop.com"] }, }, }, async ({ query, maxPrice }, extra) => { const userId = extra.http?.authInfo?.extra?.subject; const products = await search(query, { maxPrice, userId }); return { content: `Found ${products.length} products for "${query}".`, structuredContent: { products }, }; }, ), }); ``` ## Signature ```ts theme={null} server.registerTool(config: ToolConfig, handler: ToolHandler): McpServer; ``` ## Config The first argument declares the tool. ```ts theme={null} type ToolConfig = { name: string; title?: string; // human-readable label description?: string; // model-facing: decides when the model calls the tool inputSchema?: Record; // validates the call and types the handler input outputSchema?: Record; // types structuredContent annotations?: ToolAnnotations; // standard MCP hints view?: ViewConfig; // bind a view to render the result auth?: { allowsAnonymous?: boolean; scopes?: string[] }; // per-tool auth securitySchemes?: SecurityScheme[]; // low-level alternative to `auth` (mutually exclusive) _meta?: ToolMeta; // extra metadata }; ``` ### `name`, `title`, `description` All three are the tool's prompt surface: the model reads them to decide when to call it, so write each for the model. `name` is also the identifier the call uses, and `title` a short display name. ### `inputSchema`, `outputSchema` Shapes of [Standard Schema](https://standardschema.dev) validators that can emit JSON Schema: [Zod](https://zod.dev/) 4.2+ and ArkType out of the box, valibot through `@valibot/to-json-schema`. `inputSchema` validates the call arguments and types the handler's `input`. Add `.describe()` to a field to tell the model what it's for. `outputSchema` tells the model the shape to expect back, it does not type the returned value. ### `annotations` Standard MCP hints describing what the tool does, so the host can label it and order calls. They are hints: the host may surface or ignore them, and never gates a call on them. ```ts theme={null} type ToolAnnotations = { title?: string; readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean; }; ``` | Hint | Meaning | Default | | ----------------- | ----------------------------------------------------------------------------------------------------------------- | ------- | | `title` | Human-readable label; the host gives it precedence over `name`. | none | | `readOnlyHint` | The tool does not modify its environment. | `false` | | `destructiveHint` | The tool may perform destructive updates, not only additive ones. Meaningful only when `readOnlyHint` is `false`. | `true` | | `idempotentHint` | Repeating the call with the same arguments has no further effect. Meaningful only when `readOnlyHint` is `false`. | `false` | | `openWorldHint` | The tool interacts with an open world of external entities, not a closed domain. | `true` | ### `view` Bind the tool to a React view to render its result instead of plain text. Each view backs exactly one tool. ```ts theme={null} type ViewConfig = { component: ViewName; description?: string; hosts?: Array<"apps-sdk" | "mcp-app">; prefersBorder?: boolean; domain?: string; csp?: ViewCsp; _meta?: Record; }; ``` | Key | Purpose | | ----------------------------------------- | -------------------------------------------------------- | | `component` | The view's file name, type-checked against your views. | | `description` | Label the host may show during view discovery. | | `hosts` | Restrict where the view renders; defaults to all. | | `prefersBorder` | Apps SDK only: request a visible border around the view. | | `domain` | Apps SDK only: override the served domain (advanced). | | [`csp`](/api-reference/register-tool#csp) | Per-view CSP overrides; see below. | | `_meta` | Free-form metadata forwarded on the view resource. | #### `csp` A view runs in a sandboxed iframe; your server's domain is allowlisted automatically. Add external origins per directive: ```ts theme={null} type ViewCsp = { resourceDomains?: string[]; connectDomains?: string[]; frameDomains?: string[]; redirectDomains?: string[]; baseUriDomains?: string[]; }; ``` | Directive | Purpose | | ----------------- | ----------------------------------------------------- | | `resourceDomains` | Static assets: images, fonts, scripts, styles. | | `connectDomains` | Fetch / XHR targets. | | `frameDomains` | Iframe embed origins (opts into stricter app review). | | `redirectDomains` | `openExternal` targets that skip the safe-link modal. | | `baseUriDomains` | `` origins (MCP Apps only). | ### `auth` The recommended way to declare a tool's auth. With the [`oauth`](/api-reference/skybridge#oauth) field set, Skybridge enforces the declaration for you (anonymous or under-scoped calls are rejected before the handler runs). | Value | Meaning | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | *omitted* (default) | Requires sign-in, the secure default when the server has an `oauth` provider. | | `{ allowsAnonymous: true }` | Callable signed out; uses the token when one is present. | | `{ scopes: [...] }` | Requires sign-in with every listed scope. | | `{ allowsAnonymous: true, scopes: [...] }` | Callable signed out; a signed-in caller missing a listed scope gets a step-up challenge (`insufficient_scope`) instead of the anonymous path. | Declaring any `{ allowsAnonymous: true }` tool turns the server [mixed](/build/auth#mix-public-and-authenticated-tools): it serves anonymous callers, and every other tool stays sign-in-gated by default. Requiring sign-in (`{ scopes }`) needs the `oauth` provider and throws without it; `{ allowsAnonymous: true }` works either way. Omit `auth` entirely for the secure default (sign-in required whenever the server has auth). ### `securitySchemes` The low-level form behind `auth`, for cases it can't express (several alternative `oauth2` schemes, or pure `noauth` that never accepts a token). Mutually exclusive with `auth`. ```ts theme={null} type SecurityScheme = | { type: "noauth" } | { type: "oauth2"; scopes?: string[] }; ``` * **Across the array, match any.** Each entry is an alternative. * **Within an `oauth2` entry's `scopes`, match all.** The token must carry every listed scope. * **`noauth` and `oauth2` together** means "works anonymously, but auth unlocks more." With the [`oauth`](/api-reference/skybridge#oauth) field, Skybridge enforces `securitySchemes` like `auth`. With hand-wired middleware ([`requireBearerAuth`](/api-reference/require-bearer-auth) or [`optionalBearerAuth`](/api-reference/optional-bearer-auth)) it stays client-facing metadata: enforce the tool's requirements in the handler from `extra.http?.authInfo`. ### `_meta` Metadata on the tool. Skybridge recognizes the keys below; any other key you set is forwarded on the tool's `_meta` untouched. ```ts theme={null} type ToolMeta = { ui?: { visibility?: Array<"model" | "app"> }; "openai/toolInvocation/invoking"?: string; "openai/toolInvocation/invoked"?: string; "openai/fileParams"?: string[]; [key: string]: unknown; }; ``` | Key | Purpose | | ------------------------------------------- | -------------------------------------------------------------------------------------------- | | `ui.visibility` | Expose the tool to the model, the app, or both. | | `openai/toolInvocation/invoking`, `invoked` | Status text shown while the tool runs and after it completes. | | `openai/fileParams` | Top-level input fields that carry file references; see [`FileRef`](/api-reference/file-ref). | | `openai/widgetAccessible` | Deprecated, use `ui.visibility`. | The `openai/*` keys are read only on **ChatGPT**. `openai/widgetAccessible`is deprecated, prefer `ui.visibility` instead. ## Handler Runs when the tool is called. It receives the validated `input` (typed from [`inputSchema`](#inputschema-outputschema)) and the request context `extra`: ```ts theme={null} type ToolHandler = (input: Input, extra: Extra) => Promise<{ content?: string | ContentBlock | ContentBlock[]; structuredContent?: Record; isError?: boolean; _meta?: Record; }>; ``` ### `extra` The request context. | Field | Purpose | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `http?.authInfo` | Validated token from an auth middleware: `clientId`, `scopes`, `extra`. The [provider](/guides/auth-providers) types the claims in `extra`. | | `http?.req` | The HTTP request as a Web `Request`: read headers with `.headers.get()`. | | `mcpReq.signal` | An `AbortSignal`, aborted when the call is cancelled. | | `mcpReq._meta` | Client hints (below). | ### Return | Field | Purpose | | ------------------- | ------------------------------------------------------------------------------------------- | | `content` | Text for the model, shown in the conversation. A string, a `ContentBlock`, or an array. | | `structuredContent` | Typed data the model and the view read (via [`useToolInfo`](/api-reference/use-tool-info)). | | `isError` | Marks the call as failed. | | `_meta` | View-only response metadata, hidden from the model. | For a view tool, Skybridge adds a `viewUUID` to `_meta`, a reserved key used for state persistence. ### Client hints **ChatGPT** attaches conversation context to `extra.mcpReq._meta`. They are hints: tolerate their absence and never use them for authorization. ```ts theme={null} type ClientHintsMeta = { "openai/locale"?: string; "openai/userAgent"?: string; "openai/userLocation"?: { city?: string; region?: string; country?: string; timezone?: string; longitude?: number; latitude?: number; }; "openai/subject"?: string; "openai/session"?: string; "openai/organization"?: string; "openai/widgetSessionId"?: string; }; ``` | Key | Purpose | | ------------------------ | -------------------------------------------------------------------------------------------------- | | `openai/locale` | Requested locale, BCP-47, e.g. `en-US`. | | `openai/userAgent` | Browser user-agent of the ChatGPT client. | | `openai/userLocation` | Coarse location: `city`, `region`, `country`, `timezone`, `latitude`, `longitude` (each optional). | | `openai/subject` | Anonymized user id. | | `openai/session` | Anonymized conversation id, stable within a session. | | `openai/organization` | Anonymized organization id, when the user account is in an organization. | | `openai/widgetSessionId` | Stable id for the mounted view instance. | These hints are **ChatGPT** only. ### Content helpers `skybridge/server` exports helpers that build `ContentBlock`s for the handler's `content`: ```ts theme={null} import { audio, embeddedResource, image, resourceLink, text } from "skybridge/server"; return { content: [text("Here's your chart:"), image(pngBuffer, "image/png")], structuredContent: { /* ... */ }, }; ``` | Helper | Block | | ------------------------------------------ | ----------------------- | | `text(value, annotations?)` | `TextContent` | | `image(data, mimeType, annotations?)` | `ImageContent` (base64) | | `audio(data, mimeType, annotations?)` | `AudioContent` (base64) | | `embeddedResource(resource, annotations?)` | `EmbeddedResource` | | `resourceLink(link, annotations?)` | `ResourceLink` | Returning plain `ContentBlock` objects works too; the helpers are optional. The server you register tools on Read the tool's result in the view Pass files in and out of a tool # requireBearerAuth Source: https://docs.skybridge.tech/api-reference/require-bearer-auth Require a signed-in user on every request `requireBearerAuth` locks your server behind sign-in. Unauthenticated requests are turned away before any [tool](/build/tools) runs, and your tools receive the signed-in user. ## Example Every tool requires a signed-in user with the `shop.read` scope: the middleware validates the token before any handler runs. ```ts server.ts highlight={6-9} theme={null} import { Skybridge, requireBearerAuth } from "skybridge/server"; import { verifyAccessToken } from "./verify-access-token.js"; // the verifier you implement export const app = new Skybridge({ name: "shop", version: "1.0", handler }).use( "/mcp", requireBearerAuth({ verifier: { verifyAccessToken }, requiredScopes: ["shop.read"], }), ); ``` ## Signature ```ts theme={null} requireBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler; ``` ## Parameters ### `options` ```ts theme={null} type BearerAuthMiddlewareOptions = { verifier: OAuthTokenVerifier; requiredScopes?: string[]; resourceMetadataUrl?: string; }; ``` | Field | Purpose | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verifier` | The provider-specific token check you implement. See [Verifier](/api-reference/verifier). | | `requiredScopes` | A server-wide scope floor every accepted token must carry, or the request gets a 403. Layers under per-tool [`securitySchemes`](/api-reference/register-tool#securityschemes). | | `resourceMetadataUrl` | Absolute URL appended to the `WWW-Authenticate` header on a 401, pointing at your OAuth 2.0 [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) so clients can discover the authorization server. | ## Returns An Express `RequestHandler` to pass to [`server.use`](/api-reference/mcp-server#use), typically on `/mcp`. * A valid token: the request proceeds, and handlers read it from `extra.http?.authInfo`. * A missing, invalid, or expired token: a 401 with a `WWW-Authenticate` header. A token missing a required scope: a 403. A valid token only proves who the caller is. Authorizing what they can do, and scoping data to them, stays the handler's job: read `extra.http?.authInfo` and never trust a client-supplied identifier. Accept a token when present, allow anonymous otherwise Advertise your authorization server for discovery Add sign-in to your app end to end # Skybridge Source: https://docs.skybridge.tech/api-reference/skybridge Configure and run your app `Skybridge` is the root of your app: one config object that names your server, wires OAuth, and registers your [tools](/build/tools) through a `handler`. It runs an [Express](https://expressjs.com/)-backed HTTP server that serves MCP at `/mcp`, and you export its type for [`generateHelpers`](/api-reference/generate-helpers). ## Example The app registers one tool and exports its type for the typed client hooks. A separate entry file runs it. ```ts src/server.ts theme={null} import { Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "shop", version: "1.0", handler: (server) => server.registerTool(/* ... */), }); export type AppType = typeof app; // generateHelpers reads your tools from this ``` ```ts src/index.ts theme={null} import { app } from "./server.js"; export default await app.run(); ``` Keeping the definition in `server.ts` and the `run()` call in `index.ts` lets tests and [evals](/test/evals) import the app without starting a server. ## Constructor ```ts theme={null} new Skybridge(config: SkybridgeConfig); ``` `config` merges the MCP implementation info, the SDK's `ServerOptions`, and Skybridge's own fields. Every type is inferred from it: the config passed to `handler` from `setup`, the auth claims from `oauth`, and the tool registry from what `handler` returns. ```ts theme={null} type SkybridgeConfig = Implementation & ServerOptions & { handler: (server: McpServer, config: Config) => McpServer; setup?: () => Config | Promise; oauth?: OAuthConfig | OAuthProvider | ((config: Config) => OAuthConfig | OAuthProvider); json?: JsonOptions; skills?: boolean; }; ``` ### `name`, `version` The MCP implementation info (`name`, `version`, and optionally `title`, `description`, `icons`, `websiteUrl`). SDK `ServerOptions` such as `instructions` or `capabilities` are forwarded when you set them; registering tools and views advertises those capabilities for you. ### `handler` Receives a fresh [`McpServer`](/api-reference/mcp-server) and must **return** the chain of registrations. That return value is what carries your tool types into `typeof app`. The handler runs for **every request**, so keep it to registration. Anything else in its body (a connection pool, a timer, a file read) runs per request too: move it into [`setup`](#setup), whose result is the handler's second argument. Skybridge warns once in the console when a handler takes more than 50ms. The handler must stay synchronous. ### `setup` Loads what the app needs before it serves: a connection pool, a client, remote config, secrets. It runs **once**, at `run()` or on the first request, never when the module is imported. Its awaited result is passed to `handler` as the second argument, and to `oauth` when `oauth` is a function. ```ts src/server.ts theme={null} export const app = new Skybridge({ name: "shop", version: "1.0", setup: async () => loadConfig(), oauth: (config) => descopeProvider({ url: config.mcpServerUrl }), handler: (server, config) => server.registerTool(/* reads config */), }); ``` ### `oauth` An [identity provider](/guides/auth-providers) (`oauth: workosProvider({ ... })`), a raw [`OAuthConfig`](/api-reference/custom-provider#returns), or a function of the `setup` result returning either. Providers defer discovery: nothing runs at module import, the network call happens once at `run()`. When set, it mounts the well-known OAuth metadata and bearer-token verification on `/mcp`. The config also carries the claim shape its verifier produces, so handlers read `extra.http.authInfo.extra` typed, with no declaration of their own. See [Type the Claims You Read](/build/auth#type-the-claims-you-read). ### `json` Options for the [`express.json()`](https://expressjs.com/en/5x/api/express/#expressjsonoptions) parser Skybridge pre-applies, for example to raise the default 100kb body-size limit. ### `skills` Set to `true` to serve [Agent Skills over MCP](/guides/skills) from `src/skills` and declare the `io.modelcontextprotocol/skills` capability. Skills over MCP tracks [SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640), which is still under review. The feature is **experimental** and may change with the spec. ## Properties ### `express` The underlying Express app, for custom routes, middleware, and settings. Register handlers before `run()`. [Alpic Cloud](https://alpic.ai/solutions/cloud) routes traffic only to `/mcp`. Custom routes work locally and on self-hosted deployments. ## Methods Every method returns the app, so calls chain. ### `use` ```ts theme={null} app.use(...handlers: RequestHandler[]): this; app.use(path: string, ...handlers: RequestHandler[]): this; ``` Registers [Express middleware](https://expressjs.com/en/guide/using-middleware/) on the underlying app, optionally scoped to a path. Mirrors `app.use`. ### `useOnError` ```ts theme={null} app.useOnError(...handlers: ErrorRequestHandler[]): this; app.useOnError(path: string, ...handlers: ErrorRequestHandler[]): this; ``` Registers an [Express error handler](https://expressjs.com/en/guide/error-handling.html), optionally path-scoped, to run after the `/mcp` route. A default handler runs last, responding with a 500 [JSON-RPC](https://www.jsonrpc.org/) error when nothing else has sent a response. ### `run` ```ts theme={null} app.run(): Promise<{ fetch: (...args: unknown[]) => unknown } | Express | undefined>; ``` Resolves `setup` and `oauth`, applies your middleware, mounts `/mcp`, and listens (default port `3000`). On serverless platforms, export what it returns so the platform can route requests to it. See [Deploy](/ship/deploy) for the per-platform setup. ### `connect` ```ts theme={null} app.connect(transport: Transport): Promise; ``` Connects the app to a transport you manage, such as stdio for a desktop host. For HTTP, `run()` sets the transport up for you. The server your handler registers tools on Define the tools and views the app exposes Turn `AppType` into typed client hooks # stytchProvider Source: https://docs.skybridge.tech/api-reference/stytch-provider Wire OAuth from Stytch Connected Apps `stytchProvider` wires authentication through [Stytch Connected Apps](https://stytch.com/), so your tools receive a signed-in Stytch user. ## Example ```ts server.ts highlight={1,6-9} theme={null} import { Skybridge, stytchProvider } from "skybridge/server"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", oauth: stytchProvider({ domain: process.env.STYTCH_DOMAIN, audience: process.env.STYTCH_PROJECT_ID, }), handler, }); ``` ## Signature ```ts theme={null} stytchProvider(opts: StytchProviderOptions): Promise; ``` ## Parameters ### `opts` * **`domain`** is the project domain, for example `acme.customers.stytch.dev`, or a configured custom domain. * **`audience`** is the Stytch Project ID, the audience Stytch binds into the token's `aud` claim. It also accepts the shared [`CustomProviderOptions`](/api-reference/custom-provider#parameters) options: `baseUrl`, `serverUrl`, `scopes`, `requiredScopes`, and `metadataOverrides`. Requires Dynamic Client Registration enabled in the Stytch dashboard. ## Returns A `Promise` for the [`OAuthConfig`](/api-reference/custom-provider#returns) you pass to the [`oauth`](/api-reference/skybridge#oauth) field, as a value or from a function. Set up sign-in with a hosted provider Add sign-in to your app end to end Wire OAuth from any IdP's discovery document # useAppsSdkContext Source: https://docs.skybridge.tech/api-reference/use-apps-sdk-context Read a raw ChatGPT host global by key ChatGPT exposes its host context to your app, which Skybridge's hooks read and normalize for you. `useAppsSdkContext` is the escape hatch: it reads a context value raw, by key, before that normalization. It works only under ChatGPT. ## Example `useUser` canonicalizes the locale (`fr_FR` becomes `fr-FR`). Reading it raw gives exactly what ChatGPT sent. ```tsx highlight={4} theme={null} import { useAppsSdkContext } from "skybridge/web"; function LocaleBadge() { const locale = useAppsSdkContext("locale"); // raw, e.g. "fr_FR" return {locale}; } ``` ## Type Parameters ### `K` ```tsx theme={null} K extends keyof AppsSdkContext; ``` Inferred from `key`: the literal key you pass fixes the return type to `AppsSdkContext[K]`. ## Parameters ### `key` ```tsx theme={null} key: K; ``` **Required.** The context key to read. The hook re-renders when the host pushes a new value for that key, and ignores changes to other keys. ## Returns ```tsx theme={null} value: AppsSdkContext[K]; ``` The requested key's current value, read live from `window.openai`. It is un-normalized: every key also has a cross-host hook that returns a wrapped form, so reach here only for the raw value. | Key | Value | Also via | | ---------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------- | | `theme` | `"light"` or `"dark"` | [`useUser`](/api-reference/use-user) | | `locale` | locale string, not canonicalized | [`useUser`](/api-reference/use-user) | | `userAgent` | device type and input capabilities | [`useUser`](/api-reference/use-user) | | `maxHeight` | max view height in pixels | [`useViewport`](/api-reference/use-viewport) | | `displayMode` | current layout (`pip` / `inline` / `fullscreen` / `modal`) | [`useDisplayMode`](/api-reference/use-display-mode) | | `safeArea` | insets to keep clear of host chrome | [`useViewport`](/api-reference/use-viewport) | | `view` | current mode and modal params | [`useRequestModal`](/api-reference/use-request-modal) | | `toolInput` | the tool call's arguments | [`useToolInfo`](/api-reference/use-tool-info) | | `toolOutput` | the tool's structured output | [`useToolInfo`](/api-reference/use-tool-info) | | `toolResponseMetadata` | the tool's response `_meta` | [`useToolInfo`](/api-reference/use-tool-info) | | `widgetState` | persisted state: `modelContent` (model-shared), `privateContent` (view-only), `imageIds` | [`useViewState`](/api-reference/use-view-state) | The same escape hatch for the MCP Apps runtime Read the tool result the view mounted with, cross-host What the raw Apps SDK layer is, and how Skybridge unifies it # useCallTool Source: https://docs.skybridge.tech/api-reference/use-call-tool Call a tool from a view `useCallTool` lets a [view](/build/view) call one of your server's [tools](/build/tools) and tracks the call's state, so an interaction in the view can run server logic and render the result. The model does not see a view-initiated call. Import it from your [`generated helpers.ts`](/api-reference/generate-helpers): the typed hook infers the tool's argument and response types from your server, so you pass only the name. ## Examples ### Trigger a call and read the result A shopper checks out their cart without leaving the view. The button shows progress while the server creates the checkout, then turns into a Pay now link, or explains why checkout could not start. ```tsx highlight={5-6,14-17,23,27} theme={null} import { useCallTool } from "../helpers.js"; // generated, type-safe from server schema import { useOpenExternal } from "skybridge/web"; function Cart({ items }: { items: string[] }) { const { callTool, isPending, isSuccess, isError, data, error } = useCallTool("create-checkout"); const openExternal = useOpenExternal(); return (
{isSuccess && ( )} {isError &&

Could not start checkout: {String(error)}

}
); } ``` ### Await the call A shopper checks out and goes straight to payment. The view waits for the checkout, opens the payment page, and recovers if the request never completes. ```tsx highlight={5,10,12-13} theme={null} import { useCallTool } from "../helpers.js"; // generated, type-safe from server schema import { useOpenExternal } from "skybridge/web"; function Cart() { const { callToolAsync, isPending } = useCallTool("create-checkout"); const openExternal = useOpenExternal(); const checkout = async () => { try { const { structuredContent } = await callToolAsync({ items: ["jacket"] }); openExternal(structuredContent.checkoutUrl); } catch (error) { console.error("Checkout failed", error); } }; return ( ); } ``` ## Type Parameters The [generated helper](/api-reference/generate-helpers) infers both from your server. You set them by hand only when importing `useCallTool` from `skybridge/web` directly, which is not recommended. ### `ToolArgs` ```tsx theme={null} ToolArgs extends Record | null = null; ``` The arguments the tool accepts. Defaults to `null`, for a tool that takes no arguments. ### `ToolResponse` ```tsx theme={null} ToolResponse extends Partial<{ structuredContent: Record; meta: Record; }> = Record; ``` The shape of the tool's `structuredContent` and `meta`. Defaults to an empty object. Whatever you set here types those two fields on `data`. ## Parameters ### `name` ```tsx theme={null} name: string; ``` **Required.** The name of the tool to call. It must match a tool registered on your [MCP server](/api-reference/mcp-server). A view can call a tool only when the tool's `_meta.ui.visibility` includes `"app"`, which is the default behavior. See [Register Tools](/build/tools). ## Returns `useCallTool` returns two call functions plus the live [state](/build/state) of the most recent call. ### `callTool` ```tsx theme={null} callTool(toolArgs: ToolArgs, sideEffects?: SideEffects): void; callTool(sideEffects?: SideEffects): void; // overload available when the tool has no required arguments ``` Starts the call and tracks its state on the hook. Returns `void`: read the outcome from `status` / `data` / `error`. Drop `toolArgs` via the second overload when the tool has no required arguments. **`sideEffects`** Optional callbacks bound to this specific call, firing even when a later call supersedes it. ```tsx theme={null} type SideEffects = { // the call completed: the host returned a response (which may carry data.isError === true) onSuccess?: (data: CallToolResponse & ToolResponse, toolArgs: ToolArgs) => void; // the call failed to complete: the host rejected it onError?: (error: unknown, toolArgs: ToolArgs) => void; // runs after onSuccess or onError, with the other argument undefined onSettled?: ( data: (CallToolResponse & ToolResponse) | undefined, error: unknown | undefined, toolArgs: ToolArgs, ) => void; }; ``` ### `callToolAsync` ```tsx theme={null} callToolAsync(toolArgs: ToolArgs): Promise; callToolAsync(): Promise; // overload available when the tool has no required arguments ``` Tracks state on the hook exactly like `callTool`, and also returns a promise. It resolves with the [`response`](#data) when the call completes, and rejects with the thrown value when the call fails to complete. It accepts no `sideEffects`. ### `status` ```tsx theme={null} status: "idle" | "pending" | "success" | "error"; ``` * `"idle"`: no call has started. * `"pending"`: a call is in flight. * `"success"`: the most recent call completed and the host returned a response. The response may carry `data.isError === true`. * `"error"`: the most recent call failed to complete and the host rejected it. ### `isIdle`, `isPending`, `isSuccess`, `isError` ```tsx theme={null} isIdle: boolean; isPending: boolean; isSuccess: boolean; isError: boolean; ``` Each is `true` when `status` equals the matching value and `false` otherwise. Exactly one is `true` at any time. `isError` here is the call-level flag (`status` is `"error"`). It is not `data.isError`, which marks a completed call whose tool reported a failure. ### `data` ```tsx theme={null} data: (CallToolResponse & ToolResponse) | undefined; ``` The response, set only while `status` is `"success"`. It is `undefined` in every other state. A new call clears it to `undefined` as it enters `"pending"`. A tool that reports a failure still lands here, with `isError: true`; only a call that fails to complete sets `error` instead. `CallToolResponse` is the fixed part of every response, which your `ToolResponse` type parameter narrows on `structuredContent` and `meta`: ```tsx theme={null} type CallToolResponse = { content: ContentBlock[]; // the MCP content blocks the tool returned structuredContent: Record; isError: boolean; // true when the tool itself reported a failure meta?: Record; }; ``` ### `error` ```tsx theme={null} error: unknown | undefined; ``` The thrown value, set only while `status` is `"error"`. It is `undefined` in every other state. A tool that completes but reports its own failure is a `"success"`, not an `"error"`. Call tools back from a view in context The typed `useCallTool` that infers from your server Read the tool result the view mounted with # useDisplayMode Source: https://docs.skybridge.tech/api-reference/use-display-mode Read and switch how the host lays out the view A host renders the [view](/build/view) in one of a few layouts: a compact inline panel, fullscreen, or a floating picture-in-picture. `useDisplayMode` lets the view read which layout it is in and ask the host to switch to another. ## Example The product carousel shows more columns when it takes the full screen. From the inline panel it offers a "See all" button to expand, and once expanded, a "Collapse" button to shrink back. ```tsx highlight={4,10,13,16} theme={null} import { useDisplayMode } from "skybridge/web"; function Carousel({ products }: { products: Product[] }) { const [mode, setMode] = useDisplayMode(); return (
{mode === "inline" && ( )} {mode === "fullscreen" && ( )}
); } ``` ## Returns `useDisplayMode` returns a tuple: the current mode first, the setter second. ### `displayMode` ```tsx theme={null} displayMode: DisplayMode; ``` The mode the host is currently rendering the view in. It updates on its own when the host changes the layout (the user expanding or collapsing the view, for instance), so a component can render against it directly. | Mode | Layout | Requestable | | -------------- | ----------------------------------------------------------------------------------------- | :-------------------: | | `"inline"` | Compact panel embedded in the conversation, the default | | | `"fullscreen"` | The view takes over the host surface | | | `"pip"` | Picture in picture, floating above the conversation | | | `"modal"` | Overlay opened through [`useRequestModal`](/api-reference/use-request-modal), host-driven | | ### `setDisplayMode` ```tsx theme={null} setDisplayMode(mode: RequestDisplayMode): Promise<{ mode: RequestDisplayMode }>; ``` Asks the host to switch the view to `mode`. The host decides: it can grant the request, keep the current mode, or coerce to another. The promise resolves with the mode actually applied, so read the resolved value rather than assuming the request took. **`mode`** the mode to request, every [`DisplayMode`](#displaymode) except `"modal"`: ```tsx theme={null} // "inline" | "fullscreen" | "pip" type RequestDisplayMode = Exclude; ``` On mobile, **ChatGPT** coerces a `"pip"` request to `"fullscreen"`. Adapt the view to display mode, theme, and device Read the space and insets a mode switch brings Open the view as a host modal # useDownload Source: https://docs.skybridge.tech/api-reference/use-download Save files from a view to the user's device [Views](/build/view) run in sandboxed iframes where direct downloads (``, `URL.createObjectURL`) are blocked. `useDownload` hands the host one or more resources to write to the user's device, so a view can save a file it produced. ## Example The view builds a CSV from the carousel's products and hands the host an inline resource, then offers a server-hosted PDF as a resource link. ```tsx views/carousel.tsx highlight={4,11-22,32-41} theme={null} import { useDownload } from "skybridge/web"; function Carousel({ products }: { products: Product[] }) { const download = useDownload(); const exportCsv = async () => { const csv = products .map((p) => `${p.id},${p.name},${p.price}`) .join("\n"); try { const { isError } = await download({ contents: [ { type: "resource", resource: { uri: "file:///products.csv", // last segment is the suggested filename mimeType: "text/csv", text: csv, }, }, ], }); if (isError) { console.warn("Export cancelled or not supported by this host"); } } catch (err) { console.error("Download failed to complete", err); } }; const saveSpecSheet = async (product: Product) => { await download({ contents: [ { type: "resource_link", uri: `https://shop.example.com/specs/${product.id}.pdf`, name: `${product.name} spec sheet`, mimeType: "application/pdf", }, ], }); }; return (
{products.map((p) => ( ))}
); } ``` ## Returns `useDownload` returns the function directly. ### `download` ```tsx theme={null} download(params: DownloadParams): Promise; ``` Asks the host to save the resources in `params`. The promise resolves once the host responds, and rejects if the request times out or the connection is lost. **`params`** one or more resources to save, each carried inline or fetched by the host from a URL. ```tsx theme={null} type DownloadParams = { contents: (EmbeddedResource | ResourceLink)[]; }; ``` **`EmbeddedResource`** carries the file inline; `resource` holds the bytes as a UTF-8 `text` string or a base64 `blob`. The `uri`'s last segment sets the suggested filename. ```tsx theme={null} type EmbeddedResource = { type: "resource"; resource: | { uri: string; text: string; mimeType?: string; _meta?: Record } | { uri: string; blob: string; mimeType?: string; _meta?: Record }; annotations?: Annotations; // standard MCP annotations _meta?: Record; }; ``` **`ResourceLink`** points the host at a URL it fetches itself. ```tsx theme={null} type ResourceLink = { type: "resource_link"; uri: string; name: string; title?: string; description?: string; mimeType?: string; size?: number; annotations?: Annotations; // standard MCP annotations icons?: Icon[]; _meta?: Record; }; ``` The promise resolves with a `DownloadResult`: ```tsx theme={null} type DownloadResult = { isError?: boolean; }; ``` `isError` is `true` when the download did not happen: the user cancelled, or the host denied it (including a host that lacks the download capability). It is absent or `false` on success. A request that fails to complete (timeout, lost connection) rejects the promise instead of setting this flag. Move files in and out of your app Upload and resolve host-managed files Open a link outside the view # useFiles Source: https://docs.skybridge.tech/api-reference/use-files Upload, pick, and resolve files from the host ChatGPT stores your app's files and hands your code a reference to each, never the bytes. `useFiles` produces a reference from a file the user supplies, by uploading a local file or picking one from ChatGPT's library, and resolves any reference into a download URL the [view](/build/view) or a [tool](/build/tools) can fetch. ## Example A shopper attaches a receipt, from their device or their ChatGPT library, and the view hands it to a tool that scans it. ```tsx highlight={6,12-13,29,33} theme={null} import { useState } from "react"; import { useFiles } from "skybridge/web"; import { useCallTool } from "../helpers.js"; // generated, type-safe from server schema function ReceiptAttach() { const { upload, getDownloadUrl, selectFiles } = useFiles(); const { callTool, isPending } = useCallTool("scan-receipt"); const [error, setError] = useState(); const attachFromDevice = async (file: File) => { try { const meta = await upload(file); const { downloadUrl } = await getDownloadUrl(meta); callTool({ receipt: { file_id: meta.fileId, download_url: downloadUrl, file_name: meta.fileName, mime_type: meta.mimeType, }, }); } catch (e) { setError(String(e)); } }; const attachFromLibrary = async () => { try { const [picked] = await selectFiles(); if (!picked) { return; } const { downloadUrl } = await getDownloadUrl(picked); callTool({ receipt: { file_id: picked.fileId, download_url: downloadUrl, file_name: picked.fileName, mime_type: picked.mimeType, }, }); } catch (e) { setError(String(e)); } }; return (
{ const file = e.target.files?.[0]; if (file) { attachFromDevice(file); } }} /> {error &&

Could not attach receipt: {error}

}
); } ``` Both paths resolve a download URL and build a [`FileRef`](/api-reference/file-ref) by hand: the hook returns camelCase fields, while `FileRef` wants snake\_case with a required `download_url`. ## Returns `useFiles` returns three functions. ### `upload` ```tsx theme={null} upload(file: File): Promise; ``` Sends a `File` to the host and resolves with its `FileMetadata`, a reference to the stored file (the host never gives the view the bytes, only this handle): ```tsx theme={null} type FileMetadata = { fileId: string; fileName?: string; mimeType?: string; }; ``` ### `getDownloadUrl` ```tsx theme={null} getDownloadUrl(file: FileMetadata): Promise<{ downloadUrl: string }>; ``` Resolves a download URL for a host-managed file: one returned by `upload` or `selectFiles`, or one delivered to the view through a tool's output. ### `selectFiles` ```tsx theme={null} selectFiles(): Promise; ``` Opens ChatGPT's file library picker and resolves with the `FileMetadata` of the files the user authorizes for the app. The array is empty when the user picks nothing. Move files in and out of your app across hosts The schema that passes a file reference to a tool Forward an uploaded file into a tool call # useHost Source: https://docs.skybridge.tech/api-reference/use-host Identify which host is rendering the view The same [view](/build/view) runs inside every host: Claude, Cursor, Goose, and others. `useHost` reports which one, taken from the MCP Apps `ui/initialize` handshake, so the view can adapt copy, shortcuts, or layout to the host it's rendering in. It runs only on MCP Apps hosts; the name is normalized to a [`Host`](#host) slug when recognized, otherwise passed through as a raw string. ## Example An empty state suggests the next action using the wording that fits the host. ```tsx highlight={4} theme={null} import { useHost } from "skybridge/web"; function EmptyState() { const { name } = useHost(); const hint = name === "claude" ? "Ask Claude to add your first item." : "Send a message to add your first item."; return

{hint}

; } ``` ## Returns ### `name` ```tsx theme={null} name: Host | (string & {}) | undefined; ``` The host's reported name. It resolves to a [`Host`](#host) slug for recognized hosts, a raw string for hosts not yet mapped, and `undefined` until the handshake completes — the view renders first and re-renders once the host responds. The `(string & {})` keeps the known slugs in autocomplete while still accepting any string. ### `version` ```tsx theme={null} version: string | undefined; ``` The host's version string, or `undefined` until the handshake completes. ## Host The recognized hosts, as normalized slugs. An unrecognized host surfaces its raw reported name instead. ```tsx theme={null} type Host = | "chatgpt" | "claude" | "cursor" | "goose" | "mistral-vibe" | "alpic"; ``` | Slug | Reported `hostInfo.name` | | -------------- | ------------------------ | | `chatgpt` | `chatgpt` | | `claude` | `Claude` | | `cursor` | `Cursor` | | `goose` | `MCP-UI Host` | | `mistral-vibe` | `Le Chat` | | `alpic` | `alpic-playground` | Read the host's locale and device capabilities Read a raw MCP Apps context value by key Adapt the view to the host it runs in # useMcpAppContext Source: https://docs.skybridge.tech/api-reference/use-mcp-app-context Read a raw MCP Apps host context value by key An MCP Apps host (Claude and others) exposes [view](/build/view) context that Skybridge's cross-host hooks read for you. `useMcpAppContext` is the escape hatch: it reads that context raw, by key, for a protocol-level field the hooks don't surface. It runs only on MCP Apps hosts, not ChatGPT; the Apps SDK counterpart is [`useAppsSdkContext`](/api-reference/use-apps-sdk-context). ## Example [`useUser`](/api-reference/use-user) gives the locale but not the host time zone, so a schedule reads `timeZone` from the context to render times in the user's zone. ```tsx highlight={4} theme={null} import { useMcpAppContext } from "skybridge/web"; function Schedule({ events }: { events: Event[] }) { const timeZone = useMcpAppContext("timeZone"); return (
    {events.map((event) => (
  • {event.start.toLocaleString(undefined, { timeZone })}
  • ))}
); } ``` ## Type Parameters ### `K` ```tsx theme={null} K extends keyof McpAppContext; ``` Inferred from `key`: the literal key you pass fixes the return type to `McpAppContext[K]`. ## Parameters ### `key` ```tsx theme={null} key: K; ``` **Required.** The context key to read and subscribe to. The hook re-renders only when this key changes; to watch several keys, call it once per key. ### `options` ```tsx theme={null} options?: Partial<{ appInfo: Implementation }>; ``` `appInfo` (`{ name, version }`) optionally identifies the view to the host. Omit it for a generic default. ## Returns ```tsx theme={null} value: McpAppContext[K]; ``` The requested key's current value, empty until the host provides it: host-context keys are `undefined`, tool-state keys `null`. The table lists each key with the cross-host hook that also surfaces it; hosts may send others not listed. | Key | Value | Also via | | ----------------------- | -------------------------------------------------------------- | --------------------------------------------------- | | `theme` | `"light"` or `"dark"` | [`useUser`](/api-reference/use-user) | | `locale` | BCP-47 locale | [`useUser`](/api-reference/use-user) | | `timeZone` | IANA time zone | — | | `displayMode` | current layout (`inline` / `fullscreen` / `pip`) | [`useDisplayMode`](/api-reference/use-display-mode) | | `availableDisplayModes` | layouts the host supports | — | | `containerDimensions` | the view's available size (height and width) | [`useViewport`](/api-reference/use-viewport) | | `userAgent` | raw host user-agent string | — | | `platform` | `web` / `desktop` / `mobile` | [`useUser`](/api-reference/use-user) | | `deviceCapabilities` | touch and hover support | [`useUser`](/api-reference/use-user) | | `safeAreaInsets` | insets to keep clear of host chrome | [`useViewport`](/api-reference/use-viewport) | | `styles` | host theme tokens | — | | `toolInfo` | the tool definition that rendered the view | — | | `toolInput` | the tool call's arguments | [`useToolInfo`](/api-reference/use-tool-info) | | `toolResult` | the tool's full MCP result (`structuredContent` model-visible) | [`useToolInfo`](/api-reference/use-tool-info) | | `toolCancelled` | set when the host aborts the call | — | The Apps SDK counterpart for ChatGPT Read the tool result across both runtimes What the raw MCP Apps layer is, and how Skybridge unifies it # useOpenExternal Source: https://docs.skybridge.tech/api-reference/use-open-external Open a URL outside the view's iframe through the host A [view](/build/view) runs in a sandboxed iframe, so a normal external link goes nowhere: `window.open` and `target="_blank"` are both blocked. `useOpenExternal` sends the user out to a URL by routing the navigation through the host. ## Example The product card sends the shopper to the merchant's product page when they tap "View details"; the host opens it in the user's browser. ```tsx views/carousel.tsx highlight={1,10,16} theme={null} import { useOpenExternal } from "skybridge/web"; type Product = { name: string; price: number; productUrl: string; }; function ProductCard({ product }: { product: Product }) { const openExternal = useOpenExternal(); return (

{product.name}

${product.price}

); } ``` ## Returns `useOpenExternal` takes no arguments and returns a single function. ### `openExternal` ```tsx theme={null} openExternal(href: string, options?: { redirectUrl?: false }): void; ``` Routes the navigation through the host. Returns `void`: a fire-and-forget side effect, nothing crosses back to the view or the model. * **`href`** (required) The URL to open outside the iframe. The host opens it in the user's browser or hands it to the native app. * **`options`** Optional. Its only field, `redirectUrl`, is read on **ChatGPT** only: for allowlisted targets ChatGPT appends a `?redirectUrl=…` parameter (a link back into the conversation) by default, and `redirectUrl: false` skips it. On **Claude** it is ignored with a warning. Origins that `openExternal` can send the user to without the host's safe-link confirmation modal are declared in the view's `redirectDomains`. The view passes that list to the host, which honors it. See [Configure CSP](/guides/csp). Open the sandbox to external domains from a view Allowlist redirect targets with `redirectDomains` Send a message back into the conversation # useRegisterViewTool Source: https://docs.skybridge.tech/api-reference/use-register-view-tool Expose a tool the host and model can call against the running view A [tool](/build/tools) the model calls normally runs on your server. `useRegisterViewTool` registers a tool that runs in the [view](/build/view) instead: the model invokes it and the handler executes in your component, so the model can mutate view [state](/build/state) or do any work that does not need the backend. ## Example The model adds an on-screen product to the cart by calling a tool the view handles itself: the handler updates the live cart and reports back what it added. ```tsx highlight={2,7-23} theme={null} import * as z from "zod"; import { useRegisterViewTool, useViewState } from "skybridge/web"; function Carousel({ products }: { products: Product[] }) { const [{ cart }, setState] = useViewState({ cart: [] }); // synced with the model, survives remount useRegisterViewTool( { name: "carousel_add_to_cart", title: "Add to cart", description: "Add a product from the carousel to the cart by its id.", inputSchema: { productId: z.string() }, annotations: { readOnlyHint: false }, }, ({ productId }) => { const product = products.find((p) => p.id === productId); setState((s) => ({ cart: [...s.cart, productId] })); return { content: [{ type: "text", text: `Added ${product?.name ?? productId} to the cart.` }], structuredContent: { cart: [...cart, productId] }, }; }, ); return ; } ``` ## Parameters ### `config` ```tsx theme={null} config: ViewToolConfig; ``` **Required.** Declares the tool the view exposes, mirroring the server-side [`registerTool`](/api-reference/register-tool) config. Namespace `name` (for example `carousel_add_to_cart`) so it does not clash with a server tool. ```tsx theme={null} type ViewToolConfig = { name: string; // tool name the model calls title?: string; description?: string; // model-facing: decides when the model calls this tool inputSchema?: TInput; // Zod raw shape; types the handler args annotations?: ToolAnnotations; // standard MCP hints, e.g. readOnlyHint }; ``` ### `handler` ```tsx theme={null} handler: (args: InferViewToolArgs) => ViewToolResult | Promise; ``` **Required.** Runs when the model calls the tool, receiving the arguments typed from `config.inputSchema` (each field's optionality preserved). It returns the MCP `CallToolResult`; the model sees `content` and `structuredContent` as the tool result in the conversation. ```tsx theme={null} type ViewToolResult = { content?: ContentBlock[]; // returned to the model; defaults to [] structuredContent?: Record; // typed result the model reads isError?: boolean; // true marks the call as failed _meta?: Record; }; ``` ## Lifecycle The hook registers the tool on mount, removes it on unmount, and re-registers only when `name` changes. The `handler` always runs its latest version, so it sees current state; changes to the other config fields reach the host only on re-registration. Wire a view tool into a running view The inverse: call a server tool from the view Read the tool result the view mounted with # useRequestClose Source: https://docs.skybridge.tech/api-reference/use-request-close Dismiss the view from inside your app When a [view](/build/view) has run its course, dismissing it hands the user back to the conversation instead of leaving a spent panel on screen. `useRequestClose` asks the host to do that; the host owns the decision. ## Example After the order is placed, a "Done" button dismisses the confirmation and hands the shopper back to the conversation. ```tsx highlight={2,5,10} theme={null} import { useState } from "react"; import { useRequestClose } from "skybridge/web"; function CheckoutConfirmation() { const requestClose = useRequestClose(); const [closing, setClosing] = useState(false); const handleDone = async () => { setClosing(true); await requestClose(); }; return (

Your order is on its way.

); } ``` ## Returns `useRequestClose` returns a single function. ### `requestClose` ```tsx theme={null} requestClose(): Promise; ``` Asks the host to dismiss the current view. The promise resolves once the request is sent, not when the view closes: the host owns the decision and returns no acknowledgement, so the view cannot tell whether it was actually dismissed. Where dismissing a view fits in the view lifecycle Open the modal view you later close Request a different layout instead of closing # useRequestModal Source: https://docs.skybridge.tech/api-reference/use-request-modal Open the view as a modal overlay and read its modal state A modal overlay is host-owned: a [view](/build/view) cannot mount one itself. `useRequestModal` lets the view ask the host to open it as a modal, pass data into it, and read whether the modal is currently showing. ## Example The carousel opens a product in a modal to confirm adding it to the cart, then runs the add when the shopper confirms. ```tsx highlight={4,6,15} theme={null} import { useRequestModal } from "skybridge/web"; function Carousel() { const { isOpen, params, open } = useRequestModal(); if (isOpen) { return ( ); } return ( open({ title: product.name, params: { productId: product.id } }) } /> ); } ``` ## Returns `useRequestModal` returns the modal state plus an opener. ### `isOpen` ```tsx theme={null} isOpen: boolean; ``` `true` when the host is currently rendering the view in `"modal"` display mode. It tracks the modal lifecycle on its own: `true` after `open`, back to `false` when the modal closes. ### `params` ```tsx theme={null} params: Record | undefined; ``` The `params` the modal was opened with, surfaced back to the view while it is open. `undefined` when no modal is open, or when `open` was called without `params`. ### `open` ```tsx theme={null} open(options: RequestModalOptions): void; ``` Asks the host to render the view as a modal. Returns `void`: read the result from [`isOpen`](#isopen) and [`params`](#params) once the modal opens. **`options`** ```tsx theme={null} type RequestModalOptions = { title?: string; // label for the modal chrome params?: Record; // data surfaced back through `params` while the modal is open template?: string; // view resource to render, when it differs from the current view anchor?: { top?: number; left?: number; width?: number; height?: number }; // pixel bounds to position the modal }; ``` **ChatGPT** opens a native host modal with the full `RequestModalOptions`. Other hosts polyfill it, rendering the modal inside the view's iframe and honoring only `params`; `title`, `template`, and `anchor` are ignored. Adapt the view to display mode, theme, and device Read and request the non-modal display modes Ask the host to dismiss the view # useRequestSize Source: https://docs.skybridge.tech/api-reference/use-request-size Ask the host to resize the view to fit its content A [view](/build/view) renders at a size the host sets. `useRequestSize` lets the view ask the host to resize it to fit its content. ## Example The carousel grows to fit its product grid as items load, and widens to show the full catalog when the shopper expands it. ```tsx highlight={1,5,15,25} theme={null} import { useRequestSize, useViewport } from "skybridge/web"; import { useEffect, useRef } from "react"; function Carousel({ products }: { products: Product[] }) { const requestSize = useRequestSize(); const { maxHeight } = useViewport(); const rootRef = useRef(null); useEffect(() => { const root = rootRef.current; if (!root) { return; } const observer = new ResizeObserver(() => { requestSize({ height: root.scrollHeight }); }); observer.observe(root); return () => { observer.disconnect(); }; }, [requestSize]); return (
); } ``` The host applies what it can, so read the height actually granted from [`useViewport`](/api-reference/use-viewport) rather than assuming the request took. ## Returns `useRequestSize` returns a single function. ### `requestSize` ```tsx theme={null} requestSize(size: { width?: number; height?: number }): Promise; ``` Asks the host to resize the view, `width` and `height` in pixels; omit a dimension to leave it unchanged. The promise resolves once the request is sent, not once the host applies it: the host is free to clamp, ignore, or partially honor the size. Read the height the host actually grants the view Switch the view between inline, fullscreen, and pip Size and shape the view to fit the host # useSendFollowUpMessage Source: https://docs.skybridge.tech/api-reference/use-send-follow-up-message Post a user message to the conversation from a view interaction A user action in your [view](/build/view) does not advance the conversation: the model only acts on conversation turns. `useSendFollowUpMessage` posts a message into the conversation as if the user had typed it, starting the next assistant turn. ## Example Tapping a product asks the model to tell the shopper more about it, while the carousel stays on screen. ```tsx highlight={1,6,14-17} theme={null} import { useSendFollowUpMessage } from "skybridge/web"; import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema function Carousel({ products }: { products: Product[] }) { const sendFollowUpMessage = useSendFollowUpMessage(); return (
{products.map((product) => ( // scrollToBottom: false keeps the carousel in view on ChatGPT; ignored elsewhere ))}
); } ``` ## Returns `useSendFollowUpMessage` returns a single, memoized function, safe to pass to effects and handlers. ### `sendFollowUpMessage` ```tsx theme={null} sendFollowUpMessage( prompt: string, options?: { scrollToBottom?: boolean } ): Promise; ``` Posts the message and returns the host's promise. The promise carries no value: any model reply lands in the conversation, not back in the view. * **`prompt`** the text posted as a user turn, model-visible, as if the user had typed it. The view keeps running after the message is posted. * **`options`** `scrollToBottom` asks the host to scroll the conversation to the newest message after posting; omit it for the host default, or set `false` to keep the scroll position and the view on screen. `scrollToBottom` is specific to **ChatGPT**. Call a server tool without a conversation turn Read the tool result the view mounted with Sync on-screen state to the model passively # useSetOpenInAppUrl Source: https://docs.skybridge.tech/api-reference/use-set-open-in-app-url Set where the host's Open in app button navigates When a user opens your [view](/build/view) fullscreen, the host's "Open in app" button navigates to the view's current iframe path. `useSetOpenInAppUrl` redirects it to a canonical URL you set instead. ## Example Opening the carousel fullscreen and using "Open in app" deep-links to the featured product's page in the web app, instead of reopening the view. ```tsx highlight={2,5,14} theme={null} import { useEffect } from "react"; import { useSetOpenInAppUrl } from "skybridge/web"; function Carousel({ products }: { products: Product[] }) { const setOpenInAppUrl = useSetOpenInAppUrl(); const featured = products[0]; useEffect(() => { if (!featured) { return; } setOpenInAppUrl(`https://shop.example.com/products/${featured.id}`).catch( (error) => { console.error("Could not set the open-in-app URL", error); }, ); }, [featured, setOpenInAppUrl]); return ; } ``` ## Returns `useSetOpenInAppUrl` returns a single function. ### `setOpenInAppUrl` ```tsx theme={null} setOpenInAppUrl(href: string): Promise; ``` Sets `href` as the target the host's fullscreen "Open in app" button navigates to. `href` is required: an empty or whitespace-only string throws synchronously, before the host is called. The function reference is stable across renders, so it is safe to list in an effect's dependency array. The promise resolves once the host accepts the URL. **ChatGPT** is the only host with this button; calling the hook on another host throws. Adapt the view to display mode, theme, and device Read and switch the view's layout, including fullscreen Open a URL outside the view's iframe through the host # useToolInfo Source: https://docs.skybridge.tech/api-reference/use-tool-info Read the tool call that produced the current view `useToolInfo` reads the [tool](/build/tools) call that rendered the current [view](/build/view), so a view can display the data its own tool produced. Import it from your generated [`helpers.ts`](/api-reference/generate-helpers): the typed hook infers `input`, `output`, and `responseMetadata` from your server, so you pass only the tool name. ## Example The view reads the result of the tool that rendered it, branching on `status`. ```tsx highlight={4-5,7,8,10,13,15} theme={null} import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema function Carousel() { const { input, isPending, isSuccess, output, responseMetadata } = useToolInfo<"search-products">(); if (isPending) { return

Searching {input?.query}...

; } if (isSuccess) { return (

{output.products.length} results in {responseMetadata.ms}ms

); } return null; } ``` ## Type Parameters ### `ToolSignature` ```tsx theme={null} ToolSignature extends Partial<{ input: Record; output: Record; responseMetadata: Record; }> = Record; ``` Refines the shape of `input`, `output`, and `responseMetadata`. This is the direct-import form; the generated helper takes a tool name instead and infers all three. With neither, each typed field resolves to `never`. ## Returns ### `status` ```tsx theme={null} status: "pending" | "success"; ``` * `"pending"`: the tool's output and metadata have not arrived yet. * `"success"`: the tool delivered its `output` or its `responseMetadata`. ### `isPending`, `isSuccess` ```tsx theme={null} isPending: boolean; isSuccess: boolean; ``` Each is `true` when `status` equals the matching value and `false` otherwise. ### `input` ```tsx theme={null} input: ToolInput | undefined; ``` The [arguments](/api-reference/register-tool#inputschema-outputschema) the view's tool was called with. The model sees these: they are part of the tool call in the conversation. It can be `undefined` in either state, because the host may render the view before it delivers the arguments. ### `output` ```tsx theme={null} output: ToolOutput | undefined; ``` The tool's structured output, set when `status` is `"success"`. The model sees this: it is the tool [result](/api-reference/register-tool#return) in the conversation. It is `undefined` while pending, and `undefined` in success if the tool returned no structured output. ### `responseMetadata` ```tsx theme={null} responseMetadata: ToolResponseMetadata | undefined; ``` The `_meta` the tool's [handler](/api-reference/register-tool#handler) returned alongside its result, typed from your server. It is `undefined` while pending, and `undefined` in success if the tool returned no metadata. Render a view from a tool result in context The typed `useToolInfo` that infers from your server Call another tool from the view # useUser Source: https://docs.skybridge.tech/api-reference/use-user Read the host's theme, locale, and device capabilities Your [view](/build/view) runs for users on different devices, in different [locales](https://developer.mozilla.org/en-US/docs/Glossary/Locale), and under the theme they picked in the host. `useUser` reports the user's locale, theme, and device capabilities, so the view can format values, match the host's palette, and size controls for them. ## Example The carousel follows the host theme, formats prices in the host locale, and adapts its controls to the device: a touch device gets larger tap targets, and hover styles apply only when the device reports hover. ```tsx highlight={4-8,10,18} theme={null} import { useUser } from "skybridge/web"; function Carousel({ products }: { products: Product[] }) { const { locale, theme, userAgent } = useUser(); const isMobile = userAgent.device.type === "mobile"; const canHover = userAgent.capabilities.hover; const isTouch = userAgent.capabilities.touch; const formatPrice = (amount: number) => { return new Intl.NumberFormat(locale, { style: "currency", currency: "USD", }).format(amount); }; return (
{products.map((product) => (
{product.name}

{product.name}

{formatPrice(product.price)}

))}
); } ``` ## Returns ### `locale` ```tsx theme={null} locale: string; ``` The user's language and region, canonical [BCP 47](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag). The hook canonicalizes what the host reports (underscores to hyphens, casing corrected, subtags preserved) and falls back to `"en-US"` when the value is not a valid locale. It is safe to pass straight to [`Intl`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) APIs. ### `theme` ```tsx theme={null} theme: "light" | "dark"; ``` The host's active color theme. Mirror it in the view's styling for a native feel. It updates when the user toggles the host theme. ### `userAgent` ```tsx theme={null} userAgent: UserAgent; ``` The host's device class and input capabilities. ```tsx theme={null} type UserAgent = { device: { // "unknown" when the host reports no device class type: "mobile" | "tablet" | "desktop" | "unknown"; }; capabilities: { hover: boolean; // device supports hover interactions touch: boolean; // device supports touch input }; }; ``` Read the available height and safe-area insets Read and request the view's display mode Adapt the view to the user's device and locale # useViewState Source: https://docs.skybridge.tech/api-reference/use-view-state Persist view state across remounts and share it with the model A [view](/build/view) loses its local React state when the host remounts it, and the model never sees what the view is showing. `useViewState` is a drop-in `useState` that fixes both. ## Example A carousel persists the shopper's sort choice across remounts, and the model sees it on the next turn. ```tsx highlight={1,7,11-12,13} theme={null} import { useViewState } from "skybridge/web"; type CarouselState = { sort: "newest" | "price" }; function Carousel() { // shared with the model, survives remounts const [state, setState] = useViewState({ sort: "newest" }); return (
); } ``` ## Type Parameters ### `T` ```tsx theme={null} T extends Record; ``` The shape of the persisted [state](/build/state). `T` is inferred from `defaultState`; set it explicitly only when `defaultState` is omitted or `null`. ## Parameters ### `defaultState` ```tsx theme={null} defaultState?: T | (() => T | null) | null; ``` The initial state, used only when the host holds no persisted state for this view. Pass a value or a lazy initializer that runs once on mount. Pass one and `state` is never `null`; omit it, or pass `null`, and `state` is `null` until the first write. When the host already has persisted state, that value wins and `defaultState` is ignored. ## Returns `useViewState` returns a `readonly [state, setState]` tuple, the same ergonomics as `useState`. ### `state` ```tsx theme={null} state: T | null; ``` The current persisted state, model-visible: the host exposes it as structured content on the next turn. The Skybridge-internal context key written by [`DataLLM`](/api-reference/data-llm) is stripped before it reaches your component. ### `setState` ```tsx theme={null} setState(state: SetStateAction): void; ``` Updates the state and persists it on the host. Accepts a value or an updater function `(prev) => next`, exactly like React's `useState` setter. When `defaultState` is omitted the type widens to `SetStateAction`, so the setter also accepts `null`. The new value is written through to the host right away, then reflected back into `state` on the next render. ## Lifecycle **Persistence.** View state belongs to a single view instance, the view one tool call rendered. It survives that instance's re-renders, remounts, and display-mode changes, and a closed and reopened conversation remounts the instance with its previous state. A different tool invocation gets its own state, starting from `defaultState`. **Model visibility.** When a view has several instances, the model typically sees just one, whichever updated its state most recently. On **ChatGPT**, a PiP or fullscreen view always pushes updates to the model, but an inline view pushes only right after it mounts, before any conversation turn, so an inline view's later updates may not reach the model. **Durability.** View state is best-effort, not durable storage. On **Claude**, older state can be evicted as other views accumulate, so a view may reopen to its `defaultState`. Decide what to persist and share with the model Narrate the on-screen state to the model Read the tool result the view mounted with # useViewport Source: https://docs.skybridge.tech/api-reference/use-viewport Fit the view to the space the host gives it Your [view](/build/view) renders inside the host's surface, on its terms: a bounded height to fit and edges to keep clear of device chrome. `useViewport` reports those constraints so the view can size itself to the space the host actually gives it. It re-renders on every resize, so read the host theme from [`useUser`](/api-reference/use-user) instead. ## Example The carousel fits its scroll area to the height the host offers and keeps content clear of notches and home indicators. ```tsx highlight={4,9,11-14} theme={null} import { useViewport } from "skybridge/web"; function Carousel({ products }: { products: Product[] }) { const { maxHeight, safeArea } = useViewport(); return (
{products.map((product) => ( ))}
); } ``` ## Returns `useViewport` returns the host's current viewport for the view, as a `ViewportState`. ### `maxHeight` ```tsx theme={null} maxHeight: number | undefined; ``` The maximum height in pixels the host gives the view. It is `undefined` when the host does not report a height limit. It updates as the host resizes the view's container. ### `safeArea` ```tsx theme={null} safeArea: SafeArea; ``` The pixel insets the view should keep clear of host chrome and device cutouts (notches, rounded corners, home indicators). When the host reports no insets, every side is `0`. ```tsx theme={null} type SafeArea = { insets: SafeAreaInsets; }; type SafeAreaInsets = { top: number; right: number; bottom: number; left: number; }; ``` Read the host's theme, locale, and device capabilities Ask the host to resize the view Adapt the view to the host's surface # Type Utilities Source: https://docs.skybridge.tech/api-reference/utility-types Type your own code from your server's tools [`generateHelpers`](/api-reference/generate-helpers) already types [`useCallTool`](/api-reference/use-call-tool) and [`useToolInfo`](/api-reference/use-tool-info) for you. These utilities go one level lower: they pull a [tool](/build/tools)'s input, output, or name types straight from your [server](/api-reference/mcp-server) type, so you can type your own components and helpers from the same source of truth. ## Example Type a component's props from a tool's output, with no shape to redeclare: ```tsx theme={null} import type { ToolOutput } from "skybridge/server"; import type { AppType } from "../server"; type Products = ToolOutput["products"]; function ProductGrid({ products }: { products: Products }) { return (
    {products.map((p) => (
  • {p.name}
  • ))}
); } ``` ## Utilities Each takes your app type (`AppType`, that is `typeof app`), and the tool-specific ones also take a tool name. All import from `skybridge/server`. | Utility | Resolves to | | ------------------------------------- | -------------------------------------------------------------------- | | `ToolInput` | The tool's input type. | | `ToolOutput` | The tool's output (`structuredContent`) type. | | `ToolResponseMetadata` | The tool's response `_meta` type. | | `ToolNames` | A union of the server's tool names. | | `InferTools` | The full tool registry, mostly used internally by `generateHelpers`. | Typed hooks, the common path Read the tool result in the view The server you infer from # Verifier Source: https://docs.skybridge.tech/api-reference/verifier Validate access tokens for the bearer-auth middleware A verifier is the provider-specific token check you implement and pass to [`requireBearerAuth`](/api-reference/require-bearer-auth) or [`optionalBearerAuth`](/api-reference/optional-bearer-auth). The middleware calls it on each request to validate the bearer token before any [tool](/build/tools) runs. ## Example `verifyAccessToken` validates the token however your provider requires, returns an `AuthInfo` for a valid token, and throws `OAuthError` otherwise. Pass it to the middleware as `verifier: { verifyAccessToken }`. ```ts theme={null} import { type AuthInfo, OAuthError } from "skybridge/server"; async function verifyAccessToken(token: string): Promise { try { const payload = await validateToken(token); // JWT verification, introspection, etc. return { token, clientId: payload.client_id, scopes: payload.scope.split(" "), expiresAt: payload.exp, extra: { subject: payload.sub }, }; } catch (err) { throw new OAuthError( "invalid_token", err instanceof Error ? err.message : "Token validation failed", ); } } ``` How you validate the token depends on your provider. Check its docs. ## `verifyAccessToken` ```ts theme={null} verifyAccessToken(token: string): Promise; ``` The verifier's only required method. * Resolve with an `AuthInfo` for a valid token. The middleware puts it on `extra.http?.authInfo` for tool [handlers](/api-reference/register-tool#handler). * Throw `OAuthError("invalid_token", message)` for a malformed, badly signed, or expired token. The middleware returns a 401 with the right `WWW-Authenticate` header. Do not check scopes here: the middleware enforces `requiredScopes` against `authInfo.scopes` and returns a 403 on a missing scope. ## `createJwksVerifier` ```ts theme={null} createJwksVerifier(config: { issuer: string; audience?: string; jwksUri?: string }): TokenVerifier; ``` Builds a verifier that validates JWTs against a remote JWKS. The [providers](/guides/auth-providers) use it internally; call it yourself when hand-building an [`OAuthConfig`](/api-reference/custom-provider#returns). `jwksUri` defaults to `${issuer}/.well-known/jwks.json`, and omitting `audience` skips the audience check, for IdPs that bind none. Verified claims land in `extra` with `sub` renamed to `subject`, while `client_id`, `scope` and `exp` become `AuthInfo` fields. ## `AuthInfo` What `verifyAccessToken` resolves with for a valid token. ```ts theme={null} type AuthInfo = { token: string; clientId: string; scopes: string[]; expiresAt: number; extra?: Record; }; ``` | Field | Purpose | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `token` | The raw bearer token. | | `clientId` | The OAuth `client_id`, often the `azp` or `client_id` claim. | | `scopes` | The scopes granted, checked against `requiredScopes` and per-tool [`securitySchemes`](/api-reference/register-tool#securityschemes). | | `expiresAt` | Expiry in unix seconds. Required: tokens with no expiration are rejected. | | `extra` | Anything else you want available to handlers, for example `subject` or `email`. | `extra` is an untyped bag by default. Pass the claims your verifier resolves with (`Promise>`) and handlers receive that shape, since the type travels with the verifier into the server. Require a token on every request Accept a token when present, allow anonymous otherwise Add sign-in to your app end to end # workosProvider Source: https://docs.skybridge.tech/api-reference/workos-provider Wire OAuth from WorkOS AuthKit `workosProvider` wires authentication through [WorkOS AuthKit](https://www.workos.com/), so your tools receive a signed-in WorkOS user. ## Example ```ts server.ts highlight={1,6-9} theme={null} import { Skybridge, workosProvider } from "skybridge/server"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", oauth: workosProvider({ domain: process.env.AUTHKIT_DOMAIN, audience: process.env.SERVER_URL, }), handler, }); ``` ## Signature ```ts theme={null} workosProvider(opts: WorkosProviderOptions): Promise; ``` ## Parameters ### `opts` * **`domain`** is the AuthKit domain, for example `acme.authkit.app`. * **`audience`** is the [Resource Indicator](https://datatracker.ietf.org/doc/html/rfc8707) configured in the WorkOS dashboard, typically this server's public URL. AuthKit binds it into the token's `aud` claim. It also accepts the shared [`CustomProviderOptions`](/api-reference/custom-provider#parameters) options: `baseUrl`, `serverUrl`, `scopes`, `requiredScopes`, and `metadataOverrides`. Requires Dynamic Client Registration enabled in the WorkOS dashboard (Connect → Configuration). ## Returns A `Promise` for the [`OAuthConfig`](/api-reference/custom-provider#returns) you pass to the [`oauth`](/api-reference/skybridge#oauth) field, as a value or from a function. Set up sign-in with a hosted provider Add sign-in to your app end to end Wire OAuth from any IdP's discovery document # Authenticate Users Source: https://docs.skybridge.tech/build/auth Know who's behind every tool call [Tool](/build/tools) calls are anonymous by default: nothing in the protocol tells you who's asking. [OAuth](https://oauth.net/2/) closes that gap, and the host does most of the work: it discovers your authorization server, signs the user in, refreshes tokens, and attaches a Bearer token to every request. Your server keeps three jobs: publish the discovery metadata, verify the tokens, and read the user in your handlers. Using [Auth0](/guides/auth-providers#auth0), [Clerk](/guides/auth-providers#clerk), [Descope](/guides/auth-providers#descope), [Stytch](/guides/auth-providers#stytch), [WorkOS](/guides/auth-providers#workos), or [any other OAuth provider](/guides/auth-providers#any-other-provider)? Connect it in one line instead of wiring this by hand, and still [mix public and authenticated tools](#mix-public-and-authenticated-tools) with `auth: { allowsAnonymous: true }`. Here's a complete server where every tool requires sign-in: ```ts server.ts theme={null} import { Skybridge, mcpAuthMetadataRouter, requireBearerAuth } from "skybridge/server"; import { z } from "zod"; import { verifyAccessToken } from "./auth.js"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", handler: (server) => server .registerTool( { name: "search-products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string() }, securitySchemes: [{ type: "oauth2" }], view: { component: "carousel" }, }, async ({ query }, extra) => ({ structuredContent: { products: await search(query, extra.http?.authInfo) }, }), ) .registerTool( { name: "create-checkout", description: "Create a checkout session for the given products.", inputSchema: { productIds: z.array(z.string()) }, securitySchemes: [{ type: "oauth2", scopes: ["checkout"] }], }, async ({ productIds }, extra) => ({ structuredContent: { url: await checkout(productIds, extra.http?.authInfo) }, }), ), }) .use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: "https://auth.myshop.com", authorization_endpoint: "https://auth.myshop.com/authorize", token_endpoint: "https://auth.myshop.com/token", response_types_supported: ["code"], }, resourceServerUrl: new URL(process.env.SERVER_URL), }), ) .use("/mcp", requireBearerAuth({ verifier: { verifyAccessToken } })); ``` The sections below cover publishing the discovery metadata, verifying tokens with a middleware, declaring auth per tool, reading the user in handlers, and mixing public and authenticated tools. ## Publish Discovery Metadata Hosts find your authorization server through standard metadata documents ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)). [`mcpAuthMetadataRouter`](/api-reference/mcp-auth-metadata-router) serves them at `/.well-known/oauth-protected-resource`, so the host can perform the authentication flow. ```ts server.ts highlight={2-12} theme={null} export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", handler }) .use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: "https://auth.myshop.com", authorization_endpoint: "https://auth.myshop.com/authorize", token_endpoint: "https://auth.myshop.com/token", response_types_supported: ["code"], }, resourceServerUrl: new URL(process.env.SERVER_URL), }), ) .use("/mcp", requireBearerAuth({ verifier: { verifyAccessToken } })); ``` The endpoint values come from your OAuth provider's documentation. `resourceServerUrl` is this server's public URL: localhost in development, the [tunnel](/test/tunnel) URL when testing in a host, your domain in production. ## Verify Tokens with a Middleware Once the user is signed in, every request carries `Authorization: Bearer `. [`requireBearerAuth`](/api-reference/require-bearer-auth) validates it before any handler runs: missing, invalid, or expired tokens get a 401, insufficient scopes a 403. It takes a [verifier](/api-reference/verifier) you write, with one method: `verifyAccessToken(token)` resolves with an [`AuthInfo`](/api-reference/verifier#authinfo) (handlers receive it as `extra.http?.authInfo`) or throws `OAuthError` (the middleware answers 401). ```ts server.ts highlight={13} theme={null} export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", handler }) .use( mcpAuthMetadataRouter({ oauthMetadata: { issuer: "https://auth.myshop.com", authorization_endpoint: "https://auth.myshop.com/authorize", token_endpoint: "https://auth.myshop.com/token", response_types_supported: ["code"], }, resourceServerUrl: new URL(process.env.SERVER_URL), }), ) .use("/mcp", requireBearerAuth({ verifier: { verifyAccessToken } })); ``` ```ts auth.ts theme={null} import { type AuthInfo, OAuthError } from "skybridge/server"; export async function verifyAccessToken(token: string): Promise { try { // Provider-specific: JWKS verification, introspection endpoint, or SDK const payload = await validateToken(token); return { token, clientId: payload.clientId, scopes: payload.scopes, expiresAt: payload.expiresAt, extra: { subject: payload.sub }, }; } catch (err) { throw new OAuthError( "invalid_token", err instanceof Error ? err.message : "Token validation failed", ); } } ``` The validation body is provider-specific: [JWKS](https://datatracker.ietf.org/doc/html/rfc7517) verification for [JWT](https://datatracker.ietf.org/doc/html/rfc7519)-issuing providers, an introspection call for opaque tokens, or the provider's own middleware in place of [`requireBearerAuth`](/api-reference/require-bearer-auth). ## Type the Claims You Read The claim shape belongs to the verifier, so handlers get it without declaring anything. On the [provider path](/guides/auth-providers) each provider ships the claims its own docs promise, and a type argument adds the ones your tenant sends: ```ts server.ts theme={null} oauth: workosProvider<{ tenant: string }>({ domain, audience }), ``` Writing the verifier yourself? Type it `TokenVerifier` and the shape flows the same way. None of this is checked at runtime, so keep anything the IdP may omit optional. No provider ships `email` in an access token by default: WorkOS, Clerk, Descope and Stytch can add one through a JWT template or claims action, and Auth0 needs a namespaced claim, since it drops non-namespaced ones. ## Mix Public and Authenticated Tools Serving both anonymous and signed-in callers from one server takes three changes to the fully authenticated setup. On the [provider path](/guides/auth-providers) (the `oauth` config field), these three steps collapse into one `auth` declaration per tool, and Skybridge enforces it for you: ```ts theme={null} server .registerTool({ name: "search-products", auth: { allowsAnonymous: true }, /* … */ }, handler) .registerTool({ name: "create-checkout", auth: { scopes: ["checkout"] }, /* … */ }, handler); ``` Any `auth: { allowsAnonymous: true }` tool makes the server serve anonymous callers; every other tool stays sign-in-gated, with scopes enforced before the handler. No manual middleware swap or handler guard. The lower-level steps below apply when you wire the middleware by hand instead. ### Switch the Middleware [`optionalBearerAuth`](/api-reference/optional-bearer-auth) validates the token when present, lets the request through when absent, and still rejects invalid tokens with a 401. Unauthenticated requests now reach your handlers. ```ts server.ts highlight={2} theme={null} export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", handler }) .use("/mcp", optionalBearerAuth({ verifier: { verifyAccessToken } })); ``` ### Declare Tool Visibility Three declarations cover the combinations: | [`securitySchemes`](/api-reference/register-tool#securityschemes) | Meaning | | ----------------------------------------------------------------- | ------------------------------------------ | | `[{ type: "oauth2" }]` | Requires sign-in | | `[{ type: "noauth" }]` | Works signed out | | `[{ type: "noauth" }, { type: "oauth2" }]` | Works signed out, does more when signed in | ```ts server.ts highlight={5} theme={null} server.registerTool( { name: "search-products", // … securitySchemes: [{ type: "noauth" }, { type: "oauth2" }], }, async ({ query }, extra) => ({ structuredContent: { products: await search(query, extra.http?.authInfo) }, }), ); ``` `search-products` lists both schemes: anonymous callers browse the public catalog, signed-in callers get results scoped to their account. `extra.http?.authInfo` is set or `undefined` accordingly. ### Guard the Handler With unauthenticated requests reaching [handlers](/api-reference/register-tool#handler), the scope check is no longer a formality: it's the only gate on protected tools. ```ts server.ts highlight={8-10} theme={null} server.registerTool( { name: "create-checkout", // … securitySchemes: [{ type: "oauth2", scopes: ["checkout"] }], }, async ({ productIds }, extra) => { if (!extra.http?.authInfo?.scopes.includes("checkout")) { return { content: "Sign in to check out.", isError: true }; } return { structuredContent: { url: await checkout(productIds, extra.http?.authInfo) }, }; }, ); ``` You now know how to authenticate users. Learn how to test your MCP App in the next chapter. ## Go Further Define what humans and agents can do Craft interactive UIs rendered in conversation Decide what the model sees # Build Your App Source: https://docs.skybridge.tech/build/index The four decisions that make an MCP App Building an MCP App means answering four questions: what the model can do, what the user sees, what the model knows, and who is asking. Each page below covers one, in build order, on the same running example: a personal shopper with a product carousel and an authenticated checkout. | Define | Page | Core APIs | | --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | What the model can do | [Register Tools](/build/tools) | [`registerTool`](/api-reference/register-tool) | | What the user sees | [Create Views](/build/view) | [`useToolInfo`](/api-reference/use-tool-info), [`useCallTool`](/api-reference/use-call-tool) | | What the model knows | [Manage State](/build/state) | [`useViewState`](/api-reference/use-view-state), [`data-llm`](/api-reference/data-llm) | | Who is asking | [Authenticate Users](/build/auth) | [`requireBearerAuth`](/api-reference/require-bearer-auth), [`securitySchemes`](/api-reference/register-tool#securityschemes) | Define what humans and agents can do Craft interactive UIs rendered in conversation Decide what the model sees Know who's behind every tool call # Manage State Source: https://docs.skybridge.tech/build/state Decide what the model sees Between [tool](/build/tools) calls, the model is blind: it doesn't watch the user's interactions with the UI. Yet, to answer the user's next message, the model often needs to know what's on screen. State is how the [view](/build/view) closes that gap, and the design decision is always the same: what should the model see, what shouldn't, and when. Here's a complete view, the `carousel` mounted by a tool call to `search-products`, holding shared state, hidden state, and a narration for the model: ```tsx views/carousel.tsx theme={null} import { useState } from "react"; import { useViewState } from "skybridge/web"; import { useCallTool, useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output, responseMetadata } = useToolInfo<"search-products">(); const { callTool: checkout } = useCallTool("create-checkout"); const [cart, setCart] = useViewState<{ ids: string[] }>({ ids: [] }); // shared with the model, survives remounts const [hovered, setHovered] = useState(null); // invisible to the model, gone on remount return (
setCart({ ids: [...cart.ids, id] })} onCheckout={() => checkout({ productIds: cart.ids })} />
); } ``` The sections below cover the decision itself, sharing and persisting state with [`useViewState`](/api-reference/use-view-state), and narrating the screen with [`data-llm`](/api-reference/data-llm). ## Decide Who Sees What Every piece of state belongs to one of two shared channels, plus plain React for everything the model has no business knowing: | Use Case | API | Model sees it | Survives remount | | -------------------------------------------------------- | -------------------------------------------------------- | ------------- | ---------------- | | Data the conversation builds on (cart, selection, draft) | [`useViewState`](/api-reference/use-view-state) | Yes | Yes | | A plain-words description of what's on screen | [`data-llm`](/api-reference/data-llm) | Yes | Recomputed | | Anything neither the model nor the next session needs | [`useState`](https://react.dev/reference/react/useState) | No | No | The model doesn't watch the view live: it reads state when the conversation comes back to it, meaning on the next user message. Models can't write the state directly. Each view instance owns its state; models typically see one instance per view, whichever had its state most recently updated. **ChatGPT** pushes state updates to the model context depending on the display mode: updates from a PiP or fullscreen view are always pushed; updates from an inline view are pushed only while the instance has just mounted and no conversation turn has happened since. ## Persist with `useViewState` [`useViewState`](/api-reference/use-view-state) is `useState` with two superpowers: * the host persists it: a closed and reopened conversation remounts the view instances with their previous state * the model can read it: on the next conversation turn ```tsx views/carousel.tsx highlight={3,10,18,21-22} theme={null} import { useState } from "react"; import { useViewState } from "skybridge/web"; import { useCallTool, useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output, responseMetadata } = useToolInfo<"search-products">(); const { callTool: checkout } = useCallTool("create-checkout"); const [cart, setCart] = useViewState<{ ids: string[] }>({ ids: [] }); const [hovered, setHovered] = useState(null); return (
setCart({ ids: [...cart.ids, id] })} onCheckout={() => checkout({ productIds: cart.ids })} />
); } ``` `cart` is shared because the conversation needs it: the model reads it to recommend a matching item or answer "what's in my cart?". `hovered` stays in plain `useState` because a hover ends before the user's next message: by the time the model could read it, the value is stale. ## Narrate the Screen with `data-llm` While [`useViewState`](/api-reference/use-view-state) pushes structured data to the model context, [`data-llm`](/api-reference/data-llm) describes what the user currently sees in natural language. It's recomputed on every render and synced to the model: ```tsx views/carousel.tsx highlight={14} theme={null} import { useState } from "react"; import { useViewState } from "skybridge/web"; import { useCallTool, useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output, responseMetadata } = useToolInfo<"search-products">(); const { callTool: checkout } = useCallTool("create-checkout"); const [cart, setCart] = useViewState<{ ids: string[] }>({ ids: [] }); const [hovered, setHovered] = useState(null); return (
setCart({ ids: [...cart.ids, id] })} onCheckout={() => checkout({ productIds: cart.ids })} />
); } ``` This is what lets the user speak in references: "what do you think of this one?". The model resolves *this one* from the `data-llm` narration. Nest `data-llm` on inner elements and the model receives an indented outline of the screen. You now know how to manage state. Learn who's behind every tool call in the next chapter. ## Go Further Define what humans and agents can do Craft interactive UIs rendered in conversation Know who's behind every tool call # Register Tools Source: https://docs.skybridge.tech/build/tools Define what humans and agents can do Tools are the entry point of your app: the model reads their metadata and triggers them when they fit the conversation. Think of them as an API: each tool has one job, and jobs can complement each other. Bind a view to a tool, and the host [mounts](/get-started/architecture#app-lifecycle) it inline in the conversation. The model isn't the only caller: the user can trigger tools too, via the [view](/build/view). Here's a complete tool definition: ```ts server.ts theme={null} import { Skybridge } from "skybridge/server"; import { z } from "zod"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", handler: (server) => server.registerTool( { name: "search-products", title: "Search Products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string().describe("Full-text product search") }, outputSchema: { products: z.array( z.object({ id: z.string(), name: z.string(), price: z.number() }), ), }, annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false }, view: { component: "carousel" }, }, async ({ query }) => { const products = await search(query); return { content: `Found ${products.length} products for "${query}".`, structuredContent: { products: products.map(({ id, name, price }) => ({ id, name, price })), }, _meta: { images: products.map((p) => p.imageUrl) }, }; }, ), }); ``` The sections below build this definition up piece by piece: the server it lives on, the metadata and schemas the model reads, the handler, the behavior annotations, host-specific tuning, and the view binding. ## Create the App [`Skybridge`](/api-reference/skybridge) takes your app's identity and a `handler`. The handler receives a fresh [`McpServer`](/api-reference/mcp-server) for every request: chain every [`registerTool`](/api-reference/register-tool) call on it and return the chain. ```ts server.ts theme={null} export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", handler: (server) => server .registerTool(/* … */) .registerTool(/* … */), }); export type AppType = typeof app; ``` ```ts index.ts theme={null} import { app } from "./server.js"; export default await app.run(); ``` Chaining accumulates your tool signatures into one type, and returning the chain is what carries it into `AppType`. Exporting `AppType` is what makes the generated view hooks type-safe end to end. Keep the handler to registration: it runs on every request, so anything expensive belongs in [`setup`](/api-reference/skybridge#setup), whose result is the handler's second argument. ## Describe It to the Model ```ts server.ts highlight={3-5} theme={null} server.registerTool( { name: "search-products", title: "Search Products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string().describe("Full-text product search") }, outputSchema: { products: z.array( z.object({ id: z.string(), name: z.string(), price: z.number() }), ), }, annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false }, view: { component: "carousel" }, }, async ({ query }) => { /* … */ }, ); ``` * `name` identifies the tool. Keep it kebab-case and verb-led: `search-products`, `create-checkout`. * `title` is the human-readable display name. * `description` is read to decide *when* to call the tool. These are prompt engineering. Write them for the model: state what the tool does and what it shows, so the model can pick the right tool among many and narrate it correctly to the user. ## Type the Contract Schemas are plain [Zod](https://zod.dev/) shapes, or any other [Standard Schema](https://standardschema.dev) validator that can emit JSON Schema. [`inputSchema`](/api-reference/register-tool#inputschema-outputschema) declares what the model must provide. [`outputSchema`](/api-reference/register-tool#inputschema-outputschema) is optional and declares the shape of the [structured content returned](/api-reference/register-tool#return) to the model. ```ts server.ts highlight={6-11} theme={null} server.registerTool( { name: "search-products", title: "Search Products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string().describe("Full-text product search") }, outputSchema: { products: z.array( z.object({ id: z.string(), name: z.string(), price: z.number() }), ), }, annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false }, view: { component: "carousel" }, }, async ({ query }) => { /* … */ }, ); ``` `.describe()` on a field is more prompt surface: it tells the model how to fill the argument. While it's recommended to provide the output schema, it's optional and isn't used for type safety, which is inferred from the handler's return type. ## Write the Handler The [handler](api-reference/register-tool#handler) receives validated input and returns the tool's [response](/api-reference/register-tool#return). Three fields, three audiences: | Field | Consumed by | | ------------------- | ------------------------------------------ | | `content` | The model: what it reads and narrates from | | `structuredContent` | The model **and** the view | | `_meta` | The view only: never reaches the model | ```ts server.ts highlight={15-24} theme={null} server.registerTool( { name: "search-products", title: "Search Products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string().describe("Full-text product search") }, outputSchema: { products: z.array( z.object({ id: z.string(), name: z.string(), price: z.number() }), ), }, annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false }, view: { component: "carousel" }, }, async ({ query }) => { const products = await search(query); return { content: `Found ${products.length} products for "${query}".`, structuredContent: { products: products.map(({ id, name, price }) => ({ id, name, price })), }, _meta: { images: products.map((p) => p.imageUrl) }, }; }, ); ``` Choosing the recipient is a design decision: it controls what context each actor sees. Keep `content` and `structuredContent` concise, since they're model context. Use `_meta` for data the model has no use for or isn't supposed to know (image URLs, sensitive record fields): the view still accesses it, the model never sees it. Handlers also receive an `extra` argument carrying additional data such as `extra.http?.authInfo` or [client hints](/api-reference/register-tool#client-hints). ## Annotate Behavior [Annotations](/api-reference/register-tool#annotations) tell the host how cautious to be. They drive confirmation prompts before invocation, and app directories check them at review time: ```ts server.ts highlight={12} theme={null} server.registerTool( { name: "search-products", title: "Search Products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string().describe("Full-text product search") }, outputSchema: { products: z.array( z.object({ id: z.string(), name: z.string(), price: z.number() }), ), }, annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false }, view: { component: "carousel" }, }, async ({ query }) => { /* … */ }, ); ``` * `readOnlyHint`: only reads data, no side effects * `destructiveHint`: deletes or overwrites user data * `openWorldHint`: publishes content or reaches beyond the user's account Be honest with them: hosts trust these hints, and mislabeling a tool (claiming a write is read-only, hiding a destructive action) is a common cause of app directory rejection. ## Tune the Host with `_meta` Tool-level [`_meta`](/api-reference/register-tool#_meta) (distinct from the response `_meta` above) carries host-specific configuration: ```ts server.ts highlight={14-20} theme={null} server.registerTool( { name: "search-products", title: "Search Products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string().describe("Full-text product search") }, outputSchema: { products: z.array( z.object({ id: z.string(), name: z.string(), price: z.number() }), ), }, annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false }, view: { component: "carousel" }, _meta: { // Expose the tool to the model, the view, or both (default) ui: { visibility: ["model", "app"] }, // ChatGPT status messages shown while the tool runs "openai/toolInvocation/invoking": "Searching the catalog…", "openai/toolInvocation/invoked": "Found products", }, }, async ({ query }) => { /* … */ }, ); ``` ## Bind a View Adding `view` to a tool makes the host mount a React component with the tool's result. `component` names a file in `views/`: ```ts server.ts highlight={13} theme={null} server.registerTool( { name: "search-products", title: "Search Products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string().describe("Full-text product search") }, outputSchema: { products: z.array( z.object({ id: z.string(), name: z.string(), price: z.number() }), ), }, annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false }, view: { component: "carousel" }, // resolves to views/carousel.tsx }, async ({ query }) => { /* … */ }, ); ``` ```tsx views/carousel.tsx theme={null} import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output } = useToolInfo<"search-products">(); return ( ); } ``` Each model call to the tool mounts a fresh view instance. Tools without `view` are headless: data, no UI. You now know how to register tools. Learn what happens inside the view in the next chapter. ## Go Further Craft interactive UIs rendered in conversation Decide what the model sees Know who's behind every tool call # Create Views Source: https://docs.skybridge.tech/build/view Craft interactive UIs rendered in conversation Views are the face of your app: UI components the host mounts inline when the [tool](/build/tools) they're bound to returns. A view reads what the tool produced, calls tools back when the user acts, and manages the [state](/build/state) shared with the model. It's plain [React](https://react.dev/), running in a sandboxed iframe inside the host. Here's a complete view, the `carousel` mounted by `search-products`, next to a minimal version of the tools it talks to: ```tsx views/carousel.tsx theme={null} import { useCallTool, useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { input, output, responseMetadata, isPending } = useToolInfo<"search-products">(); const { callTool: checkout, isPending: isCheckingOut, isSuccess: hasCheckedOut, data } = useCallTool("create-checkout"); if (isPending) { return ; } if (hasCheckedOut) { return
Pay now; } return ( checkout({ productIds: ids })} /> ); } ``` ```ts server.ts theme={null} server .registerTool( { name: "search-products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string() }, view: { component: "carousel" }, }, async ({ query }) => { const products = await search(query); return { content: `Found ${products.length} products for "${query}".`, structuredContent: { products: products.map(({ id, name, price }) => ({ id, name, price })), }, _meta: { images: products.map((p) => p.imageUrl) }, }; }, ) .registerTool( { name: "create-checkout", description: "Create a checkout session for the given products.", inputSchema: { productIds: z.array(z.string()) }, }, async ({ productIds }) => ({ structuredContent: { url: await checkout(productIds) }, }), ); ``` The sections below walk through it: the component itself, reading the tool, calling tools back, the typed helpers binding views to the server, and opening the sandbox to external domains. ## The Component A view is a `.tsx` file with a default-exported React component in `views/`. The binding happens when you [register](/api-reference/register-tool) the tool: ```ts server.ts highlight={6} theme={null} server.registerTool( { name: "search-products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string() }, view: { component: "carousel" }, // resolves to views/carousel.tsx }, async ({ query }) => { /* … */ }, ); ``` Beyond that, it's regular React: hooks, sub-components, any library you like. Each tool call mounts a fresh instance in a sandboxed iframe, so think in instances, not singletons: two carousels in one conversation are two separate mounts. ## Read the Tool Call The host mounts the view as soon as the model calls the tool, often before your [handler](/api-reference/register-tool#handler) returns. [`useToolInfo`](/api-reference/use-tool-info) tracks that lifecycle: `status` moves from `"pending"` to `"success"`, with `isPending` and `isSuccess` as shortcuts. ```tsx views/carousel.tsx highlight={4,6-8,12-13} theme={null} import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { input, output, responseMetadata, isPending } = useToolInfo<"search-products">(); if (isPending) { return ; } return ( ); } ``` The rest of the hook is the exchange itself: what the model sent in, and the two halves of what your handler [sent back](/api-reference/register-tool#return): * `input`: the tool call arguments, validated * `output`: the response `structuredContent`, also surfaced to the model * `responseMetadata`: the response `_meta`, not surfaced to the model ## Call Tools Back [`useCallTool`](/api-reference/use-call-tool) lets the view call any tool on your server without involving the model. It returns the trigger and the mutation state: ```tsx views/carousel.tsx highlight={5,11-13,19-20} theme={null} import { useCallTool, useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { input, output, responseMetadata, isPending } = useToolInfo<"search-products">(); const { callTool: checkout, isPending: isCheckingOut, isSuccess: hasCheckedOut, data } = useCallTool("create-checkout"); if (isPending) { return ; } if (hasCheckedOut) { return Pay now; } return ( checkout({ productIds: ids })} /> ); } ``` `status` runs `idle`, `pending`, then `success` (read the result on `data`) or `error`. Tool calls initiated from a view happen outside the conversation: the model sees neither the call nor its result, and no view gets mounted. The response goes to the calling view alone. ## Generate Type-Safe Hooks The [hooks](/api-reference/overview#hooks) above aren't imported from `skybridge/web` directly: they come from `helpers.ts`, a bridge file that infers every type from your server. Projects scaffolded with `npx skybridge create` include it out of the box: ```ts helpers.ts theme={null} import { generateHelpers } from "skybridge/web"; import type { AppType } from "./server.js"; export const { useToolInfo, useCallTool } = generateHelpers(); ``` Import `useToolInfo` and `useCallTool` from `helpers.ts` everywhere, and you get autocomplete on tool names, plus typed inputs, outputs, and metadata on both hooks. Type-safe hooks are generated using [generateHelpers](/api-reference/generate-helpers). ## Open the Sandbox The iframe ships with a strict [Content Security Policy](https://developer.mozilla.org/fr/docs/Web/HTTP/Guides/CSP): your server's domain is allowed automatically, and everything else is blocked. If the view fetches from an external API or loads assets from the outside world, declare the domains on the view config, server side: ```ts server.ts highlight={8-11} theme={null} server.registerTool( { name: "search-products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string() }, view: { component: "carousel", csp: { connectDomains: ["https://api.myshop.com"], // fetch / XHR targets resourceDomains: ["https://cdn.myshop.com"], // images, fonts, scripts }, }, }, async ({ query }) => { /* … */ }, ); ``` `frameDomains` (embedded iframes) and `redirectDomains` (external redirects) follow the same pattern. See [Configure CSP](/guides/csp) for the full walkthrough. You now know how to create views. Learn what the view holds between tool calls, and who sees it, in the next chapter. ## Go Further Define what humans and agents can do Decide what the model sees Know who's behind every tool call # Examples Source: https://docs.skybridge.tech/examples Demo apps built with Skybridge Explore real-world examples built with Skybridge. Each app demonstrates different features and integrations. Click an app to see a full-page walkthrough. Explore the Skybridge showcase to discover production MCP Apps shipped by real teams in ChatGPT and Claude. ## Basic Starter examples and reference implementations for learning the core Skybridge patterns. ## Use cases Open-source apps that show how Skybridge supports maps, commerce, travel, games, dashboards, and other rich MCP App workflows. ## Auth OAuth examples showing how to connect common authentication providers to Skybridge apps.
Full OAuth authentication with Auth0 and personalized coffee shop search. View code on GitHub Full OAuth authentication with Clerk and personalized coffee shop search. View code on GitHub Full OAuth authentication with Descope and personalized coffee shop search. View code on GitHub Full OAuth authentication with Stytch and personalized coffee shop search. View code on GitHub Full OAuth authentication with WorkOS AuthKit and personalized coffee shop search. View code on GitHub
## UI and component libraries Examples for building rich interfaces and reusable components in MCP Apps. # Auth0 Source: https://docs.skybridge.tech/examples/auth-auth0 Full OAuth authentication with Auth0 and personalized coffee shop search. The Auth0 example app demonstrates a full OAuth authentication flow using [Auth0](https://auth0.com/), with a personalized coffee shop finder view that displays user-specific favorites. ## Skybridge APIs used * [`auth0Provider`](/api-reference/auth0-provider) * [`registerTool`](/api-reference/register-tool) * [`useToolInfo`](/api-reference/use-tool-info) # Authplane Source: https://docs.skybridge.tech/examples/auth-authplane Full OAuth authentication with Authplane and personalized coffee shop search. The Authplane example app demonstrates a full OAuth authentication flow using [Authplane](https://authplane.ai), with a personalized coffee shop finder view that displays user-specific favorites. ## Skybridge APIs used * [`authplaneProvider`](/api-reference/authplane-provider) * [`registerTool`](/api-reference/register-tool) * [`useToolInfo`](/api-reference/use-tool-info) # Clerk Source: https://docs.skybridge.tech/examples/auth-clerk Full OAuth authentication with Clerk and personalized coffee shop search. The Clerk example app demonstrates a full OAuth authentication flow using [Clerk](https://clerk.com/), with a personalized coffee shop finder view that displays user-specific favorites. ## Skybridge APIs used * [`clerk`](/api-reference/clerk-provider) * [`registerTool`](/api-reference/register-tool) * [`useToolInfo`](/api-reference/use-tool-info) # Descope Source: https://docs.skybridge.tech/examples/auth-descope Full OAuth authentication with Descope and personalized coffee shop search. The Descope example app demonstrates a full OAuth authentication flow using [Descope](https://www.descope.com/), with a personalized coffee shop finder view that displays user-specific favorites. It ships in two flavors. The default wires auth with the branded [`descopeProvider`](/api-reference/descope-provider), which expects Descope's Dynamic Client Registration enabled. The [`auth-descope-alpic`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-descope-alpic) variant wires the low-level [`customProvider`](/api-reference/custom-provider) for a Descope project with DCR disabled, advertising this server as the authorization server so the Alpic DCR proxy sits in the registration path. ## Skybridge APIs used * [`descopeProvider`](/api-reference/descope-provider) * [`customProvider`](/api-reference/custom-provider) * [`registerTool`](/api-reference/register-tool) * [`useToolInfo`](/api-reference/use-tool-info) # Stytch Source: https://docs.skybridge.tech/examples/auth-stytch Full OAuth authentication with Stytch and personalized coffee shop search. The Stytch example app demonstrates a full OAuth authentication flow using [Stytch](https://stytch.com/), with a personalized coffee shop finder view that displays user-specific favorites. ## Skybridge APIs used * [`stytchProvider`](/api-reference/stytch-provider) * [`registerTool`](/api-reference/register-tool) * [`useToolInfo`](/api-reference/use-tool-info) # WorkOS Source: https://docs.skybridge.tech/examples/auth-workos Full OAuth authentication with WorkOS AuthKit and personalized coffee shop search. The WorkOS example app demonstrates a full OAuth authentication flow using [WorkOS AuthKit](https://workos.com/docs/user-management/authkit), with a personalized coffee shop finder view that displays user-specific favorites. ## Skybridge APIs used * [`workosProvider`](/api-reference/workos-provider) * [`registerTool`](/api-reference/register-tool) * [`useToolInfo`](/api-reference/use-tool-info) # Capitals Explorer Source: https://docs.skybridge.tech/examples/capitals Interactive world map with geolocation and dynamic capital exploration. Capitals Explorer uses Skybridge tools to fetch country data and create a rich, fullscreen map experience. ## Skybridge APIs used * [`useCallTool`](/api-reference/use-call-tool) * [`useDisplayMode`](/api-reference/use-display-mode) * [`data-llm Attribute`](/api-reference/data-llm) * [`useToolInfo`](/api-reference/use-tool-info) # ChatGPT Files Source: https://docs.skybridge.tech/examples/chatgpt-files Pick a file from the filesystem or the ChatGPT library and zip it Handles ChatGPT files end to end: takes a file attachment, compresses it server-side, stores the archive in Cloudflare R2, and hands back a presigned download link. Relies on the ChatGPT Apps SDK file APIs, so it runs in ChatGPT. ## Skybridge APIs used * [`FileRef`](/api-reference/file-ref) * [`useCallTool`](/api-reference/use-call-tool) * [`useFiles`](/api-reference/use-files) * [`useViewport`](/api-reference/use-viewport) * [`useOpenExternal`](/api-reference/use-open-external) * [`useToolInfo`](/api-reference/use-tool-info) # Chess Source: https://docs.skybridge.tech/examples/chess Play chess against the assistant with view-provided tools Play chess against the assistant inside the conversation. Pick a side on the lobby screen, pop the board into picture-in-picture, and drag your pieces — the assistant plays the other color. Showcases MCP Apps view-provided tools: the server only opens the board, and the view registers the tools the model uses to play, executing them against a chess.js engine running inside the widget. ## Skybridge APIs used * [`registerTool`](/api-reference/register-tool) — a single `start_game` tool that opens the board * [`useRegisterViewTool`](/api-reference/use-register-view-tool) — register `chess_get_board_state`, `chess_get_legal_moves`, `chess_make_move`, and `chess_reset_game` from the view * [`createStore`](/api-reference/create-store) — synced match state (position, move log, last move) pushed into the model's context and surviving remounts * [`useDisplayMode`](/api-reference/use-display-mode) — pop the board into picture-in-picture when a side is picked * [`useCallTool`](/api-reference/use-call-tool) — call server tools from the view * [`useSendFollowUpMessage`](/api-reference/use-send-follow-up-message) — prompt the assistant to play its move * [`useToolInfo`](/api-reference/use-tool-info) — read the `start_game` tool output # Ecommerce Carousel Source: https://docs.skybridge.tech/examples/ecommerce-carousel Ecommerce with search results carousel and product detail view. Ask for something to buy and the model searches the catalog, curates the results, and renders its picks as a carousel. Tap a card to open a fullscreen detail with an image gallery, a variant picker, specs, and a link out to the store. Showcases a two-tool split: a viewless search tool the model curates from, then a render tool that carries the full product data to the view, where variants resolve in place and prices follow the user's locale. ## Skybridge APIs used * [`useDisplayMode`](/api-reference/use-display-mode) * [`useViewport`](/api-reference/use-viewport) * [`useOpenExternal`](/api-reference/use-open-external) * [`useSetOpenInAppUrl`](/api-reference/use-set-open-in-app-url) * [`useToolInfo`](/api-reference/use-tool-info) * [`useUser`](/api-reference/use-user) * [`useViewState`](/api-reference/use-view-state) # Everything Source: https://docs.skybridge.tech/examples/everything Comprehensive playground showcasing Skybridge hooks and utilities. Everything is the reference view that demonstrates every Skybridge hook and utility in one place. ## Skybridge APIs used * [`createStore`](/api-reference/create-store) * [`data-llm Attribute`](/api-reference/data-llm) * [`useCallTool`](/api-reference/use-call-tool) * [`useDisplayMode`](/api-reference/use-display-mode) * [`useFiles`](/api-reference/use-files) * [`useViewport`](/api-reference/use-viewport) * [`useOpenExternal`](/api-reference/use-open-external) * [`useRequestModal`](/api-reference/use-request-modal) * [`useSendFollowUpMessage`](/api-reference/use-send-follow-up-message) * [`useToolInfo`](/api-reference/use-tool-info) * [`useUser`](/api-reference/use-user) * [`useViewState`](/api-reference/use-view-state) # Flight Booking Source: https://docs.skybridge.tech/examples/flight-booking Flight search carousel with route details, pricing comparison, and external booking links. Flight Booking showcases a Skyscanner-style flight carousel with outbound/inbound legs, price comparison, and external redirect via `useOpenExternal`. ## Skybridge APIs used * [`useViewport`](/api-reference/use-viewport) * [`useOpenExternal`](/api-reference/use-open-external) * [`useToolInfo`](/api-reference/use-tool-info) # Generative UI Source: https://docs.skybridge.tech/examples/generative-ui LLM-generated dynamic UIs with json-render and shadcn/ui components. Generative UI lets the LLM compose rich interfaces on the fly using [json-render](https://json-render.dev) and 36 pre-built shadcn/ui components. The AI generates a json-render spec, the server validates it against the component catalog, and the view renders it instantly. ## How it works The MCP server builds a json-render catalog from `@json-render/shadcn` and embeds the auto-generated component documentation in the `render` tool description. When the LLM calls the tool, it passes a `{ root, elements }` spec as input. The server validates the spec against the catalog, auto-fixes common mistakes, and returns it to the view as structured content. The view renders the spec with `@json-render/react`'s `Renderer` and `JSONUIProvider`, using the pre-built shadcn component registry. ## Skybridge APIs used * [`useToolInfo`](/api-reference/use-tool-info) ## Third-party integrations * [`@json-render/core`](https://github.com/vercel-labs/json-render) — catalog, validation, prompt generation * [`@json-render/react`](https://github.com/vercel-labs/json-render) — Renderer, JSONUIProvider, defineRegistry * [`@json-render/shadcn`](https://github.com/vercel-labs/json-render) — 36 pre-built shadcn/ui components # Investigation Game Source: https://docs.skybridge.tech/examples/investigation-game Interactive murder mystery game for MCP Apps An interactive murder mystery game with multi-screen gameplay, fullscreen display mode, and dynamic story progression driven by follow-up messages. ## Skybridge APIs used * [`useDisplayMode`](/api-reference/use-display-mode) * [`useOpenExternal`](/api-reference/use-open-external) * [`useSendFollowUpMessage`](/api-reference/use-send-follow-up-message) # Manifest UI Source: https://docs.skybridge.tech/examples/manifest-ui Manifest UI is an agentic component library for building rich, interactive views. Manifest UI is an agentic component library that provides beautifully designed, ready-to-use views for building rich AI-powered experiences. ## Skybridge APIs used * [`generateHelpers`](/api-reference/generate-helpers) * [`useOpenExternal`](/api-reference/use-open-external) * [`useToolInfo`](/api-reference/use-tool-info) # Productivity Source: https://docs.skybridge.tech/examples/productivity Data visualization dashboard for MCP Apps Interactive charts with theme adaptation, multi-language support, fullscreen toggle, and bidirectional tool calls. ## Skybridge APIs used * [`data-llm Attribute`](/api-reference/data-llm) * [`useCallTool`](/api-reference/use-call-tool) * [`useDisplayMode`](/api-reference/use-display-mode) * [`useViewport`](/api-reference/use-viewport) * [`useOpenExternal`](/api-reference/use-open-external) * [`useSendFollowUpMessage`](/api-reference/use-send-follow-up-message) * [`useToolInfo`](/api-reference/use-tool-info) * [`useUser`](/api-reference/use-user) # Starter Source: https://docs.skybridge.tech/examples/starter The default app scaffolded by `npm create skybridge`. A guided onboarding tour through Skybridge's core APIs. The Starter is what `npm create skybridge` scaffolds. It's a short, interactive onboarding deck featuring the Skybridge mascot and a swappable hat. The deck walks new users through Skybridge's core primitives: reading tool output, sharing view state with the model, and triggering tools from the view. ## What it demonstrates The Starter registers two tools on the server and a single view that walks through four steps: * **`start`**: opens the onboarding view, with an optional `name` input the view reads back to greet the user. * **`get-fortune-cookie`**: a plain tool the view calls on demand to fetch a random prediction. Each step of the deck highlights one Skybridge API: 1. **Reading tool output**: the view hydrates with the `name` passed to `start`. 2. **Sharing view state**: clicking "Change my hat" updates persisted view state and surfaces the current hat to the model via `data-llm`. 3. **Calling tools**: the view invokes `get-fortune-cookie` from a button and renders the result. 4. **Examples & docs**: outro linking to further reading. ## Skybridge APIs used * [`useToolInfo`](/api-reference/use-tool-info): read the input and output of the tool that opened the view. * [`useViewState`](/api-reference/use-view-state): persist UI state on the host and expose it to the model. * [`useCallTool`](/api-reference/use-call-tool): invoke a server tool from within the view. * [`useUser`](/api-reference/use-user): read the host theme and locale. * [`data-llm`](/api-reference/data-llm): describe what the user sees so the model can collaborate. # Supabase Triplog Source: https://docs.skybridge.tech/examples/supabase-triplog Supabase-backed personal trip log with CRUD tools, filtering, and a rich carousel view. Supabase Triplog is a personal travel log backed by a Supabase `triplog` table. It demonstrates how Skybridge tools can fetch, create, update, and delete records while the React view handles filtering, trip focus, theme adaptation, and rich detail browsing. ## Skybridge APIs used * [`generateHelpers`](/api-reference/generate-helpers) * [`useViewport`](/api-reference/use-viewport) * [`useToolInfo`](/api-reference/use-tool-info) ## Third-party integrations * [`@supabase/supabase-js`](https://supabase.com/docs/reference/javascript/introduction) — Supabase table reads and writes * [`@t3-oss/env-core`](https://env.t3.gg/docs/core) — Environment variable validation # Time's Up Source: https://docs.skybridge.tech/examples/times-up Word-guessing party game for MCP Apps A word-guessing party game where the user sees a secret word and gives hints to the AI, which tries to guess it. Features hidden metadata, PiP display mode, multilingual support, and view-to-tool communication. ## Skybridge APIs used * [`registerTool`](/api-reference/register-tool) — with `_meta` for hidden card data and `view.csp.resourceDomains` config * [`useToolInfo`](/api-reference/use-tool-info) — access the drawn card's metadata (word + illustration) * [`useCallTool`](/api-reference/use-call-tool) — draw new cards from within the view * [`useDisplayMode`](/api-reference/use-display-mode) — switch to PiP when the game starts * [`useSendFollowUpMessage`](/api-reference/use-send-follow-up-message) — notify the AI of newly drawn cards * [`useUser`](/api-reference/use-user) — adapt the displayed word to the user's locale # Architecture Source: https://docs.skybridge.tech/get-started/architecture Design conversational experiences leveraging model intelligence MCP Apps have three actors involved: the app, the user, and the model. This creates context asymmetry: each actor holds a partial picture of the system. The model is blind to the UI: it can’t see visuals or user interactions unless you explicitly sync them back. The user can’t see the tool output flowing underneath. Building well means understanding these blind spots and designing the information flow between all three. The core design challenge isn’t layout or styling: it’s deciding who sees what, and when. What does the model need to know? What should stay hidden from it? Where should natural language replace traditional UI? The sections below introduce the vocabulary, walk through the lifecycle of an MCP App and how data flows at each step, and close with a working example that ties everything together. ## Terminology A few MCP-App-specific terms used throughout this section: * **Host**: the AI client that embeds the model and runs your app (e.g., ChatGPT, Claude) * **MCP App**: the unit you build; a server exposing tools, plus optional views * **MCP Server**: the tool set exposed to models and views * **[Tool](/build/tools)**: a function on the server that the model or a view can call * **[View](/build/view)**: a React component shipped bound to a tool and rendered inside the host * **Runtime**: the host's bridge between the server's response, the model, and the view * **View Instance**: one mounted occurrence of a view * **[View State](/build/state)**: state attached to a view instance ## App Lifecycle An MCP App moves through three phases: it's **mounted** when a tool call brings the view into the conversation, it **runs** while the user interacts with that view, and it's **torn down** when the conversation closes. Each phase changes who's driving and what data flows where. ### App is Mounted The user talks to the model, which calls a tool on your server. The server's response includes: * a view reference that the host uses to mount the view inline with the conversation * an output (text, structured content, media) shared with the model and fed to the view A new instance of the view will be mounted each time the model calls the associated tool. ```mermaid theme={null} sequenceDiagram actor U as User box Host participant M as Model participant R as Runtime participant V as View end participant T as MCP Server U->>M: prompt M->>T: tool call T-->>R: response R->>M: tool output R-->>V: mount ``` The [tool output shape](/api-reference/register-tool#return) lets you address each piece of data to a specific recipient: some fields can be sent only to the model, some only to the view, and some to both. Choosing the recipient is a design decision: it controls what context each actor sees. ### App is Running Once mounted, the view becomes the active surface where the user can trigger: * tool calls * state mutations State is shared with both the view and the model so the model stays aware of what's happening on screen. Only the view can mutate the state. Each view instance has its own state. ```mermaid theme={null} sequenceDiagram actor U as User box Host participant M as Model participant V as View participant S as View State end participant T as MCP Server U->>V: interacts V->>S: read / write V->>T: tool call T-->>V: response M->>S: reads ``` The view can call the tools it's bound to, or any other tool. Tool calls initiated from the view won't mount views. ### App Teardown When the conversation is closed, every view instance unmounts. The next time it's loaded, each one remounts with its own [state](/build/state) restored. ## Hands On: Build a Personal Shopper A shopping app driven by two tools: `search-products` and `create-checkout`. The user tells the model what they're looking for; the model calls `search-products` once to surface a catalog in a carousel; from there the user picks items, and a view-side button hands off to `create-checkout` when they're ready to pay. What turns the model into a *personal shopper* is **state**. The view holds the cart and the currently displayed products. The model reads them to give context-aware advice in chat, pointing at items already on screen based on what's been added. Here's how it plays out at runtime: ```mermaid theme={null} sequenceDiagram actor U as User box Host participant M as Model participant V as View participant S as View State end participant T as MCP Server U->>M: I want winter clothes M->>T: search-products("winter") T-->>M: catalog T-->>V: mount with results U->>V: adds a jacket V->>S: cart += jacket U->>M: what else would you suggest? M->>S: reads cart M->>U: "the boots in the carousel would match" U->>V: adds boots V->>S: cart += boots U->>V: clicks Checkout V->>T: create-checkout(cart) T-->>V: checkout URL ``` And the implementation: ```ts server.ts theme={null} import { Skybridge } from "skybridge/server"; import { z } from "zod"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", handler: (server) => server .registerTool( { name: "search-products", description: "Show products matching a query in a carousel.", inputSchema: { query: z.string() }, view: { component: "carousel" }, }, async ({ query }) => { return { structuredContent: { products: await search(query) } }; }, ) .registerTool( { name: "create-checkout", description: "Create a checkout session for the cart.", inputSchema: { productIds: z.array(z.string()) }, }, async ({ productIds }) => { return { structuredContent: { url: await checkout(productIds) } }; }, ), }); ``` ```tsx views/carousel.tsx theme={null} import { useViewState } from "skybridge/web"; import { useToolInfo, useCallTool } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output } = useToolInfo<"search-products">(); const [cart, setCart] = useViewState<{ ids: string[] }>({ ids: [] }); const { callTool: checkout } = useCallTool("create-checkout"); return ( setCart({ ids: [...cart.ids, id] })} onCheckout={() => checkout({ productIds: cart.ids })} /> ); } ``` ## Pair with the Skill The Skybridge Skill is the best teacher for crafting excellent MCP App architecture. It knows the lifecycle, the data-flow patterns, and the [design moves](/guides/ux) that actually work inside ChatGPT and Claude. Install it now: ```bash theme={null} npx skills add alpic-ai/skybridge -s skybridge ``` ## Go Further Learn everything that makes great MCP Apps Validate your app at every step Deploy your app to a stable, public URL # Start building with Skybridge Source: https://docs.skybridge.tech/get-started/introduction Everything you need to build MCP Apps that run in ChatGPT and Claude Skybridge is a fullstack TypeScript framework for building MCP Apps: interactive [views](/build/view) that render inside model hosts. Define [tools](/build/tools), write React components, and let Skybridge do the rest: ```ts server.ts theme={null} import { Skybridge } from "skybridge/server"; import { z } from "zod"; export const app = new Skybridge({ name: "my-app", version: "0.0.1", handler: (server) => server.registerTool( { name: "greeting", description: "Greet someone by name.", inputSchema: { name: z.string() }, view: { component: "greeting" }, }, async ({ name }) => { return { structuredContent: { message: `Hello, ${name}!` } }; }, ), }); ``` ```tsx views/greeting.tsx theme={null} import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Greeting() { const { output } = useToolInfo<"greeting">(); return

{output.message}

; } ```
Start building now by [scaffolding your first project](/get-started/quickstart#scaffold-your-project): ```bash theme={null} npx skybridge create ``` ## MCP Apps Are Hard, but Not for You MCP Apps are the surface on which humans and agents use software collaboratively. In practice, those apps use the [Model Context Protocol](https://modelcontextprotocol.io/) to run inside model hosts such as ChatGPT and Claude. MCP Apps introduce a fundamental shift: unlike bidirectional web apps, they are three-way systems where data flows between your app, the user, and the model. It boils down to mastering these core concepts: Design experiences leveraging model intelligence. Define what humans and agents can see and do. Craft interactive UIs rendered in conversation. Skybridge is built around all three; it's the fastest, most complete way to build full-fledged MCP Apps. Protocol, [authentication](/build/auth), and [host support](http://localhost:3000/api-reference/overview) are baked in. It comes with a dead-simple architecture and a complete tooling suite, including HMR, [emulator](/test/devtools), [tunneling](/test/tunnel), [playground](/test/playground), and [deployment](/ship). Your sole focus: what makes your app great. ## The AI Sidekick of Your AI Sidekick With the Skybridge Skill, coding agents can autonomously develop MCP Apps. Better still, Skybridge-pilled LLMs help you brainstorm ideas, design [UX](/guides/ux), and craft great apps that actually [make sense](/get-started/architecture) inside ChatGPT or Claude. Install it now: ```bash theme={null} npx skills add alpic-ai/skybridge -s skybridge ``` Build, test, and deploy your first MCP App Design conversational experiences leveraging model intelligence Learn everything that makes great MCP Apps # Quickstart Source: https://docs.skybridge.tech/get-started/quickstart Build, test, and deploy your first MCP App To get a running codebase right away, you can scaffold a project from our templates. They come pre-configured with: * a working [MCP server](/api-reference/skybridge) * a complete development environment with a [local emulator](/test/devtools), file watching, and hot module reload Prerequisite: Skybridge runs on [Node 24+](https://nodejs.org/en/download) ## Scaffold Your Project Set up your app with a single command: ```bash theme={null} npx skybridge create ``` The default `demo` template comes with two tools: * `start`: mounts the `onboarding` view * `get-fortune-cookie`: with no view [Tools](/build/tools) are defined in `src/server.ts`, which `src/index.ts` runs, and [UI components](/build/view) live in the `views` directory. Tools are bound to views in the tool definition. To scaffold a bare-bones project without tools or view placeholders, select the `blank` template. You can also start from any app in the [examples](/examples) folder of the Skybridge repository: ```bash theme={null} npx skybridge create my-app --example auth-descope ``` ## Run Locally Start the development server from the project root: ```bash npm theme={null} npm run dev ``` ```bash pnpm theme={null} pnpm dev ``` ```bash yarn theme={null} yarn dev ``` ```bash bun theme={null} bun dev ``` ```bash deno theme={null} deno task dev ``` This spins up two routes: * `http://localhost:3000/mcp`: the MCP server your app is running on * `http://localhost:3000/`: the [DevTools](/test/devtools) for testing and debugging your app **Updates to your codebase are reflected right away:** server and views are reloaded on file save. ## Connect to ChatGPT and Claude You can plug your locally running server directly into AI hosts by exposing your app on the internet. To do so, start the dev server with a [tunnel](/test/tunnel): ```bash npm theme={null} npm run dev -- --tunnel ``` ```bash pnpm theme={null} pnpm dev --tunnel ``` ```bash yarn theme={null} yarn dev --tunnel ``` ```bash bun theme={null} bun dev --tunnel ``` ```bash deno theme={null} deno task dev --tunnel ``` You'll be provided a public URL (e.g., `https://superb-marmot-fondue-420.alpic.dev/mcp`) to [register your server as a ChatGPT app or a Claude connector](/test/tunnel#connect-to-hosts). [Alpic tunnel](https://docs.alpic.ai/cli/tunnel) comes with a free [LLM Playground](/test/playground) on `/try`. No config needed: it's already wired to your local server. ## Ship Deploy your app to the [Alpic Cloud](https://docs.alpic.ai/) by running: ```bash npm theme={null} npm run deploy ``` ```bash pnpm theme={null} pnpm deploy ``` ```bash yarn theme={null} yarn deploy ``` ```bash bun theme={null} bun deploy ``` ```bash deno theme={null} deno task deploy ``` After deployment succeeds, the CLI prints the app's public URL. You're now live! See [Deploy](/ship/deploy) for all deployment options. ## Go Further Design conversational experiences leveraging model intelligence Learn everything that makes great MCP Apps Validate your app at every step # Connect an Identity Provider Source: https://docs.skybridge.tech/guides/auth-providers Wire sign-in through a hosted OAuth provider [Authenticating users](/build/auth) wires sign-in through a hosted identity provider in one config field, so your tools receive a signed-in user. ChatGPT and Claude drive the flow the same way; what varies is the provider. The sections below cover one each: [Auth0](#auth0), [Authplane](#authplane), [Clerk](#clerk), [Descope](#descope), [Stytch](#stytch), and [WorkOS](#workos), plus a [custom provider](#any-other-provider) for any other. These providers require sign-in on every request by default. Set [`auth: { allowsAnonymous: true }`](/api-reference/register-tool#auth) on any tool to [mix public and authenticated tools](/build/auth#mix-public-and-authenticated-tools): the server then serves anonymous requests and Skybridge enforces each tool's own auth declaration before the handler runs. ## Auth0 [Auth0](https://auth0.com/docs/) issues opaque tokens by default, so you register an API to get verifiable JWTs. 1. In the [Auth0 dashboard](https://manage.auth0.com/), create a **Regular Web Application** and note its **Domain**. 2. Create an **API** (**Applications → APIs**) with an **Identifier** (e.g. `https://your-mcp-server.com`); this identifier is your audience and need not be a real URL. 3. Under **Settings → Tenant Settings → API Authorization Settings**, set **Default Audience** to that identifier and enable **Dynamic Client Registration**. 4. Enable **Application Connections**, promote your login connection to domain level, and set the API's **user access policy** to **Allow**, so DCR-created clients can sign users in. Pass the tenant domain, the API Identifier as `audience`, and this server's public URL as `serverUrl` to [`auth0Provider`](/api-reference/auth0-provider): ```ts server.ts highlight={1,6-10} theme={null} import { Skybridge, auth0Provider } from "skybridge/server"; export const app = new Skybridge({ name: "personal-shopper", version: "0.0.1", oauth: auth0Provider({ domain: process.env.AUTH0_DOMAIN, audience: process.env.AUTH0_API_IDENTIFIER, serverUrl: process.env.SERVER_URL, }), handler, // search-products, requires oauth2 }); ``` See the runnable [`auth-auth0`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-auth0) example. ## Authplane [Authplane](https://authplane.ai) binds the token's `aud` to this server's resource identifier, so the provider takes both the advertised resource and the expected audience from `resource`. Dynamic Client Registration is supported natively, so clients register with Authplane directly and your server stays out of the authorization path. 1. Deploy or point at an Authplane authorization server and note its URL, e.g. `https://auth.acme.com`. 2. Register this MCP server as a protected resource, using the public URL clients will reach — the same value you pass as `resource`, character for character. Pass the authorization server URL and this server's public URL to [`authplaneProvider`](/api-reference/authplane-provider): ```ts server.ts highlight={3-6} theme={null} export const app = new Skybridge({ ...config, oauth: authplaneProvider({ issuer: process.env.AUTHPLANE_ISSUER, resource: process.env.SERVER_URL, }), handler, // search-products, requires oauth2 }); ``` See the runnable [`auth-authplane`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-authplane) example. ## Clerk [Clerk](https://clerk.com/) access tokens carry no `aud` claim, so there is no audience to configure. 1. Sign up at [clerk.com](https://clerk.com/) and create an application. 2. Create an OAuth application with **Dynamic client registration** enabled and **Generate access tokens as JWTs** turned on. 3. Copy your **Frontend API URL** (**API keys**), e.g. `acme.clerk.accounts.dev`. Pass the domain alone to [`clerkProvider`](/api-reference/clerk-provider), no audience: ```ts server.ts highlight={3-5} theme={null} export const app = new Skybridge({ ...config, oauth: clerkProvider({ domain: process.env.CLERK_FRONTEND_API, }), handler, // search-products, requires oauth2 }); ``` See the [`auth-clerk`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-clerk) example. ## Descope A [Descope](https://docs.descope.com/mcp) MCP Server binds the token's `aud` to the project, so the provider derives the audience from the Discovery URL you pass. 1. Sign up at [descope.com](https://www.descope.com/) and create a project. 2. In the console's **MCP Servers** section, create an MCP Server and enable **Dynamic Client Registration** on it. 3. Copy the MCP Server's **Discovery URL** from its Connection Information. Pass that URL to [`descopeProvider`](/api-reference/descope-provider); the audience defaults to the Project ID derived from it: ```ts server.ts highlight={3-5} theme={null} export const app = new Skybridge({ ...config, oauth: descopeProvider({ url: process.env.DESCOPE_MCP_SERVER_URL, }), handler, // search-products, requires oauth2 }); ``` See the [`auth-descope`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-descope) example. ## Stytch [Stytch Connected Apps](https://stytch.com/docs/connected-apps) ships its consent screen only as a React component, so the login, consent, and callback pages are served as static HTML from your server, and the Connected App points at them. 1. Sign up at [stytch.com](https://stytch.com/) and create a **Consumer** project. 2. Under **Connected Apps**, create a Connected App with Dynamic Client Registration enabled. 3. Note your **Project ID**, **project domain** (e.g. `acme.customers.stytch.dev`), and **Public Token**. 4. Set the Connected App's **Authorization URL** to your server's consent page and add its callback to the **Redirect URLs** allowlist. The [`auth-stytch`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-stytch) example ships those HTML pages. Pass the project domain and the Project ID as the audience to [`stytchProvider`](/api-reference/stytch-provider): ```ts server.ts highlight={3-6} theme={null} export const app = new Skybridge({ ...config, oauth: stytchProvider({ domain: process.env.STYTCH_DOMAIN, audience: process.env.STYTCH_PROJECT_ID, }), handler, // search-products, requires oauth2 }); ``` ## WorkOS [WorkOS AuthKit](https://www.workos.com/docs/user-management/authkit) needs DCR enabled and your server registered as a Resource Indicator so issued tokens carry the right `aud`. 1. Sign up at [workos.com](https://www.workos.com/) and enable AuthKit. 2. Enable Dynamic Client Registration under **Connect → Configuration**. 3. Register your server URL as a **Resource Indicator**, so AuthKit sets each token's `aud` to it. 4. Copy your **AuthKit domain** (e.g. `acme.authkit.app`). Pass the domain and your server URL as the audience to [`workosProvider`](/api-reference/workos-provider): ```ts server.ts highlight={3-6} theme={null} export const app = new Skybridge({ ...config, oauth: workosProvider({ domain: process.env.AUTHKIT_DOMAIN, audience: process.env.SERVER_URL, }), handler, // search-products, requires oauth2 }); ``` See the runnable [`auth-workos`](https://github.com/alpic-ai/skybridge/tree/main/examples/auth-workos) example. ## Any Other Provider For an identity provider without a branded wrapper, [`customProvider`](/api-reference/custom-provider) wires any IdP that meets three requirements: * **[Dynamic Client Registration (DCR)](https://datatracker.ietf.org/doc/html/rfc7591)** enabled, so hosts can register themselves without a hand-issued client ID. * **JWT access tokens** (not opaque ones), so the server can verify a token by its signature. * **An audience** the provider binds into the token's `aud` claim. Pass the issuer and the audience it binds: ```ts server.ts highlight={3-6} theme={null} export const app = new Skybridge({ ...config, oauth: customProvider({ issuer: "https://auth.example.com", audience: process.env.SERVER_URL, }), handler, // search-products, requires oauth2 }); ``` Omit `audience` for a provider that binds none, and set `serverUrl` when Skybridge must advertise itself as the authorization server, as Auth0 and the Alpic DCR proxy require. [Google](https://developers.google.com/identity/protocols/oauth2) and [GitHub](https://docs.github.com/en/apps/oauth-apps) don't support DCR, so `customProvider` can't wire them directly. To offer Google or GitHub sign-in, configure a branded provider (Auth0, Clerk, Stytch, or WorkOS) with them as an upstream social connection. After sign-in, [reading the user and scoping data to them](/build/auth) is the same across every provider: handlers read `extra.http?.authInfo`, and a tool declaring [`securitySchemes`](/api-reference/register-tool#securityschemes) with scopes gates access to them. Publish metadata, verify tokens, read the user Declare which tools require sign-in Test the OAuth flow in a real host # Configure CSP Source: https://docs.skybridge.tech/guides/csp Let your views reach the domains they need Views render in a sandboxed iframe under a strict [Content Security Policy](https://developer.mozilla.org/fr/docs/Web/HTTP/Guides/CSP): it blocks every cross-origin request the view hasn't declared. Your server's own origin is allowed automatically, so a view that only talks to its server needs no configuration. For anything else, list the origin on the [tool's config `view.csp`](http://localhost:3000/api-reference/register-tool#csp). ```ts server.ts theme={null} server.registerTool( { name: "search-products", inputSchema: { query: z.string() }, view: { component: "carousel", csp: { connectDomains: ["https://api.myshop.com"], resourceDomains: ["https://cdn.myshop.com"], frameDomains: ["https://www.youtube.com"], redirectDomains: ["https://checkout.myshop.com"], }, }, }, async ({ query }) => { /* … */ }, ); ``` Each field maps to one kind of request the iframe would otherwise block. The sections below cover the four you'll reach for, what's allowed without any config, and how the policy differs per host. ## Call an External API `connectDomains` lists the origins the view may reach with [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) or [`XHR`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest). Without it, a direct request from the view is blocked. ```ts server.ts highlight={8} theme={null} server.registerTool( { name: "search-products", inputSchema: { query: z.string() }, view: { component: "carousel", csp: { connectDomains: ["https://api.myshop.com"], resourceDomains: ["https://cdn.myshop.com"], frameDomains: ["https://www.youtube.com"], redirectDomains: ["https://checkout.myshop.com"], }, }, }, async ({ query }) => { /* … */ }, ); ``` The carousel checks live stock from `api.myshop.com`, so that origin is listed. Your server's origin is already in `connectDomains`, so same-origin requests need no entry. In development, the dev server's [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) origin is added too, for hot reload. ## Load External Assets `resourceDomains` covers the static assets the browser loads from another origin: images, fonts, scripts, and stylesheets, whether from a CDN, a fonts provider, or any other host. ```ts server.ts highlight={9} theme={null} server.registerTool( { name: "search-products", inputSchema: { query: z.string() }, view: { component: "carousel", csp: { connectDomains: ["https://api.myshop.com"], resourceDomains: ["https://cdn.myshop.com"], frameDomains: ["https://www.youtube.com"], redirectDomains: ["https://checkout.myshop.com"], }, }, }, async ({ query }) => { /* … */ }, ); ``` The carousel's product images come from `cdn.myshop.com`, so that origin has to be listed or they won't render. Your server's origin is already in `resourceDomains`, so assets served from it need no entry. ## Embed an Iframe `frameDomains` lists the origins the view may embed in a nested iframe, like a map or a video player. Third-party iframes opt your app into stricter review when you submit it to a directory. ```ts server.ts highlight={10} theme={null} server.registerTool( { name: "search-products", inputSchema: { query: z.string() }, view: { component: "carousel", csp: { connectDomains: ["https://api.myshop.com"], resourceDomains: ["https://cdn.myshop.com"], frameDomains: ["https://www.youtube.com"], redirectDomains: ["https://checkout.myshop.com"], }, }, }, async ({ query }) => { /* … */ }, ); ``` The carousel embeds a product video from `www.youtube.com`, so that origin is listed. ## Redirect Off-App `redirectDomains` lists the origins [`useOpenExternal`](/api-reference/use-open-external) can send the user to without the host's safe-link confirmation modal. ```ts server.ts highlight={11} theme={null} server.registerTool( { name: "search-products", inputSchema: { query: z.string() }, view: { component: "carousel", csp: { connectDomains: ["https://api.myshop.com"], resourceDomains: ["https://cdn.myshop.com"], frameDomains: ["https://www.youtube.com"], redirectDomains: ["https://checkout.myshop.com"], }, }, }, async ({ query }) => { /* … */ }, ); ``` The carousel hands off to `checkout.myshop.com` to take payment; listing it skips the interstitial on the way out. ## Verify Your CSP To check your CSP, run your app in a real host through the [tunnel](/test/tunnel), or let the [Audit](/test/audit) verify it before you submit. [DevTools](/test/devtools) runs a loose policy, so CSP violations don't surface locally: a view that loads in the emulator can still have its requests blocked in production. For the full field reference, see [registerTool](/api-reference/register-tool#csp). ## Go Further Craft interactive UIs rendered in conversation Account for what makes an MCP App different Validate before submission # Build an Ecommerce App Source: https://docs.skybridge.tech/guides/ecommerce Scaffold a catalog search and carousel UI, then fill it with your data and brand Ecommerce inside a conversation is a hard UX problem. A storefront's job has to fit the host-controlled frame, and merchandising moves to a model. The `ecom` template ships that architecture as a skeleton, with opinionated patterns refined by the [Alpic build team](https://alpic.ai/solutions/build/studio): an iterative search loop, an inline carousel opening into a fullscreen product detail, sparse variant selection, and a token-based design system. The wiring is in place, the data is not: a coding agent skill fills in your catalog and brand, pausing for your sign-off at each design decision. The template is backend-agnostic: it assumes a catalog you already run, on [Shopify](https://www.shopify.com), [PrestaShop](https://prestashop.com), [Medusa](https://medusajs.com), or a custom API or database, and builds the conversational storefront on top. It never stores products itself. ## Scaffold the Template Scaffold a project with the `--ecom` flag: ```bash theme={null} npx skybridge create my-shop --ecom ``` The template works best with the `chatgpt-app-builder` skill, which drives the fill workflow below. The scaffolder installs it by default; to add it to an existing project: ```bash theme={null} npx skills add alpic-ai/skybridge --skill chatgpt-app-builder ``` ## Install the Tooling A few MCP servers let the agent do work it would otherwise hand back to you: * **[Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp)**: a browser for the agent: it [drives the local DevTools](/test/devtools#drive-devtools-from-a-coding-agent) to verify its own work, previews UI components, and inspects your live site's styles for token extraction. * **[Playwright MCP](https://github.com/microsoft/playwright-mcp)**: the same, as a fallback when Chrome DevTools is unavailable. * **[Figma MCP](https://www.figma.com/mcp-catalog/):** if your brand lives in Figma, the agent extracts color ramps, type scale, and spacing itself. All are optional: without them, the agent asks you instead. ## What the Template Ships Four patterns carry the app. The fill workflow customizes their values, never their shape. ### Search, Then Render Two [tools](/build/tools) split the flow. `search-products` takes a keyword, filters, and a sort, and returns matching products as model-facing structured output. `render-carousel` takes the curated ids in display order and mounts the carousel [view](/build/view). The split [separates data processing from UI rendering](https://developers.openai.com/plugins/build/chatgpt-ui#separate-data-processing-from-ui-rendering): the model applies its intelligence to the results before the user sees any UI. Prompts drive an iterative loop: it searches several times, varying keywords and filters to capture intent that facets alone can't express, drops products the conversation already ruled out, then curates a handful of ids. Raw search results never render: they ground the curation. It also degrades gracefully: a host without view support still calls `search-products`, and the model answers in text. ```mermaid theme={null} sequenceDiagram actor User box Host participant Model participant View end participant Server as MCP Server User->>Model: I need a warm jacket Model->>Server: search-products("jacket") Server-->>Model: 42 hits: ids + facts Model->>Server: search-products("jacket", maxPrice: 300) Server-->>Model: 12 hits: ids + facts Model->>Server: render-carousel([a, c, f]) Server-->>View: full products in _meta Server-->>Model: trimmed grounding View-->>User: carousel of 3 products ``` `render-carousel` answers on three channels: full products (every variant, all media) ride `_meta` for the view; a trimmed projection goes to `structuredContent` so the model can answer follow-ups; `content` is a one-line status. ### Open the Product Detail Tapping a card switches to `fullscreen` and renders the product detail over the carousel: one view, two screens. The detail reads the same `_meta` products, so no extra fetch. The model gets the full product spec through [view state](/build/state). ### Pick Variants Every variant is a complete, buyable product; a `Product` groups siblings and declares option axes. The variant list is sparse: a combination that does not exist is simply absent, and availability is derived from the list, never encoded as rules. Each option value resolves to in stock, sold out (selectable, only the buy CTA locks), or nonexistent (disabled). The model knows which variant the user is looking at. It can act as a salesperson: answer about any variant, compare, and advise on variations. ### Restyle from Tokens A vanilla-extract design system under `src/design/` styles every component from one set of tokens: primitives, a semantic color contract, light and dark themes, sprinkles, and a typography recipe. A theme that leaves a contract slot unset fails the build. The template ships brand-neutral placeholders. Every component comes with [Ladle](https://ladle.dev) stories covering its edge cases (long titles, missing images, sold-out variants); `npm run ladle` previews them against the tokens, light and dark one click apart. ## Fill It with Your Agent Every decision the skeleton defers is marked with a `@todo` comment in `src/`: filter facets, image aspect ratios, section order, brand tokens. The skill walks a coding agent through that worklist in six gated phases, recording each decision in `SPEC.md`. ```mermaid theme={null} flowchart LR G[1. Gather] --> E[2. Explore data] --> UX["3. Decide UX ✍️"] --> S[4. Server] --> C[6. Components] --> F["Final gate ✍️"] G --> D["5. Design ✍️"] --> C ``` Fill this ecom template following the chatgpt-app-builder skill's ecommerce reference. Start with phase 1 and ask me for everything you need. The agent asks for your inputs up front: the data source (a Shopify or Medusa API, your own database, docs, credentials), brand assets (Figma file, live site, or screenshots, plus fonts), and the live site for layout inspiration. Then it explores, proposes, and builds. You are pulled in at three gates: * **Wireframes.** Before any UI code, the agent plays back the carousel card and the product detail as ASCII wireframes populated with real catalog values. * **Retheme.** The extracted brand tokens, previewed on the Ladle stories. * **Final gate.** The worklist is empty, the build passes, and both tools are verified against live data. ## Verify the Result `npm run dev` serves DevTools on the root: call both tools with real arguments and drive the view through display modes, themes, mobile widths, and locales. Add the [tunnel](/test/tunnel) flag to also get a [Playground](/test/playground) on `/try` of the printed public URL, and run the app in a real host with an actual model. ```bash theme={null} npm run dev -- --tunnel ``` For a finished build, the [ecommerce example](https://github.com/alpic-ai/skybridge/tree/main/examples/ecom-carousel) connects this template to a Medusa catalog. Define what humans and agents can do Decide what the model sees Call tools and render views locally, without a host # Handle Files Source: https://docs.skybridge.tech/guides/files Move files in and out of your app [Views](/build/view) run in a sandboxed iframe, cut off from the conversation and the user's disk. Each host opens its own door for files, and the two work differently: ChatGPT keeps a file store your [tools](/build/tools) and views read and write; Claude only lets a view save a file to the user's device. What you can build depends on the host. ## Files in ChatGPT ChatGPT manages files for you: it stores each one and hands your code a reference to it, never the bytes. References survive across tool calls, so files move through the app in both directions, into a tool and back out. ### Receive a File A tool can take a file the user added to the conversation. The model only passes files already there, so the user adds one first, from their device or library; the handler receives a reference and fetches the contents. ```ts server.ts highlight={11,13,16} theme={null} import { FileRef, Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "expenses", version: "0.0.1", handler: (server) => server.registerTool( { name: "scan-receipt", description: "Extract line items from a receipt.", inputSchema: { receipt: FileRef }, outputSchema: { summary: FileRef }, _meta: { "openai/fileParams": ["receipt"] }, }, async ({ receipt }) => { const bytes = await fetch(receipt.download_url).then((r) => r.blob()); return { structuredContent: { summary: await summarize(bytes) } }; }, ), }); ``` Each file input is a [`FileRef`](/api-reference/file-ref) listed in `_meta["openai/fileParams"]` (top-level fields only), which tells ChatGPT to route the attachment to it. The host fills `download_url`, so `scan-receipt` reads the bytes straight from `receipt.download_url`. ### Return a File A tool can also hand a file back. The host surfaces it in the conversation, so the user can download it and the model can see it. ```ts server.ts highlight={12,17} theme={null} import { FileRef, Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "expenses", version: "0.0.1", handler: (server) => server.registerTool( { name: "scan-receipt", description: "Extract line items from a receipt.", inputSchema: { receipt: FileRef }, outputSchema: { summary: FileRef }, _meta: { "openai/fileParams": ["receipt"] }, }, async ({ receipt }) => { const bytes = await fetch(receipt.download_url).then((r) => r.blob()); return { structuredContent: { summary: await summarize(bytes) } }; }, ), }); ``` `scan-receipt` declares `summary` as a [`FileRef`](/api-reference/file-ref) in its `outputSchema` and returns it in `structuredContent`; `summarize` produces the ref: a `file_id` and a `download_url` the host uses to fetch and cache the file. ### Steer from the View The viewcan run the whole exchange itself: pick a file, hand it to a tool, and resolve what comes back, no model in the loop. Here a receipt scanner lets the user pick a receipt and get a summary in return, built up in three steps: #### Pick a File `upload` takes a file from the device and `selectFiles` opens the user's library. Both return a `FileMetadata`, a `fileId` with no bytes attached, which we hold in state. ```tsx views/receipt-scanner.tsx highlight={7,13,18} theme={null} import type { ChangeEvent } from "react"; import { useState } from "react"; import { type FileMetadata, useFiles } from "skybridge/web"; export default function ReceiptScanner() { const { upload, selectFiles } = useFiles(); const [file, setFile] = useState(); const fromDevice = async (event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { const picked = await upload(file) setFile(picked); } }; const fromLibrary = async () => { const [picked] = await selectFiles(); if (picked) { setFile(picked); } }; return (
{file &&

{file.fileName ?? file.fileId}

}
); } ``` #### Hand It to a Tool `scan` resolves a fresh `download_url` for the held file with `getDownloadUrl`, packs it into a [`FileRef`](/api-reference/file-ref), and calls `scan-receipt` with it. ```tsx views/receipt-scanner.tsx highlight={7,17} theme={null} import { useState } from "react"; import { type FileMetadata, useFiles } from "skybridge/web"; import { useCallTool } from "../helpers.js"; // generated, type-safe from server schema export default function ReceiptScanner() { const { upload, selectFiles, getDownloadUrl } = useFiles(); const { callTool } = useCallTool("scan-receipt"); const [file, setFile] = useState(); /* fromDevice and fromLibrary as above */ const scan = async () => { if (!file) { return; } const { downloadUrl } = await getDownloadUrl({ fileId: file.fileId }); callTool({ receipt: { file_id: file.fileId, download_url: downloadUrl, file_name: file.fileName, mime_type: file.mimeType, }, }); }; return (
{/* file pickers as above */}
); } ``` #### Resolve the Returned File `scan-receipt` returns its own [`FileRef`](/api-reference/file-ref) on `data.structuredContent`. Resolve it from `summary.file_id` when the user asks: a `download_url` is a temporary cache URL that expires; the `file_id` is the durable handle. Keep the `file_id` (in [state](/build/state), or in the model's context) and regenerate a URL on demand. ```tsx views/receipt-scanner.tsx highlight={7,18} theme={null} import { useState } from "react"; import { type FileMetadata, useFiles } from "skybridge/web"; import { useCallTool } from "../helpers.js"; // generated, type-safe from server schema export default function ReceiptScanner() { const { upload, selectFiles, getDownloadUrl } = useFiles(); const { data, callTool } = useCallTool("scan-receipt"); const [file, setFile] = useState(); /* fromDevice, fromLibrary and scan as above */ const downloadSummary = async () => { const summary = data?.structuredContent?.summary; if (!summary) { return; } const { downloadUrl } = await getDownloadUrl({ fileId: summary.file_id }); window.open(downloadUrl, "_blank"); }; return (
{/* pickers and Scan button as above */} {data?.structuredContent && ( )}
); } ``` [`useFiles`](/api-reference/use-files) is ChatGPT only. It won't work in other hosts. ## Files in Claude Claude has no file store. The one operation is egress: the view hands the host content, and the host writes it to the user's disk after a confirmation prompt. ### Download from the View A view can save a file to the user's device, whether content it built itself or a file returned by an earlier tool call. The host shows a confirmation, then writes it to disk. ```tsx views/receipt-summary.tsx highlight={7-17} theme={null} import { useDownload } from "skybridge/web"; export default function ReceiptSummary({ items }: { items: LineItem[] }) { const download = useDownload(); const exportCsv = async () => { const csv = items.map((i) => `${i.label},${i.amount}`).join("\n"); await download({ contents: [ { type: "resource", resource: { uri: "file:///receipt.csv", // filename hint mimeType: "text/csv", text: csv, }, }, ], }); }; return ; } ``` `ReceiptSummary` builds a CSV from its line `items`, then hands [`download`](/api-reference/use-download) a single resource: `text` carries the content, `mimeType` types it, and the `uri`'s last segment sets the suggested filename, here `receipt.csv`. A resource can instead carry a base64 `blob`, for bytes the view rendered or a file an earlier tool returned. The call fires from the Export button, not on mount: the host rejects downloads the user didn't trigger. **Claude** doesn't support `resource_link` as a content type. ## Go Further Craft interactive UIs rendered in conversation Define what humans and agents can do Account for what makes an MCP App different # Guides Source: https://docs.skybridge.tech/guides/index Cross-cutting how-tos for building MCP Apps Each one is self-contained: it names the concern it solves, shows the [APIs](/api-reference) involved, and works a single example end to end. They don't build on each other, so read whichever fits the problem in front of you. Let your views reach the domains they need Scaffold a catalog and carousel, then fill it with your brand Charge a shopper, charge an agent, or carry sponsored slots Account for what makes an MCP App different Move files in and out of your app Bring an existing MCP App over Wire sign-in through a hosted OAuth provider Ship Agent Skills alongside your server # Migrate to Skybridge Source: https://docs.skybridge.tech/guides/migrate Bring an existing MCP App over, whatever it's built on The Skybridge Skill migrates an existing MCP App for you, whatever framework or library it's built on. It teaches your coding agent Skybridge, then drives the rewrite. ## Install the Skill ```bash npm theme={null} npx skills add alpic-ai/skybridge -s skybridge ``` ```bash pnpm theme={null} pnpm dlx skills add alpic-ai/skybridge -s skybridge ``` ```bash yarn theme={null} yarn dlx skills add alpic-ai/skybridge -s skybridge ``` ```bash bun theme={null} bunx skills add alpic-ai/skybridge -s skybridge ``` ```bash deno theme={null} deno run -A npm:skills add alpic-ai/skybridge -s skybridge ``` ## Prompt Your Agent Migrate my app to /skybridge Your agent scaffolds a Skybridge project, ports the [tools](/build/tools) and [views](/build/view), and wires them up. From there the same Skill handles ongoing work: building features, debugging, and [deploying](/ship). Upgrading from Skybridge 1.x? Point your agent at the release notes instead: `Migrate my app based on https://github.com/alpic-ai/skybridge/releases/tag/v2.0.0`. If your app calls `window.openai` or the `ext-apps` protocol directly, the [feature mapping](/resources/apps-sdk-and-mcp-apps#feature-mapping) gives the Skybridge hook that replaces each one. ## Go Further Define what humans and agents can do Craft interactive UIs rendered in conversation Decide what the model sees # Monetize Your MCP App Source: https://docs.skybridge.tech/guides/monetization Discover the different ways to make money with your MCP App Three parties can pay you for an MCP App, and which one you pick decides the rest of the integration: * a person, paying on a checkout page you already run * the agent itself, paying for a tool call out of a wallet its user funded * an advertiser, paying for a labeled slot in your tool results The first is where almost every app shipping today sits. The second is newer and still experimental. They are not exclusive: an app can hand off to checkout and carry sponsored slots on the tools it gives away. ## Hand Off to Your Own Checkout The simplest option, and what the [`ecom` template](/guides/ecommerce) ships: send the shopper to a page you already run. The navigation has to go through the host with [`useOpenExternal`](/api-reference/use-open-external). ```tsx views/carousel/detail/index.tsx theme={null} import { useOpenExternal } from "skybridge/web"; function BuyButton({ variant }) { const openExternal = useOpenExternal(); return ( ); } ``` List your checkout origin in [`redirectDomains`](/guides/csp#redirect-off-app) so the host skips its safe-link confirmation on the way out. ```ts server.ts theme={null} server.registerTool( { name: "browse-catalog", inputSchema: { query: z.string() }, view: { component: "carousel", csp: { redirectDomains: ["https://checkout.myshop.com"] }, }, }, async ({ query }) => { /* … */ }, ); ``` What you give up is the return leg. The host opens the page outside the iframe and nothing comes back, so the view keeps rendering the cart and the model still believes the order is pending. This is usually fine for catalogs focused on sending qualified traffic to a storefront. If you want to update the cart after redirect, one strategy is to have the view poll the cart status until it's updated elsewhere. ## Charge the Agent An agent can also pay for a tool call itself, with no person present, under the [Machine Payments Protocol](https://docs.stripe.com/payments/machine/mpp). An unpaid call comes back as a payment challenge, the agent fetches a credential from its user's wallet, and the retry returns the real result with a receipt attached. ```mermaid theme={null} sequenceDiagram participant Agent participant Server as Your server participant Wallet as Agent wallet Agent->>Server: call, no credential Server-->>Agent: error -32042 plus challenge Agent->>Wallet: request a credential Wallet-->>Agent: credential Agent->>Server: retry, credential in `_meta` Server-->>Agent: result plus receipt ``` ```bash theme={null} npm install mppx stripe @modelcontextprotocol/sdk ``` Charge from [`mcpMiddleware`](/api-reference/mcp-server) rather than inside the tool handler. The challenge has to reach the client as a JSON-RPC error, carrying the amount and the payment method the agent needs to pay: an exception thrown inside a handler is flattened into an `isError` tool result instead, which leaves the agent with a message and nothing to pay against. ```ts server.ts theme={null} import { Mppx, stripe, Transport } from "mppx/server"; import { Skybridge } from "skybridge/server"; import Stripe from "stripe"; import { z } from "zod"; const PRICES: Record = { "generate-report": "2.50" }; export const app = new Skybridge({ name: "reports", version: "1.0", setup: () => ({ payment: Mppx.create({ methods: [ stripe.charge({ client: new Stripe(process.env.STRIPE_SECRET_KEY), networkId: process.env.STRIPE_PROFILE_ID, currency: "usd", decimals: 2, paymentMethodTypes: ["card"], }), ], secretKey: process.env.MPP_SECRET_KEY, realm: "reports.example.com", transport: Transport.mcpSdk(), }), }), handler: (server, { payment }) => { server.mcpMiddleware("tools/call", async (request, _extra, next) => { const amount = PRICES[request.params.name]; if (!amount) { return next(); } const result = await payment.stripe.charge({ amount, description: request.params.name, })({ _meta: request.params._meta }); if (result.status === 402) { throw result.challenge; } return result.withReceipt(await next()); }); return server.registerTool( { name: "generate-report", description: "Generate a market report.", inputSchema: { ticker: z.string() }, }, async ({ ticker }) => ({ content: [{ type: "text", text: `Report for ${ticker}` }], }), ); }, }); ``` The middleware runs before the handler, so an unpaid call never reaches your code. Pricing lives in one table keyed by tool name, and a tool absent from it stays free. `MPP_SECRET_KEY` signs the challenges, so it has to be at least 32 bytes (`openssl rand -base64 32`), and setting `realm` avoids a fallback warning at boot. This is experimental on every side. It needs [Shared Payment Token](https://docs.stripe.com/agentic-commerce/concepts/shared-payment-tokens) access and a Stripe profile, the protocol is young, and no chat host pays for a tool call on a user's behalf today. Agent harnesses with their own wallet, such as a coding agent, are where it currently applies. We have verified the challenge leg against a live Skybridge server; paying one and redeeming the receipt is untested. `mppx` needs the v1 `@modelcontextprotocol/sdk` to build the payment error and declares it as an optional peer, so install it yourself alongside Skybridge's v2 packages. Stripe [documents a different shape](https://docs.stripe.com/agentic-commerce/monetize-mcp) for the same protocol, where a tool returns a payment link and a separate HTTP endpoint handles the exchange. That endpoint also serves human browsers, so it covers both audiences with one URL, at the cost of a custom route Alpic Cloud does not serve. ## Carry Sponsored Slots Instead of charging anyone for your app, you can let an advertiser pay for a slot in it. [Lulu](https://getlulu.dev) is an ad network for MCP servers and agent tools: it sells the demand, matches an advertiser to each tool call, and pays publishers 70% of the conversions it bills, with a \$100 payout minimum. Its SDK attaches one labeled sponsored field to your tool results, through a Skybridge adapter built on [`mcpMiddleware`](/api-reference/mcp-server). ```bash theme={null} npm install lulu-ads ``` ```ts server.ts theme={null} import { withLuluAdsSkybridge } from "lulu-ads/skybridge"; handler: (server) => { withLuluAdsSkybridge(server); return server.registerTool(/* ... */); }, ``` Credentials come from `LULU_ADS_PUBLISHER_ID` and `LULU_ADS_API_KEY`. Every tool registered on the server then carries a field like this one: ```json theme={null} { "label": "Sponsored", "text": "Direct flights TLV to BKK from $412", "url": "https://ads.getlulu.dev/c/9f2a1c" } ``` Two properties matter more than the integration. The field is data rather than an instruction, so the model decides on its own whether to surface it and nothing tells it to. And it fails open within 800ms, so a slow or dead ad backend leaves your tool result untouched instead of breaking the call. The payload lands on `_meta["ads.getlulu.dev/sponsored"]` and never on `structuredContent`. Protocol middleware sees the result but not the tool's `outputSchema`, and an undeclared field in `structuredContent` would fail validation. Charging some users and showing ads to the rest needs the client directly, because the adapter above takes no per-request decision. Build a `LuluAds` yourself, and let the `enabled` flag read whatever your auth already knows about the caller. ```ts server.ts theme={null} import { LuluAds } from "lulu-ads"; const ads = new LuluAds({ publisherId: process.env.LULU_ADS_PUBLISHER_ID, apiKey: process.env.LULU_ADS_API_KEY, }); server.registerTool( { name: "search-flights", inputSchema: { from: z.string(), to: z.string() }, }, async ({ from, to }, extra) => { const sponsored = await ads.sponsoredSlot({ context: { tool: "search-flights" }, enabled: extra.http?.authInfo?.extra.tier !== "paid", }); return { content: [{ type: "text", text: await searchFlights(from, to) }], _meta: { "ads.getlulu.dev/sponsored": sponsored }, }; }, ); ``` Reach for this instead of `withLuluAdsSkybridge`, not alongside it: the adapter already decorates every tool, so a tool doing its own slot would get two. Lulu also ships [result widgets](https://github.com/Lulu-The-Narwhal/lulu-ads#result-widgets), four templates that render your own tool output with the sponsored strip built in, for hosts that support rendered widgets. ## Go Further Define what humans and agents can do Let your views reach the domains they need Scaffold a catalog and carousel UI # Serve Skills over MCP Source: https://docs.skybridge.tech/guides/skills Ship Agent Skills alongside your server Skills over MCP tracks [SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640), still under review. The feature is **experimental** and may change with the spec. Few hosts support it yet, so skills you serve today may go unused until they adopt the standard. A [tool](/build/tools) description tells the model *what* a tool does. A **skill** tells it *how* to run a workflow: a directory of instructions (`SKILL.md` plus supporting files) the host loads into context on demand. ## Enable Skills Drop your skills under `src/skills/`, one directory per skill, and turn the option on: ```ts server.ts theme={null} export const app = new Skybridge({ name: "shop", version: "1.0", skills: true, handler, }); ``` ``` src/skills/ refunds/ SKILL.md # required: frontmatter + instructions templates/ email.md # supporting files, read on demand ``` Each `SKILL.md` opens with YAML frontmatter whose `name` matches its directory: ```md src/skills/refunds/SKILL.md theme={null} --- name: refunds description: Process customer refund requests per company policy. --- 1. Look up the order with `get-order`. 2. ... ``` Skybridge validates every skill at build and dev startup: a bad `name`, a missing `description`, or a name that mismatches its directory fails loudly. Each valid skill is then served at `skill:///SKILL.md`, with its supporting files alongside. Hosts discover skills through the `skills/list` method, which returns each skill's URI, its verbatim frontmatter, and the complete file set with per-file SHA-256 digests; `skills/get` returns the same entry for a single skill by URI. This is the surface [ChatGPT's skill importer](https://developers.openai.com/plugins/build/mcp-server#import-skills-from-the-mcp-server) consumes when you submit your app. Skills are markdown files only: `SKILL.md` and any supporting `.md` files are served as `text/markdown`, other formats and symlinks are ignored. ## How Hosts Use Skills Hosts don't inject skills on their own. To put one in front of the model, reference its URI from a tool description or a tool result, for example *"see `skill://refunds/SKILL.md`"*. The host pulls in the full instructions only when they're relevant. A skill is a set of instructions a model may or may not act on: it does not bypass the model's system directives or guardrails. ## Go Further Define what humans and agents can do Account for what makes an MCP App different Ship your server to any host # UX Design Source: https://docs.skybridge.tech/guides/ux Account for what makes an MCP App different An MCP App is a new kind of surface with its own UX principles. The [view](/build/view) shares the screen with an ongoing conversation, in a frame the host controls. Skybridge surfaces that environment through [hooks](/api-reference/overview#hooks), so the view can read its context and adapt. ```tsx views/carousel.tsx theme={null} import { useDisplayMode, useUser, useViewport } from "skybridge/web"; import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output } = useToolInfo<"search-products">(); const { theme, userAgent } = useUser(); const { maxHeight, safeArea } = useViewport(); const [mode, setMode] = useDisplayMode(); return (
{mode === "inline" && ( )}
); } ``` The sections below cover fitting the space the host gives you, matching its theme, responding to the display mode, and adapting to the user's device. ## Fit the Available Space [`useViewport`](/api-reference/use-viewport) reports the room the host gives the view: `maxHeight`, and the `safeArea` insets that keep content clear of device notches and the chat composer. ```tsx views/carousel.tsx highlight={7,13} theme={null} import { useDisplayMode, useUser, useViewport } from "skybridge/web"; import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output } = useToolInfo<"search-products">(); const { theme, userAgent } = useUser(); const { maxHeight, safeArea } = useViewport(); const [mode, setMode] = useDisplayMode(); return (
{mode === "inline" && ( )}
); } ``` The carousel caps its height at `maxHeight` and pads its bottom by `safeArea.insets.bottom`, so the last row clears the composer instead of hiding behind it. ## Match the Theme [`useUser`](/api-reference/use-user) reports the host's color scheme as `theme`, `"light"` or `"dark"`. A view on its own palette looks pasted into the conversation; read `theme` and follow it. ```tsx views/carousel.tsx highlight={6,12} theme={null} import { useDisplayMode, useUser, useViewport } from "skybridge/web"; import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output } = useToolInfo<"search-products">(); const { theme, userAgent } = useUser(); const { maxHeight, safeArea } = useViewport(); const [mode, setMode] = useDisplayMode(); return (
{mode === "inline" && ( )}
); } ``` The carousel adds a `dark` class when the host is dark, the convention Tailwind's `dark:` variants key off, so its styles track the host. ## Respond to the Display Mode [`useDisplayMode`](/api-reference/use-display-mode) returns the current mode and a setter. The three modes give the view different room and purpose: * `inline` is the default: a compact panel embedded in the conversation. The smallest surface, so it suits a single result or a short list. * `fullscreen` takes over the surface for richer, multi-step tasks. * `pip`(picture in picture) floats above the conversation and stays open, which fits live or changing content. On mobile it coerces to fullscreen. Switching is user-triggered, and the host can decline the request. ```tsx views/carousel.tsx highlight={8,17,20-22} theme={null} import { useDisplayMode, useUser, useViewport } from "skybridge/web"; import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output } = useToolInfo<"search-products">(); const { theme, userAgent } = useUser(); const { maxHeight, safeArea } = useViewport(); const [mode, setMode] = useDisplayMode(); return (
{mode === "inline" && ( )}
); } ``` The carousel shows two columns inline and four in fullscreen, with a "See all" button that requests fullscreen only while inline. Keep inline compact, and let the denser layout wait for the room fullscreen gives you. ## Adapt to the User [`useUser`](/api-reference/use-user) also reports the `locale` and the `userAgent`: the device type, and whether it supports `hover` and `touch`. ```tsx views/carousel.tsx highlight={6,18} theme={null} import { useDisplayMode, useUser, useViewport } from "skybridge/web"; import { useToolInfo } from "../helpers.js"; // generated, type-safe from server schema export default function Carousel() { const { output } = useToolInfo<"search-products">(); const { theme, userAgent } = useUser(); const { maxHeight, safeArea } = useViewport(); const [mode, setMode] = useDisplayMode(); return (
{mode === "inline" && ( )}
); } ``` The carousel renders a compact card on mobile. Reach for `capabilities.hover` before relying on hover affordances, and `locale` to translate copy. ## Iterate The [DevTools](/test/devtools) environment inspector flips theme, display mode, locale, and device type live, so you can watch the view adapt on localhost, then confirm it in a real host through the [tunnel](/test/tunnel). ## Go Further Craft interactive UIs rendered in conversation Move files in and out of your app Let your views reach the domains they need # Apps SDK and MCP Apps Source: https://docs.skybridge.tech/resources/apps-sdk-and-mcp-apps How ChatGPT and other hosts run your views, and what Skybridge unifies MCP Apps is the open contract for rendering interactive views inside an AI client. ChatGPT also exposes the Apps SDK: an optional layer of `window.openai` APIs for its own capabilities, on top of that same contract. Skybridge targets the MCP Apps baseline and reaches the Apps SDK only where you opt into a ChatGPT feature, so one server and one set of views run on both. ## MCP Apps MCP Apps extends the [Model Context Protocol](https://modelcontextprotocol.io) with a UI contract: a tool result can carry a view, and the view talks to the host over a JSON-RPC `ui/*` bridge to read the tool's data, call other tools, sync state, and request layout. It is the portable baseline, so a view built against it runs on any host that implements the spec. The full contract is in the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx). ## Apps SDK ChatGPT implements MCP Apps and layers the Apps SDK on top: `window.openai`, a set of APIs for ChatGPT-only capabilities such as [file params](/api-reference/file-ref) and the [open-in-app URL](/api-reference/use-set-open-in-app-url). OpenAI's guidance is to use the MCP Apps `ui/*` bridge by default and reach for `window.openai` only for those extras. The [Apps SDK reference](https://developers.openai.com/apps-sdk/reference) documents the surface, and [MCP Apps in ChatGPT](https://developers.openai.com/apps-sdk/mcp-apps-in-chatgpt/) covers how the two fit together. ## In Skybridge You write one MCP server and one set of views. Skybridge detects the runtime when the view loads and routes each call to the right layer: the MCP Apps bridge everywhere, `window.openai` only for ChatGPT features. Where a capability exists on one host and not the other, it surfaces as ChatGPT versus Claude in the [compatibility overview](/api-reference/overview). When you need a raw value the cross-host hooks don't expose, [`useAppsSdkContext`](/api-reference/use-apps-sdk-context) and [`useMcpAppContext`](/api-reference/use-mcp-app-context) are the escape hatches to each layer. ### Feature Mapping Each hook, and the host API it routes to on either runtime. | Skybridge | Apps SDK (`window.openai`) | MCP Apps (`ext-apps`) | | --------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------- | | [`useToolInfo`](/api-reference/use-tool-info) | `toolInput`, `toolOutput`, `toolResponseMetadata` | `ui/notifications/tool-input`, `ui/notifications/tool-result` | | [`useViewState`](/api-reference/use-view-state) | `viewState`, `setViewState()` | `ui/update-model-context` | | [`useCallTool`](/api-reference/use-call-tool) | `callTool()` | `tools/call` | | [`useSendFollowUpMessage`](/api-reference/use-send-follow-up-message) | `sendFollowUpMessage()` | `ui/message` | | [`useOpenExternal`](/api-reference/use-open-external) | `openExternal()` | `ui/open-link` | | [`useDisplayMode`](/api-reference/use-display-mode) | `displayMode`, `requestDisplayMode()` | `ui/request-display-mode` | | [`useUser`](/api-reference/use-user) | `theme`, `locale`, `userAgent` | `ui/notifications/host-context-changed` | | [`useViewport`](/api-reference/use-viewport) | `maxHeight`, `safeArea` | `ui/notifications/host-context-changed` | | [`useRequestModal`](/api-reference/use-request-modal) | `requestModal()` | Polyfilled in-iframe | | [`useRequestClose`](/api-reference/use-request-close) | `requestClose()` | `ui/notifications/request-teardown` | | [`useRequestSize`](/api-reference/use-request-size) | Not supported | `ui/notifications/size-changed` | | [`useFiles`](/api-reference/use-files) | `uploadFile()`, `getFileDownloadUrl()` | Not supported | | [`useSetOpenInAppUrl`](/api-reference/use-set-open-in-app-url) | `setOpenInAppUrl()` | Not supported | Which hosts each API runs on Read a raw ChatGPT (Apps SDK) value Read a raw MCP Apps value # Telemetry Source: https://docs.skybridge.tech/resources/telemetry What Skybridge collects, why, and how to opt out Skybridge collects anonymous usage telemetry to understand how the framework is used and where to improve it. It comes from two places: the [CLI](/api-reference/cli) when you run `skybridge dev`, `build`, or `start`, and the server runtime when a Skybridge-powered [MCP server](/api-reference/mcp-server) handles a `tools/call`. Telemetry is optional, and the same controls turn off both. We collect only aggregate usage data. We never collect personal information, source code, file contents, tool names, arguments, or results, end-user prompts, secrets, or any other sensitive data. ## What we collect ### CLI commands Running a CLI command sends: | Data | Description | Example | | ------------ | --------------------------------------- | -------------------------- | | Command | The CLI command that ran | `dev`, `build`, `start` | | Version | The Skybridge CLI version | `1.2.3` | | Machine ID | A random UUID generated on first run | `a1b2c3d4-...` | | Session ID | A unique ID for this execution | `e5f6g7h8-...` | | Outcome | Whether the command succeeded or failed | `success`, `failure` | | Error | The error message if it failed | `Port 3000 in use` | | Platform | Your operating system | `darwin`, `linux`, `win32` | | Node version | Your Node.js version | `v24.0.0` | | Is CI | Whether it ran in a CI environment | `true`, `false` | ### Server tool calls Every `tools/call` increments one anonymous counter so we can measure aggregate production usage. The only data attached is the runtime version, as a tag: | Data | Description | Example | | ------- | ------------------------------------- | ------- | | Version | The runtime version, major.minor only | `1.2` | Unlike the CLI, this carries no machine or session ID. Development builds emit nothing. ## Why we collect it * See which commands are used and where developers spend time * Surface the errors developers hit most * Decide which platforms and Node versions to support * Measure production adoption and which versions stay in active use ## How to opt out Preferences are per-machine. On your own machine, use the CLI; on CI runners and [deployed servers](/ship), use an environment variable. ### Configuration file `~/.skybridge/config.json` stores settings per-machine: ```json theme={null} { "machineId": "a1b2c3d4-e5f6-...", "telemetry": { "enabled": false } } ``` It is created on the first CLI run with `telemetry.enabled` set to `true`. Set it to `false` to opt out. In CI (GitHub Actions, GitLab CI, Jenkins), telemetry is enabled by default and the machine ID is set to the CI provider name, for example `GitHub Actions`. ### CLI ```bash theme={null} skybridge telemetry disable # opt out skybridge telemetry enable # opt back in skybridge telemetry status # check the current setting ``` All three read and write `~/.skybridge/config.json`. ### Environment variables ```bash theme={null} # in your shell profile (.bashrc, .zshrc, ...) export SKYBRIDGE_TELEMETRY_DISABLED=1 # or the standard opt-out signal export DO_NOT_TRACK=1 ``` Environment variables take precedence over the config file. With either set, telemetry is off regardless of the config file, or of whether one exists at all. ## Debug mode To see the CLI telemetry payload without sending it: ```bash theme={null} SKYBRIDGE_TELEMETRY_DEBUG=1 skybridge dev ``` It prints the event to stderr instead of sending it. Debug mode is CLI-only: the runtime counter carries nothing beyond the version, so there is nothing to inspect. ## Data handling **CLI events** go to [PostHog](https://posthog.com/), stored on its US servers. Events are anonymized, retained in aggregate for product analytics, and cannot be linked to a user. **Tool-call events** are emitted as a [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/) counter over UDP, fire-and-forget so they never block tool execution, and received by a Skybridge-operated [Vector](https://vector.dev/) instance. Only per-version counters are stored, with no per-event record, so individual calls cannot be linked to a server, project, or user. # Deploy Source: https://docs.skybridge.tech/ship/deploy Take your app to production A deployed MCP App is a server on a public URL: hosts connect to `https://your-domain.com/mcp` and the built [views](/build/view) are served from the same origin. Two commands take it there: * [`skybridge build`](/api-reference/cli) compiles the views and the server into `dist/` * `skybridge start` runs the result with `NODE_ENV=production`: pre-built assets on `/assets`, the [MCP server](/api-reference/mcp-server) on `/mcp`, dev tooling excluded Run them on any [Node.js](https://nodejs.org/)-compatible infrastructure, or pick a platform that automates them: ## Alpic [Alpic](https://alpic.ai) is the preferred deployment target: a cloud platform built for MCP Apps, by the company behind Skybridge. Deployed apps get MCP analytics, logs, insights, and a public [playground](/test/playground). Deploying is free, and one command away from any Skybridge project: ```bash npm theme={null} npm run deploy ``` ```bash pnpm theme={null} pnpm run deploy ``` ```bash yarn theme={null} yarn deploy ``` ```bash bun theme={null} bun run deploy ``` ```bash deno theme={null} deno task deploy ``` For continuous deployment, push your app to GitHub and connect the repository from [app.alpic.ai](https://app.alpic.ai): every commit then ships automatically. Alpic also ships its own [MCP App](https://docs.alpic.ai/features/mcp-server) to manage your deployments from any MCP client: connect to `https://mcp.alpic.ai/mcp`. ## Cloudflare Workers Skybridge runs on [Cloudflare Workers](https://developers.cloudflare.com/workers/) via [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/) and the [`nodejs_compat`](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) runtime. Static assets (your built views) are served by Cloudflare's edge directly; the worker handles `/mcp` traffic and any other dynamic routes. Add a `wrangler.jsonc` at your project root: ```jsonc wrangler.jsonc theme={null} { "name": "your-skybridge-app", "main": "dist/__entry.js", "compatibility_date": "2025-09-01", "compatibility_flags": ["nodejs_compat"], "assets": { "directory": "dist/assets" }, "define": { "process.env.NODE_ENV": "\"production\"" } } ``` Each setting is load-bearing: * `compatibility_date >= 2025-09-01` and `nodejs_compat`: enable `cloudflare:node`'s `httpServerHandler`, which Skybridge uses to bridge the Express app to the Workers fetch event. * `assets.directory`: points at the [views](/build/view) built by `skybridge build`. Cloudflare serves these at the edge before requests reach your worker. * `define.process.env.NODE_ENV`: forces production mode even under `wrangler dev`. Without it, wrangler defaults to `development` locally and Skybridge's dev tooling ([Vite](https://vite.dev/), the [DevTools](/test/devtools) server) gets pulled into the worker bundle, where neither runs. Then build and deploy: ```bash npm theme={null} npm run build npx wrangler deploy ``` ```bash pnpm theme={null} pnpm build pnpm dlx wrangler deploy ``` ```bash yarn theme={null} yarn build yarn dlx wrangler deploy ``` ```bash bun theme={null} bun run build bunx wrangler deploy ``` `wrangler dev` runs your worker in workerd on your machine, the same runtime as production, not a Node.js fallback. Use `skybridge dev` for fast iteration with HMR; use `wrangler dev` to validate the production worker bundle before shipping. ## Docker Projects [scaffolded](/get-started/quickstart#scaffold-your-project) with `npx skybridge create` include a multi-stage [`Dockerfile`](https://docs.docker.com/reference/dockerfile/), so you can self-host on any container platform: ```bash theme={null} docker build -t my-app . docker run -p 3000:3000 my-app ``` The package manager is detected from the lockfile (`package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml`). Bun and Deno are not supported yet: adapt the build stage inside the Dockerfile. ## Vercel `skybridge build` emits a [Build Output API](https://vercel.com/docs/build-output-api) tree under `.vercel/output/`: a bundled serverless function, the static asset tree, and the routing config. No `vercel.json` required. ```bash npm theme={null} npm run build npx vercel deploy --prebuilt ``` ```bash pnpm theme={null} pnpm build pnpm dlx vercel deploy --prebuilt ``` ```bash yarn theme={null} yarn build yarn dlx vercel deploy --prebuilt ``` ```bash bun theme={null} bun run build bunx vercel deploy --prebuilt ``` `.vercel/` is gitignored by [Vercel CLI](https://vercel.com/docs/cli) convention, so the build artifacts stay out of your tracked working tree. ## Path Prefixes Skybridge can run under a path prefix like `https://your-domain.com/v1/mcp`, reading it from the `x-forwarded-prefix` header to serve assets under that path. This is how you run [several versions of one app](/ship/versioning) on a shared domain. # Ship Source: https://docs.skybridge.tech/ship/index Deploy and version your app A deployed MCP App is a server on a public URL where hosts connect to `/mcp` and the built [views](/build/view) are served alongside it. Take it there on any [Node.js](https://nodejs.org/)-compatible platform, then run more than one version of it on the same domain. Deploy your app to a stable, public URL Run several versions of one app on the same domain # Version Your App Source: https://docs.skybridge.tech/ship/versioning Run several versions of one app on the same domain ChatGPT requires every version of an app to live on the same (sub)domain: you can't point `v1` and `v2` at separate hosts. You separate versions by path prefix on that shared origin instead, and a reverse proxy or your cloud platform routes each prefix to its deployment. ``` https://your-domain.com/v1/mcp → version 1 https://your-domain.com/v2/mcp → version 2 ``` ## Serve versioned assets [Views](/build/view) fetch their assets by absolute URL, so a view served under `/v2` must request its bundle from `/v2/assets/...`. Skybridge reads the prefix from the `x-forwarded-prefix` header on each request and prepends it to every asset URL a view emits, alongside the origin it reads from `x-forwarded-host`. The build is identical across versions: the prefix is applied when the [server](/api-reference/mcp-server) renders the view, not baked into the bundle. One running process serves any number of versions at once, each gets view HTML pointing at its own asset path. ## Set the header The prefix comes from your infrastructure, not your code: set `x-forwarded-prefix` on each version's route in your reverse proxy or cloud environment, for example `x-forwarded-prefix: /v1`. **[Alpic](https://app.alpic.ai)** injects `x-forwarded-prefix` natively, so versioned deployments work with no extra configuration. On other providers, make sure the header is set when you [deploy](/ship/deploy). # Audit Source: https://docs.skybridge.tech/test/audit Catch spec and platform issues before submission Before a platform accepts your app, the server must conform to the MCP specs, and each platform layers its own requirements on top, which differ between [OpenAI](https://developers.openai.com/apps-sdk/app-submission-guidelines) and [Anthropic](https://claude.com/docs/connectors/building/submission). The Audit checks your app's conformance and performs end-to-end tests in real ChatGPT and Claude conversations, triggering [tools](/build/tools) and rendering [views](/build/view). ## Run the Audit The Audit needs your server publicly reachable: start the [tunnel](/test/tunnel), then click **Audit** in the [DevTools](/test/devtools) header. ```bash npm theme={null} npm run dev -- --tunnel ``` ```bash pnpm theme={null} pnpm dev --tunnel ``` ```bash yarn theme={null} yarn dev --tunnel ``` ```bash bun theme={null} bun dev --tunnel ``` ```bash deno theme={null} deno task dev --tunnel ``` The Audit is powered by [Alpic Beacon](https://docs.alpic.ai/testing/beacon). It can also be launched from [beacon.alpic.ai](https://beacon.alpic.ai) with your server's public URL, or via the [Alpic CLI](https://docs.alpic.ai/cli/tunnel). ## What It Checks * **MCP protocol**: tool and resource conformance with the [official specification](https://modelcontextprotocol.io/specification/2025-11-25). * **[MCP Apps](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)**: view resource requirements (metadata, Content Security Policy, annotations) for both ChatGPT and Claude. * **Platform requirements**: each platform's own submission guidelines. * **App behavior**: the app actually works, verified by triggering tools and rendering views in real ChatGPT and Claude conversations. Audit Illustration The report returns an overall score, a readiness status per platform, and the issues found, each with its severity (error, warning, info), the affected component, and the solution. **Improve your score** turns the report into a prompt for your coding agent to fix them all. ## Limitation OAuth-protected servers aren't supported yet. ## Go Further Call tools and render views locally, without a host Expose your local server to real hosts Chat with a real model running your app # DevTools Source: https://docs.skybridge.tech/test/devtools Call tools and render views locally, without a host Testing through a real host means exposing a public URL, registering a connector, starting a conversation, and prompting the model until it calls the right [tool](/build/tools). DevTools cuts that loop down to a file save: it runs locally, emulates the host runtime, and lets you call tools directly, no model or host involved. ## Start the Emulator DevTools ships with the dev server. In a [scaffolded](/get-started/quickstart#scaffold-your-project) project, start it from the project root: ```bash npm theme={null} npm run dev ``` ```bash pnpm theme={null} pnpm dev ``` ```bash yarn theme={null} yarn dev ``` ```bash bun theme={null} bun dev ``` ```bash deno theme={null} deno task dev ``` Two routes come up: * `http://localhost:3000/mcp`: your MCP server * `http://localhost:3000/`: the DevTools Every [tool](/build/tools) registered on the server appears in the sidebar automatically, and edits to the server or the [views](/build/view) reload the preview on save. ## Explore the Features Select a tool in the sidebar and DevTools gives you the full exchange: DevTools Illustration * **Tool controls**: generated from the tool's [input schema](/api-reference/register-tool#inputschema-outputschema). Inputs can be saved and re-run from the tool header. * **Tool output**: the raw server [response](/api-reference/register-tool#return) (`content`, `structuredContent`, `_meta`), with status, latency, and payload size. * **State inspector**: the view [state](/build/state) as a JSON tree, updated as the view mutates it. * **View preview**: the rendered [View](/build/view), with live controls to switch theme, locale, and device type; the preview updates immediately. The [display mode](/api-reference/use-display-mode#displaymode) is shown as a badge rather than a control, because only the view changes it, through [`setDisplayMode`](/api-reference/use-display-mode#setdisplaymode). * **Context warnings**: a badge on the tool output and the view state when either grows large enough to crowd the model's context, at 5,000 estimated tokens for tool output and 20,000 for view state. The same warning prints in the browser and dev server consoles. Nothing is blocked or truncated, and the full payload stays inspectable. * **Call logs**: every runtime API call the view makes (`setViewState`, `callTool`, `requestDisplayMode`) with its arguments and responses. ## Preview in a Host Conversation The panel tells you whether a view works. It does not tell you how the view looks once a host puts it in a conversation, next to a sidebar and under a thread of messages, which is where most layout surprises come from. Click **preview** in the toolbar and the panel becomes a mock conversation with your real view inside it. Everything around the view is a skeleton: sidebar, message thread, and a composer that does nothing. DevTools Preview Illustration * **Switch client** between ChatGPT and Claude. Each shell is measured against the real app, in light and dark, and the mode resets to inline when you switch. * **Toggle device** to render the shell inside a 390 x 844 phone frame, with the mobile layout that client uses. * **Display modes** follow what the selected client supports. Inline and fullscreen work everywhere, pip only on ChatGPT desktop, where it renders as a floating card. A `pip` request becomes fullscreen on ChatGPT mobile, and Claude keeps the current mode.- **Quit preview** returns to the panel with your tool state intact. While preview is active, the `ui/initialize` handshake carries that client's own style variables, container dimensions, and available display modes, so a view that reads the host's [theme tokens](/api-reference/use-mcp-app-context) themes itself the way it will in production. The preview client is not persisted: reloading DevTools brings you back to the panel. ## Drive DevTools from a Coding Agent DevTools exposes its actions as [WebMCP](https://github.com/webmachinelearning/webmcp) tools, so a coding agent that drives your browser runs them directly. The agent connects through [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp), an MCP server that discovers and calls a page's WebMCP tools. On the DevTools page it can: * **Run any registered tool** and render its view in the preview. * **Read the rendered view** by screenshotting the preview, then drive it as a real page. * **Switch the preview controls**: theme, locale, and device. The display mode is not among them, since the view owns it. ### Set It Up Register the MCP server with the WebMCP flag, forwarding the browser feature flag to the Chrome it launches. ```bash theme={null} claude mcp add chrome-devtools --scope user -- \ npx chrome-devtools-mcp@latest \ --categoryExperimentalWebmcp=true \ --chrome-arg=--enable-features=WebMCP,DevToolsWebMCPSupport ``` ```bash theme={null} codex mcp add chrome-devtools -- \ npx chrome-devtools-mcp@latest \ --categoryExperimentalWebmcp=true \ --chrome-arg=--enable-features=WebMCP,DevToolsWebMCPSupport ``` [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=chrome-devtools\&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImNocm9tZS1kZXZ0b29scy1tY3BAbGF0ZXN0IiwiLS1jYXRlZ29yeUV4cGVyaW1lbnRhbFdlYm1jcD10cnVlIiwiLS1jaHJvbWUtYXJnPS0tZW5hYmxlLWZlYXR1cmVzPVdlYk1DUCxEZXZUb29sc1dlYk1DUFN1cHBvcnQiXX0=) See the [client configuration guide](https://github.com/ChromeDevTools/chrome-devtools-mcp#mcp-client-configuration), or add this to your MCP config: ```json mcp.json theme={null} { "mcpServers": { "chrome-devtools": { "command": "npx", "args": [ "-y", "chrome-devtools-mcp@latest", "--categoryExperimentalWebmcp=true", "--chrome-arg=--enable-features=WebMCP,DevToolsWebMCPSupport" ] } } } ``` Run `npm run dev` and open the DevTools URL printed in the terminal. Open the app in DevTools with chrome-devtools-mcp, list its WebMCP tools, call one with sample input, and screenshot the preview to check the view renders. Your agent now closes its own loop: it edits a view, runs the tool, reads the rendered result, and fixes what is wrong. WebMCP is experimental and supported Chrome version 149 or newer. ## Authenticated Servers When your server [requires OAuth](/build/auth), DevTools registers itself through Dynamic Client Registration on first connect and walks the full PKCE flow as a public client. A server that needs a pre-registered client or a different grant type won't connect from DevTools. DevTools caches the authorization within the browser, so later sessions reconnect without prompting. To clear a cached registration, click **Sign out** in the header. ## Limitations DevTools emulates, and three gaps separate the emulation from production: * **No model**: you pick the [tool](/build/tools) and type the arguments yourself. Tool selection, the prompt surface (names, descriptions, schemas), and follow-up messages are never exercised. * **Mocked runtimes**: DevTools mocks the [MCP Apps](/resources/apps-sdk-and-mcp-apps#mcp-apps) runtime and also injects a `window.openai` shim for the [Apps SDK](/resources/apps-sdk-and-mcp-apps#apps-sdk), so an Apps SDK call that works here can still be host-specific in production. Host behaviour outside those mocks is not reproduced, and the conversation around a preview is a skeleton, not a working client. * **Loose [CSP](/guides/csp)**: external resources blocked by hosts in production may load locally. To test your app against a real LLM, use the [playground](/test/playground) or connect your server to ChatGPT or Claude using the [tunnel](/test/tunnel). ## Go Further Expose your local server to real hosts Chat with a real model running your app Catch spec and platform issues before submission # Evals Source: https://docs.skybridge.tech/test/evals Check what the model does with your app, in a test A tool that works is not the same as a tool the model decides to call. [DevTools](/test/devtools) and the [playground](/test/playground) let you check that by hand; evals check it in a test. `@skybridge/test` runs a real conversation against your app, in process, and hands you the tool calls the model made so you can assert on them. ## Set Up Add the test runtime (published on the `beta` dist-tag while the API settles), vitest, the AI SDK, and the provider you want to drive the conversation: ```bash npm theme={null} npm install -D @skybridge/test@beta vitest ai @ai-sdk/anthropic ``` ```bash pnpm theme={null} pnpm add -D @skybridge/test@beta vitest ai @ai-sdk/anthropic ``` ```bash yarn theme={null} yarn add -D @skybridge/test@beta vitest ai @ai-sdk/anthropic ``` ```bash bun theme={null} bun add -d @skybridge/test@beta vitest ai @ai-sdk/anthropic ``` Turn on `evals` in the Vite plugin. It registers the `expect.chat` matchers, picks up `evals/**/*.eval.ts`, raises the per-scenario timeout to two minutes, and loads your `.env` so the provider key is available: ```ts vite.config.ts highlight={7} theme={null} import { skybridge } from "@skybridge/vite-plugin"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ skybridge({ evals: {} }), react(), ], }); ``` Then add a script to run them: ```json package.json theme={null} "evals": "vitest run evals" ``` ## Write a Scenario A scenario opens a conversation against your [app](/api-reference/skybridge), sends a prompt, and asserts on the tools the model called: ```ts evals/search.eval.ts theme={null} import { anthropic } from "@ai-sdk/anthropic"; import { start } from "@skybridge/test"; import { expect, it } from "vitest"; import { app } from "../src/server.js"; it("reaches the search tool from a natural prompt", async () => { const chat = await start({ app, model: anthropic("claude-sonnet-4-5") }); await chat.send("Find me running shoes under 100 dollars"); expect.chat(chat).toHaveCalledToolWith("search-products", { query: "running shoes" }); }); ``` `start` serves the app in process: no port, no fixture. Each `send` is one turn, during which the model can call several tools before it answers. The session closes when the test finishes. This is why the app lives in `src/server.ts` and `run()` in `src/index.ts`: importing the app starts nothing. ## Assert on Tool Calls `expect.chat(chat)` is typed against your app, so tool names autocomplete and arguments are checked against each tool's input schema. | Matcher | Passes when | | ----------------------------------- | ----------------------------------------------------------------- | | `toHaveCalledToolOnce(name, args?)` | exactly one successful call to `name`, optionally matching `args` | | `toHaveCalledToolWith(name, args)` | some successful call to `name` matched `args` | | `toNeverHaveCalledTool(name)` | `name` was never attempted | | `toHaveFailedToolCall(name)` | a call to `name` was refused or threw | | `toHaveSaid(text)` | an assistant turn contains `text` (string or `RegExp`) | Every matcher supports `.not`. On failure, the message lists the calls the model actually made, arguments included. `chat.toolCalls` and `chat.assistantTurns` are also available for custom assertions. ## Test Authenticated Tools For an app behind [OAuth](/build/auth), claim an identity for the session: ```ts evals/checkout.eval.ts highlight={4} theme={null} const chat = await start({ app, model: anthropic("claude-sonnet-4-5"), authInfo: { token: "eval", clientId: "evals", scopes: ["checkout"], extra: { email: "ada@example.com" } }, }); ``` Only token verification is skipped. Per-tool schemes and scope checks run for real against those claims, and `extra` reaches your handlers as `extra.http.authInfo.extra`. Omit `authInfo` to exercise the anonymous path, auth challenges included. `setup` and `oauth` still resolve on the first request, so an app wired to an identity provider needs its `.env` to run evals, the same as `dev`. ## Tune the Run Defaults every scenario starts from go in the plugin option, and all but `timeout` can be overridden per `start`: ```ts vite.config.ts theme={null} skybridge({ evals: { temperature: 0, // default systemPrompt: "You are a shopping assistant.", maxSteps: 8, // tool-call rounds per turn, default timeout: 120_000, // per scenario, default }, }) ``` Evals are live model calls: they cost money and the wording varies from run to run. Keep the temperature at `0`, assert on the calls rather than on exact phrasing, and reach for `toHaveSaid` with a loose pattern when the answer itself matters. ## Go Further The app your scenarios import Chat with a real model running your app Write descriptions the model can act on # Test Your App Source: https://docs.skybridge.tech/test/index Pick the right tool for what you're testing Testing an MCP App means assessing how well it integrates with the host, interacts with the model, and responds to the user. The tools below put these actors in the loop, each trading setup for fidelity. | To | Use | Real model | Real host | Interactive | | --------------------------------------------------------- | ------------------------------ | --------------------- | --------------------- | --------------------- | | Iterate on [tools](/build/tools) and [views](/build/view) | [DevTools](/test/devtools) | | | | | Verify behavior in ChatGPT and Claude | [Tunnel](/test/tunnel) | | | | | Check what the model does with your app | [Playground](/test/playground) | | | | | Assert on the tools the model calls, in CI | [Evals](/test/evals) | | | | | Validate before submission | [Audit](/test/audit) | | | | Call tools and render views locally, without a host Expose your local server to real hosts Chat with a real model running your app Assert on the tool calls a real model makes Catch spec and platform issues before submission # Playground Source: https://docs.skybridge.tech/test/playground Chat with a real model running your app Putting a real model in the loop normally means registering your app in a host. The Playground skips that: a chat wired to your local server, where a real LLM uses your [tools](/build/tools) and the [views](/build/view) render inline. ## Start the Playground The Playground comes with the [tunnel](/test/tunnel). Start it with the `--tunnel` flag (or toggle **Tunnel** in the [DevTools](/test/devtools) header): ```bash npm theme={null} npm run dev -- --tunnel ``` ```bash pnpm theme={null} pnpm dev --tunnel ``` ```bash yarn theme={null} yarn dev --tunnel ``` ```bash bun theme={null} bun dev --tunnel ``` ```bash deno theme={null} deno task dev --tunnel ``` Your local server becomes publicly available, and so does the Playground: it runs on the tunnel URL suffixed with `/try` (e.g., `https://cool-marmot-fondue-420.alpic.dev/try`). Anyone with the link can open it from any browser and chat with your app. ## Exercise the Model Loop With a model in the loop, the prompt surface and the state sync become testable: Playground Illustration * **Tool selection**: assess what the model understands from your [tool names and descriptions](/api-reference/register-tool#name-title-description). * **Argument filling**: check how it fills inputs from your [schemas](/api-reference/register-tool#inputschema-outputschema). * **Narration**: see how it presents your tool [outputs](/api-reference/register-tool#return) in the conversation. * **State**: verify what it picks up from the view [state](/build/state) on its next turn. ## Limitations * Host specifics (model behavior, [state push policies](/build/state#decide-who-sees-what), view feature support) still need a real host: connect your app to ChatGPT and Claude using the [tunnel](/test/tunnel). * The playground runs on [Alpic](https://docs.alpic.ai/distribution/playground): check the [host compatibility matrix](/api-reference/overview#hooks) for which hooks it supports. ## Go Further Call tools and render views locally, without a host Expose your local server to real hosts Catch spec and platform issues before submission # Connect to Hosts Source: https://docs.skybridge.tech/test/tunnel Tunnel your local server to ChatGPT and Claude ChatGPT and Claude connect to MCP servers over the internet, and your dev server lives on localhost. The tunnel bridges the two: it exposes your local server on a public URL, so real hosts run your app while it keeps reloading on every file save. Desktop MCP clients (VSCode, Goose, Postman, MCPJam) connect directly to `http://localhost:3000/mcp`: no tunnel needed. ## Start the Tunnel Pass the `--tunnel` flag to the dev server, or toggle **Tunnel** in the [DevTools](/test/devtools) header: ```bash npm theme={null} npm run dev -- --tunnel ``` ```bash pnpm theme={null} pnpm dev --tunnel ``` ```bash yarn theme={null} yarn dev --tunnel ``` ```bash bun theme={null} bun dev --tunnel ``` ```bash deno theme={null} deno task dev --tunnel ``` Skybridge runs an [Alpic tunnel](https://docs.alpic.ai/cli/tunnel), then prints your public URL (e.g., `https://cool-marmot-fondue-420.alpic.dev/mcp`). ## Connect to Hosts ### ChatGPT 1. Go to **Profile → Apps → Create app** 2. Enter the printed `/mcp` URL 3. Click **Create**, then select your app with the **+** button in a new conversation Creating apps requires developer mode to be enabled: **Settings → Apps → Advanced settings → Developer mode**. ### Claude 1. Go to **Customize → Connectors → Add custom connector** 2. Enter your app name and the printed `/mcp` URL 3. Click **Add**, then enable the connector in a new conversation ## Iterate Without Reconnecting The Alpic tunnel URL is stable across restarts: register the app or connector once. Hot reload works through it too: view edits show up in the host on save, on desktop and mobile alike. ## Read View Logs on Mobile or Desktop Apps When your app runs in the ChatGPT or Claude desktop or mobile app, there's no console to open, so the [view](/build/view)'s logs are out of reach. Vite's [`forwardConsole`](https://vite.dev/config/server-options#server-forwardconsole) prints them in the terminal running your dev server. The [scaffold](/get-started/quickstart#scaffold-your-project) limits it to errors: add the levels you want to `logLevels` in `vite.config.ts`. ```ts vite.config.ts theme={null} server: { forwardConsole: { unhandledErrors: true, logLevels: ["error"], // [!code --] logLevels: ["error", "warn", "log"], // [!code ++] }, }, ``` A log placed in a component's render body appears twice per render: views run under React `StrictMode` in development, which invokes each render twice. ## Limitation ChatGPT and Claude cache your tool definitions at registration. When the server schema changes (a [tool](/build/tools) added or renamed, an [input schema](/api-reference/register-tool#inputschema-outputschema) edited), refresh the app or connector in the host so the model sees the new definitions. ## Go Further Call tools and render views locally, without a host Chat with a real model running your app Catch spec and platform issues before submission # Troubleshooting Source: https://docs.skybridge.tech/troubleshooting Fix the errors you're most likely to hit Common Skybridge errors, grouped by symptom, each with its cause and fix. ## Typed hooks report "Property does not exist on type" The hooks came from `skybridge/web` instead of the [generated `helpers.ts`](/api-reference/generate-helpers). The bare [hooks](/api-reference/overview#hooks) aren't typed against your [app](/api-reference/skybridge); only the generated ones carry your [tool names, inputs, and outputs](/api-reference/register-tool). ```ts theme={null} import { useCallTool } from "skybridge/web"; // [!code --] import { useCallTool } from "../helpers.js"; // [!code ++] ``` ## Tool names don't autocomplete Type inference needs the [app](/api-reference/skybridge) to export its type and `helpers.ts` to consume it, with every tool registered by method chaining so it lands in `AppType`. ```ts server.ts highlight={6-8,11} theme={null} import { Skybridge } from "skybridge/server"; export const app = new Skybridge({ name: "shop", version: "0.0.1", handler: (server) => server .registerTool(/* search-products */) .registerTool(/* create-checkout */), // chained, so AppType captures both }); export type AppType = typeof app; ``` ```ts helpers.ts highlight={2,4} theme={null} import { generateHelpers } from "skybridge/web"; import type { AppType } from "./server.js"; export const { useToolInfo, useCallTool } = generateHelpers(); ``` A tool registered on its own statement is invisible to inference. See [`generateHelpers`](/api-reference/generate-helpers) for the full setup. ## A hook throws or returns `isError` Some hooks only work on one host. Skybridge picks the runtime at load, and calling a host-specific hook on the other one fails, either throwing or returning `{ isError: true }`. Check that each hook runs on the host your view targets . ## A hook has no effect [`useDownload`](/api-reference/use-download), [`useRequestModal`](/api-reference/use-request-modal), and [`setDisplayMode`](/api-reference/use-display-mode#setdisplaymode) must be user-initiated. Hosts reject calls fired from an effect on mount, with no visible result. Trigger them from a click or menu action instead. ## Mobile or desktop app logs cannot be accessed When your app runs in the ChatGPT or Claude desktop or mobile app, the [view](/build/view) has no console you can open. Run the dev server through the [tunnel](/test/tunnel) and update `forwardConsole` in `vite.config.ts` to forward the view's logs to your terminal: see [Read View Logs on Mobile or Desktop Apps](/test/tunnel#read-view-logs-on-mobile-or-desktop-apps). ## The view loads in DevTools but breaks in a real host [DevTools](/test/devtools) runs a loose CSP, so violations don't surface locally. Production enforces the policy and blocks any origin the view didn't declare. List every domain the view reaches in the tool's CSP config, then confirm through the [tunnel](/test/tunnel) or the [Audit](/test/audit). See [Configure CSP](/guides/csp) for the field reference. ## DevTools fails to authenticate with `invalid_client` [DevTools](/test/devtools#authenticated-servers) cached an OAuth registration that the server no longer recognizes, after you switched servers or rotated credentials. Click **Sign out** in the DevTools header to clear it and force a fresh registration on the next connect. ## Go Further Let your views reach the domains they need Validate before submission