Apps in ChatGPT are a great way to let users try your machine learning models or other kinds of apps entirely by chatting in familiar chat application. OpenAI has released the Apps SDK for developers to build complete applications, but you can use Gradio to build ChatGPT apps very quickly, based off of your Gradio MCP server. We will also see how Gradio's built-in share links make it especially easy to iterate on your ChatGPT app!
Building a ChatGPT app requires doing two things:
Building a Gradio MCP server with at least one tool exposed. If you're not already familiar with building a Gradio MCP server, we recommend reading this guide first.
Building a custom UI with HTML, JavaScript, and CSS that will be displayed when your tool is called, an exposing that as an MCP resource.
We will walk through the steps in more detail below.
gradio>=6.0 installed with the mcp add-on:pip install --upgrade gradio[mcp]Now, let's walk through two examples of how you can build build ChatGPT apps with Gradio.
The first example is an ChatGPT app that counts the occurrence of letters in a word and displays a card with the word and specified letters highlighted, like this:
So how do we build this? You can find the complete code for the letter counter app in a single file here, or follow the steps below:
def letter_counter(word: str, letter: str) -> int:
"""
Count the number of letters in a word or phrase.
Parameters:
word (str): The word or phrase to count the letters of.
letter (str): The letter to count the occurrences of.
"""
return word.count(letter)with gr.Blocks() as demo:
with gr.Row():
with gr.Column():
word = gr.Textbox(label="Word")
letter = gr.Textbox(label="Letter")
btn = gr.Button("Count Letters")
with gr.Column():
count = gr.Number(label="Count")
btn.click(letter_counter, inputs=[word, letter], outputs=count)mcp_server=True demo.launch(mcp_server=True)As covered in earlier guides, you will now be able to test the tool using any MCP Client, such as the MCP Inspector tool. Test it and confirm that it behaves as you expect.
window.openai?.toolInput?.word and window.openai?.toolInput?.letter. The window.openai object is automatically inserted by ChatGPT with the data from the user's tool call. This is what the complete function looks like:@gr.mcp.resource("ui://widget/app.html", mime_type="text/html+skybridge")
def app_html():
visual = """
<div id="letter-card-container"></div>
<script>
const container = document.getElementById('letter-card-container');
function render() {
const word = window.openai?.toolInput?.word || "strawberry";
const letter = window.openai?.toolInput?.letter || "r";
let letterHTML = '';
for (let i = 0; i < word.length; i++) {
const char = word[i];
const color = char.toLowerCase() === letter.toLowerCase() ? '#b8860b' : '#000000';
letterHTML += `<span style="color: ${color};">${char}</span>`;
}
container.innerHTML = `
<div style="
background: linear-gradient(135deg, #f5f5dc 0%, #e8e4d0 100%);
background-image:
repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(139, 121, 94, 0.03) 2px, rgba(139, 121, 94, 0.03) 4px),
linear-gradient(135deg, #f5f5dc 0%, #e8e4d0 100%);
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
max-width: 600px;
margin: 20px auto;
font-family: 'Georgia', serif;
text-align: center;
">
<div style="
font-size: 48px;
font-weight: bold;
letter-spacing: 8px;
line-height: 1.5;
">
${letterHTML}
</div>
</div>
`;
}
render();
window.addEventListener("openai:set_globals", (event) => {
if (event.detail?.globals?.toolInput) {
render();
}
}, { passive: true });
</script>
"""
return visualNote that we've provided a URI for the gr.mcp.resource at ui://widget/app.html. This is arbitrary, but we'll need to use the same URI later on. We also need to specify the mimetype of the resource to be mime_type="text/html+skybridge". Finally, note that we attached an event listener in the JavaScript for "openai:set_globals", which is generally a good practice as it allows the widget to update whenever a new tool call is triggered.
gr.Code component, e.g. like this: html = gr.Code(language="html", max_lines=20)
# ... the rest of your Gradio app
btn.click(app_html, outputs=html)_meta attributes to your MCP tool. We need to connect the MCP tool that we created to the UI that we created for our app. We can do this by adding this decorator to our MCP tool function:@gr.mcp.tool(
_meta={
"openai/outputTemplate": "ui://widget/app.html",
"openai/resultCanProduceWidget": True,
"openai/widgetAccessible": True,
}
)The key thing to observe is that the "openai/outputTemplate" must match the URI of the MCP resource that we created earlier.
share=True. This will make it very easy to test within ChatGPT. Note the MCP server URL that is printed to your terminal, e.g. https://2e879c6066d729b11b.gradio.live/gradio_api/mcp/. demo.launch(share=True, mcp_server=True)This will print a public URL that your Gradio app will be running on.

