From Telegram to demo.contextforce.com — wiring an AI agent to ship HTML on demand
Updated · 10 min read

Problem My agent answered Telegram requests with Docker-internal URLs like http://172.18.0.2:8090 that my phone could not open.
Solution
Pre-wire the hosting once (a persistent http.server behind a Cloudflare Tunnel) and reduce the agent's job to picking a folder name. The reply URL becomes https://demo.contextforce.com/<name>/, so the agent cannot return a wrong one.
A weekend rabbit hole: I wanted my AI agent to answer Telegram messages by spinning up live HTML previews under my own domain. What I expected to take an hour took most of a day, and the dead ends taught me more about agent orchestration than the happy path ever could.
I run an AI agent called Hermes on a Hostinger VPS. It’s a Claude-powered assistant that talks to me over Telegram, takes design and coding requests, and acts on them. The pitch is simple — type a prompt on my phone, get back a working artifact. The reality, as always, was more interesting.
This post is the recap of the workflow I ended up with: Telegram message → Hermes agent → static HTML deployed to demo.contextforce.com/<project>/, with a permanent URL that survives reboots. If you’re trying to do something similar — give your agent a way to “ship” actual artifacts that you can open from your phone — this is the playbook.
The setup, in one paragraph
Hermes runs as a Docker container on a Hostinger VPS. Inside the container, a tiny Python http.server serves /opt/data/projects/ on port 8000. Every project the agent creates lives in its own subdirectory there. A Cloudflare Tunnel on the host VPS forwards https://demo.contextforce.com/ to that port. The agent’s only job per request is to write an index.html into /opt/data/projects/<name>/ and reply to me with a URL like https://demo.contextforce.com/<name>/. That’s it.
That clean architecture is where I ended up. The path to it was anything but clean.
First attempt: ngrok and a tangled skill library
I started with ngrok. It’s the obvious choice for “give me a public URL pointing at localhost,” especially when your VPS host (Hostinger, in my case) wraps everything in a Traefik reverse proxy with a self-signed certificate. Ngrok gave me https://xxxx-xx-xx-xx-xx.ngrok-free.app in two commands, and the first preview worked.
Then it started misbehaving.
I’d ask Telegram for a landing page. Three minutes later — three minutes! — Hermes would reply with a URL like http://172.18.0.2:8082. That’s a Docker internal IP. My phone has no route to it. Click the link, get nothing. Send another message. Get back http://172.18.0.2:8090. Different unreachable IP, same useless result.
The first thing I did wrong was assume I knew the cause. I ran around making fixes — I cleaned up a stale skill, deleted the cached agent session, restarted things, updated memory entries — and each time, the next Telegram request came back with the same broken format. I was treating the symptom and not the source.
Lesson 1. When an agent does the wrong thing, the question isn’t “what skill did I want it to use.” It’s “what skill did it actually load, and what does that skill tell it to do?” Read the session log first. Always.
Reading the session JSON revealed the real bug
Eventually I cracked open the session file at /opt/data/sessions/session_*.json and replayed every tool call the agent had made. The smoking gun was right there:
[4] TOOL_RESULT: skills_list returned [..., "claude-design", ...]
[6] TOOL_RESULT: skill_view("claude-design") loaded
[10] TOOL_RESULT: write_file("/opt/data/instant-search/index.html") OK
[12] TOOL_RESULT: terminal("python3 -m http.server 8090") started
[13] TOOL_RESULT: terminal("hostname -I") returned "172.18.0.2"
[18] ASSISTANT: "Live at: http://172.18.0.2:8090/"
The agent had loaded a skill called claude-design, and that skill’s preview workflow literally said:
“mkdir
/opt/data/<project>/, startpython -m http.serveron a unique port, runhostname -Ifor the IP, returnhttp://<IP>:<port>to the user.”
The agent was following its instructions perfectly. The instructions were just wrong for my VPS, where only one specific port was wired through to the public internet, and where the Docker bridge IP was meaningless to anything outside the container.
I’d built a second skill called html-preview-ngrok with correct instructions, hoping the agent would prefer it. It didn’t — Hermes’ skill triage matched the request “make me an HTML page” more strongly to claude-design’s description (“Design one-off HTML artifacts”) than to mine (“Build HTML page, serve via ngrok”). My new skill was never loaded.
Lesson 2. Adding a more specific skill doesn’t override an existing general one. Skill triage is description-based string matching — if the existing skill’s description hits the user’s intent more directly, the agent picks it and never sees yours. Patch the source, don’t bolt new layers on top.
Fix at the source: rewrite the skill
So I patched claude-design’s preview section directly. New rules:
- Write to
/opt/data/projects/<name>/index.html— single canonical location - Don’t start any server — one is already running
- Don’t pick a port — only one has tunnel routing
- Don’t run
hostname -I— Docker IPs are unreachable - Reply with
https://<public-host>/<name>/and only that
And I deleted my redundant html-preview-ngrok skill, since the architecture was now consolidated where the triage already pointed.
The architecture flip: pre-wired hosting, agent picks a folder name
Here’s the move that made everything click. Up to this point, the agent was responsible for too much — creating folders, picking ports, starting servers, constructing URLs. Every one of those decisions was a chance to do something wrong.
What I did instead: pre-wire all the infrastructure permanently, and reduce the agent’s job to a single decision — what to call the folder.
Concretely:
- Run
python3 -m http.server 8000from/opt/data/projects/, once, persistently. - Tunnel
https://demo.contextforce.com/to that port, once, persistently. - The agent only writes to
/opt/data/projects/<name>/index.html. - The reply URL is
https://demo.contextforce.com/<name>/— purely a function of the folder name.
Now the agent cannot return a wrong URL. It has no port to choose, no host to construct. The folder name is the only variable, and it shows up directly in the URL path. As a bonus, every past preview stays live at its own URL — I get a portfolio for free.
Lesson 3. The most reliable way to make an agent do the right thing is to remove its ability to do the wrong thing. Pre-wire infrastructure. Make the agent’s only decisions the ones that genuinely require its judgment.
Replacing ngrok with Cloudflare Tunnel
The pre-wired hosting made me realize ngrok was the weakest link. Free tier rotates the hostname every time the process restarts. So if I rebooted the VPS, every URL I’d ever sent myself in Telegram died. Bookmarks died. Worse — if I shared a URL with anyone, that link broke without warning.
I switched to Cloudflare Tunnel. It does the same job as ngrok — outbound connection from your server, public URL on Cloudflare’s edge — but with a permanent hostname tied to a real domain you control, free, no rate limits, real cert, no browser warning page.
The setup, on Ubuntu 24.04 as a sudo user:
# 1. Install
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | \
sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
https://pkg.cloudflare.com/cloudflared noble main" | \
sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared
# 2. Authenticate (opens a browser)
cloudflared tunnel login
# 3. Create the tunnel
cloudflared tunnel create hermes-demo
# 4. Write /etc/cloudflared/config.yml:
# tunnel: <UUID>
# credentials-file: /home/user/.cloudflared/<UUID>.json
# ingress:
# - hostname: demo.contextforce.com
# service: http://<container-ip>:8000
# - service: http_status:404
# 5. Wire DNS automatically
cloudflared tunnel route dns hermes-demo demo.contextforce.com
# 6. Run as a systemd service
sudo cloudflared service install
sudo systemctl enable --now cloudflared
Within ten minutes, https://demo.contextforce.com/ was serving from my container, with a real cert, and the URL was permanent. Survives reboots, restarts on crash, no rotation ever.
Lesson 4. If your previews matter beyond a single session — bookmarks, links you share with collaborators, links you reference in a thread — ngrok free is the wrong tool. Cloudflare Tunnel is just better, and it’s free if you have any domain on Cloudflare.
One subtle gotcha: network_mode: host wasn’t actually set
The first time I ran cloudflared tunnel run, Cloudflare connected fine but every request returned 502. The tunnel was reaching the host, but localhost:8000 was empty from the host’s perspective. I’d assumed the Hermes container was using network_mode: host based on stale notes — turns out it wasn’t. It was on a Docker bridge, with only one port (the ttyd web UI) exposed.
The python http.server, listening on port 8000 inside the container, was completely invisible to the host’s loopback.
The fix was a one-line config change — point cloudflared at the container’s bridge IP instead of localhost:
service: http://172.18.0.2:8000 # not localhost
It works, but it’s fragile — the IP can change if Docker rebuilds the network. The clean fix is to add -p 8000:8000 to the Hermes docker-compose.yml so the port lands on the host and localhost:8000 Just Works. I’ll do that next time I have a reason to bounce the container.
Lesson 5. Verify your assumptions about container networking before debugging anything that depends on them.
docker inspecttakes ten seconds and beats hours of guessing.
The last polish: a real index page
Python’s default directory listing is functional and ugly. So I wrote a 100-line script (rebuild_preview_index.py) that scans /opt/data/projects/, pulls the <title> from each project’s index.html, and renders a styled landing page with a list of all live previews — the same dark-mode, lime-accent aesthetic as the projects themselves. The agent runs that script as the last step of every preview request, so the gallery always reflects what’s actually live.
The final flow
What it looks like end-to-end now:
- I send a Telegram message: “make me a modern dark mode page about a serverless commerce search SaaS”
- The Hermes gateway agent receives it, loads the patched
claude-designskill - It picks a folder name (
serverless-commerce-search), writes the HTML - It runs the index rebuilder so the new project shows on the gallery
- It replies:
https://demo.contextforce.com/serverless-commerce-search/ - I open the link on my phone. It loads. It stays loaded.
Total round-trip is well under a minute. The URL is permanent. The gallery at the root of the domain is a portfolio of every preview the agent has ever shipped for me. And the architecture is robust — the agent has so few decisions to make that there’s nowhere left to go wrong.
The takeaways
Five things I’d tell anyone wiring an agent to ship artifacts:
- Read the session log when behavior surprises you. Don’t assume which skill loaded — verify.
- Patch the source skill, not bolt on a new one. Triage is description-matched; you can’t outrank an entrenched skill from outside.
- Pre-wire infrastructure permanently. Reduce the agent’s per-request decisions to ones that actually need judgment.
- Use a tunnel that doesn’t rotate. Cloudflare Tunnel is free with any domain and replaces ngrok cleanly.
- Verify container networking before debugging routing. A single
docker inspectwould have saved me an hour.
The agent stack is finally invisible the way good infrastructure should be. I send a Telegram message; a URL comes back; the page loads. Everything between is wired once, right, and never thought about again.
If you’re building something similar and run into walls, the patterns above generalize. The specifics — Cloudflare Tunnel, Hermes, Telegram bot tokens, ngrok, Docker bridge IPs — are details. The shape of the lesson is always the same: find the source of truth the agent actually reads, fix it there, and remove the surface area where it can deviate.
Questions people ask
- Why did the agent keep returning unreachable 172.18.0.2 URLs?
- It had loaded the claude-design skill, whose preview workflow said to start python -m http.server on a unique port, run hostname -I, and return http://<IP>:<port>. On the VPS that IP is a Docker bridge address that nothing outside the container can reach.
- Why didn't adding a more specific skill fix it?
- Skill triage is description-based string matching. The existing skill's description matched "make me an HTML page" more directly, so the new skill was never loaded. Patch the source skill instead of bolting new layers on top.
- Why replace ngrok with Cloudflare Tunnel?
- Free ngrok rotates the hostname every time the process restarts, so every URL died on reboot. Cloudflare Tunnel gives a permanent hostname on your own domain, free, with a real certificate and no browser warning page.