alpic-ai/skybridgeReview behavior before running
SKILL DETAIL
chatgpt-app-builder
alpic-ai/skybridge/chatgpt-app-builder
Guide developers through creating and updating ChatGPT apps. Covers the full lifecycle: brainstorming ideas against UX guidelines, bootstrapping projects, implementing tools/views, debugging, running dev servers, deploying and connecting apps to ChatGPT. Use when a user wants to create or update a ChatGPT app / MCP server for ChatGPT, or use the Skybridge framework.
Installs · 223View source
Installation
npx skills add https://github.com/alpic-ai/skybridge --skill chatgpt-app-builder
Skill files
SKILL.md
Last synced · Sep 11, 2026
evals/architecture.json›
[
{
"query": "SPEC.md:\n# Event Tickets\n\n## Value Proposition\nFind concerts and events nearby, see available seats, and purchase tickets.\n\n## Product Context\n- **Existing product**: TicketHub website\n- **API**: Event search API, seat availability API, purchase API\n- **Constraints**: Payment handled via Stripe checkout",
"expected_behavior": "View (browse_events) for search results + seat selection UI. Tool (purchase_tickets) for checkout link. View handles event browsing and seat picking, tool handles payment redirect."
},
{
"query": "SPEC.md:\n# Restaurant Finder\n\n## Value Proposition\nFind nearby restaurants and make reservations.\n\n## Product Context\n- **Existing product**: ReserveNow booking platform\n- **API**: Restaurant search API, reservation API\n- **Constraints**: Reservations require user phone number",
"expected_behavior": "View (search_restaurants) showing results with 'Reserve' button. Tool (make_reservation) for booking. Phone number collected via view input."
},
{
"query": "SPEC.md:\n# PTO Checker\n\n## Value Proposition\nLet employees check their PTO balance and request time off for a specific time range.\n\n## Product Context\n- **Existing product**: HR portal\n- **API**: PTO balance API, time-off request API\n- **Constraints**: Requests need manager approval",
"expected_behavior": "Two flows: (1) Check balance - tools only, (2) Request time off - view with calendar/date picker is acceptable. Tool (get_pto_balance) for balance. View or tool for requesting time off (both valid)."
},
{
"query": "SPEC.md:\n# Flight Tracker\n\n## Value Proposition\nTrack flight status with geolocalization and get delay alerts.\n\n## Product Context\n- **Existing product**: Airline mobile app\n- **API**: Flight status API, notifications API\n- **Constraints**: Real-time updates every 5 min",
"expected_behavior": "View (track_flight) showing flight status card with map, times, gate info. Tool (set_alert) for notifications. View displays rich visual data."
},
{
"query": "SPEC.md:\n# Movie Showtimes\n\n## Value Proposition\nFind movies playing nearby and see showtimes.\n\n## Product Context\n- **Existing product**: CinemaHub website\n- **API**: Movie listings API\n- **Constraints**: None",
"expected_behavior": "View (search_movies) showing movie cards with showtimes. One view, no tools - purely browsing, no booking."
},
{
"query": "SPEC.md:\n# Support Ticket\n\n## Value Proposition\nLet users create support tickets and check ticket status.\n\n## Product Context\n- **Existing product**: HelpDesk support portal\n- **API**: Ticket API (create, get status)\n- **Constraints**: Tickets require category and description",
"expected_behavior": "Tools only - no view needed. Tool (create_ticket) takes description and category, returns ticket ID. Tool (get_ticket_status) returns status text. Pure conversational action, no browsing or visual data."
}
]
evals/communicate-with-host.json›
[
{
"query": "I have a color picker view. When users click a color swatch, I need to remember their choice.",
"expected_behavior": "Uses useViewState with selectedColor. Shows setState on click."
},
{
"query": "My playlist builder has an 'Add Song' button in the search results and a 'Remove' button in the playlist view. Both components need access to the same playlist.",
"expected_behavior": "Suggests createStore for shared state. Shows store with addSong/removeSong actions accessed by both components."
},
{
"query": "Users browse recipes in my view. When they ask the LLM 'what ingredients do I need?', it doesn't know which recipe they're viewing.",
"expected_behavior": "Uses data-llm on the recipe container. Dynamic value with recipe name and key details."
},
{
"query": "I want a 'Compare these options' button that asks the AI to analyze the pros and cons of items the user has shortlisted.",
"expected_behavior": "Uses useSendFollowUpMessage. Button onClick calls sendMessage with comparison prompt."
}
]
evals/discover.json›
[
{
"query": "I run a small pizza chain called Tony's Pizza. We have an online ordering API that handles menu, cart, and checkout. I want customers to order through ChatGPT - like 'get me a large pepperoni' and it adds to cart, confirms toppings, and places the order.",
"expected_behavior": "PASS. Work through Phases 1-4, asking questions one at a time (never inferring). Create SPEC.md with Value Proposition, Why ChatGPT?, UI Overview, and Product Context sections. Offer to proceed to implementation."
},
{
"query": "We're a law firm with 500+ contract templates stored in Google Drive. Our attorneys waste hours finding the right template. I want an app where they describe what they need - 'NDA for a software contractor in California' - and it returns the right template with prefilled fields.",
"expected_behavior": "PASS. Work through Phases 1-4, asking questions one at a time (never inferring). Create SPEC.md with Value Proposition, Why ChatGPT?, UI Overview, and Product Context sections. Offer to proceed to implementation."
},
{
"query": "I want to build a ChatGPT app for my business",
"expected_behavior": "EXPLORE Phase 1. Too vague - missing problem, user, pain, core actions. Ask clarifying questions one at a time. Never infer or assume answers."
},
{
"query": "I have a travel blog with 200 articles about destinations. Can I make a ChatGPT app that lets people read my articles?",
"expected_behavior": "FAIL at Phase 2. Matches fail pattern: 'Long-form or static content better suited for a website'. Stop and explain the gap. Suggest pivot: destination recommendations, trip planning, or Q&A where 'just say it' beats clicking."
},
{
"query": "I want to bring our entire Salesforce CRM into ChatGPT so sales reps can do everything they do in Salesforce but through chat.",
"expected_behavior": "FAIL at Phase 1 (Core actions). Matches fail pattern: 'Full app ports instead of focused atomic actions'. Ask user to narrow to 1-3 high-frequency tasks (logging calls, checking deal status) before proceeding."
},
{
"query": "I want to build an app that shows a grid of 20 KPIs updating in real-time - revenue, active users, conversion rates, etc.",
"expected_behavior": "FAIL at Phase 2. Matches fail pattern: 'Dashboards (use tables, lists, or short paragraphs instead)'. Stop and explain the gap. Suggest pivot: query-based KPI lookup, anomaly alerts, or comparative analysis."
},
{
"query": "I want to build a ChatGPT app for my restaurant. We have a website and a booking system.",
"expected_behavior": "EXPLORE. Ask questions one at a time to complete Phase 1: What problem? For whom? How solved today? What 1-3 core actions? Never infer details about the booking system or website."
}
]
evals/fetch-and-render-data.json›
[
{
"query": "I'm building a restaurant finder. Users search by cuisine and location, browse restaurants with photos, and reserve a table. Show me the code.",
"expected_behavior": "Server: registerTool('search-restaurants') with inputSchema {cuisine, location} and view.component 'search-restaurants', returns structuredContent {restaurants[]}, _meta {images[]} for photos. registerTool('make-reservation') with inputSchema {restaurantId, partySize, date}. UI: useToolInfo<'search-restaurants'>() for input/output/responseMetadata, useCallTool('make-reservation') for Reserve button."
},
{
"query": "I want a product catalog where users browse by category and see product cards with thumbnails. They can add items to cart and then generate a checkout session.",
"expected_behavior": "Server: registerTool with view.component returns structuredContent {products[]} with id/name/price, _meta {thumbnails[]} to hide images from LLM. registerTool('create-checkout'). UI: useToolInfo with responseMetadata.thumbnails for images, useCallTool for Checkout button."
},
{
"query": "I need weather functionality. Users ask about weather in any city and can set temperature alerts. No visual UI needed.",
"expected_behavior": "Server: Two registerTool calls only (get-weather, set-alert), no view property since output is conversational text. Returns content array for LLM. No UI components needed."
},
{
"query": "Building a job board. Users search jobs by keywords and location, see listings with salary info, and apply with one click. Show loading state while searching.",
"expected_behavior": "Server: registerTool('search-jobs') with view.component 'search-jobs' and inputSchema {keywords, location}, registerTool('apply-job'). UI: useToolInfo<'search-jobs'>() with isPending for loading state, useCallTool('apply-job') with jobId on Apply button."
},
{
"query": "My product search returns 50 products that the view needs for a carousel, but sending every product directly to the model would use too much context. How should the tool divide its output?",
"expected_behavior": "Return a concise summary or immediately useful product fields in structuredContent, keep additional product details or display-only content such as the full carousel dataset in _meta, and use content only for a short status such as 'Found 50 products.' The view can selectively expose the currently relevant products through concise data-llm annotations instead of copying all 50 products into model-visible context."
}
]
evals/open-external-links.json›
[
{
"query": "In fullscreen mode there's an 'Open in App' button. I want it to link to the current article on my website.",
"expected_behavior": "Uses useSetOpenInAppUrl from skybridge/web. Calls setOpenInAppUrl with article URL in useEffect. Must have same origin as view server."
},
{
"query": "I need a button that opens Stripe checkout in a new tab.",
"expected_behavior": "Uses useOpenExternal from skybridge/web. Calls openExternal(stripeUrl) on button click. Shows confirmation dialog by default."
},
{
"query": "When users click 'Book Now' it should redirect to our booking site without any confirmation popup.",
"expected_behavior": "Uses useOpenExternal for the redirect. Whitelist domain in the tool's view.csp.redirectDomains array to skip confirmation."
}
]
evals/README.md›
# Evals
Manual evaluations for the creating-chatgpt-app skill.
## Format
Each eval file is a JSON array:
```json
[
{
"query": "User input to test",
"expected_behavior": "OUTCOME. What the response should do."
}
]
```
## Running Evals
In Claude Code:
```
Run the evals in evals/<reference>.json. For each query, spawn a Sonnet agent with the relevant skill context and compare the response against expected_behavior. Feed the whole SKILL.md and evaluated reference file without compression. Report pass/fail for each.
```evals/skill.json›
[
{
"query": "I want to build a ChatGPT app for my toy store",
"expected_behavior": "Must start with discovery workflow. Should ask Phase 1 questions (problem, user, pain, core actions). Must NOT jump to implementation, copy template, or write code."
},
{
"query": "I have a SPEC.md ready. How do I set up the project?",
"expected_behavior": "Should reference copy-template.md for bootstrapping. Should mention run-locally.md for dev server. Should NOT start discovery since SPEC.md exists."
},
{
"query": "What state management library does createStore wrap?",
"expected_behavior": "Must read state-and-context.md and answer: Zustand. createStore is a thin wrapper around Zustand."
},
{
"query": "Add WorkOS sign-in to my MCP server so tools require auth.",
"expected_behavior": "Must read oauth.md. Answer: set oauth: workosProvider({ domain, audience }) on the Skybridge config. Must NOT hand-mount requireBearerAuth / mcpAuthMetadataRouter or hand-write a JWKS verifier."
},
{
"query": "How does an authenticated server prompt the user to sign in when they haven't yet?",
"expected_behavior": "Must read oauth.md. Answer: the oauth field auto-mounts discovery + Bearer verification; unauthenticated requests to /mcp get HTTP 401 before any handler runs and the host walks the user through OAuth. Handlers do NOT return a custom auth error for this."
},
{
"query": "My server has an OAuth provider but I want one tool to stay usable without signing in.",
"expected_behavior": "Must read oauth.md. Answer: keep the oauth provider and set auth: { allowsAnonymous: true } on that tool (auth: { scopes: [...] } for gated ones); skybridge enforces it before the handler. Must NOT say mixed auth requires manual wiring with optionalBearerAuth, nor that handlers must check authInfo themselves."
},
{
"query": "I'm on the manual wiring path with optionalBearerAuth. What do I return from a gated tool handler when there's no token?",
"expected_behavior": "Must read oauth.md. Answer: return isError: true with a _meta['mcp/www_authenticate'] array holding a Bearer challenge whose resource_metadata points at /.well-known/oauth-protected-resource. Must NOT throw a plain Error, and must NOT claim the handler can send an HTTP 401."
},
{
"query": "What CSP property triggers stricter review during publishing?",
"expected_behavior": "Must read csp.md. Answer: frameDomains (for iframe embeds) triggers stricter review."
},
{
"query": "What hook do I use to trigger an LLM response from a button click?",
"expected_behavior": "Must read prompt-llm.md. Answer: useSendFollowUpMessage from skybridge/web."
},
{
"query": "How do I set the 'Open in App' button URL in fullscreen mode?",
"expected_behavior": "Must read open-external-links.md. Answer: useSetOpenInAppUrl hook. Must have same origin as view server by default."
},
{
"query": "What happens to PiP mode on mobile devices?",
"expected_behavior": "Must read ui-guidelines.md. Answer: PiP coerces to fullscreen on mobile."
},
{
"query": "How do I pass large data like images to the view without the LLM seeing it?",
"expected_behavior": "Must read fetch-and-render-data.md. Answer: Use _meta in the return object. _meta never reaches the model, only the view sees it via responseMetadata."
},
{
"query": "What's the difference between data-llm and useViewState?",
"expected_behavior": "Must read state-and-context.md. Answer: useViewState persists data and LLM can read it. data-llm is one-way context annotation for LLM to understand 'this one' references—view doesn't read it back."
}
]
evals/state-and-context.json›
[
{
"query": "Build a quiz view. Users answer multiple choice questions and see their score update. When they close and reopen the view, they should continue where they left off.",
"expected_behavior": "useViewState for score and answered questions - persists across reopens, LLM sees progress. useState would lose state on close."
},
{
"query": "I need a dashboard with a sidebar showing metric filters and a main area with charts. When users toggle metrics in the sidebar, the charts should update to show those metrics.",
"expected_behavior": "createStore for selectedMetrics - shared between sidebar and chart components. NOT separate useViewState in each component."
},
{
"query": "Property listing browser. Users scroll through apartments and hover over cards to see a quick preview popup. Nothing special needs to happen with the hover state.",
"expected_behavior": "useState for hovered item - ephemeral UI state, resets on reopen, LLM doesn't need it. NOT useViewState."
},
{
"query": "Job board view where users browse listings. When they find one they like and ask 'Am I qualified for this?' or 'What's the salary range?', the AI should know which job they mean.",
"expected_behavior": "data-llm on job card with human-readable summary (title, company, key requirements). NOT JSON.stringify. Enables LLM to understand 'this job'."
},
{
"query": "Photo gallery for selecting images. Users can pick multiple photos to create an album. Sometimes they browse without selecting anything, and might ask 'help me pick the best ones'.",
"expected_behavior": "useViewState for selections (persists, LLM sees picks). data-llm with fallback: shows selected photo names OR 'Browsing N photos, none selected' when empty."
},
{
"query": "A product search tool already returns the products to both the model and the view. The view must preserve selected products and let the user ask 'compare this one'. What belongs in view state and data-llm?",
"expected_behavior": "Keep only the persistent selection state, preferably selected product IDs, in useViewState. Use data-llm for a concise description of the currently focused product. Do not copy the complete product search output into view state or data-llm because the model already received it through structuredContent."
}
]
evals/ui-guidelines.json›
[
{
"query": "My dashboard should show a summary view first, but let users expand to see all charts.",
"expected_behavior": "View starts inline (default). Uses useDisplayMode to read displayMode and render compact/full view. Provides expand button calling setDisplayMode('fullscreen'). Provides collapse button to return to inline."
},
{
"query": "Building a language learning flashcard game. Needs to stay visible while user asks for hints in chat.",
"expected_behavior": "View starts inline. Provides button calling setDisplayMode('pip') to switch. Uses useDisplayMode. PiP mode persists and stays fixed during chat. Notes that PiP coerces to fullscreen on mobile."
},
{
"query": "Just need to show order confirmation with a tracking number. Nothing fancy.",
"expected_behavior": "Inline mode (default) is sufficient. No display mode switching needed. Simple card view renders before model response. Max 2 CTAs."
},
{
"query": "My view looks jarring when ChatGPT is in dark mode. How do I make the colors match?",
"expected_behavior": "Uses useUser hook from skybridge/web. Destructures theme ('light' | 'dark'). Shows conditional styling based on theme value."
},
{
"query": "I want to show dates and numbers formatted for the user's language. Like if they're French, dates should be DD/MM/YYYY.",
"expected_behavior": "Uses useUser hook from skybridge/web. Destructures locale. Shows using locale with Intl.DateTimeFormat or similar for formatting. Extracts language code with locale.split('-')[0] if needed."
},
{
"query": "My view has a complex grid that doesn't work well on phones. Can I show a simpler list view on mobile?",
"expected_behavior": "Uses useUser hook from skybridge/web. Destructures userAgent. Checks userAgent.device.type ('mobile' | 'tablet' | 'desktop' | 'unknown'). Shows conditional rendering or className based on device type."
},
{
"query": "I have hover tooltips that don't work on tablets. How do I handle touch devices differently?",
"expected_behavior": "Uses useUser hook from skybridge/web. Destructures userAgent. Checks userAgent.capabilities.hover (false for touch-only) or userAgent.capabilities.touch. Shows alternative interaction pattern for touch devices."
},
{
"query": "My fullscreen view content is getting cut off at the bottom on phones with notches.",
"expected_behavior": "Uses useViewport hook from skybridge/web. Destructures safeArea.insets (top, right, bottom, left). Applies insets as padding to avoid device notches, composer overlay, and navigation bars."
},
{
"query": "My view content is too tall and causes awkward scrolling in the chat. How do I constrain its height?",
"expected_behavior": "Uses useViewport hook from skybridge/web. Destructures maxHeight. Applies maxHeight to container style. Content should auto-fit without nested scrolling in inline mode."
},
{
"query": "I need a confirmation dialog before deleting items. User clicks delete, sees 'Are you sure?', then confirms or cancels.",
"expected_behavior": "Uses useRequestModal hook from skybridge/web. Destructures isOpen, open, params. Calls open() with params on delete click. Renders confirmation UI when isOpen is true. Modal is an overlay on top of current display mode, not a mode switch."
},
{
"query": "I want users to enter a custom name before saving, but I don't want to clutter the main view with a form.",
"expected_behavior": "Uses useRequestModal hook from skybridge/web. Opens modal with form content. Modal renders outside view iframe, not constrained by view boundaries. Triggered by user interaction only."
}
]
evals/update-existing.json›
[
{
"query": "SPEC.md:\n# Pet Adoption\n\n## Value Proposition\nHelp users find adoptable pets nearby and submit adoption applications.\n\n## UX Flows\nAdopt a pet:\n1. Search pets by type, breed, location\n2. Browse results, view pet profiles\n3. Submit adoption application\n\n## Tools and Views\n**View: search_pets**\n- Input: { type, breed, location }\n- Output: { pets[] }\n- Views: pet list, pet profile, application form\n\n**Tool: submit_application**\n- Input: { petId, applicantInfo }\n- Output: { applicationId, status }\n\n---\nUser: I want to add the ability to send a question to the shelter about a specific pet via email.",
"expected_behavior": "Follow architecture.md to design the addition. Should propose a tool (contact_shelter) not a view - sending an email is a backend action with no browsing/visual need. Must update SPEC.md with the new tool before implementing."
},
{
"query": "SPEC.md:\n# Plant Shop\n\n## Value Proposition\nBrowse houseplants, get care recommendations, and order plants.\n\n## Tools and Views\n**View: browse_plants**\n- Input: { category, lightLevel }\n- Output: { plants[] }\n- Views: plant grid, plant detail, cart\n\n**Tool: place_order**\n- Input: { items[], shippingAddress }\n- Output: { orderId, deliveryDate }\n\n---\nUser: I want to add a get_plant_info tool so the LLM can tell users about a specific plant's care needs.",
"expected_behavior": "REJECT per architecture.md: don't create a tool that duplicates model-relevant data the view already returns through structuredContent. The browse_plants view already returns plant data including care details to the LLM. Explain this and suggest the LLM re-invoke browse_plants instead."
},
{
"query": "SPEC.md:\n# Gym Class Booking\n\n## Value Proposition\nBrowse gym classes by schedule and book a spot.\n\n## Tools and Views\n**View: browse_classes**\n- Input: { date, classType }\n- Output: { classes[] }\n\n---\nUser: I want to add a save_favorite_class tool so users can bookmark classes they like.",
"expected_behavior": "REJECT per architecture.md: 'View UI handles its own state - cart, selections, and form inputs live in the view, not as tools.' Favorites is view state. Suggest managing favorites within the browse_classes view using useViewState instead."
},
{
"query": "SPEC.md:\n# Furniture Store\n\n## Value Proposition\nBrowse furniture catalog, see room previews, and purchase items.\n\n## Tools and Views\n**View: browse_furniture**\n- Input: { category, room }\n- Output: { items[] }\n- Views: item grid, item detail with room preview\n\n**Tool: create_checkout**\n- Input: { itemIds[] }\n- Output: { checkoutUrl }\n\n---\nUser: I want to add a load_room_preview tool that fetches the 3D room render when the user taps on an item.",
"expected_behavior": "REJECT per architecture.md: don't add a tool whose only purpose is to hydrate the view. The browse_furniture view should return the room preview data needed for its initial interaction upfront, with additional preview details or display-only content such as image URLs in _meta rather than structuredContent."
},
{
"query": "SPEC.md:\n# Vet Appointment\n\n## Value Proposition\nBook vet appointments for pets and view appointment history.\n\n## Tools and Views\n**View: find_vet**\n- Input: { location, specialty }\n- Output: { vets[], availability[] }\n\n**Tool: book_appointment**\n- Input: { vetId, petId, timeSlot }\n- Output: { appointmentId }\n\n---\nUser: I want to add the ability to cancel an appointment.",
"expected_behavior": "Follow architecture.md. Should propose a tool (cancel_appointment) - this is a different flow from finding/booking. Tool-only is appropriate since cancellation is conversational (LLM confirms details). Must update SPEC.md before implementing."
},
{
"query": "SPEC.md:\n# Bike Rental\n\n## Value Proposition\nFind available bikes nearby and rent them.\n\n## Tools and Views\n**Tool: find_bikes**\n- Input: { location }\n- Output: { bikes[] }\n\n**Tool: rent_bike**\n- Input: { bikeId, duration }\n- Output: { rentalId, unlockCode }\n\n---\nUser: I want to add a map view so users can see bike locations visually and pick one.",
"expected_behavior": "Follow architecture.md. This is evolving the existing find flow - visual/map data improves understanding and selection. Should propose converting find_bikes from a tool to a view with a map view. Must update SPEC.md before implementing."
}
]
references/architecture.md›
# Architecture Workflow
## Concepts
A **tool** is a backend action with no UI. It takes input and returns structured output. It can CRUD data and perform operations (checkout, submit, etc.).
A **view** is a tool with a UI. It renders the tool output visually. The UI is a React app that can:
- navigate multiple views (search → detail → confirmation)
- manage its own state
- call other tools to fetch data absent from the view output schema or trigger actions.
## Step 1: Identify the UX Flows
A **flow** is an end-to-end user journey that accomplishes one goal (e.g., "book a flight" = search → select → checkout).
Extract flows from the SPEC's value proposition. **Stick to the spec**: don't invent flows or infer intermediate steps.
**Example:**
Input (SPEC):
> Book flights by destination and dates, and cancel existing bookings by booking ID.
✅ Good output:
```
Book flight:
1. Search flights
2. Select flight
3. Checkout
Cancel booking:
1. Cancel booking
```
❌ Bad output:
```
Search flights:
1. Search flights
2. View results
Book flight: ← wrong: split booking into separate flow
1. Select flight
2. Enter passenger details
3. Checkout
Cancel booking:
1. List bookings ← wrong: invented step
2. Cancel booking
```
**Do not proceed to Step 2 yet**: validate with user, adjust based on feedback.
## Step 2: Does the flow need UI?
Based on the UX flow:
**YES if:**
- Browsing/comparing multiple items
- Visual data improves understanding (maps, charts, images)
- Selections are easier in a visual layout
**NO if:**
- Inputs are naturally conversational (amounts, dates, descriptions)
- Output is simple enough as text
- No visual element would meaningfully improve the experience
## Step 3: Design the API
### Best Practices
**Naming:** Both views and tools start with a verb: `search_flights`, `get_details`, `create_checkout`.
**One view per flow/intent:** Different flows can have separate views
❌ `search_flights` view + `view_flight` view (same flow → merge into one view)
✅ `search_flights` view + `manage_bookings` view (different flows)
**Don't duplicate:** The view's `structuredContent` and optional `content` are returned to the LLM for conversation, while `_meta` is delivered only to the view. The view can be re-invoked. Don't create a tool that duplicates what the view fetches.
❌ `search_flights` view + `get_flights` tool (same data → view already fetches this)
✅ unique `search_flights` view that can be re-invoked by LLM or user
**View UI handles its own state:** Cart, selections, and form inputs live in the view - not as tools.
❌ `add_to_cart` tool (cart is view state)
❌ `select_seat` tool (selection is view state)
❌ `update_quantity` tool (form input is view state)
✅ Tools are for backend operations only: `create_checkout`, `submit_order`, `make_reservation`
**Don't lazy-load:** Tool calls are expensive. Return all data needed for the view's initial interaction upfront in one tool result. Keep fields immediately useful to the model concise in `structuredContent`, and put additional details or display-only content not worth their full context cost in `_meta` rather than adding a tool whose only purpose is to hydrate the view.
❌ `search_flights` view + `get_flight_details` tool (lazy-loading details)
✅ `search_flights` view returns immediately useful flight data in `structuredContent`, and additional flight details or display-only content in `_meta`
---
For each identified flow:
### If NEEDS UI → View + Optional Tool(s)
**Example: Flight Booking**
UX Flow:
1. Search flights by dates, destination
2. Browse results, select flight
3. View flight details
4. Click checkout → redirect to payment
API:
**View: search_flights**
- Input: `{ dates, destination }`
- Output: `{ flights }` → rendered as list
- Views: search results, flight detail + passenger form
- Calls `create_checkout` tool → redirects to payment
**Tool: create_checkout**
- Input: `{ flightId, passengers[] }`
- Output: `{ checkoutUrl }` → view redirects to Stripe
### If DOES NOT NEED UI → Tool(s) Only
**Example: Manage Bookings**
UX Flow:
1. User: "Cancel my flight to Paris"
2. LLM asks for email, fetches bookings, asks clarifying questions if needed
3. LLM confirms and cancels
API:
**Tool: list_bookings**
- Input: `{ email }`
- Output: `{ booking[] }` → LLM says "You have two upcoming flights for Paris, which one do you want to cancel?"
**Tool: cancel_booking**
- Input: `{ bookingId }`
- Output: `{ booking }` → LLM summarizes: "Your booking for Paris on Jan 1 has been canceled."
## Step 4: Review
Present the final architecture to the user, adjust based on feedback.
## Step 5: Update SPEC.md
Update SPEC.md with the UX flows and API design.
**Example:**
```markdown
...
## UX Flows
Book a flight:
1. Search flights by destination and dates
2. Browse results, select flight
3. Enter passenger details
4. Checkout (redirect to Stripe)
Cancel booking:
1. Provide email
2. Select booking to cancel
## Tools and Views
**View: search_flights**
- **Input**: `{ destination, dates }`
- **Output**: `{ flights[] }`
- **Views**: results list, flight detail, passenger form
- **Behavior**: manages passenger state locally, calls `create_checkout` tool
**Tool: create_checkout**
- **Input**: `{ flightId, passengers[] }`
- **Output**: `{ checkoutUrl }`
**Tool: list_bookings**
- **Input**: `{ email }`
- **Output**: `{ bookings[] }`
**Tool: cancel_booking**
- **Input**: `{ bookingId }`
- **Output**: `{ success, booking }`
```
references/copy-template.md›
# Start From Template
Scaffold a project by setting up the Skybridge template starter. Skybridge is a TypeScript framework for building MCP servers with type-safe APIs and React views.
## Workflow
1. Ask: "Which package manager?" (npm / pnpm / yarn / bun / deno)
2. Run (do not `rm` beforehand—create handles conflicts):
```bash
{pm} create skybridge@latest {target-dir}
# deno
deno init --npm skybridge {target-dir}
```
Template flags: `--blank` (minimal, no tools), `--ecom` (ecommerce starter) or `--example <name>` (a copy of `examples/<name>` from the Skybridge repo, downloaded from GitHub). With npm, separate flags: `npm create skybridge@latest {target-dir} -- --ecom`.
Scaffolding with `--ecom`? → follow [ecommerce.md](ecommerce.md) to fill it.
3. [Start the dev server](run-locally.md). Read logs to assess readiness/health; fix any errors (TypeScript, etc.) before proceeding.
4. Start implementing your app using these core concepts:
- Server handlers and view components → [fetch-and-render-data.md](fetch-and-render-data.md)
- View state and LLM context → [state-and-context.md](state-and-context.md)
- Display modes → [ui-guidelines.md](ui-guidelines.md)
5. Delete unused views files and leftover code.
references/csp.md›
# Content Security Policy
Views run in sandboxed iframes with strict CSP. Whitelist external domains under the tool's `view.csp`:
| Property | Purpose |
|----------|---------|
| `connectDomains` | Fetch/XHR requests to external APIs |
| `resourceDomains` | Static assets (images, fonts, scripts, styles) |
| `redirectDomains` | (optional) `openExternal` destinations without safe-link modal |
| `frameDomains` | (optional) Iframe embeds — triggers stricter review |
```typescript
server.registerTool(
{
name: "search-flights",
description: "Search flights",
inputSchema: { ... },
view: {
component: "search-flights",
description: "Flight results",
csp: {
connectDomains: ["https://api.example.com"],
resourceDomains: ["https://cdn.example.com"],
frameDomains: ["https://maps.example.com"],
redirectDomains: ["https://checkout.example.com"],
},
},
},
async (input) => ({ ... })
);
```
Skybridge auto-includes the server's domain. Only add external domains.
references/deploy.md›
# Deploy
Deploy to Alpic using Alpic CLI.
## Parameters
- {path-to-project} is the path to the project directory. It is relative to the current working directory.
- When executing a command requiring `{path-to-project}`, check that you provided the correct path to the project.
## Steps
1. **Make sure the user is logged in to Alpic**
Execute `npx alpic@latest login` to login to Alpic.
2. **Deploy to Alpic**
If it's a first time deployment (absence of `.alpic/` folder in the project directory), **ask the user for the project name**.
Then, execute `npx alpic@latest deploy --yes --project-name {project-name} {path-to-project}`.
3. **Subsequent deployments**
For subsequent deployments (presence of `.alpic/` folder in the project directory), execute `npx alpic@latest deploy --yes {path-to-project}`.
4. **Setup GitHub integration**
If it's a new project, ask the user first if they want to setup git.
If yes:
- **Push to GitHub** — Commit and push code
- **Link to Alpic project** - Use `npx alpic@latest git connect --yes {path-to-project}`
Full docs: [docs.alpic.ai/quickstart](https://docs.alpic.ai/quickstart)
references/discover.md›
# Discovery Workflow
**Goal: Idea maturation, not speed.**
**Proceed in phases.** Even if the user provides details, complete each phase through conversation. Do not infer or assume but discuss and validate with user. Proceed one phase at a time—do not write SPEC.md nor proceed to implementation until all phases are validated.
---
## Phase 1: Value Proposition
1. **Problem + User**: What problem? For whom?
2. **Pain**: How solved today? What's painful?
3. **Core actions**: 1-3 focused actions (not a full app port)
---
## Phase 2: Why LLM?
1. **Conversational win**: Where does "just say it" beat clicking?
2. **LLM adds**: What does the LLM contribute? (intent, generation, reasoning)
3. **What LLM lacks**: Your data? APIs? Ability to take real actions?
**Fail patterns** (stop if any match):
- Long-form or static content better suited for a website
- Complex multi-step workflows that exceed display modes
- Dashboards (use tables, lists, or short paragraphs instead)
- Full app ports instead of focused atomic actions
- No clear answer to "why inside an AI assistant vs standalone?"
→ If fails: explain gap, suggest different interface or narrower scope.
---
## Phase 3: UI Overview
Describe the user journey through core actions:
1. **First view**: What does the user see when they start?
2. **Key interactions**: What happens at each core action?
3. **End state**: How does the experience conclude?
---
## Phase 4: Product Context
Gather: existing products, APIs/data, auth method, constraints.
---
## Phase 5: Create SPEC.md
**Only after phases 1-4 are discussed and validated with the user.** Do not write SPEC.md from the initial query alone.
Assemble from phases. Target: cwd if empty, else `{app-name}/`.
### Example
```markdown
# Pizza Ordering App
## Value Proposition
Order pizza through conversation. Target: PizzaCo customers wanting quick orders. Pain: navigating menus is slower than describing what you want.
**Core actions**: Browse menu, customize order, track delivery.
## Why LLM?
**Conversational win**: "My usual but with mushrooms" = one sentence vs. multiple screens.
**LLM adds**: Intent from natural descriptions, handles modifications.
**What LLM lacks**: Real menu and pricing data, order placement.
## UI Overview
**First view**: Popular pizzas with quick "reorder last" option.
**Browsing**: Menu with categories, filters, and customization options.
**Checkout**: Order summary, confirm, and place order.
**Tracking**: Live delivery status with ETA and map.
## Product Context
- **Existing products**: Mobile app, website
- **API**: REST at api.pizzaco.com (OAuth2, 100 req/min)
- **Auth**: PizzaCo account (OAuth2)
- **Constraints**: Payment via existing account only
```
After SPEC.md is created, confirm with user before proceeding to implementation.
references/download-file.md›
# Download file
Save content to the user's filesystem → `useDownload`
Views run in sandboxed iframes where `<a download>` and `URL.createObjectURL` are blocked. `useDownload` asks the host to perform the save; the host shows a confirmation dialog first.
> MCP Apps only. On ChatGPT (Apps SDK), use `useFiles` to work with attachments instead.
## Inline text (CSV, JSON, markdown)
```tsx
import { useDownload } from "skybridge/web";
function ExportButton({ rows }: { rows: Row[] }) {
const download = useDownload();
const handleClick = async () => {
const csv = rows.map((r) => `${r.id},${r.name}`).join("\n");
const { isError } = await download({
contents: [
{
type: "resource",
resource: {
uri: "file:///orders.csv", // filename hint
mimeType: "text/csv",
text: csv,
},
},
],
});
if (isError) {
// user cancelled or host denied — soft fail, not an exception
}
};
return <button onClick={handleClick}>Export CSV</button>;
}
```
## Inline binary
```tsx
await download({
contents: [
{
type: "resource",
resource: {
uri: "file:///chart.png",
mimeType: "image/png",
blob: base64EncodedPng,
},
},
],
});
```
## Resource link (host fetches)
```tsx
await download({
contents: [
{
type: "resource_link",
uri: "https://api.example.com/reports/q4.pdf",
name: "Q4 Report",
mimeType: "application/pdf",
},
],
});
```
## Notes
- Must be user-initiated (button/menu click). Calls from mount effects will be rejected.
- The `uri` is a filename hint; the host derives the suggested save name from the last path segment.
- `isError: true` is a soft signal (user cancelled / host denied). Transport errors throw.
- For binary content above a few hundred KB, prefer `resource_link` over inline base64.
references/ecommerce.md›
# Fill the template
The `ecom` template is a skeleton: the wiring is in place, the data is not. Two tools: `search-products` (keyword + filters in, matching products out as model-facing structured output, no view) and `render-carousel` (curated product/variant ids in, an inline carousel out). A vanilla-extract design system under `src/design/` styles everything. This reference connects the tools to a real catalog and the design system to the brand.
Not in a scaffolded `ecom` project (no `src/tools/`)? Scaffold it first with the `--ecom` flag: [copy-template.md](copy-template.md). Then return here.
## The path
Six phases, each ending at a gate. Do not start a phase before the previous gate passes. Phases 4 and 5 are independent of each other (5 needs only phase 1's brand assets); phase 6 needs both.
```
1 Gather ──► 2 Explore data ──► 3 Decide UX ──► 4 Server ──┐
└────────────────────────────────────► 5 Design ──────┴──► 6 Components ──► Final gate
```
Ground rules for every phase:
- Never invent a schema, endpoint, or credential. Everything comes from the user or the live data source.
- `grep -rn "@todo" src` is the master worklist. Resolving a marker means making the decision, applying it, and removing the `@todo` tag; whatever grep still returns is what remains to do.
- Record each confirmed decision in `SPEC.md` as you go. Later phases read it instead of re-asking.
- Delegate heavy-payload exploration (live API queries, Figma reads, devtools dumps) to subagents with fresh context windows; only distilled findings enter the main context. Phases 2 and 5 say when.
- `{pm} run build` typechecks the whole tree at any point.
Orientation map (curated; each phase details its own files):
```
src/
config.ts shared tuning (carousel size, search iterations)
types.ts Price / Spec schemas, Product / Variant / Option model
server.ts name, version, prompt, tool registration
catalog/ the data seam: search + getProducts
index.ts picks the provider (one re-export line)
mock.ts placeholder catalog, no backend
shopify.ts Shopify Storefront API
tools/
search-products.ts keyword + filters -> matching products (no view)
render-carousel.ts curated ids -> products for the carousel view
design/ vanilla-extract design system (see Phase 5)
components/ ViewFrame, ProductCard, ProductCarousel, EmptyState,
ImageGallery, VariantPicker, Chip, ExpandableText
lib/ format, cx, variants (resolve a selection to a variant)
views/carousel/ render-carousel view: carousel + product detail
detail/ fullscreen product detail (display-mode switch)
```
## Phase 1: gather
Prompt the user for the resources below, in one turn, and wait for their answer. Do not research the catalog or brand on your own yet. For whatever they say they lack, write a short complementary research plan (what you would look up, where, and why) and get the user to sign off on it before running any of it.
**Data source.** API or database, base URL or connection string, auth method, the product schema (fields and types), the filters and sort options it supports, the image and price fields, and how it paginates. Request any reference docs and save them under `docs/` (create it if missing); read them before writing code. Shopify store? `src/catalog/shopify.ts` already implements it: ask only for the store domain and a Storefront access token.
**Brand assets.** A Figma link if the brand has one (a design system, or the design of an existing web / mobile / desktop app). Otherwise the web app URL and/or screenshots. Either way, the brand's font files. No brand at all (internal tool, prototype)? Note that and move on.
**Layout inspiration.** The URL of the catalog's live site, if one exists: phase 3 studies its product cards and product page before proposing layouts. Distinct from the brand assets above, which feed token extraction.
**Access.** List the credentials the data source needs and have the user fill `.env` (the server sources it at startup). Never commit `.env` or print secrets.
**Gate 1:**
- [ ] Data source docs saved under `docs/` and read.
- [ ] Brand assets collected, or "no brand" noted.
- [ ] Live site URL collected, or noted absent.
- [ ] `.env` filled.
## Phase 2: explore the data source
Everything downstream (tool schemas, UX decisions, component choices) is built on what this phase reports, so the discovery must be extensive: docs describe the API's intent, only live responses show its actual shape. Delegate it to a subagent with a fresh context window: it runs the real queries and returns only the findings, so large API payloads never enter the main context.
The subagent's brief:
- **Run many varied queries, not one.** Several keywords across different categories, plus a misspelled and a zero-result query (what does an empty result look like?). Note which response fields are always present and which are optional or null; a field seen once is not a field you can rely on.
- **Exercise every declared capability.** Each documented sort key, each filter facet (and where do facet values come from: an enum, a dedicated endpoint, the response itself?), and the pagination model (page/offset/cursor, page size limits, total count). Confirm they actually work; drop what doesn't.
- **Fetch single products by id**, the way `render-carousel` will: confirm the lookup endpoint, whether ids from search resolve there unchanged, and the batch behavior (one call per id, or a multi-id endpoint?).
- **Dissect variants on a variant-rich product.** Which axes exist (color, size, capacity), how sibling variants and their option values are represented, whether every combination exists or the matrix is sparse, and whether price / stock / images / url vary per variant.
- **Inventory the extras.** Ratings, review counts, discounts and original prices, badges, stock and delivery info, spec attributes, brand, product page URL, number of images per product. Phase 3's layout decisions may only use fields that exist here, so record per field: type, coverage (always / sometimes / rare), and a real example value.
- **Sample the imagery.** A handful of real image URLs from different categories: aspect ratios, cutout vs in-context photography, transparent or white backgrounds, resolution, and any URL-based resizing params. Phase 6 chooses the card and gallery aspect ratio / fit by looking at these, never by guessing.
- **Note the sharp edges.** Rate limits, auth quirks, encoding oddities, slow endpoints, fields whose content is HTML rather than text.
Have it return the findings mapped onto the template's shapes (`Product`/`Variant`/`Option` in `src/types.ts`, `productSchema` in search-products), a full raw JSON sample of one representative product, plus one variant-rich and one edge-case example (out of stock, single image, or missing description).
**Gate 2:**
- [ ] The field mapping, the per-field inventory (type, coverage, example), the raw samples, and the image URLs are appended to `SPEC.md`, so nothing re-runs the exploration.
- [ ] Every capability the tools will expose (sort keys, filters, pagination, id lookup) was verified against the live source, not just the docs.
## Phase 3: decide the UX
Only now, with the confirmed field inventory in `SPEC.md`, settle how the catalog is presented; never decide these ad hoc while coding, and never propose a layout built on a field the data does not carry (no rating row if the API has no ratings, no thumbnail rail if products ship one image). Study the live site from phase 1 for how it lays out its cards and product page, then lock down five decisions; phase 6 applies them:
**Carousel:**
- D1, card fields: which fields beyond image, title, and price to show (rating, discount, badges, tags) and how (for example a tag row).
- D2, framing: plain (default), each card boxed (border + surface), or the whole strip boxed. At most one, never both.
**Product detail**:
- D3, sections: which sections appear (identity, price, description, specs, custom fields) and their order, in particular what sits before or after the CTA
- D4, thumbnail rail: whether the detail gallery shows the desktop thumbnail rail or stays swipe-only.
- D5, specs presentation: how the product facts (`specs`): a simple `label: value` list (default), a two-column table, grouped sections, or inline chips/bullets. Match the shape to the data (a few labeled specs, many grouped specs, or short label-less highlights).
Play the layout back as two ASCII wireframes and get sign-off: the carousel card (every field placed, D1/D2 visible) and the product detail (every section in order, the gallery/rail per D4, the CTA position per D3, the specs in the D5 shape). Populate them with real values from the phase 2 exploration, not invented ones. Cheap to redraw, expensive to rebuild. For example:
```
CAROUSEL CARD (one card of the strip, D1 fields placed, D2 = boxed card)
┌──────────────────┐ ┌────────────
│ ╭──────────────╮ │ │ ╭──────────
│ │ │ │ │ │
│ │ image │ │ │ │ next
│ │ [-30%] │ │ │ │ card…
│ ╰──────────────╯ │ │ ╰──────────
│ Product title on │ │ Other titl…
│ two lines max… │ │
│ €249 ~€349~ │ │ €89
│ ★ 4.6 (1 204) │ │ ★ 4.2 (87)
│ (eco) (bestsell) │ │ (new)
└──────────────────┘ └────────────
image: 1:1, contain, grey stage badge: discount, top-right
price: current + struck original tag row: max 2 chips
```
```
PRODUCT DETAIL (desktop two-column, D4 = rail on; mobile stacks, swipe gallery)
│ Ref #
┌────┬───────────────────┐ │ Brand · Product title
│ th │ │ │ ★ 4.6 (1 204 reviews)
├────┤ │ │ €249 ~€349~ (in stock)
│ th │ image │ │
├────┤ │ │ Color: [◉ black] [○ sand]
│ th │ │ │ Size: [S] [M] [L] [XL]
├────┤ │ │
│ .. │ │ │ Description text, clamped
└────┴───────────────────┘ │ with "read more"…
│
│ [ View on site ▸ ]
│
│ SPECS
│ Material recycled wool
│ Weight 340 g
│ Organic, Made in Portugal
order: identity ► rating ► price ► variants ► description ► CTA ► specs
gallery sticky on desktop; CTA follows the selected variant's url
```
**Gate 3:**
- [ ] Every field in the wireframes exists in the phase 2 inventory.
- [ ] D1-D5 decided; wireframes signed off by the user.
- [ ] `SPEC.md` records the decisions and the agreed wireframes.
## Phase 4: server
Fill `config.ts`, `types.ts`, `server.ts`, the catalog provider, and the two tools. Start the dev server and keep it running:
```bash
{pm} run dev # prints the local MCP URL (default http://localhost:3000/mcp)
```
### Shared
- [ ] `.env.template`: regenerate from the user's `.env` (same keys, values blanked).
- [ ] `src/server.ts` `name` / `version`.
- [ ] `src/server.ts` `instructions`: adapt the server-wide prompt to the catalog.
- [ ] `src/config.ts` `CAROUSEL_MAX_SIZE`: max products the carousel shows.
- [ ] `src/config.ts` `MIN_SEARCH_ITERATIONS`: minimum searches before rendering.
### Catalog (`src/catalog/`)
The app's only data seam: both tools read products through `search()` and `getProducts()`, re-exported from `src/catalog/index.ts`. Providers return domain types (`Product`, `SearchResult` from `src/types.ts`), never tool-shaped output; each tool projects its own.
- [ ] Product model (`Product`, `Variant`, `Option`, `Meta` in `src/types.ts`): match your catalog. A `Product` groups sibling `Variant`s and declares the `Option` axes. `variants` is sparse (list only the combinations that exist; a missing one encodes a contingent variation). `card` (required) is what the carousel shows for the product, surfaced both to the view and to the model (`structuredContent` is projected from it).
- [ ] Provider: point `index.ts` at `./shopify.js` for a Shopify store, or write your own module next to it with the same two exports. Delete `mock.ts` once you do.
- [ ] `search()`: query the data source with the input params and map each hit into a `Product`; set `pages` and `totalHits` if the backend reports them.
- [ ] `getProducts()`: fetch each id and map results into `Product[]` (mapping strategy below).
**Mapping ids to products (`getProducts`).** Whatever `search()` put in each `id` is what arrives here; the two must stay consistent. Preserve the `ids` order, and decide how to handle ids with no match (skip, or surface them).
- **No variants (simple products):** one `Product` per id with a single variant, `card` set to that variant, `options: []`. Nothing else to decide.
- **Variants, grouped (one card per product):** all queried variants of a product collapse into one carousel item. `card` is the union of the available variants (a "from" price, in stock if any variant is), and `card.media` holds one picture per requested variant.
- **Variants, one card per requested variant:** each requested variant is its own carousel item, `card` set to that variant.
Either way, each item's `Product` must hold ALL the variants the data source returned in `variants` (only `card` differs): the detail view reads `variants` so the client can switch to any of them.
### `search-products` (`src/tools/search-products.ts`)
Keyword/filter search returning model-facing grounding. No view, so keep the output to what the model needs to curate (ids + facts), never presentational data (images, media): render-carousel handles that.
- [ ] `description`: describe the catalog, its categories, and the search/curate loop.
- [ ] `_meta`: the invoking and invoked status messages.
- [ ] `inputSchema.keyword`: rewrite the description for the catalog's vocabulary.
- [ ] `inputSchema.sort`: set the real sort options, or remove.
- [ ] `inputSchema` filters: replace `priceRange` with one optional param per real facet.
- [ ] `productSchema` / `outputSchema`: adjust the model-facing fields (`id`, `title`, `description`, `price`, `outOfStock`, `specs`).
- [ ] `productSchema` custom fields (optional): add any typed field the model should curate on (e.g. `rating`, `discountPct`).
- [ ] `toStructuredContent()`: project each product's `card` into the model-facing grounding, dropping presentational fields (media, url).
- [ ] `narrate()` NEXT STEPS: adapt the post-search guidance to your flow (framing only; it carries no result data).
### `render-carousel` (`src/tools/render-carousel.ts`)
Takes the curated ids and returns the products for the carousel. The full product data (variants, media, options) rides in `_meta` for the view; a trimmed grounding subset goes to `structuredContent` for the model.
- [ ] `toStructuredContent()`: trim each product's `card` and `options` into the model-facing grounding, dropping presentational fields (media, url). The view reads the full products from `_meta`.
- [ ] `Meta` custom fields (optional): add any typed field the view renders (e.g. `rating`, `discountPct`).
- [ ] `description`: adapt the wording and brand voice; the behavioral rules (order, no-repeat, accuracy) apply to any catalog.
- [ ] `_meta`: the invoking and invoked status messages.
- [ ] `view.csp`: add your image host to `resourceDomains` (product images) and the product site to `redirectDomains` (the detail CTA and the host "open in app" URL). Shopify: `https://cdn.shopify.com` and your store domain. `view` itself is already wired to the `carousel` view.
**Gate 4: verify both tools with curl** against the running server. `Accept` must include `text/event-stream` or the SDK rejects the request. (`"method":"tools/list"` with no `params` lists the registered schemas.)
```bash
curl -s http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"<tool>","arguments":<args>}}'
```
- [ ] `search-products` with `{"keyword":"<real keyword>"}` plus any sort/filters you implemented: `result.structuredContent` carries real products from the live source (`result.content` is just the next-step guidance).
- [ ] `render-carousel` with `{"ids":["<real id>", ...]}` in display order: `result._meta.products` carries the full products, `result.structuredContent` the trimmed grounding.
## Phase 5: design system
Reskin `src/design/` to the brand using the phase 1 assets. This phase is only the tokens and theme; the components that consume them are phase 6.
**The layers.** A five-tier pipeline; change values, not the shape:
- `primitives.css.ts`: raw, mode-agnostic scales (space, radius, font, grey ramp, accent, status). The only place hexes and sizes are literal.
- `contract.css.ts`: semantic color slots (`surface` / `content` / `border` / `common`), all `null`. The compiler forces both themes to fill every slot.
- `themes/{light,dark}.css.ts`: map each slot to a primitive, per mode.
- `sprinkles.css.ts`: atomic style props. Leave as-is unless you change the contract.
- `recipes/typography.css.ts`: the `text` recipe. Add a component recipe (button, tag) only when a component has real variants; otherwise style with sprinkles or a `style()` block.
`components/view-frame.tsx` (`ViewFrame`) wraps every view: it activates the theme and pins the base surface, font, and content color. `src/design/tokens.ts` is the single import door: `import { text, sprinkles } from "../design/tokens"`.
**Fill:**
- [ ] `primitives.css.ts`: replace the neutral placeholders with real brand tokens (colors, type scale, spacing, radius).
- [ ] `themes/{light,dark}.css.ts`: map the slots; keep both in sync (the contract enforces it).
- [ ] `contract.css.ts`: add or remove semantic slots only if your UI needs them.
- [ ] `design/fonts.css` + `public/fonts/`: brand `@font-face` (served under `/assets/fonts/`), then point `primitives.font.family` at it.
- [ ] `components/view-frame.tsx`: theme policy (follow the host theme, or lock to light).
**Source the values** one of three ways, per what phase 1 turned up. For the first two routes, delegate the extraction to a subagent with a fresh context window, same as phase 2: Figma payloads and devtools dumps are large and noisy, and only the distilled token mapping (values + provenance) belongs in the main context.
- **Figma file (prefer whenever one exists).** The subagent uses the Figma MCP (Dev Mode). It locates the foundation frames itself (the MCP can enumerate pages, search by name, switch pages); it asks only if the structure is ambiguous. `get_variable_defs` on those frames returns the color ramps, type scale, and spacing: "primitives" / "foundations" frames for the raw palette, "semantic" / "core" frames for the light and dark mappings.
- **Existing app (no Figma).** URL: the subagent inspects the live site's computed styles and `:root` CSS custom properties (fonts, color ramps, spacing, radii) via browser devtools and captures exact values. Screenshots: derive the palette, type scale, and spacing from them, with the collected font files.
- **No brand.** Keep the neutral primitives. Set at most a font family and an accent color, confirm light and dark contrast, and move on. Do not invent a brand.
Whatever the route, record provenance in a comment (Figma `fileKey` + node ids, or the source URL) so the tokens are regenerable.
The design system must be signed off by the user, same as the wireframes in phase 3. Present the retheme for review: point them at the running Ladle (`{pm} run ladle`), alongside a short summary of the chosen tokens (font, accent, surfaces) and where each came from. Rework until they approve; every component inherits these tokens, so a wrong accent fixed now is fixed everywhere.
**Gate 5:**
- [ ] `{pm} run build` passes (the contract fails the build if either theme leaves a slot unset).
- [ ] Retheme previewed in light and dark with `{pm} run ladle`: the stories render the still-skeleton components with the new tokens against mock data.
- [ ] User signed off on the retheme.
## Phase 6: components
The view layer under `src/components/` and `src/views/`, built on the design system. Preview any component in Ladle (`{pm} run ladle`; each `*.stories.tsx` is a story); the devtools emulator shows a view against a live tool call. Verify your own work in the emulator as you go, don't just tell the user to look: drive it through the Chrome DevTools MCP, preferring its WebMCP tools (`list_webmcp_tools` / `execute_webmcp_tool`) over click/screenshot loops to call tools and switch display mode, theme, mobile, and locale; see [run-locally.md](run-locally.md). Apply D1-D4 from `SPEC.md`; use the phase 2 image URLs for every imagery choice.
### Labels (i18n)
All user-facing text is centralized in `src/i18n.ts`, shared across every component. `useLabels()` reads the host locale from `useUser()`, matches on the language subtag (`en-US` -> `en`), and falls back to English. Ships English only.
- [ ] `src/i18n.ts`: adapt the English copy to the brand voice; add a locale key per language you want to support.
### Carousel
`render-carousel`'s inline view (`src/views/carousel/`), plus `ProductCard` (presentational), `ProductCarousel` (scroll-snap track, desktop nav buttons; reports on-screen cards), and `EmptyState`. The view reads `responseMetadata.products` (the tool's `_meta`), renders one card per product, and narrates the on-screen ones via `data-llm`.
- [ ] `product-card.css.ts`: image aspect ratio, `object-fit`, and the surface behind the image, decided by looking at the phase 2 image sample: square vs portrait/landscape; `contain` for cutouts/mixed ratios vs `cover` for consistent photos; neutral grey behind transparent cutouts vs white for full-bleed photos. Use the same choice in the gallery. Also `TITLE_LINES` (title clamp).
- [ ] `product-card.tsx` (D1): extra images from `media` (e.g. hover cross-fade to `media[1]`); and any rich `Meta` field (rating, discount, badges), threaded through `ProductCardProps` and the carousel view, then rendered (stars, badge, chips).
- [ ] `product-carousel.css.ts`: tuning knobs (`gap`, `CARDS_VISIBLE`, `CARDS_VISIBLE_COMPACT`, `COMPACT_MAX_WIDTH`).
- [ ] Framing (D2): the `FRAMED` flag boxes each card (`product-card.tsx`, includes its skeleton) or the whole strip (`product-carousel.tsx`). Both `false` for plain (default); set at most one to `true`. If you enable one, tune the frame (padding, border, radius, shadow) in the matching `.css.ts`.
### Product detail
The fullscreen page opened by tapping a carousel card (`src/views/carousel/detail/`). Not a second tool or view: the carousel orchestrator (`views/carousel/index.tsx`) switches display mode to `fullscreen` and renders the detail over the carousel (hidden, not unmounted). It reads the same `_meta` products, so opening a product and switching variants needs no fetch. Selection, carousel scroll, and the full product spec ride in `useViewState`, so an open detail survives a host remount.
Building blocks in `src/components/` (each previews in Ladle): `ImageGallery`, `VariantPicker` + `Chip`, `ExpandableText`. Variant logic is pure in `src/lib/variants.ts`.
Two facts to preserve when customizing:
- **Variant selection.** `variants` is sparse, so contingency is derived, never ruled (`src/lib/variants.ts`). Availability is top-down: each option axis is constrained only by the axes declared before it, so the `options` order is semantic. Three chip states: in stock (normal), sold out (struck, still clickable; the CTA locks as "Out of stock"), nonexistent combination (hard-disabled). A pick keeps still-existing later choices and snaps the rest onto a real variant; an axis no matching variant carries hides its row. Selection is in-place (each axis is local state, no remount); on open, the tapped variant (its id equals the opened product id) is preselected, else the first in-stock one, so the buy CTA is live. Preserve this when restyling: sold-out values stay visible and selectable, only the CTA ever locks, and its label names the cause.
- **Grounding.** The detail's `data-llm` narrates only the variant on screen; the full spec (every variant) is pushed to `useViewState` by the orchestrator, so the model can answer questions beyond what is visible. The on-screen spec table is a display choice, not the model's source.
- [ ] Section order (D3): apply the agreed sections and before/after-CTA placement in `detail/index.tsx`.
- [ ] `variant-picker`: chips are the default. For a long text-only axis (many sizes), swap the chip row for a native `<select>`; keep image chips for swatch axes (color, material). Promote an axis to a cross-product switch only if the catalog models it as separate products.
- [ ] `image-gallery` (D4): `THUMBNAIL_RAIL` adds a desktop thumbnail rail (off = swipe only); style the progress bar and, if enabled, the rail.
- [ ] `detail.css.ts`: the two-column breakpoint, and whether the gallery is sticky on desktop.
- [ ] CTA: `viewOnSite` deep-links to `variant.url ?? card.url` (`useOpenExternal`), and `setOpenInAppUrl` points the host "open in app" at the same URL. Needs the product site in the view CSP (phase 4).
- [ ] Specs (D5): implement the agreed presentation.
- [ ] Custom fields (D1): render the typed `Meta` fields you added, read variant-first (`variant ?? card`), in their agreed spots (rating by the title, discount by the price, badges as chips).
## Final gate
- [ ] `grep -rn "@todo" src` returns nothing.
- [ ] `{pm} run build` passes.
- [ ] Both phase 4 curl checks still pass against live data.
- [ ] Carousel and detail previewed (emulator or Ladle) in light and dark.
- [ ] `SPEC.md` matches what was built.
references/evals.md›
# Evals
Assert on what a real model does with the app's tools → `@skybridge/test`
DevTools proves a tool works when called. An eval proves the model *calls* it, with the right arguments, from a natural prompt. Use one when a tool's `name`/`description`/schema changes, when two tools could be confused, or when the user asks how the app behaves in a real conversation. Evals are live model calls: they cost money and need an API key, so they are not unit tests. Keep them few and behavior-focused.
## Setup
1. Dev dependencies: `@skybridge/test@beta` (published on the `beta` dist-tag only), `vitest`, `ai`, and an AI SDK provider (`@ai-sdk/anthropic`, `@ai-sdk/openai`, ...).
2. `vite.config.ts`: `skybridge({ evals: {} })`. This registers the `expect.chat` matchers, picks up `evals/**/*.eval.ts`, raises the per-scenario timeout to two minutes, and loads `.env`.
3. `package.json`: `"evals": "vitest run evals"`.
4. The provider key in `.env` (`ANTHROPIC_API_KEY` for `@ai-sdk/anthropic`). If the app has `oauth`, its provider env is needed too: `setup` and `oauth` resolve on the first request.
The default `demo` template from `create skybridge` already has all of this, plus `evals/start.eval.ts` to copy from; the `blank` template has none of it.
## Scenario
```typescript
// evals/search-flights.eval.ts
import { anthropic } from "@ai-sdk/anthropic";
import { start } from "@skybridge/test";
import { expect, it } from "vitest";
import { app } from "../src/server.js";
it("searches flights from a natural prompt", async () => {
const chat = await start({ app, model: anthropic("claude-sonnet-4-5") });
await chat.send("I need to fly to Lisbon next weekend");
expect.chat(chat).toHaveCalledToolOnce("search-flights", { destination: "Lisbon" });
expect.chat(chat).toNeverHaveCalledTool("book-flight");
});
```
`start` serves the app in process: no port, no HTTP server. This only works because `src/server.ts` exports the app and `src/index.ts` runs it; never put `run()` in `server.ts`. Each `send` is one user turn, during which the model may call several tools. The session closes with the test.
## Matchers
All typed against the app's registry (`name` autocompletes, `args` is checked against the tool's `inputSchema`); all support `.not`.
| Matcher | Passes when |
|---|---|
| `toHaveCalledToolOnce(name, args?)` | exactly one *successful* call, optionally matching `args` (partial, `objectContaining`) |
| `toHaveCalledToolWith(name, args)` | some successful call matched `args` |
| `toNeverHaveCalledTool(name)` | no call was attempted |
| `toHaveFailedToolCall(name)` | a call was refused (auth) or threw |
| `toHaveSaid(text \| RegExp)` | an assistant turn contains it (string match is case- and whitespace-insensitive) |
On failure the message lists every call the model made, with arguments. `chat.toolCalls` and `chat.assistantTurns` are available for custom assertions.
## Authenticated apps
Claim an identity per session; only token verification is skipped, per-tool `auth` and scope checks run for real:
```typescript
const chat = await start({
app,
model: anthropic("claude-sonnet-4-5"),
authInfo: { token: "eval", clientId: "evals", scopes: ["orders:read"], extra: { subject: "user-1" } },
});
```
Omit `authInfo` to test the anonymous path: a gated tool then shows up as `toHaveFailedToolCall`.
## Defaults
`evals: { temperature, systemPrompt, maxSteps, timeout }` in the Vite plugin sets what every scenario starts from (temperature `0`, `maxSteps` 8, timeout 120s). `temperature`, `systemPrompt` and `maxSteps` can be overridden per `start`.
## Pitfalls
- Assert on tool calls and arguments, not on exact wording; use `toHaveSaid` with a loose pattern when the answer matters.
- A failing eval usually means the tool `description` or schema `.describe()` text is unclear to the model, not that the handler is wrong. Fix the prompt surface first.
- Do not run evals in a loop while iterating on UI; run them once a tool's contract changes.
references/fetch-and-render-data.md›
# Fetch and render data
- Fetch structured data and render with custom UI → `view`
- Fetch textual data or trigger actions → `tool`
- Tool can be triggered by user interaction within a view UI
## Project Structure
```
my-app/
├── src/
│ ├── server.ts # Skybridge app: tool + view registration in `handler`
│ ├── index.ts # runs the app
│ ├── helpers.ts # Type-safe hooks via generateHelpers
│ ├── index.css # Global CSS, must be imported in every view
│ └── views/ # React components (filename = view component name)
│ └── search-flights.tsx
└── package.json
```
**Naming convention**: View filename must match the `view.component` name using kebab-case.
`search_flights` → register with `view.component: "search-flights"` → file `views/search-flights.tsx`
## Server Handlers
Output:
- **`structuredContent`**: concise JSON the view uses and the model reads. Include only what the model should see.
- **`content`** (optional): concise narration (Markdown or plaintext) shown to the LLM.
- **`_meta`** (optional): additional details or display-only content kept out of the model's direct context, such as large payloads or image URLs. The view can selectively expose relevant parts through `data-llm`. `_meta` is delivered to the client, so never put server secrets in it.
Keep these channels complementary. Avoid copying the same payload into `content`, `structuredContent`, view state, and `data-llm`. A short status in `content` may summarize the result, while `structuredContent` carries the fields useful to the model immediately and `_meta` carries additional details or display-only content intentionally omitted from its direct context.
Annotations (set `true` when):
- **`readOnlyHint`**: only reads data, no side effects
- **`openWorldHint`**: publishes content or reaches outside user's account
- **`destructiveHint`**: deletes or overwrites user data
**Example**:
- src/server.ts
```typescript
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: "search-flights",
description: "Search for flights",
inputSchema: { destination: z.string(), dates: z.string() },
annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
view: {
component: "search-flights",
description: "Flight results",
},
},
async ({ destination, dates }) => {
const flights = await fetchFlights(destination, dates);
const structuredContent = { flights: [] };
const _meta = { images: [] }
for (const { id, departureTime, price, airlineLogo } of flights) {
structuredContent.flights.push({ id, departureTime, price });
_meta.images.push(airlineLogo);
}
return {
structuredContent,
content: [{ type: "text", text: `Found ${flights.length} flights.` }],
_meta // mind the underscore prefix
};
}
)
.registerTool(
{
name: "book-flight",
description: "Book a flight",
inputSchema: { flightId: z.string() },
annotations: { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
},
async ({ flightId }) => {
const confirmationId = await bookFlight(flightId);
return {
structuredContent: { confirmationId },
content: [{ type: "text", text: `Flight booked. Confirmation: ${confirmationId}` }],
};
}
),
});
export type AppType = typeof app;
```
- src/index.ts
```typescript
import { app } from "./server.js";
export default await app.run();
```
The `handler` runs on every request: keep it to registration and return the chain (that return carries the tool types into `AppType`). Anything expensive goes in the `setup` field, whose awaited result is the handler's second argument.
## UI Components
- generate type-safe hooks with `generateHelpers`
- `useToolInfo`: access view input/output
- `useCallTool`: trigger tool from UI
**Example**:
- src/helpers.ts
```typescript
import { generateHelpers } from "skybridge/web";
import type { AppType } from "./server.js";
export const { useToolInfo, useCallTool } = generateHelpers<AppType>();
```
- src/views/search-flights.tsx
```tsx
import "@/index.css";
import { useToolInfo, useCallTool } from "../helpers.js";
export default function SearchFlights() {
const { input, output, isPending, responseMetadata } = useToolInfo<"search-flights">();
const {
callTool, // returns void, use `data` to get the actual output
data: bookFlightOutput,
isPending: isBooking,
isSuccess: isBooked,
} = useCallTool("book-flight");
if (isPending) {
return <div>Searching flights to {input?.destination}...</div>;
}
if (isBooked) {
return <div>Booked! Confirmation: {bookFlightOutput.structuredContent.confirmationId}</div>;
}
return (
<div>
<h2>Flights to {input.destination}</h2>
<ul>
{output.flights.map((flight, i) => (
<li key={i}>
<img src={responseMetadata.images[i]} />
{flight.departureTime} - ${flight.price}
<button
onClick={() => callTool({ flightId: flight.id })}
disabled={isBooking}
>
{isBooking ? "Booking..." : "Book"}
</button>
</li>
))}
</ul>
</div>
);
}
```
references/migrate-to-v1.md›
# Migrate an existing app to Skybridge v1
Use this when upgrading from Skybridge `< 0.36.x`. The breaking changes first landed in [v0.36.0](https://github.com/alpic-ai/skybridge/releases/tag/v0.36.0) and were restated in [v1.0.0](https://github.com/alpic-ai/skybridge/releases/tag/v1.0.0); users might refer to `0.36.x+` as "v1". A greenfield app scaffolded from the current example apps already has everything below correct; these gotchas apply to migrators who carry over their own config.
Read the release notes first: they are the fastest source. Use this guide for the parts the notes get wrong or leave out. When a note is imprecise, defer to this document and verify against the installed package instead of guessing: grep its dist types (`node_modules/skybridge/dist/web/index.d.ts`, `dist/server/server.d.ts`, `dist/web/plugin/scan-views.js`), or run `npm pack skybridge@<version>` to read them before installing.
## Step 1 — Mechanical renames (the notes are to be trusted here)
These are exactly as documented. Apply them verbatim:
| v0 | v1 |
|----|----|
| `registerWidget("name", viewMeta, toolDef, handler)` | `registerTool({ name, ...toolDef, view: {...} }, handler)` |
| `mountWidget(<View />)` at end of view file | delete it — views auto-mount from `src/views/` |
| `useWidgetState` | `useViewState` |
| `widgetsDevServer` | `viewsDevServer` |
| `server/` + `web/` split | flat `src/` with `src/views/` |
| `return { ..., result }` in tool response | remove the `result` field — dropped from `CallToolResponse` (keep `isError`, see below) |
Two of these are not pure 1:1 renames:
- `useViewState`'s return type depends on whether you pass a default: with one it is `[T, …]`, without it is `[T | null, …]`. `useWidgetState` was always nullable, so the swap can change your state type.
- Only the Skybridge-specific `result` field was dropped, **not** `isError`. `isError` is part of the MCP `CallToolResult` and is still how you flag a failed call. Keep returning `isError: true` on error paths (or `throw` inside the handler — the SDK catches it and sets `isError: true` for you). If you drop `isError` from an error response, the missing field defaults to `false` and the host treats the failure as a successful result.
## Step 2 — What the notes omit
Each of these blocks a working build, and several fail with error messages that point away from the actual cause.
### 2.1 The `skybridge/web` → `skybridge/vite` rename is only the Vite plugin
On Skybridge 2.x the plugin lives in its own package: install `@skybridge/vite-plugin` and read `@skybridge/vite-plugin` wherever this guide says `skybridge/vite`.
The notes show `import { skybridge } from "skybridge/web"` → `"skybridge/vite"` and read like a blanket rename. It is not. **Only the Vite plugin moved.** Every React hook still lives in `skybridge/web`. The split is by runtime: `skybridge/vite` is build-time Node code (the plugin runs in your Vite config), while `skybridge/web` is browser code that ships to the view — so a blanket rename moves browser hooks into a module the browser bundle can't resolve.
```ts
// vite.config.ts — the ONLY thing that moves to skybridge/vite
import { skybridge } from "skybridge/vite";
// helpers.ts and views — hooks stay in skybridge/web
import { generateHelpers, useDisplayMode, useSendFollowUpMessage, useViewState } from "skybridge/web";
```
`useToolInfo` / `useCallTool` are a special case: you don't import them from `skybridge/web` at all — they come from your local `helpers.ts` via `generateHelpers<AppType>()`.
Symptom if you rename them all blindly: `Module '"skybridge/vite"' has no exported member 'generateHelpers'`.
### 2.2 `registerTool` itself changed to a 2-arg signature
The notes only describe `registerWidget` → `registerTool`. They don't say that the *existing* `registerTool` signature also changed. v0 was 3 args `(name, config, handler)`; v1 is 2 args, with the name inside the config object:
```ts
// v0
.registerTool("search", { description, inputSchema }, handler)
// v1
.registerTool({ name: "search", description, inputSchema }, handler)
```
This applies to **every** tool, including data-only ones you didn't otherwise touch. Symptom: `Expected 2 arguments, but got 3`.
### 2.3 A `vite.config.ts` is now required at the project root
The old config lived at `web/vite.config.ts`. When the two-directory layout collapses, that file must move to the **project root** and import the plugin from `skybridge/vite`:
```ts
import react from "@vitejs/plugin-react";
import path from "node:path";
import { skybridge } from "skybridge/vite";
import { defineConfig, type PluginOption } from "vite";
export default defineConfig({
plugins: [skybridge() as PluginOption, react()],
resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src") } },
});
```
`skybridge()` is the only Skybridge-required plugin; `react()` is standard for views. Keep whatever CSS plugin your v0 config used (`@tailwindcss/vite`, `@vanilla-extract/vite-plugin`, etc.) and add it back exactly as before — don't add one you weren't already using, or Vite fails to resolve the import before the build starts.
The `skybridge build` CLI loads this via Vite's config resolution from the root. Without it the build fails with `Cannot resolve entry module index.html` — an error that says nothing about the actual cause (a missing root config).
### 2.4 View names typecheck via a generated, committed `.skybridge/views.d.ts`
`view.component` is typed as `ViewName`, which is `keyof ViewNameRegistry`. `ViewNameRegistry` ships **empty**, so out of the box `ViewName` is `never` and any `component: "x"` fails. Skybridge fills it by scanning `src/views/` and generating `.skybridge/views.d.ts`, which augments the registry via declaration merging:
```ts
// .skybridge/views.d.ts (generated, commit it — it is NOT gitignored)
declare module "skybridge/server" {
interface ViewNameRegistry { "show-products": true; }
}
```
For `tsc` to see it, add it to `include` **as an explicit file path** — `["src", ".skybridge/views.d.ts"]`. Listing just `".skybridge"` silently fails to match the dotfile directory, so the augmentation never loads.
Symptom when it's missing: `Type 'string' is not assignable to type 'never'` on `view.component`, which then cascades — `output` in the view infers as `never` and every field access errors. Fix the `views.d.ts` include first and the field-access errors disappear with it — don't chase them individually, they're all downstream of `ViewName` being `never`.
Generate the file by running `skybridge dev` or `skybridge build` once after the views exist.
### 2.5 An extracted tool config needs `as ViewName`
If your `view.component` is written inline in the `registerTool({...})` call, it typechecks via contextual typing — no cast. Inline, TypeScript checks the literal against the expected `ViewName` parameter type directly, so it stays a literal; pulled into a standalone `const` with no annotation (a common refactor), it has no expected type to check against and widens to `string`, breaking against `ViewName`. So either annotate the const's type or cast the field:
```ts
const CONFIG = { name: "show-products", view: { component: "show-products" as ViewName } };
```
The view component name is independent of the tool name: it is the kebab-case file or directory in `src/views/` (`get-gas-and-power-quote`), while the tool keeps its own name (`get_gas_and_power_quote`) and the helper generics (`useToolInfo<"get_gas_and_power_quote">`) key off the tool name. Renaming the tool to match the view changes the published MCP tool identity.
### 2.6 View CSP moved to `view.csp`
In v0, CSP was nested under `_meta.ui.csp`. In v1 it is a direct field on `view`:
```ts
view: {
component: "show-products",
csp: { resourceDomains: ["https://cdn.example.com"], redirectDomains: ["https://example.com"] },
}
```
No type error if you leave it under `_meta` — the CSP just silently never applies, which surfaces later as blocked images or redirects.
### 2.7 Update tsconfig include and paths
The old `include` and `@/*` path pointed at the split layout. Repoint both at `src`:
```json
{
"include": ["src", ".skybridge/views.d.ts"],
"compilerOptions": { "paths": { "@/*": ["./src/*"] } }
}
```
If `include` still names the deleted `server/src` / `web/src`, `tsc` reports `No inputs were found` and silently typechecks nothing — a passing build that verified zero files.
### 2.8 Move view providers into the default export
Step 1 removes `mountWidget`. If `mountWidget(<Provider><View /></Provider>)` wrapped the view in a context provider (theme, host detection, store, i18n), auto-mount has no slot for it — it renders the view's default export directly. Move the provider into the default export:
```tsx
export default function MyView() {
return <HostProvider><MyViewInner /></HostProvider>;
}
```
There is no error if the wrapper is dropped: the provider never mounts and its context falls back to defaults (for example, host detection always reports the default host).
### 2.9 Views can be directories; loose `.tsx` files in `src/views/` are scanned as views
The scanner globs both `src/views/*.{tsx,jsx}` and `src/views/*/index.{tsx,jsx}`, and the view name is the file or directory basename. Two consequences:
- A multi-file view can stay a directory: `src/views/my-view/index.tsx` plus its helper components in the same folder. Siblings of `index.tsx` are not scanned as views, so flattening is not required.
- Helper components placed as loose files directly in `src/views/` are each scanned as a view and added to `ViewNameRegistry` (and can trigger the duplicate-name check). Keep helpers in the view's directory or under `src/components/`.
## Step 3 — Version strategy
The steps above land you on the v1 surface. Skybridge 2.x is the current major, so continue with the [v2.0.0 release notes](https://github.com/alpic-ai/skybridge/releases/tag/v2.0.0) before validating, and migrate against a **fixed floor first**, so a failure means "my migration is wrong," not "a later release changed something":
```json
"skybridge": "2.0.0",
"@skybridge/devtools": "2.0.0",
"@skybridge/vite-plugin": "2.0.0"
```
Get this fully working and validated (Step 4). Only then bump to the latest `2.x` and re-validate. A minor bump is non-breaking by semver but can still change generated output or defaults; re-running Step 4 catches a regression introduced by the bump rather than by your migration. Revert to `2.0.0` if the bump breaks anything, which cleanly separates migration bugs from version-drift bugs.
## Step 4 — Validate (a green build is not enough)
Using the project's configured package manager, run in order:
1. Install dependencies.
2. `tsc --noEmit` — catches the import, `registerTool`, and view-name issues above.
3. `skybridge build`.
4. `skybridge dev`, then **open the view in devtools** and confirm it renders.
A passing typecheck and build prove the app compiles, not that it works: the view can render nothing while both are green (for example, a data-flow mismatch between `structuredContent` and `_meta`). Confirm the view renders before treating the migration as done.
references/oauth.md›
# OAuth Authentication
Enable user authentication so tools can access user-specific data.
## How it works
1. Set the `oauth` field on the `Skybridge` config
2. Skybridge auto-mounts the OAuth discovery endpoints (`/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`) and Bearer JWT verification on `/mcp`
3. The host reads the metadata, walks the user through OAuth, refreshes tokens, and calls `/mcp` with `Authorization: Bearer <token>`
4. By default every tool requires sign-in: unauthenticated/invalid requests **to `/mcp`** get HTTP 401 before any tool handler runs
5. Tool handlers read user identity from `extra.http?.authInfo`
The `oauth` field guards `/mcp` and nothing else. A route you mount yourself outside `/mcp` is unauthenticated — gate it with `requireBearerAuth`, your own verifier, and the same `requiredScopes` the `oauth` config sets, or it accepts under-scoped tokens `/mcp` rejects. Note that Alpic Cloud only routes traffic to `/mcp` (custom paths work locally and self-hosted).
## Which path?
The `oauth` field covers all-or-nothing **and** mixed auth. Manual wiring is only for an IdP whose tokens the framework can't verify.
```
Does the IdP publish an OAuth discovery document with a jwks_uri?
├─ Yes
│ ├─ A branded provider fits your IdP ─────────→ Pick a provider
│ │ (WorkOS · Auth0 · Clerk · Stytch · Descope · Authplane)
│ └─ No helper for it ──────────────────────────→ customProvider
│ (either way, if some tools stay public ──────→ add per-tool `auth`)
└─ No ───────────────────────────────────────────→ Manual wiring
(no discovery doc, or opaque tokens you
verify by introspection — a JWKS alone
isn't enough: customProvider reads the
jwks_uri *out of* the discovery document)
```
- [Pick a provider](#1-pick-a-provider) · [`customProvider`](#2-any-other-idp--customprovider) · [Per-tool auth](#4-mixed-auth-per-tool-auth) · [Manual wiring](#manual-wiring)
## 1. Pick a provider
The branded providers discover the IdP's OAuth metadata and build the whole config. Pass the result straight to `oauth` (`oauth: descopeProvider(...)`); discovery runs when the app starts, not when `server.ts` is imported. Most need **Dynamic Client Registration (DCR)** enabled on the IdP side (Authplane has it natively; Descope without DCR goes through the Alpic proxy).
The table below covers what goes in the code. For the dashboard steps that produce those values, send the user to `docs/guides/auth-providers.mdx` — provider UIs change, and this file isn't the source of truth for them.
```typescript
// src/server.ts
import { Skybridge, descopeProvider } from "skybridge/server";
export const app = new Skybridge({
name: "my-app",
version: "0.0.1",
oauth: descopeProvider({
url: env.DESCOPE_MCP_SERVER_URL, // MCP Server Discovery URL (Issuer)
}),
handler: (server) => server.registerTool(/* ... */),
});
```
The provider's claims type `extra.http.authInfo.extra` in every tool handler. Keep `handler` inline in the config: an extracted handler needs a hand-written server type and loses that inference.
| Provider | Import | Required options | Notes |
|---|---|---|---|
| WorkOS AuthKit | `workosProvider` | `domain`, `audience` | `domain` = AuthKit domain; `audience` = Resource Indicator (this server's URL). |
| Auth0 | `auth0Provider` | `domain`, `audience`, `serverUrl` | `audience` = API Identifier. Runs skybridge-as-AS (`serverUrl`) and bakes `?audience=` into `/authorize`. Set `scopes` to what the app needs (e.g. `["openid","profile","email"]`) — Auth0 won't grant a DCR client its full OIDC set. |
| Clerk | `clerkProvider` | `domain` | `domain` = Frontend API URL. No `audience` (Clerk tokens carry no `aud`). Verification only works if the OAuth app issues **JWT** access tokens — opaque tokens fail. |
| Stytch | `stytchProvider` | `domain`, `audience` | `domain` = project domain; `audience` = Stytch Project ID. |
| Descope | `descopeProvider` | `url` | `url` = MCP Server Discovery URL (Issuer). `audience` defaults to the Project ID derived from the URL. DCR disabled + Alpic DCR proxy → use `customProvider` with `serverUrl` (see `examples/auth-descope-alpic`). |
| Authplane | `authplaneProvider` | `issuer`, `resource` | `resource` = this server's public URL, and it also supplies the expected `aud` (RFC 8707). Pass it exactly as Authplane advertises it — the provider throws if URL normalization would rewrite it (bare origin, uppercase host, explicit default port). |
Working servers for each: `examples/auth-workos`, `auth-auth0`, `auth-clerk`, `auth-stytch`, `auth-descope`, `auth-authplane`.
## 2. Any other IdP — `customProvider`
For an IdP without a branded helper, point `customProvider` at its issuer; it reads the OAuth discovery document (requires a `jwks_uri`):
```typescript
import { customProvider } from "skybridge/server";
oauth: customProvider({
issuer: "https://your-idp.com",
audience: "my-api", // omit only if the IdP binds no aud
scopes: ["openid", "email", "profile"],
// serverUrl: env.SERVER_URL, // skybridge-as-AS: needed when skybridge must
// sit in the auth path (e.g. Alpic DCR proxy)
}),
```
`customProvider` also accepts `baseUrl` (this server's public URL; inferred from request headers when omitted), `requiredScopes` (server-wide floor), `metadataOverrides`, and `authorizationServer` (advertise a different AS than the discovery issuer — `serverUrl` wins if both are set).
## 3. Read auth in handlers
`extra.http?.authInfo` carries the verified token. Its `extra.subject` holds the `sub` claim; all other JWT claims are spread alongside it, typed from the claims the provider documents, so no cast is needed.
```typescript
server.registerTool(
{ name: "get-orders", description: "Get user orders" },
async (_input, extra) => {
const orders = await fetchOrders(extra.http?.authInfo?.extra?.subject);
return {
structuredContent: { orders },
content: [{ type: "text", text: `Found ${orders.length} orders` }],
};
},
);
```
For a claim the provider does not ship by default, name it on the provider: `workosProvider<{ email?: string }>({ ... })`. No provider puts `email` in an access token unless a JWT template or claims action adds it.
## 4. Mixed auth: per-tool `auth`
With an `oauth` provider set, each tool declares its own requirement. Omit `auth` for the secure default (sign-in required, no specific scope).
```typescript
server
.registerTool(
{
name: "browse-catalog",
description: "Browse the public catalog",
auth: { allowsAnonymous: true }, // callable signed out; token still read when present
},
(_input, extra) => ({ ...(extra.http?.authInfo ? greet(extra.http.authInfo) : guest()) }),
)
.registerTool(
{ name: "checkout", description: "Place an order", auth: { scopes: ["checkout"] } },
handler,
);
```
Skybridge enforces this before the handler runs: each `tools/call` is checked against the calling tool's declaration: a missing token gets a 401 `invalid_token`, a missing scope a 403 `insufficient_scope` (a non-batched ChatGPT request gets the equivalent in-band `mcp/www_authenticate` challenge instead of the transport status). So a gated handler can rely on `extra.http?.authInfo` being present.
⚠️ **One anonymous tool unlocks every non-`tools/call` method.** Declaring `allowsAnonymous` anywhere switches the whole `/mcp` route to optional Bearer, and only `tools/call` is checked per tool. So `initialize`, `tools/list`, `prompts/*` and `resources/read` — **including your view resources** — become reachable with no token at all. In a mixed server, never put user-specific data in a view resource or a prompt; return it from a gated tool's response instead.
`auth` compiles down to SEP-1488 `securitySchemes` advertised on the tool descriptor: `{ scopes }` becomes `[{ type: "oauth2", scopes }]`, and `allowsAnonymous` emits **both** `[{ type: "noauth" }, { type: "oauth2" }]`. Setting `securitySchemes` by hand is the low-level escape hatch — it disables the `auth` shorthand for that tool (they're mutually exclusive) and skips no enforcement, but you own the mapping.
Requiring sign-in (`auth: { scopes }`, or `auth: {}`) throws at registration when the server has no `oauth` provider. `auth: { allowsAnonymous: true }` is accepted either way, but without a provider it's silently dropped and no `noauth` scheme is advertised.
Working server: `examples/auth-descope-mixed`.
## Manual wiring
Only needed when the framework can't verify the IdP's tokens: **no OAuth discovery document, or opaque tokens** (you verify by introspection instead of JWKS). Mixed auth does *not* require this — see [per-tool `auth`](#4-mixed-auth-per-tool-auth). The primitives are exported from `skybridge/server`.
### Write a verifier
`verifyAccessToken` resolves with `AuthInfo` for a good token, or throws `OAuthError("invalid_token", message)`. For a JWT IdP, verify against its JWKS:
```typescript
import { type AuthInfo, OAuthError } from "skybridge/server";
import * as jose from "jose";
const jwks = jose.createRemoteJWKSet(new URL("https://your-idp.com/.well-known/jwks.json"));
export async function verifyAccessToken(token: string): Promise<AuthInfo> {
try {
const { payload } = await jose.jwtVerify(token, jwks, {
issuer: "https://your-idp.com",
audience: "my-api", // omit only if the IdP binds no aud
});
return {
token,
clientId: (payload.client_id ?? payload.azp ?? "") as string,
scopes: typeof payload.scope === "string" ? payload.scope.split(" ") : [],
expiresAt: payload.exp, // required: requireBearerAuth rejects tokens with no expiry
extra: { subject: payload.sub },
};
} catch (err) {
throw new OAuthError("invalid_token", err instanceof Error ? err.message : String(err));
}
}
```
### Mount metadata + enforcement
`mcpAuthMetadataRouter` serves the well-known endpoints. `requireBearerAuth` rejects every unauthenticated request; `optionalBearerAuth` lets unauthenticated requests through, validating a token only when one is sent.
```typescript
import {
mcpAuthMetadataRouter,
optionalBearerAuth,
Skybridge,
} from "skybridge/server";
import { verifyAccessToken } from "./auth.js";
export const app = new Skybridge({ name: "my-app", version: "0.0.1", handler })
.use(
mcpAuthMetadataRouter({
oauthMetadata: {
issuer: "https://your-idp.com",
authorization_endpoint: "https://your-idp.com/authorize",
token_endpoint: "https://your-idp.com/token",
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
},
resourceServerUrl: new URL(process.env.SERVER_URL),
}),
)
.use("/mcp", optionalBearerAuth({ verifier: { verifyAccessToken } }));
```
Without an `oauth` provider the `auth` shorthand is unavailable, so declare each tool's requirement with `securitySchemes` (`{ type: "noauth" }` for public, `{ type: "oauth2", scopes? }` for gated):
```typescript
server.registerTool(
{ name: "public-search", description: "...", securitySchemes: [{ type: "noauth" }] },
handler,
);
server.registerTool(
{ name: "get-orders", description: "...", securitySchemes: [{ type: "oauth2", scopes: ["orders:read"] }] },
async (_input, extra) => {
// No `oauth` provider means no framework enforcement: securitySchemes is
// advertised to the host only, and optionalBearerAuth lets token-less
// requests through. A gated handler MUST do both checks itself.
if (!extra.http?.authInfo) return signInChallenge(["orders:read"]);
if (!extra.http.authInfo.scopes.includes("orders:read")) {
return insufficientScope(["orders:read"]);
}
// ...
},
);
```
Declaring `scopes` in `securitySchemes` enforces nothing on its own — the framework only acts on it when an `oauth` provider is set. `optionalBearerAuth` verifies the token's signature, not what it's allowed to do, so without the `scopes.includes` check every signed-in user passes a scope-gated tool.
### Reject from inside a handler
Don't `throw` on missing auth. The handler runs after the transport, so it can't send a 401, and a thrown error reaches the host as an opaque tool failure — it never triggers the sign-in flow. Return the in-band challenge instead: `isError` plus a `mcp/www_authenticate` header array in `_meta`, pointing at your protected-resource metadata. This is the shape the `oauth` field emits for ChatGPT, and what ChatGPT acts on.
```typescript
const challenge = (
error: "invalid_token" | "insufficient_scope",
text: string,
scopes: string[],
) => ({
isError: true,
content: [{ type: "text" as const, text }],
_meta: {
"mcp/www_authenticate": [
`Bearer error="${error}", error_description="${text}", scope="${scopes.join(" ")}", ` +
`resource_metadata="${process.env.SERVER_URL}/.well-known/oauth-protected-resource"`,
],
},
});
const signInChallenge = (scopes: string[]) =>
challenge("invalid_token", "Sign in to use this tool.", scopes);
const insufficientScope = (scopes: string[]) =>
challenge("insufficient_scope", "Missing required scope for this tool.", scopes);
```
For an all-or-nothing manual server, swap `optionalBearerAuth` for `requireBearerAuth` and drop the per-tool `securitySchemes` — then `authInfo` is guaranteed in every handler and no challenge is needed.
references/open-external-links.md›
# Open external links
- "Open in App" button URL → `useSetOpenInAppUrl`
- External redirect → `useOpenExternal`
## "Open in App" button
Top right corner in fullscreen mode. Set programmatically. If the origin matches the view server URL, ChatGPT navigates to the full href (any path). If the origin differs, ChatGPT falls back to the view server URL.
**Example**:
```tsx
import { useSetOpenInAppUrl } from "skybridge/web";
import { useEffect } from "react";
function ProductDetail({ productId }: { productId: string }) {
const setOpenInAppUrl = useSetOpenInAppUrl();
useEffect(() => {
setOpenInAppUrl(`https://example.com/products/${productId}`).catch(console.error);
}, [productId]);
return <div>{/* Product details */}</div>;
}
```
## External redirect
**Example**:
```tsx
import { useOpenExternal } from "skybridge/web";
function ExternalLink() {
const openExternal = useOpenExternal();
return (
<button onClick={() => openExternal("https://example.com")}>
Visit Website
</button>
);
}
```
You can control return-path behavior with an optional second argument (ChatGPT only):
```tsx
openExternal("https://example.com", { redirectUrl: false });
```
Use `redirectUrl: false` to skip automatic `?redirectUrl=...` appending.
Shows confirmation dialog unless domain is whitelisted:
```typescript
// src/server.ts
server.registerTool(
{
name: "search-flights",
description: "Search for flights",
inputSchema: { destination: z.string(), dates: z.string() },
view: {
component: "search-flights",
description: "Flight results",
csp: {
redirectDomains: ["https://airline.example.com"],
},
},
},
async ({ destination, dates }) => { /* ... */ }
);
```
references/prompt-llm.md›
# Prompt model
Trigger an LLM completion from user interaction with `useSendFollowUpMessage`.
**Example:**
```tsx
import { useSendFollowUpMessage } from "skybridge/web";
export function FindBestFlightButton() {
const sendMessage = useSendFollowUpMessage();
return (
<button onClick={() => sendMessage({
prompt: "Find the best flight option, based on user preferences and agenda."
})}>
Find Best Flight
</button>
);
}
```
references/publish.md›
# Publish to Directories
## 1. Audit Annotations
**Common cause of rejection.** Ensure all tools and views have correct annotations. See [fetch-and-render-data.md](fetch-and-render-data.md).
## 2. Audit CSP
Ensure all external domains are declared in the tool's `view.csp`. See [csp.md](csp.md).
## 3. Submit
### ChatGPT
Guide user to submit the app at [platform.openai.com](https://platform.openai.com) → Apps.
OpenAI verifies app ownership via `/.well-known/openai-apps-challenge`. Guide user to Alpic **Distribution** tab → **OpenAI Apps Verification Token** → paste the token from OpenAI.
### Claude
Guide user to submit the app on the [Anthropic Connectors Directory FAQ](https://support.claude.com/en/articles/11596036-anthropic-connectors-directory-faq).
references/run-locally.md›
# Running Locally Workflow
## 1. Start Dev Server
Install dependencies and start the dev server in the background:
```bash
{pm} install && {pm} run dev
```
For Deno projects, use `deno task dev` instead.
When started, output the local MCP server and devtools URL.
Hot reload enabled (nodemon for server, HMR for views).
## 2. Test in DevTools via Chrome DevTools MCP (Optional)
The devtools page renders views locally, and it exposes its actions as [WebMCP](https://github.com/webmachinelearning/webmcp) tools, so you can drive it directly instead of clicking around: run any registered tool and see its view render in the preview, read the rendered result, and switch the preview controls. This closes the loop on your own work (edit a view, run the tool, read the result, fix what is wrong) and is much faster than click/fill/screenshot interactions.
### Setup
The agent reaches the page's WebMCP tools through [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp), registered with the WebMCP category flag and the Chrome feature flags forwarded to the browser it launches. When configured, the `list_webmcp_tools` and `execute_webmcp_tool` tools are available; if they are absent, offer the user the registration command (WebMCP is experimental and needs Chrome 149+):
```bash
claude mcp add chrome-devtools --scope user -- \
npx chrome-devtools-mcp@latest \
--categoryExperimentalWebmcp=true \
--chrome-arg=--enable-features=WebMCP,DevToolsWebMCPSupport
```
(Codex: `codex mcp add chrome-devtools -- npx ...` with the same args. Other clients: see the [client configuration guide](https://github.com/ChromeDevTools/chrome-devtools-mcp#mcp-client-configuration).)
### Workflow
1. `navigate_page` to the devtools URL output by the dev server
2. `list_webmcp_tools` to discover the page's tools:
- one tool per app tool: executes it on the local MCP server, returns its result, and renders its view in the preview pane
- `devtools_set_view_options`: sets any subset of `darkTheme` (boolean), `mobileDevice` (boolean), `locale` (BCP 47 code); the display mode is driven by the view itself
3. `execute_webmcp_tool` with `toolName` and JSON-stringified `input`
Interactions inside the rendered view itself are not WebMCP tools: use regular DOM understanding and interactions there. Use `take_screenshot` only to visually verify rendering, and screenshot the preview iframe (accessible name `html-preview` in the page snapshot) rather than the full page.
No WebMCP (older Chrome, or the user declines the setup)? Fall back to driving the devtools page with regular chrome-devtools-mcp interactions (snapshot, click, fill) or playwright if available.
## 3. Connect to AI Assistants (Optional)
Ask user if they want to test in ChatGPT/Claude or just use local devtools.
If yes, expose the local server via Alpic tunnel:
```bash
alpic tunnel --port 3000
```
Extract the forwarding URL from Alpic tunnel output (e.g., `https://cool-marmot-fondue-420.alpic.dev`).
### Connect to ChatGPT
Provide the user with these instructions to create the app in ChatGPT:
1. Go to [Apps Settings](https://chatgpt.com/apps#settings/Connectors) → Create App
2. Enter a name and description for the app
3. Paste this URL: `{tunnel-url}/mcp`
4. Set the appropriate Authentication scheme. In doubt, pick "No Authentication"
5. Click Create
6. Test by typing `@{app-name}` in a ChatGPT chat
**Troubleshooting:**
- 'Create App' button missing: ask user to enable Developer mode in Settings → Apps → Advanced Settings
- 'Create App' button not working: confirm they have ChatGPT Plus, Pro, Business, or Enterprise/Edu plan
### Connect to Claude
Provide the user with these instructions to create the app in Claude:
1. Go to [Connector Settings](https://claude.ai/settings/connectors) → Add Custom Connector
2. Enter a name and URL: `{tunnel-url}/mcp`
3. Click Create
4. In Claude chat, click the `+` button and select `@{app-name}`
**Troubleshooting:**
- 'Add Custom Connector' button missing: confirm they have a Claude paid planreferences/state-and-context.md›
# Manage View State and LLM Context
- View state (`useViewState`/`createStore`) persists and is visible to LLM as structured data.
- `data-llm` gives LLM context for referential language ("this one").
- React `useState` is ephemeral and invisible to LLM.
**Decision guide:**
| Need | Use |
|------|-----|
| Persist data, single component | `useViewState` |
| Persist data, shared across components, complex mutations | `createStore` |
| Help LLM understand "this one" | `data-llm` |
| Ephemeral UI only (hover, animation) | `useState` |
## useViewState
Single component, simple access patterns.
```tsx
function SeatPicker({ seats }) {
const [{ selectedSeat }, setState] = useViewState({ selectedSeat: null });
return (
<div className="seat-grid">
{seats.map(seat => (
<button
key={seat.id}
onClick={() => setState((prev) => ({ ...prev, selectedSeat: seat.id }))}
className={selectedSeat === seat.id ? "selected" : ""}
>
{seat.id}
</button>
))}
</div>
);
}
```
**Why useViewState:** Single component reads `selectedSeat` to highlight button. View or LLM reads when booking.
## createStore
Shared across components, complex mutations. `createStore` is a thin wrapper around Zustand.
```tsx
import { createStore } from "skybridge/web";
const useCartStore = createStore<CartState>((set) => ({
cart: [],
add: (item) => set((s) => ({ cart: [...s.cart, item] })),
remove: (id) => set((s) => ({ cart: s.cart.filter(i => i.id !== id) })),
}));
// ProductCard.tsx
function ProductCard({ product }) {
const add = useCartStore((s) => s.add);
return <button onClick={() => add(product)}>Add to Cart</button>;
}
// CartSummary.tsx
function CartSummary() {
const cart = useCartStore((s) => s.cart);
return <span>{cart.length} items</span>;
}
```
**Why createStore:** Cart accessed by multiple components. View or LLM reads items at checkout.
## data-llm
Tell the LLM what user is viewing/doing. One-way—view doesn't read it back. These are annotations—don't put complex objects here.
```tsx
function ProductDetail({ product }) {
return (
<div data-llm={`Viewing: ${product.name}, $${product.price}, ${product.inStock ? "in stock" : "out of stock"}`}>
<h1>{product.name}</h1>
<p>${product.price}</p>
</div>
);
}
```
**Why data-llm:** When user asks "Is this one good?" or "Add this to cart", LLM knows what "this" refers to.
## Common mistakes
```tsx
// DON'T: useState is not persisted, LLM can't see it
const [selected, setSelected] = useState(null);
// DO: useViewState persists and LLM sees it
const [{ selected }, setState] = useViewState({ selected: null });
```
```tsx
// DON'T: Complex object in data-llm
<div data-llm={JSON.stringify(cart)}>
// DO: Human-readable summary
<div data-llm={`Cart: ${cart.length} items, $${total}`}>
```
## Avoid duplicated model context
Model-visible context accumulates across tool `content`, `structuredContent`, persisted view state, and `data-llm`. Give each channel a distinct role instead of copying the same payload between them:
- Keep tool `content` to a short status or summary.
- Put model-relevant result fields in `structuredContent`.
- Persist only UI state that must survive and help the conversation; don't copy the complete tool output into view state.
- Use `data-llm` for a concise description of the user's current focus or action, not for data already available to the model.
`_meta` is different: it is available to the view but not the model, so it can carry additional details or display-only content intentionally omitted from direct model context.
## Combined example
Todo list. User checks off tasks, asks "what should I prioritize?"
```tsx
function TaskList() {
// PERSIST: All tasks with completed status
const [{ tasks }, setState] = useViewState({
tasks: [
{ id: 1, title: "Buy groceries", completed: false },
{ id: 2, title: "Call mom", completed: true },
]
});
// EPHEMERAL: Task user is viewing — reset on reopen
const [viewing, setViewing] = useState(null);
return (
// CONTEXT: What user is looking at — LLM answers "how should I handle this task?"
<div data-llm={viewing
? `Viewing: "${viewing.title}"`
: `${tasks.filter(t => !t.completed).length} tasks remaining`
}>
{tasks.map(t => (
<Task
key={t.id}
task={t}
onView={() => setViewing(t)}
onToggle={() => setState((prev) => ({
...prev,
tasks: prev.tasks.map(task =>
task.id === t.id ? { ...task, completed: !task.completed } : task
)
}))}
/>
))}
</div>
);
}
```
**Why each?**
| What | API | Why |
|------|-----|-----|
| `tasks` | `useViewState` | Persists. Tasks and progress survive reopen. |
| `viewing` | `useState` | Ephemeral. Current focus resets on reopen. |
| `"Viewing: Buy groceries"` | `data-llm` | LLM context. Understands "this task" in conversation. |
references/ui-guidelines.md›
# UI Guidelines
## Contents
- [Display Modes](#display-modes) — inline, fullscreen, PiP, switching
- [Modal](#modal) — overlay on top of any display mode
- [Adapting to Host](#adapting-to-host) — layout constraints, theme
- [Adapting to User](#adapting-to-user) — device, locale
## Display Modes
Views render **inline by default**. Add fullscreen and/or PiP when the use case benefits from it—implement triggers (button, gesture) to let users switch.
### Inline (default)
View appears embedded in conversation above the model response.
**Use for:** Single result display, quick actions, browsing items.
**Constraints:**
- Max 2 CTAs (one primary, one secondary)
- No `overflow: scroll/auto`—content must fit within available space
- No tabs or deep navigation
**Patterns:**
- **Card** — Single-purpose view (order confirmation, weather, status)
- **Carousel** — 3-8 browsable items with image + title + max 3 lines metadata
### Fullscreen
Immersive experience for complex tasks. Host composer remains overlaid at bottom.
**Use for:** Multi-step workflows, rich editing, explorable content, detailed comparisons.
**Constraints:**
- Composer overlay always visible at bottom
- User can still chat while in fullscreen
### Picture-in-Picture (PiP)
Persistent floating window that stays visible during conversation.
**Use for:** Live sessions (timers, streams), games, real-time status.
**Constraints:**
- Must update/respond to user interaction—don't use for static content
- Minimal controls—this is a glanceable surface
- On mobile, PiP coerces to fullscreen
### Switching Modes
Use `useDisplayMode` to read current mode and request changes.
**Constraints:**
- User-triggered only—never switch programmatically
- Host may reject the request
```tsx
import { useDisplayMode } from "skybridge/web";
function ExpandableView() {
const [displayMode, setDisplayMode] = useDisplayMode();
const isFullscreen = displayMode === "fullscreen";
if (isFullscreen) {
return (
<div className="fullscreen-view">
{/* Expanded layout */}
<button onClick={() => setDisplayMode("inline")}>Collapse</button>
</div>
);
}
return (
<div className="inline-view">
{/* Compact layout */}
<button onClick={() => setDisplayMode("fullscreen")}>Expand</button>
</div>
);
}
```
## Modal
Overlay rendered outside the view iframe, on top of the current display mode.
**Use for:** Confirmations, additional input before an action.
**Constraints:**
- Triggered by user interaction only
- Host injects close controls
```tsx
import { useRequestModal } from "skybridge/web";
function SettingsView() {
const { isOpen, open, params } = useRequestModal();
if (isOpen) {
return (
<div className="modal">
<h2>Are you sure?</h2>
<p>This will delete item {params.itemId}</p>
<button onClick={() => console.log("Confirmed")}>Yes, Delete</button>
<button onClick={() => console.log("Cancelled")}>Cancel</button>
</div>
);
}
return (
<button onClick={() => open({ title: "Confirm", params: { itemId: "123" } })}>
Delete Item
</button>
);
}
```
## Adapting to Host
Use `useViewport` to read host environment constraints. It re-renders on every resize, so keep it in the components that size themselves.
### Layout Constraints
- `maxHeight`: Maximum height available for the view in pixels
- `safeArea.insets`: Padding to avoid device notches, composer overlay, and navigation bars
```tsx
import { useViewport } from "skybridge/web";
function Container({ children }) {
const { maxHeight, safeArea } = useViewport();
const { top, right, bottom, left } = safeArea.insets;
return (
<div style={{ maxHeight, padding: `${top}px ${right}px ${bottom}px ${left}px` }}>
{children}
</div>
);
}
```
### Theme
Match the host color scheme using `theme` from `useUser`.
```tsx
import { useUser } from "skybridge/web";
function Container({ children }) {
const { theme } = useUser();
const isDark = theme === "dark";
return <div className={isDark ? "bg-surface-dark" : "bg-surface-light"}>{children}</div>;
}
```
## Adapting to User
Use `useUser` to read user context.
### Device
- `device.type`: `"mobile" | "tablet" | "desktop" | "unknown"`
- `capabilities.hover`: `true` if device supports hover (mouse)
- `capabilities.touch`: `true` if device supports touch
```tsx
import { useUser } from "skybridge/web";
function ProductCard({ product }) {
const { userAgent } = useUser();
const { device, capabilities } = userAgent;
return (
<div className={capabilities.hover ? "hover:shadow-lg" : ""}>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
{device.type === "mobile" && <button>Add to Cart</button>}
{capabilities.touch && <p className="hint">Swipe for more</p>}
</div>
);
}
```
### Locale
Use `locale` from `useUser` to adapt content to user's language.
```tsx
import { useUser } from "skybridge/web";
function LocalizedGreeting() {
const { locale } = useUser();
const greetings = {
en: "Hello!",
fr: "Bonjour!",
zh: "你好!",
};
const language = locale.split("-")[0];
const greeting = greetings[language] || greetings.en;
return <h1>{greeting}</h1>;
}
```SKILL.md›
---
name: chatgpt-app-builder
description: |
Guide developers through creating and updating ChatGPT apps.
Covers the full lifecycle: brainstorming ideas against UX guidelines, bootstrapping projects, implementing tools/views, debugging, running dev servers, deploying and connecting apps to ChatGPT.
Use when a user wants to create or update a ChatGPT app / MCP server for ChatGPT, or use the Skybridge framework.
---
# Creating Apps For LLMs
ChatGPT apps are conversational experiences that extend ChatGPT through tools and custom UI views. They're built as MCP servers invoked during conversations.
⚠️ The app is consumed by two users at once: the **human** and the **ChatGPT LLM**. They collaborate through the view—the human interacts with it, the LLM sees its state. Internalize this before writing code: the view is your shared surface.
SPEC.md keeps track of the app's requirements and design decisions. Keep it up to date as you work on the app.
**Building an ecommerce app?** → Read [ecommerce.md](references/ecommerce.md) first.
**No SPEC.md?** → Read [discover.md](references/discover.md) first. Nothing else until SPEC.md exists.
**SPEC.md exists?** → Read SPEC.md, then follow [architecture.md](references/architecture.md) to design the change. Update SPEC.md, then read the relevant Implementation references below before writing code.
**Migrating from Skybridge `< 0.36.x`?** → Read [migrate-to-v1.md](references/migrate-to-v1.md) first. Users may reference `skybridge >= 0.36.x` as v1.
**Migrating from Skybridge `1.x` to `2.x`?** → Fetch the [v2.0.0 release notes](https://github.com/alpic-ai/skybridge/releases/tag/v2.0.0) first and follow them.
## Setup
1. **Copy template** → [copy-template.md](references/copy-template.md): when starting a new project with ready SPEC.md
2. **Run locally** → [run-locally.md](references/run-locally.md): when ready to test, need dev server or ChatGPT connection
3. **Evals** → [evals.md](references/evals.md): when checking that a real model reaches the right tools from natural prompts, in a test
## Architecture
Design or evolve UX flows and API shape → [architecture.md](references/architecture.md)
## Implementation
- **Fetch and render data** → [fetch-and-render-data.md](references/fetch-and-render-data.md): when implementing server handlers and view data fetching
- **State and context** → [state-and-context.md](references/state-and-context.md): when persisting view UI state and updating LLM context
- **Prompt LLM** → [prompt-llm.md](references/prompt-llm.md): when view needs to trigger LLM response
- **UI guidelines** → [ui-guidelines.md](references/ui-guidelines.md): display modes, layout constraints, theme, device, and locale
- **External links** → [open-external-links.md](references/open-external-links.md): when redirecting to external URLs or setting "open in app" target
- **OAuth** → [oauth.md](references/oauth.md): when tools need user authentication to access user-specific data
- **CSP** → [csp.md](references/csp.md): when declaring allowed domains for fetch, assets, redirects, or iframes
## Deploy
- **Ship to production** → [deploy.md](references/deploy.md): when ready to deploy via Alpic
- **Publish to ChatGPT Directory** → [publish.md](references/publish.md): when ready to submit for review
Full API docs: [https://docs.skybridge.tech/api-reference.md](https://docs.skybridge.tech/api-reference.md)
Release notes & changelog: [https://skybridge.tech/changelog.md](https://skybridge.tech/changelog.md)