And that's it! Once the Connector has been created, you can start prompting it by saying something like, "Use @letter-counter to count the number of r's in Gradio."
Next, let's see a more complex ChatGPT app for image enhancement. The ChatGPT app includes a "Brighten" button that lets the user call the tool directly from the app UI.
Here's the complete code for this app:
import gradio as gr
import tempfile
from PIL import Image
import numpy as np
@gr.mcp.tool(
_meta={
"openai/outputTemplate": "ui://widget/app.html",
"openai/resultCanProduceWidget": True,
"openai/widgetAccessible": True,
}
)
def power_law_image(input_path: str, gamma: float = 0.5) -> str:
"""
Applies a power-law (gamma) transformation to an image file and saves
the result to a temporary file.
Args:
input_path (str): Path to the input image.
gamma (float): Power-law exponent. <1 brightens, >1 darkens.
Returns:
str: Path to the saved temporary output image.
"""
img = Image.open(input_path).convert("RGB")
arr = np.array(img, dtype=np.float32) / 255.0
arr = np.power(arr, gamma)
arr = np.clip(arr * 255, 0, 255).astype(np.uint8)
out_img = Image.fromarray(arr)
tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
out_img.save(tmp_file.name)
tmp_file.close()
return tmp_file.name
@gr.mcp.resource("ui://widget/app.html", mime_type="text/html+skybridge")
def app_html():
visual = """
<style>
#image-container {
position: relative;
display: inline-block;
max-width: 100%;
}
#image-display {
max-width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
#brighten-btn {
position: absolute;
bottom: 16px;
right: 26px;
padding: 12px 24px;
background: #1a1a1a;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
#brighten-btn:hover {
background: #000000;
}
</style>
<div id="image-container">
<img id="image-display" alt="Processed image" />
<button id="brighten-btn">Brighten</button>
</div>
<script>
const imageEl = document.getElementById('image-display');
const btnEl = document.getElementById('brighten-btn');
function extractImageUrl(data) {
if (data?.text?.startsWith('Image URL: ')) {
return data.text.substring('Image URL: '.length).trim();
}
if (data?.content) {
for (const item of data.content) {
if (item.type === 'text' && item.text?.startsWith('Image URL: ')) {
return item.text.substring('Image URL: '.length).trim();
}
}
}
}
function render() {
const url = extractImageUrl(window.openai?.toolOutput);
if (url) imageEl.src = url;
}
async function brightenImage() {
btnEl.disabled = true;
btnEl.textContent = 'Brightening...';
const result = await window.openai.callTool('power_law_image', {
input_path: imageEl.src
});
const newUrl = extractImageUrl(result);
if (newUrl) imageEl.src = newUrl;
btnEl.disabled = false;
btnEl.textContent = 'Brighten';
}
btnEl.addEventListener('click', brightenImage);
window.addEventListener("openai:set_globals", (event) => {
if (event.detail?.globals?.toolOutput) render();
}, { passive: true });
render();
</script>
"""
return visual
with gr.Blocks() as demo:
with gr.Row():
with gr.Column():
original_image = gr.Image(label="Original Image", type="filepath")
btn = gr.Button("Brighten Image")
with gr.Column():
output_image = gr.Image(label="Output Image", type="filepath")
html = gr.Code(language="html", max_lines=20)
btn.click(power_law_image, inputs=original_image, outputs=original_image)
btn.click(app_html, outputs=html)
if __name__ == "__main__":
demo.launch(mcp_server=True, share=True)
We won't break down the code in as much detail since many of the pieces are the same. But note the following differences from the earlier example:
window.openai.callTool() to invoke the MCP tool directly from a button click, without requiring ChatGPT to call it:const result = await window.openai.callTool('power_law_image', {
input_path: imageEl.src
});callTool() contains a content array that needs to be parsed to extract data:function extractImageUrl(data) {
if (data?.content) {
for (const item of data.content) {
if (item.type === 'text' && item.text?.startsWith('Image URL: ')) {
return item.text.substring('Image URL: '.length).trim();
}
}
}
}const newUrl = extractImageUrl(result);
if (newUrl) imageEl.src = newUrl;With these examples, you've seen how to build both simple reactive widgets and more advanced interactive apps that can call tools directly from the UI. By combining Gradio's MCP server capabilities with the OpenAI Apps SDK, it's time to start create richer ChatGPT integrations that enhance the conversational experience with custom visualizations and user interactions!