1. The problem with “random” tunnels
When you first run ngrok http 3000 or a quick Cloudflare Quick Tunnel, it feels like magic: bam — and your http://localhost:3000 turns into https://random-1234.tunnelprovider.com. You copy the URL into ChatGPT Dev Mode, and GPT happily loads your app.
Then you restart the tunnel… and get a new domain. The old URL in your ChatGPT Dev app settings suddenly becomes a “dead link”, GPT dutifully says “App unavailable”, and you go back to settings again, change the URL, click Save, wait for it to update, and silently hate the whole stack.
For a one‑off “evening playaround”, it’s tolerable. But when you:
- work on the app every day;
- want to show an intermediate version to a colleague/manager;
- spin up staging and production in parallel,
re‑connecting Dev Mode to every new random URL becomes pure suffering.
Plus, if you’ve already added anything to the app that depends on the domain (for example, an OAuth redirect URI or webhooks), each new URL breaks those too. You get a cascade: change the tunnel — and now you have to fix the App configuration, the redirect URL at your OAuth provider, and the webhook receiver settings.
This leads to the lecture’s key idea: a stable dev URL isn’t a luxury — it’s a developer’s mental health safeguard.
Insight
ChatGPT has very strict timeouts when working with your app, and it’s easy to underestimate them. An MCP tool call has a time limit: maximum 2 minutes — after that the platform just considers the call failed, even if your server is still doing something.
It’s even stricter with app registration (Store or Dev Mode): ChatGPT gives roughly 20 seconds to read the manifest, resources, and tool descriptions. If in that time your MCP server doesn’t manage to initialize, return the list of tools/resources, etc., app registration will fail with a timeout.
Recommendation: all heavy initialization should happen before you go to Dev Mode or the Store. Warming up DB connections, loading large configs, lazy caches — do all that in advance, for example by hitting the server once via MCP Jam or a separate internal script. From the platform’s point of view, the MCP server must be “warm” and respond within seconds, not “wake up” during registration.
2. What a “grown‑up” tunnel is
Let’s fix what distinguishes a “grown‑up” tunnel from what you launched at the start of the course.
The early mode (module 2) looked like this:
# Example with ngrok
ngrok http 3000
# You get: https://random-abc123.ngrok-free.app
You took that one‑off URL and pasted it into Dev Mode. On the next ngrok run, the URL is already different, and the ChatGPT configuration is outdated.
In the “grown‑up” approach you have:
- a static subdomain from the tunnel provider (or your own domain);
- the same domain is always forwarded to your localhost:3000;
- you can restart the tunnel, the machine, the router, but the URL stays the same.
Such static subdomains are available, for example:
- in ngrok — a free static domain per account;
- in Cloudflare Tunnel — via a named tunnel bound to your domain.
And the ChatGPT app in Dev Mode is configured for exactly this one URL and stops bothering you.
Formally, the requirements for our “grown‑up” tunnel are:
- the same public HTTPS domain, consistently;
- a valid TLS certificate (the provider handles this for us);
- a config that says: “everything coming to https://dev.yourdomain.com should be forwarded to http://localhost:3000”;
- optionally — minimal security measures (at least don’t post the URL to StackOverflow).
3. Configuring a stable dev URL: Cloudflare Tunnel example
In the course we recommend Cloudflare Tunnel as the main tool because it works well for dev and more serious scenarios. You saw a basic setup in module 2; now we’ll “tighten it up” to a permanent dev URL.
Assume we have a sample app, GiftGenius, and we want a stable URL giftgenius-dev.yourdomain.com.
Minimal steps (simplified, without Cloudflare UI specifics):
- Attach the domain to your Cloudflare account (once, via their dashboard).
- Install cloudflared locally and log in.
brew install cloudflare/cloudflare/cloudflared # macOS
cloudflared login # opens the browser for auth
3. Create a named tunnel:
cloudflared tunnel create giftgenius-dev
4. Configure the route in ~/.cloudflared/config.yml:
tunnel: giftgenius-dev
credentials-file: /Users/you/.cloudflared/giftgenius-dev.json
ingress:
- hostname: giftgenius-dev.yourdomain.com
service: http://localhost:3000 # our Next.js dev server
- service: http_status:404
5. Run the tunnel:
cloudflared tunnel run giftgenius-dev
Now, while npm run dev and cloudflared tunnel run are running, your local Next.js is available at the permanent URL https://giftgenius-dev.yourdomain.com. That’s exactly what you specify in ChatGPT Dev Mode settings.
How this fits our app
If you open in a browser the app URL you enter in ChatGPT when connecting a Dev app:
https://giftgenius-dev.yourdomain.com/mcp
you’ll see a response (an error) — something like this:
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}
That’s absolutely normal, since the server at /mcp doesn’t expect a GET request. All other parts of the app — the widget, the MCP endpoint /mcp, API routes — go through the same tunnel; you don’t need to remember a new domain every time.
4. Alternative: a stable subdomain in ngrok
If you’re used to ngrok, you can “grow it up” in roughly the same way using a static domain. Since 2023, ngrok even allows you to pin one static subdomain on the free plan, like myapp-dev.ngrok-free.app.
Minimal setup:
# ~/.config/ngrok/ngrok.yml
authtoken: <your token>
tunnels:
giftgenius-dev:
addr: 3000
proto: http
domain: giftgenius-dev.ngrok-free.app
Start it:
ngrok start giftgenius-dev
As a result, the URL https://giftgenius-dev.ngrok-free.app will be permanent, and that’s what you give to ChatGPT Dev Mode as the app’s base URL.
Same philosophy:
- no “random” addresses;
- only the tunnel’s internal state changes (running/not running), not the domain;
- no need to reconnect Dev Mode.
Cloudflare and ngrok are just different flavors of ice cream in this sense. Some like their own domains and “thin” DNS control (Cloudflare), others prefer “spin up YAML — done” (ngrok). Both approaches are valid for the course; the main thing is a stable URL.
5. Diagram: ChatGPT Dev Mode ↔ tunnel ↔ local stack
To formalize what’s happening a bit, let’s draw a picture.
flowchart TD
ChatGPT["ChatGPT (Dev Mode)"]
AppCfg["Dev app (config: https://giftgenius-dev...)"]
Tunnel["Cloudflare/ngrok tunnel (giftgenius-dev...)"]
Next["Next.js dev server localhost:3000 + MCP handler"]
ChatGPT --> AppCfg
AppCfg -->|"the config points to https://giftgenius-dev.../.well-known/openai-app"| Tunnel
Tunnel -->|"HTTPS → HTTP proxying"| Next
ChatGPT never knows what exactly you’re running on your laptop. For it, there is only a single HTTPS endpoint. What’s behind it — Vercel, a local tunnel, Kubernetes — is your business. In this lecture we care specifically about keeping that HTTPS endpoint stable for local development.
The last step is to make sure that within our app this address is also a single “source of truth”, rather than drifting across hardcoded strings — that’s what the next section is about.
6. Environment variables and baseURL in code
To make all of this work without surprises, it’s useful to define a “base external app URL” once in Next.js code and rely only on it.
For example, in the app/lib/config.ts folder of our GiftGenius app, create:
// app/lib/config.ts
export const baseUrl =
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; // fallback
export const mcpEndpoint = `${baseUrl}/mcp`; // MCP server URL
And in .env.local during development, set:
NEXT_PUBLIC_APP_URL=https://giftgenius-dev.yourdomain.com
Then:
- inside the widget and any links you always use baseUrl;
- for ChatGPT Dev Mode and the browser everything looks consistent;
- if tomorrow you move to Vercel staging with the domain https://giftgenius-staging.vercel.app, you only need to change the environment variable.
This is especially important for:
- callback URLs (e.g., for OAuth, webhook handlers);
- links you show the user in the widget (an “Open in browser” button via openExternal);
- any absolute URLs in app logic.
We’re talking only about the dev URL for now, but the architectural idea of “one source of truth for baseUrl” works great and will migrate with you to staging/production.
7. Updating the URL in ChatGPT Dev Mode
Okay, we’ve set up a nice stable domain. How do we live with it in Dev Mode now?
The logic is:
- In your Dev app settings, specify the root URL once: https://giftgenius-dev.yourdomain.com/
- ChatGPT fetches the manifest from it (.well-known/openai-app) and then uses the same roots to reach MCP (/mcp), static assets, etc.
- If you only change code (the React widget, MCP handlers, styles), there’s no need to change the URL at all. It’s enough to have the tunnel running and the Next.js server responding.
- If you change the domain itself (rarely, e.g., from ngrok to Cloudflare), you need to go to Dev Mode once and change the endpoint.
In some cases, ChatGPT caches the manifest, and changes in it may not appear instantly. The Dev Mode UI usually has a button like “Reload configuration / Refresh App”, but in the worst case, even a simple “disconnect and reconnect the app with the same URL” helps.
Important: as long as you don’t change the URL, Dev Mode automatically “picks up” new code versions. The main trigger for the app is the domain, not a commit hash.
8. Switching between dev / staging / prod in Dev Mode
A stable dev domain is only the first step. To avoid drowning in a chaos of URLs as the project grows, it helps to immediately understand how the dev tunnel fits into the overall environment scheme (dev/staging/prod) and Dev Mode. Although staging and prod are more a topic for the next lecture about Vercel, Dev Mode can already work with multiple environments.
For the reasoning below, this table is helpful:
| Environment | Base URL | Where the code runs |
|---|---|---|
| Local | |
Local Next.js + MCP via tunnel |
| Staging | |
Vercel Preview / staging deploy |
| Prod | |
Vercel Production |
There are two ways to work with Dev Mode.
First — a single Dev app, but you update its URL in settings from time to time to poke at staging or prod (carefully). This approach is fine early on, but it’s easy to mix up: today you tested local, tomorrow staging, the day after you forgot to switch and accidentally run Dev app requests against prod.
The second — a healthier one: several Dev apps, each tied clearly to an environment:
- GiftGenius Dev → giftgenius-dev.yourdomain.com;
- GiftGenius Staging → giftgenius-staging.vercel.app;
- GiftGenius (production, via Store) → giftgenius.vercel.app.
In this lecture we take it step by step, bringing order at least to the dev URL. In the next lecture, you’ll see how to logically tie Vercel and preview deploys to staging/production.
9. Working in a team: multiple developers and one tunnel
As long as we have one dev domain and one introverted developer, the tunnel is your personal friend. But once a team joins the project, tunnels and environments start overlapping, and it’s important not to start a “war for one subdomain”.
Imagine two developers decide to use the same static subdomain, say giftgenius-dev.ngrok-free.app. Both run ngrok start giftgenius-dev locally. In the best case, one tunnel simply won’t start (domain conflict); in the worst case, you’ll keep “overriding” each other’s session, and ChatGPT will sometimes hit one, sometimes the other.
There are several strategies here.
The simplest — personal dev domains:
- alex.dev.giftgenius.app;
- maria.dev.giftgenius.app.
And a separate Dev app in ChatGPT for each, e.g., GiftGenius Dev (Alex) and GiftGenius Dev (Maria). Then everyone runs local dev peacefully without disturbing others.
A more “team‑oriented” path — a shared staging endpoint:
- Every developer has a personal dev tunnel (for personal debugging).
- Plus there’s staging on Vercel, where feature branches are merged, and which the shared Dev app GiftGenius Staging points to.
That approach is common in real teams:
- a feature is born locally and debugged via a personal tunnel;
- after a pull request and merge, it’s tested by everyone via staging (no tunnels, just the Vercel URL).
10. Dev tunnel security (short and without paranoia)
A tunnel is a convenient way to expose your local server to the internet. And the internet, as we know, mostly consists of bots, scanners, and people who enjoy checking whether you forgot the password admin/admin.
Basic things to remember already at the dev stage:
- a tunnel exposes everything that’s running on that port; don’t put your DB admin UI, phpMyAdmin, and “my test CRM without a password” there;
- don’t publish the tunnel URL in public repos and chats;
- turn off the tunnel when you’re not working (and sometimes turn off the laptop too — it needs rest as well).
More serious measures like Basic Auth, checking special headers, or a token in the URL will be covered in the security modules. For now, only one thing matters: a tunnel is a development tool, not a hardened server. Production will live on proper hosting like Vercel, with different protection mechanisms — we’ll get there in the next lecture.
11. Practice: configure a stable dev URL for our app
Now from theory and caveats to practice: let’s tie all this to our sample Next.js app (Apps SDK template).
Assume the project structure looks like this:
apps/
web/ # Next.js app + widget
mcp-server/ # (optional) separate MCP, or /mcp handler in web
In practice, you can keep MCP right in Next.js, in app/api/mcp/route.ts, but the principle is the same.
Step 1. Edit .env.local
Add the stable dev URL of the tunnel:
NEXT_PUBLIC_APP_URL=https://giftgenius-dev.yourdomain.com
In dev code we already used baseUrl from this variable (see above). If you didn’t — now’s the time to extract it.
Step 2. Start the dev server and the tunnel
cd apps/web
npm run dev # Next.js on localhost:3000
# separate terminal
cloudflared tunnel run giftgenius-dev
Check in the browser that https://giftgenius-dev.yourdomain.com opens and shows your app.
Step 3. Connect in ChatGPT Dev Mode
In the ChatGPT interface (developer section):
- create or edit GiftGenius Dev;
- in the URL/Endpoint field specify https://giftgenius-dev.yourdomain.com/;
- save.
After this, ChatGPT fetches the manifest at /.well-known/openai-app, then starts running your app on top of that domain.
Now you can:
- change widget code, MCP handlers, styles;
- restart npm run dev;
- restart cloudflared tunnel run giftgenius-dev;
and at the same time never touch Dev app settings again as long as the domain stays the same.
12. What this looks like in code logic: example with openExternal
To close the loop with what we wrote in previous lectures, let’s add a “Open the full interface in the browser” button to the widget, which will also use the stable dev URL.
Assume we have a widget React component GiftWidget:
// app/components/GiftWidget.tsx
"use client";
import { baseUrl } from "../lib/config"; // take baseUrl from env
export function GiftWidget() {
const handleOpenFull = () => {
window.openai.openExternal({
// open the app page in a separate tab
url: `${baseUrl}/full`,
label: "Open the full interface",
});
};
return (
<div>
<button onClick={handleOpenFull}>
Full mode
</button>
</div>
);
}
If NEXT_PUBLIC_APP_URL points to the tunnel, then:
- during local development it will open https://giftgenius-dev.yourdomain.com/full;
- after deploying to staging — https://giftgenius-staging.vercel.app/full;
- in prod — the production domain.
Again — one source of truth for the domain: we change the environment, not the code.
13. A mini‑strategy: how to think about the tunnel “like a grown‑up”
Summing it up into a simple mental model, consider that:
- a tunnel is just a temporary wire between your laptop and a stable public domain;
- ChatGPT Dev Mode only knows the domain and doesn’t care where the code runs physically;
- the less often you change the domain, the less time you spend in ChatGPT and OAuth provider settings;
- a dev tunnel is just one line on your overall environments map, next to staging (Vercel preview) and prod (Vercel production).
The next lecture will show how this wire is replaced by full‑fledged hosting on Vercel, and how to connect all this to Git branches, preview deploys, and production.
14. Common mistakes when using a “grown‑up” tunnel
Mistake #1: “I set up a static domain, but I still keep using random URLs.”
Sometimes a developer sets up a nice giftgenius-dev.yourdomain.com once, but out of habit runs ngrok http 3000 without a config. As a result, ChatGPT looks at one domain, while the code runs behind another. If you’ve set up a stable dev URL — use only it and start the tunnel via the config (a named tunnel/profile).
Mistake #2: Hardcoded localhost:3000 in code.
This happens when a React component or an MCP handler uses fetch("http://localhost:3000/api/..."). It sort of works locally, but breaks instantly in Dev Mode and even more on staging/prod. Always extract the base URL to config (baseUrl, NEXT_PUBLIC_APP_URL) and use it everywhere you need absolute links.
Mistake #3: Constantly editing the URL in Dev Mode instead of using a stable tunnel.
If you catch yourself thinking “fine, I’ll change the URL in settings one more time, whatever,” — that’s a red flag. Setting up a static subdomain in ngrok/Cloudflare takes 10–15 minutes once, and saves hours during development.
Mistake #4: A shared static domain for the team with no rules.
Two developers, one domain giftgenius-dev.ngrok-free.app, and both start the tunnel whenever they want. The result — tunnel conflicts, “mysteriously” disappearing responses in Dev Mode, and debugging in the “but it worked for me” style. For a team, either have personal dev domains, or a single staging domain on real hosting.
Mistake #5: Treating the tunnel as “almost production”.
Sometimes someone thinks: “Well, if I have a stable HTTPS URL via the tunnel, let’s route real users/payments through it.” That’s a road to pain: the laptop is turned off — the app dies; the internet drops — same thing; and security is at best symbolic. A tunnel is a dev tool. Production traffic is for Vercel and other grown‑up infrastructure, which we’ll reach in the next lecture.
Mistake #6: Forgotten sync between environment variables and Dev Mode.
People often change NEXT_PUBLIC_APP_URL in .env.local but forget to change the URL in Dev Mode (or vice versa). As a result, the widget generates links to one domain while ChatGPT calls another. Keep a simple table “environment ↔ domain ↔ app in ChatGPT” and update it when things change — it’s cheaper than guessing which URL is the real one right now.
GO TO FULL VERSION