What this skill does
Turns a service running on a local port (default: a Starchild preview, but works for any HTTP service) into something the world can reach at
, using
Cloudflare Tunnel. No public IP required, no inbound ports opened, free SSL.
Two roles in the flow:
- User does manually (must, can't be automated): create Cloudflare account, buy/transfer domain to Cloudflare, create API Token.
- Agent does automatically (this skill): verify token, pick zone, create tunnel, configure ingress, create DNS, install + start , verify the public URL works.
Audience assumption
Treat the user as a beginner. They may have never used Cloudflare. Walk them through one micro-step at a time, wait for confirmation, then move on. Do NOT dump the whole 10-step plan and disappear.
Workflow
Phase 0 — Set the stage (1 message)
Tell the user in plain language what's about to happen, in 4 phases:
- They register a Cloudflare account + add a domain (manual, ~5 min)
- They create an API Token and give it to you securely (manual, ~2 min)
- You build the tunnel + DNS + start it (automatic, ~1 min)
- You test the URL together (automatic)
Ask: "Do you already have a domain on Cloudflare, or do we need to start from scratch?" Branch on the answer.
Phase 1 — Get the user a domain on Cloudflare
If they don't have one yet:
- Direct them to https://dash.cloudflare.com/sign-up to register
- Then https://dash.cloudflare.com/?to=/:account/domains to buy a domain (Cloudflare sells
.com / .net / .org / .io / .dev / .app
etc. at registry cost), OR add an existing domain and change nameservers
- Wait for them to confirm "domain is active in Cloudflare" before proceeding
Beginner hint to share: "When the domain shows status Active in your Cloudflare dashboard, we're good to continue."
If they already have one: skip to Phase 2.
Phase 2 — Create the API Token
Send them this exact link (it pre-fills the right permissions when possible, and the user can also build it manually):
Required permissions (tell them to add these three):
- Account → Cloudflare Tunnel → Edit
- Zone → DNS → Edit
- Zone → Zone → Read
Account Resources: their account. Zone Resources: Include All zones (or specifically the domain). TTL: leave default.
After they click Continue to summary → Create Token → Cloudflare shows the token once. Tell them: do not paste it in chat.
Phase 3 — Receive the token securely
env_vars=[{"key": "CLOUDFLARE_API_TOKEN", "label": "Cloudflare API Token", "required": true}]
reason="Used to create the tunnel and DNS record on your domain. Stored locally in workspace/.env, never echoed in chat."
Wait for the user to submit it via the secure popup. Do not retry-loop if they don't submit immediately — just wait.
Phase 4 — Verify token + pick the zone
Run
python3 skills/cloudflare-tunnel-publish/scripts/verify.py
. It prints:
- Token validity
- The user's account_id (saves to )
- All zones (domains) on the account
If multiple zones, ask the user which domain to use. Save
and
to state.
Phase 5 — Decide what to publish
Ask the user two things:
- Subdomain (e.g., , , ) → final hostname will be . Apex domain () is also allowed.
- Local port (e.g., , ). If they say "my Starchild preview", run
cat /data/previews.json 2>/dev/null
to look up an existing preview's port; otherwise ask explicitly.
Phase 6 — Build the tunnel (automated)
Run
python3 skills/cloudflare-tunnel-publish/scripts/setup.py --hostname <full_hostname> --port <port>
.
Tunnel reuse is the default. If a healthy tunnel already exists on the
account,
adds the new hostname to that tunnel's ingress and points
the DNS CNAME at it — instead of creating a separate tunnel. One tunnel =
one
process = one keepalive watchdog. Creating a new tunnel per
site is how you end up with N processes to babysit and N ways to silently go
dark (the exact bug that prompted this: a second tunnel's
died
and nothing was watching it, because the watchdog only knew about the first).
Pass
only when you have a reason to isolate traffic (e.g. very
high volume on one site that would saturate the shared edge connections).
The script does, in order:
- Reuse a healthy tunnel if one exists (or create one named if none, or use to force a new one)
- Fetch the tunnel run token (a long base64 string used to start )
- PUT the ingress configuration: merge → into the existing ingress rules (preserving other hostnames), fallback
- Create a CNAME DNS record: →
<tunnel_id>.cfargotunnel.com
, proxied = true
- Append the site to the array in (multi-site support — see "Multiple sites" below)
If a tunnel with the same name exists, reuse it instead of erroring.
Phase 7 — Start cloudflared
Run
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
— this is the
canonical way to bring the site up.
is the single start+heal
brain (see "Keeping it alive" below): it reads
, starts the app
(if you recorded
) and the tunnel, and verifies the public URL.
is the lower-level tunnel-only launcher that
calls — it downloads
to
if missing, reads the
run_token from
, and runs
.
Tell the user the site is up. The SAME
is what you'll wire into
boot + a schedule for durability — don't hand-roll a separate starter.
Phase 8 — Verify
⚠️
Do not use the container's directly — the container's resolver caches stale NXDOMAIN for new domains and will lie to you. Always verify via DoH:
bash
curl -sS "https://dns.google/resolve?name=<hostname>&type=A" | python3 -m json.tool
Three possible outcomes:
- + IPs in → live. Now
curl -I https://<hostname>
should return 200/301/302. Show the user their URL. 🎉
- (NXDOMAIN) + Authority = TLD registry NS (e.g. ) → TLD registry hasn't propagated the new domain yet. Tell the user: configuration is 100% done, wait 30–60 min (newly registered domains can take up to 24 h), then retry. Don't keep polling — let them check on their own device.
- Tunnel logs show errors (check
bash_process(action='log', session_id=...)
) → real config bug. Common culprits: ingress not pointing at the right port, local service not running, wrong CNAME target.
Decision rules
-
User says "my service is on my laptop, not in Starchild" → exact same flow, but Phase 7 must run on their laptop, not in this container. Give them the equivalent install command for their OS:
- macOS:
brew install cloudflared && cloudflared tunnel run --token <TOKEN>
- Linux/Windows: link to https://github.com/cloudflare/cloudflared/releases/latest
Send the run_token via if needed, or just print it once and tell them to copy it (it's safe to share with their own machine, but never paste back to chat).
-
User wants multiple subdomains → reuse the same tunnel; PUT a new ingress config that lists all hostnames; create one CNAME per hostname. This is now the default behavior — just run
for each subdomain and it will merge into the existing tunnel's ingress.
-
User wants to remove it → run
python3 skills/cloudflare-tunnel-publish/scripts/teardown.py
(deletes DNS + tunnel + kills the local cloudflared process).
Gotchas (⚠️ all confirmed in real runs)
Token / API
- does NOT grant listing. Calling returns an empty list even with a valid token. Solution: derive from any zone's embedded field — already does this. Do NOT add
Account:Account Settings:Read
just to fix it; the zone trick is cleaner.
- The "run token" from
GET /accounts/{id}/cfd_tunnel/{tunnel_id}/token
is what cloudflared tunnel run --token
consumes. Do not confuse with:
- tunnel secret — only relevant for legacy locally-managed tunnels (we don't use)
- API Token — used to call api.cloudflare.com
Tunnel / Ingress
- The CNAME target must be
<tunnel_id>.cfargotunnel.com
, NOT the tunnel name.
- Remotely-managed tunnel () routes via the API config endpoint, NOT a local . Do not generate one.
- Creating a tunnel via API requires a field (32 random bytes, base64) even for . generates one automatically.
Universal SSL provisioning lag — the OTHER big trap
After DNS propagates, the user may still hit
ERR_SSL_VERSION_OR_CIPHER_MISMATCH
in the browser. This is NOT a bug — Cloudflare hasn't issued the
Universal SSL certificate for the new hostname yet.
Diagnosis (run from container — no auth needed):
bash
echo | timeout 10 openssl s_client -connect <hostname>:443 -servername <hostname> 2>&1 | grep -E "(handshake|peer certificate|Cipher is)"
no peer certificate available
+ → cert not issued yet ⏳
- Real cert returned → working ✅
Timing:
- Established zones with prior certs: usually < 5 min
- Brand-new domains: 15 min ~ 24 h (DNS validation + CA signing + edge propagation)
What to tell the user:
- Open
dash.cloudflare.com → <domain> → SSL/TLS → Edge Certificates
- Look for a row like and check status:
- → done, refresh browser
- / → wait
- Confirm
SSL/TLS → Overview → Encryption mode
is Full (not Flexible, not Full Strict). Tunnel always carries HTTPS to the origin, so Full is the right match.
Don't: Tell the user to add an Advanced Certificate ($$$) or to change DNS — neither helps. Just wait.
DNS propagation — the big trap
Newly registered domains take 30 min ~ 2 h (sometimes up to 24 h) to propagate across the global TLD registry, even when the Cloudflare dashboard shows "Active" instantly. Symptoms:
dig @1.1.1.1 yourdomain.com NS
returns NXDOMAIN (Status=3)
- The Authority section shows the TLD's registry NS (e.g., for via Tucows), NOT Cloudflare's NS
- tunnel is connected and healthy, but returns DNS resolution failure
This is NOT a bug in the skill — it's TLD registry sync lag. Use the diagnostic snippet below to distinguish it from real issues. Tell the user: "Configuration is complete. Wait 30–60 minutes and try again. Nothing more to do on our side."
Container DNS — false negative
When testing from inside the Starchild container, the container's local resolver may not see new domains for hours. Always cross-check with public DoH:
bash
curl -sS "https://dns.google/resolve?name=hello.example.com&type=A" | python3 -m json.tool
- + array with IPs → working ✅
- (NXDOMAIN) +
Authority: ns.trs-dns.com
(or similar registry NS) → TLD propagation pending ⏳
- but no → CNAME exists but Cloudflare orange-cloud not yet routing → wait 30s
Keeping it alive — one script, two triggers
This is the part agents get wrong. "Publish" is easy; keeping a tunnel site up
for weeks is the real job. The Starchild container restarts without warning
(platform updates, OOM, migration, user reboot), and
also dies
on its own mid-life (network blip, edge reset, QUIC failure) while the container
keeps running. Either one leaves
returning
502 / 521 / 530 / 1033 until something restarts the processes. DNS and the
Cloudflare-side tunnel config survive (they live on Cloudflare's servers) — only
the local processes need relaunching.
The design: ONE idempotent recovery brain () called from
TWO triggers. Do not write per-project starter/healer scripts — that's how the
two copies drift apart.
ships with this skill and is generic: it
reads
,
,
,
from
, so the same
file works for any domain. The calling agent writes ZERO project-specific shell.
- Probe the public URL (reachability, not just PID — a process
can be alive but disconnected).
- Healthy → log one line, exit silently.
- Down → diagnose: local app port closed → restart app (via ) and
tunnel; only the tunnel dead → restart just the tunnel. Then re-verify with a
few retries (covers cold-start warm-up).
- Report on state transitions only (tracked in ):
newly-recovered or newly-failed prints one line; steady-state (healthy, or
already-known-down) is silent. So a scheduled task pushes signal, never spam.
Step 1 — record how to start the app (at setup time)
Pass
/
to
so keepalive can restart the app,
not just the tunnel:
python3 setup.py --hostname app.example.com --port 8765 \
--app-cmd "python3 server.py" --app-dir projects/myapp
If you omit
, keepalive guards the
tunnel only and cannot revive a
crashed app. Always record it unless the app is supervised elsewhere.
Step 2 — start the site
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh
Idempotent: starts whatever is down, no-op when healthy.
Step 3 — survive container restarts (boot trigger)
Add keepalive to
(runs on every container boot):
bash
# Bring the tunnel site back after a restart. keepalive.sh only READS
# .cf_state.json — no Cloudflare API call, no token needed at boot.
if [ -f /data/workspace/.cf_state.json ]; then
bash /data/workspace/skills/cloudflare-tunnel-publish/scripts/keepalive.sh &
disown
fi
🚫
NEVER put in . is config-time: it calls
the Cloudflare API, may rotate the run_token, and overwrites
.
Running it on every boot is wasteful, can hit rate limits, breaks if the API
token was removed, and can change a working config. Boot must only
read state
— that's exactly what
does. The name "setup" tempts you to put
it in "setup.sh"; resist it.
Step 4 — survive mid-life process death (watchdog trigger)
Schedule the SAME script as a cheap
-mode task.
One schedule
guards all sites — keepalive.sh iterates every site in
in a single pass, so you never need a per-site watchdog:
scheduled_task(action="schedule",
schedule="every 2 minutes",
command="cd /data/workspace && bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh",
title="cloudflare tunnel keepalive (all sites)")
- Use a relative command with . An absolute
path can be normalized by the scheduler into a
non-existent (the segment gets dropped), so
every run fails silently. + relative path is immune. (Confirmed in a real
run.)
- Keep at its default so the transition-only alerts actually reach the
user. keepalive is already silent on healthy runs, so there's no spam to
suppress — and a real outage should ping you.
- The interval may be normalized (e.g. "every 2 minutes" → 3 min) — fine.
Verify durability (do all of this before claiming "stable")
bash
# 1. start + idempotency
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh # brings up
bash skills/cloudflare-tunnel-publish/scripts/keepalive.sh # silent no-op
tail -5 logs/keepalive.log # ok https://...
# 2. boot wired
grep keepalive setup.sh
# 3. watchdog registered + command stored correctly (relative path!)
# scheduled_task(action="list")
Don't claim "long-term stable" after only editing . That covers
restarts but not mid-life death. Confirm BOTH triggers (boot + schedule) point at
keepalive.sh, and that the first run logged
.
Port collision is a silent failure. Other workspace projects may already hold
common ports (8000/8080/8765). If your app's
fails with
it exits, but
still returns 200 — someone else's
app is answering. Use a high, project-unique port and verify the page is
your
content (
curl https://yourdomain.com | head
), not just a 200.
Tell the user explicitly:
🔔 你的站点跑在容器里。容器可能因更新/内存/迁移
随时重启,隧道进程偶尔也会自己掉线(域名变 502/521/530/1033)。我已经把一个自愈脚本写进了
(开机自动拉起)并设了每几分钟一次的巡检(掉线自动重拉、恢复/失败才通知你)。两层都指向同一个脚本,你基本不用管。真打不开时让我看一眼
和
就能定位。
Plan limits
- Free plan is enough. No upsell needed.
- Free plan only proxies ports 80/443 publicly — irrelevant to us, since the tunnel always exposes 443 to the world; can be anything.
State file format
supports
multiple sites. The canonical store is
the
array; the flat top-level fields are kept for backward compat and
always reflect the last-configured site:
json
{
"account_id": "...",
"zone_id": "...",
"zone_name": "example.com",
"sites": [
{
"hostname": "app.example.com",
"port": 8080,
"tunnel_id": "...",
"tunnel_name": "starchild-app-example-com",
"run_token": "...",
"app_cmd": "python3 server.py",
"app_dir": "/data/workspace/projects/myapp"
},
{
"hostname": "blog.example.com",
"port": 3000,
"tunnel_id": "...",
"run_token": "...",
"app_cmd": "",
"app_dir": ""
}
],
"hostname": "blog.example.com",
"port": 3000,
"tunnel_id": "...",
"run_token": "...",
"app_cmd": "",
"app_dir": ""
}
reads
and guards
every site in one pass — one
watchdog, all hostnames. This is the fix for the "each site got its own tunnel
- its own keepalive, and only one was watched" bug: there is now exactly one
keepalive process that knows about every configured site.
Multiple sites — the design
One tunnel, many hostnames, one watchdog. This is the only sane topology:
- defaults to reusing the first healthy tunnel it finds on the
account. It merges the new hostname into the tunnel's existing ingress rules
(preserving other hostnames) and points the DNS CNAME at that tunnel.
- holds a array. Each run appends (or
replaces) one entry. removes one entry.
- iterates and guards every site. One process, one
watchdog, all hostnames. If a site shares a tunnel with another, the same
process serves both — keepalive won't start a second one.
When the agent gets this wrong (the bug that prompted this section):
creating a new tunnel per site means N
processes, N PID files,
N watchdogs to wire — and in practice only one watchdog ever gets set up. The
other tunnels silently die and nobody notices for weeks. Reuse the tunnel,
append to
, let one keepalive guard them all.