I picked up WebCMD to answer one question for myself: is "an agent that uses websites" a real building block, or a demo that falls apart the moment a second person shows up?
So I built the least glamorous thing I could think of — a District movie-booking app — and tried to make it something a stranger could actually use, not just something I could film once. This is a builder's account. I do not work on WebCMD, and there are parts of it I treated as a black box on purpose. That turned out to be most of the point.
TL;DR. WebCMD's five-minute demo worked, but turning it into a multi-user booking app required isolated sessions, hosted browsers, a middleware safety layer, and human handoff for OTP login. The app only reports a booking after verifying it, though the final confirmation still has a race condition.
The five-minute version that fooled me
The first thing I did was the thing everyone does.
npm install -g @agentrhq/webcmd
Then I opened an agent — I used Codex, but Claude or opencode work the same — installed WebCMD, and asked it to find a movie and start a booking. It drove a browser I could watch, I signed into District myself when it asked, and it handed me a link to pay. It worked on the first try, which is exactly the kind of thing that makes you overconfident.
Because that version has one user: me. I am the login. I am the session. I am the human who types the OTP. A product does not get to assume any of that.
Everything the word "product" dragged in
The moment I decided other people should use this, a single assumption shattered: that there is one browser and one signed-in human.
Suddenly I needed people to register and log in, so their District sessions stayed separate. I needed browsers that lived somewhere other than my laptop. I needed the agent to finish the job reliably, not just perform once. And I needed a UI that did not look like I had thrown it together in an afternoon — even though I had.
I did not have to build the hosted-browser-and-isolated-profile part myself. That is what WebCMD Cloud is: it gives every user their own profile and workspace with a hosted browser, so sessions do not bleed into each other. I treated it as a service I consumed. I never needed to know how it worked inside — I needed to know what it guaranteed. That distinction saved me a lot of time.
The one layer I would build first if I did it again
Early on I made a decision that everything else leaned on: the agent would never touch WebCMD directly. It would go through a wrapper I wrote, called moviectl.
I cannot overstate how much this one choice bought me. moviectl is the only door between an unpredictable language model and a real payment provider, and I gave it three jobs.
It derives who you are instead of trusting what the agent says. My wrapper takes the session key and computes the user id and the workspace name from it — the workspace is literally a hash of the session:
workspace = movie_
The agent cannot ask to use someone else's workspace. It gets the one its session hashes to, full stop. However badly a model might behave, it cannot name its way into another user's District session.
It gives the agent a short list of verbs, not a shell. Search, showtimes, seats, prepare-checkout, checkout, login. There is no general-purpose "run this" command for the model to be tricked into abusing.
And it puts the rules that matter into code, not just into instructions. I did also write those rules into the agent's persona and its skill file — "never store passwords, OTPs, or card details," a step-by-step workflow, an error-recovery table. Belt and suspenders. But I learned quickly that prose alone is a suggestion. The parts that must hold — identity, one-shot checkout — hold because they are code the model cannot route around.
If you build on WebCMD, or on any agent tool, write this middleware seam before anything else. It is where you decide what the agent is allowed to do, and it is the difference between a demo and something you would let a stranger touch.
{/* SCREENSHOT: the chat UI mid-booking, agent recommending screenings. alt: "Movie-booking chat assistant showing recommended showtimes" */}
The login I could not automate
District logs you in with a phone number and a one-time code. I spent a little while trying to think of a clever way around that, and then realized the clever thing was to not try.
An agent has no business receiving your OTP. So the login is the one step I deliberately did not automate — I handed it back to the person.
Here is where being an outside consumer of WebCMD Cloud actually helped. I did not have to solve interactive login myself. Cloud opens a live, interactive browser inside the user's own isolated workspace and gives my app a short-lived URL to it. My app relays that one URL. The user opens it, types their phone number and the code on District's own real pages, and the browser comes back logged in. I genuinely do not know the details of how that handoff is implemented under the hood, and I did not need to. I needed a URL to hand the user, and I got one.
The nice consequence is a clean split. My app stores who you are on my side — an email and a password, hashed with scrypt, in a plain SQLite file, because this is a proof of concept and SQLite is more than enough. But it never stores anything about your District identity. The credential you type stays between you and District. My agent is forbidden from even seeing it.
{/* SCREENSHOT: the live login view where the user enters phone + OTP on District. alt: "Interactive hosted login view for District sign-in" */}
The checkout I refused to fake
I could not verify that a user actually paid. So I made my app incapable of pretending it could. This is the design decision I am happiest with.
Checkout is a tiny state machine. Preparing one writes a record marked "awaiting confirmation." Running the checkout — which can only happen once per confirmation, and only if you have no other payment already in flight — flips it to "pending payment," calls the provider, and returns exactly one thing: a hosted payment link. Then it stops. No retry. No status lookup. No invented booking reference. A user telling me "I paid" is not evidence of anything.
I handled the failures with the same suspicion. Login expired? Roll back and ask them to sign in again. Seats gone? Expire the attempt and make them pick new ones. Ambiguous error, the kind where the payment might have gone through? Leave it pending and do nothing, because guessing is worse than stopping.
And I did not leave "never claim a booking" as a rule I had to remember. I made it impossible. There is a "confirmed" status in my types, but the database layer throws if anything tries to write it — "confirmed status is read-only in the local demo." Every write path physically cannot set it. My app is not merely reluctant to tell you it booked your ticket. It cannot.
When your software cannot know something, the honest design is one where it cannot say it either. A wall beats good intentions.
{/* SCREENSHOT: the checkout summary and the payment handoff link. alt: "Checkout summary with a hosted payment link" */}
The UI I did not write
My first UI was ugly, and a product cannot ship ugly. But I did not want to spend my week on CSS for something whose point was the agent, not the interface.
So I went shopping in open source. There are several good chat UIs built for agent harnesses — opencode, Open WebUI, Hermes' own web UI. I liked opencode's, so I pointed Codex at it and had it lift the UI layer and retarget it for movie booking. What I got is a clean SolidJS interface, built with Vite, served as static files by the same little Node server that runs my API. It looks intentional. It took an afternoon, not a week.
Behind it the plumbing is deliberately dull: the browser streams from a Node API over server-sent events, each conversation is one agent session, the agent runs my persona and skill, which call moviectl, which calls WebCMD. A per-user queue keeps one person's requests in order so the agent never trips over its own last reply.
Getting it onto a real server
The app runs on an Ubuntu VM as a couple of systemd services. I will keep this short, because it is the least surprising part — and that is the compliment.
The rule I followed everywhere: the process that drives people's logged-in browsers should own as little as it can. So it runs as a dedicated service account that cannot log in as a person. Releases are immutable, named by their git commit, with the live one pointed at by a symlink. Deploys build and test a new release off to the side and only flip that symlink after every check passes — and if the new release fails its health check, it rolls straight back to the previous one. Secrets sit in root-owned files the service can read but not own, never in the repo or the logs.
None of that is clever. It is just treating a browser agent in production like the real service it is.
{/* SCREENSHOT: optional — systemctl status showing the app and agent services active. alt: "systemd target showing the app and agent gateway running" */}
What is still broken
One thing, and I would rather show it to you than pretend it is not there.
When checkout succeeds, the payment viewer takes a beat to render on the cloud side. My checkout command does not wait for it — it grabs the URL and returns immediately. Most of the time the viewer is ready and everything is fine. Sometimes my command is too fast, the URL is not there yet, and the agent has nothing to hand back.
The fix is obvious in hindsight: wait for the viewer to finish rendering instead of reading it the instant checkout returns. I am flagging it because it is a bug with a general shape. In agent systems, "my command finished" and "the thing it triggered finished" are almost never the same moment, and if you assume they are, you ship something that works on your machine and stutters for a stranger. That gap is worth budgeting for from the start.
What I would tell the next person
Four things, in the order they cost me.
| Lesson | What it meant for me |
|---|---|
| The demo lies about how done you are | One user is a demo. A website is logins, isolation, and hosted browsers you never needed at your terminal. |
| Build the middleware seam first | Everything safe about my app lives in the wrapper between the agent and WebCMD. Write it before the features. |
| Do not automate what a human owns | The OTP was never mine to handle. I handed it back and relayed one URL. |
| Make the dishonest outcome impossible | I could not verify payment, so I removed my app's ability to claim it. Code beats good intentions. |
You do not need to understand every layer of your tools to build something real on them. I still could not tell you how WebCMD Cloud does half of what it does. I could tell you exactly what it guaranteed me, and that was enough to ship. If you want to try it, it is npm install -g @agentrhq/webcmd, and the docs go from local mode to Cloud.
FAQ
Do you need to understand WebCMD's internals to build on it?
No. I built a real multi-user app while treating WebCMD Cloud as a black box. What mattered was what it guaranteed — isolated per-user browsers and an interactive login handoff — not how it delivered them.
What is the single most important part of an app like this?
The middleware between the agent and the tool. In my app that is moviectl, and it is where user identity is derived and the payment path is locked down. Build it before the features.
How do you handle a login an agent cannot complete, like SMS OTP?
Hand it to the human. WebCMD Cloud opens a live browser in the user's workspace and gives you a URL to relay. The user completes the OTP on the real site; the agent never sees the code.
How do you stop the app from falsely confirming a booking?
Remove its ability to. My checkout ends at a "pending payment" handoff and the "confirmed" state is read-only at the database layer, so no code path can claim a booking the app did not verify.
Did you build the chat UI yourself?
No. I lifted opencode's UI and had an agent retarget it for movie booking. There are several good open-source agent UIs; adapting one is far faster than building from scratch.
What is the hardest part of shipping a browser agent for real users?
Realizing the demo hid the hard parts. Session isolation, a login you cannot automate, timing gaps between a command finishing and the browser finishing — none of that shows up until a second person uses it.