
Chatgpt App Builder
- 3.8k installs
- 2k repo stars
- Updated August 4, 2026
- alpic-ai/skybridge
chatgpt-app-builder is an agent skill for build chatgpt apps with skybridge patterns for tool wiring, oauth, and hosted action endpoints.
About
The chatgpt-app-builder skill Build ChatGPT apps with Skybridge patterns for tool wiring, OAuth, and hosted action endpoints. 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. No SPEC.md? → Read discover.md first. Nothing else until SPEC.md exists. No SPEC.md? → Read discover.md first. Nothing else until SPEC.md exists. SPEC.md exists? → Read SPEC.md, then follow architecture.md to design the change. Update SPEC.md, then read the relevant Implementation references below before writing code. 1. Copy template → copy-template.md: when starting a new project with ready SPEC.md 2.
- Copy template → copy-template.md: when starting a new project with ready SPEC.md
- Run locally → run-locally.md: when ready to test, need dev server or ChatGPT connection
- Fetch and render data → fetch-and-render-data.md: when implementing server handlers and view data fetching
- State and context → state-and-context.md: when persisting view UI state and updating LLM context
- Prompt LLM → prompt-llm.md: when view needs to trigger LLM response
Chatgpt App Builder by the numbers
- 3,767 all-time installs (skills.sh)
- +113 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #208 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
chatgpt-app-builder capabilities & compatibility
- Capabilities
- copy template → copy template.md: when starting · run locally → run locally.md: when ready to test · fetch and render data → fetch and render data.md · state and context → state and context.md: when p · prompt llm → prompt llm.md: when view needs to t
- Use cases
- orchestration · api development
What chatgpt-app-builder says it does
ChatGPT apps are conversational experiences that extend ChatGPT through tools and custom UI views. They're built as MCP servers invoked during conversations.
SPEC.md keeps track of the app's requirements and design decisions. Keep it up to date as you work on the app.
1. **Copy template** → [copy-template.md](references/copy-template.md): when starting a new project with ready SPEC.md
npx skills add https://github.com/alpic-ai/skybridge --skill chatgpt-app-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.8k |
|---|---|
| repo stars | ★ 2k |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | alpic-ai/skybridge ↗ |
How do I build chatgpt apps with skybridge patterns for tool wiring, oauth, and hosted action endpoints with documented agent guidance?
Build ChatGPT apps with Skybridge patterns for tool wiring, OAuth, and hosted action endpoints.
Who is it for?
Developers who need ai & agent building help during build work.
Skip if: Skip when the task falls outside AI & Agent Building scope described in SKILL.md.
When should I use this skill?
Build ChatGPT apps with Skybridge patterns for tool wiring, OAuth, and hosted action endpoints.
What you get
Completed ai & agent building workflow aligned with SKILL.md steps and validation.
- GPT Action View schemas
- GPT Action Tool definitions
- API-to-action mapping
By the numbers
- Copy template → copy-template.md: when starting a new project with ready SPEC.md
- Run locally → run-locally.md: when ready to test, need dev server or ChatGPT connection
- Fetch and render data → fetch-and-render-data.md: when implementing server handlers and view data fetching
Files
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.
No SPEC.md? → Read discover.md first. Nothing else until SPEC.md exists.
SPEC.md exists? → Read SPEC.md, then follow architecture.md to design the change. Update SPEC.md, then read the relevant Implementation references below before writing code.
Setup
1. Copy template → copy-template.md: when starting a new project with ready SPEC.md 2. Run locally → run-locally.md: when ready to test, need dev server or ChatGPT connection
Architecture
Design or evolve UX flows and API shape → architecture.md
Implementation
- Fetch and render data → fetch-and-render-data.md: when implementing server handlers and view data fetching
- State and context → state-and-context.md: when persisting view UI state and updating LLM context
- Prompt LLM → prompt-llm.md: when view needs to trigger LLM response
- UI guidelines → ui-guidelines.md: display modes, layout constraints, theme, device, and locale
- External links → open-external-links.md: when redirecting to external URLs or setting "open in app" target
- OAuth → oauth.md: when tools need user authentication to access user-specific data
- CSP → csp.md: when declaring allowed domains for fetch, assets, redirects, or iframes
Deploy
- Ship to production → deploy.md: when ready to deploy via Alpic
- Publish to ChatGPT Directory → publish.md: when ready to submit for review
Full API docs: https://docs.skybridge.tech/api-reference.md
[
{
"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."
}
]
[
{
"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."
}
]
[
{
"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."
}
]
[
{
"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": "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
Manual evaluations for the creating-chatgpt-app skill.
Format
Each eval file is a JSON array:
[
{
"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.[
{
"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": "How do I return an auth error that prompts the user to sign in?",
"expected_behavior": "Must read oauth.md. Answer: return isError: true with _meta containing 'mcp/www_authenticate' key pointing to oauth-protected-resource endpoint."
},
{
"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."
}
]
[
{
"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": "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 useLayout 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 useLayout 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 useLayout 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."
}
]
[
{
"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 duplicate - view output is returned to the LLM for conversation. Don't create a tool that duplicates what the view fetches.' The browse_plants view already returns plant data including details. 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 lazy-load - tool calls are expensive. Return all needed data upfront.' The browse_furniture view should include room preview data in its output. Suggest returning preview URLs upfront in the view output instead."
},
{
"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."
}
]
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 bookingDo 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: View output is returned to the LLM for conversation. 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 needed data upfront. ❌ search_flights view + get_flight_details tool (lazy-loading details) ✅ search_flights view returns full flight data including details
---
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_checkouttool → 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:
...
## 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 }`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):
{pm} create skybridge@latest {target-dir}
# deno
deno init --npm skybridge {target-dir}3. Start the dev server. 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
- View state and LLM context → state-and-context.md
- Display modes → ui-guidelines.md
5. Delete unused views files and leftover code.
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 |
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.
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
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
# 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 onlyAfter SPEC.md is created, confirm with user before proceeding to implementation.
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)
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
await download({
contents: [
{
type: "resource",
resource: {
uri: "file:///chart.png",
mimeType: "image/png",
blob: base64EncodedPng,
},
},
],
});Resource link (host fetches)
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
uriis a filename hint; the host derives the suggested save name from the last path segment. isError: trueis a soft signal (user cancelled / host denied). Transport errors throw.- For binary content above a few hundred KB, prefer
resource_linkover inline base64.
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 # McpServer with tool + view registration
│ ├── 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.jsonNaming 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): narration (Markdown or plaintext) shown to LLM
- `_meta` (optional): large or sensitive data exclusively for the view. _meta never reaches the model.
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
import { McpServer } from "skybridge/server";
import { z } from "zod";
const server = new McpServer(
{ name: "my-app", version: "0.0.1" },
{ capabilities: {} },
)
.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}` }],
};
}
);
server.run();
export type AppType = typeof server;UI Components
- generate type-safe hooks with
generateHelpers useToolInfo: access view input/outputuseCallTool: trigger tool from UI
Example:
- src/helpers.ts
import { generateHelpers } from "skybridge/web";
import type { AppType } from "./server.js";
export const { useToolInfo, useCallTool } = generateHelpers<AppType>();- src/views/search-flights.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>
);
}OAuth Authentication
Enable user authentication so tools can access user-specific data.
How it works
1. MCP server exposes OAuth discovery endpoints 2. Host reads them, walks the user through OAuth, refreshes tokens 3. Host calls /mcp with Authorization: Bearer <token> 4. requireBearerAuth middleware verifies the token and rejects with HTTP 401 if invalid — tool handlers never run unauthenticated 5. Tool handlers read user identity from extra.authInfo
1. Discovery endpoints
Mount OAuth metadata so MCP clients can discover the authorization server:
// src/server.ts
import { mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { McpServer } from "skybridge/server";
const server = new McpServer(
{ name: "my-app", version: "0.0.1" },
{ capabilities: {} },
).use(
mcpAuthMetadataRouter({
oauthMetadata: {
issuer: "https://your-oauth-provider.com",
authorization_endpoint: "https://your-oauth-provider.com/oauth2/authorize",
token_endpoint: "https://your-oauth-provider.com/oauth2/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
},
// SERVER_URL: this server's public URL (localhost:3000, Alpic tunnel, or prod)
resourceServerUrl: new URL(process.env.SERVER_URL),
}),
);This serves /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource.
2. Write a token verifier
requireBearerAuth takes a verifier with verifyAccessToken(token): Promise<AuthInfo>. Verify the provider's JWT against its JWKS:
⚠️ Fetch your provider's docs for the exact JWKS URL and issuer.
// src/auth.ts
import { InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import * as jose from "jose";
const jwks = jose.createRemoteJWKSet(
new URL("https://your-oauth-provider.com/oauth2/jwks"),
);
export async function verifyAccessToken(token: string): Promise<AuthInfo> {
const { payload } = await jose.jwtVerify(token, jwks, {
issuer: "https://your-oauth-provider.com",
});
if (!payload.sub || typeof payload.sub !== "string") {
throw new InvalidTokenError("missing sub claim");
}
return {
token,
clientId: (payload.client_id ?? payload.azp ?? "") as string,
scopes: typeof payload.scope === "string" ? payload.scope.split(" ") : [],
expiresAt: payload.exp,
extra: { sub: payload.sub },
};
}3. Enforce auth on /mcp
// src/server.ts (continued)
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { verifyAccessToken } from "./auth.js";
server.use(
"/mcp",
requireBearerAuth({
verifier: { verifyAccessToken },
requiredScopes: ["openid", "email", "profile"], // optional
}),
);Unauthenticated requests get HTTP 401 before any tool handler runs.
4. Read auth in handlers
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
server.registerTool(
{
name: "get-orders",
description: "Get user orders",
},
async (_input, extra) => {
const auth = extra.authInfo as AuthInfo;
const orders = await fetchOrders(auth.extra?.sub as string);
return {
structuredContent: { orders },
content: [{ type: "text", text: `Found ${orders.length} orders` }],
};
},
);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:
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:
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):
openExternal("https://example.com", { redirectUrl: false });Use redirectUrl: false to skip automatic ?redirectUrl=... appending.
Shows confirmation dialog unless domain is whitelisted:
// 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 }) => { /* ... */ }
);Prompt model
Trigger an LLM completion from user interaction with useSendFollowUpMessage.
Example:
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>
);
}Publish to Directories
1. Audit Annotations
Common cause of rejection. Ensure all tools and views have correct annotations. See fetch-and-render-data.md.
2. Audit CSP
Ensure all external domains are declared in the tool's view.csp. See csp.md.
3. Submit
ChatGPT
Guide user to submit the app at 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.
Running Locally Workflow
1. Start Dev Server
Install dependencies and start the dev server in the background:
{pm} install && {pm} run devFor 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)
Use the devtools to render views locally. Use this method when iterating with the user on the rendered result of its app.
The devtools page exposes WebMCP tools, powering faster interactions than with traditional click/fill/screenshot interactions. Requires the Chrome DevTools MCP server running with --categoryExperimentalWebmcp=true, which adds the list_webmcp_tools and execute_webmcp_tool tools.
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 ofdisplayMode(inline|pip|fullscreen),darkTheme(boolean),mobileDevice(boolean),locale(BCP 47 code)
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. Use take_screenshot only to visually verify rendering — screenshot the preview iframe (accessible name html-preview in the page snapshot) rather than the full page.
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:
alpic tunnel --port 3000Extract 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 → 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 → 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 plan
Manage View State and LLM Context
- View state (
useViewState/createStore) persists and is visible to LLM as structured data. data-llmgives LLM context for referential language ("this one").- React
useStateis 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.
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.
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.
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
// 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 });// 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}`}>Combined example
Todo list. User checks off tasks, asks "what should I prioritize?"
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. |
UI Guidelines
Contents
- Display Modes — inline, fullscreen, PiP, switching
- Modal — overlay on top of any display mode
- Adapting to Host — layout constraints, theme
- 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
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
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 useLayout to read host environment constraints.
Layout Constraints
maxHeight: Maximum height available for the view in pixelssafeArea.insets: Padding to avoid device notches, composer overlay, and navigation bars
import { useLayout } from "skybridge/web";
function Container({ children }) {
const { maxHeight, safeArea } = useLayout();
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 useLayout.
import { useLayout } from "skybridge/web";
function Container({ children }) {
const { theme } = useLayout();
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:trueif device supports hover (mouse)capabilities.touch:trueif device supports touch
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.
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>;
}Related skills
How it compares
chatgpt-app-builder is an agent skill for build chatgpt apps with skybridge patterns for tool wiring, oauth, and hosted action endpoints, not a generic alternative.
FAQ
Who is chatgpt-app-builder for?
Developers using AI & Agent Building workflows with agent-guided SKILL.md steps.
When should I use chatgpt-app-builder?
Build ChatGPT apps with Skybridge patterns for tool wiring, OAuth, and hosted action endpoints.
Is chatgpt-app-builder safe to install?
Review the Security Audits panel on this page before installing in production.