-
-
The console before it knows who you are. It has to ask for your deployment's address, because there is no list of deployments anywhere.
-
The owner's own agent: it shows what it looked at, and prints what the reply drew from the day's free neuron allowance.
-
Human in the loop. The agent proposes; it cannot write. Every change is a row the owner says Yes or No to, or one tap for all of them.
-
The same console in the dark theme. One set of tokens, redefined once.
-
It updates itself into the owner's own GitHub, and only reaches 100% when it observes the deployment reporting the new version.
-
When the agent cannot answer from the owner's material it does not guess — it hands the conversation over, with the reason.
-
Nutrient DWS reads the original price list as rows with a confidence each. Two came back low — those are the two to check before saying yes.
-
SerpApi, three engines. Shopping says what others charge and who sells it — every row named, so a price can be checked before it is copied.
-
The agent on the website she already had — one script tag. It answers from her price list, and hands over the one it will not guess at.
-
An agent runtime, not a chat widget: the loop, retrieval, storage and models all land in her Cloudflare account. $0 to start, no card.
Muxel
We cannot show you a list of our users. We do not have one.
Muxel is an AI agent platform that runs inside the owner's own Cloudflare account. Retrieval over their own documents, a tool-calling loop, and an approval gate on every write. Deployed in about ten minutes, on the free plan, with no card asked for.
It reaches customers where they already are: as a widget on the website they already have, one script tag, and as a Telegram bot. Same agent, same documents, same voice.
There is no server of ours anywhere in the path. That is not a privacy promise; it is an architecture in which we never receive the data. When you open the console at app.muxel.site it has to ask you for your own deployment's address, because it genuinely does not know.
And it costs us nothing to serve a customer. The inference bill that is the largest cost at every other AI product is, here, the owner's own Cloudflare bill. That is the business, and it is set out below under Business model.
Inspiration
A business owner in Bangkok gets the same six questions on Telegram every day. Do you deliver to Thonglor. How much is the Chemex. Are you open Sunday. The answers are all in a price list she already has.
Every product that solves this asks her to sign up, upload her customers to somebody else's database, and pay per seat forever. She is one person with one business. She will not do that, and she is right not to.
So we built the version where none of that is required: the agent runs in her cloud, on her keys, answering from her documents, and the people who wrote it cannot see any of it.
What it is
An agent runtime, not a chat widget. The widget is one of its front doors.
| Piece | What it does |
|---|---|
| Workers | The agent loop, the tools, the console API |
| Vectorize | Retrieval over the owner's own documents |
| D1 | Businesses, customers, conversations, price lists, approvals |
| Workers AI | Qwen 3.8 27B, Gemma 4 26B, Llama 3.3 70B, chosen per business |
| KV | Sealed keys and deployment state |
| R2 | Optional, and off by default. The original of each uploaded file, when a bucket is bound |
Two things, nothing to type. A Cloudflare account and a GitHub account. The deploy form asks for no secret at all: the deployment issues itself a console key on its first run and prints it on its own page until the first time you sign in. A Telegram console bot is optional and can be added afterwards from the Worker's own settings.
Start at $0. Everything above sits inside Cloudflare's free plan: 10,000 neurons a day, which we measured at about 880 replies on Gemma 4. The default is Qwen 3.8 27B, which follows instructions better and which we have not measured, so the figure we quote is the one we can stand behind rather than the one that flatters. Past that you are on Cloudflare's own pricing, in your own account, where you can see it.
What it does
- Answers on the site you already have. One
<script>tag, in a shadow root so nothing of your CSS reaches in and nothing of the widget's reaches out. It takes your colours and sits in your corner. - Answers on Telegram. The same agent, the same knowledge, a different door.
- Answers from your own material, and nothing else. Price lists, menus, policy documents (PDF, Word, Excel, text) become one searchable body of knowledge in your own Vectorize index, rebuilt the moment anything changes.
- Does not guess. A question your documents do not answer never becomes an invented one. It goes to a person, and the console says so.
- Gives you an agent over your own business. A chat that can read every business, price list, rule and conversation, and propose changes. It cannot make one: every write becomes a card you tap Yes on. The model is told plainly that nothing has happened, so it cannot report a success that did not occur. And your tap is answered: it reads the business back out of the database and tells you what you now have, because a row turning green is a claim about a request and not about the shop.
- Takes a file in the conversation. Drop a menu, a spreadsheet or a photograph of the board behind the counter into the chat. It is read once, on arrival, and kept as the text that came out of it; the agent reads what it needs by the name you gave it. Where it goes is a change like any other — into that business's knowledge, with its price list pulled out of it — so it is a card you tap Yes on.
- Reads a price list as data, with Nutrient DWS. See below.
- Looks things up on the live web, for you only, with SerpApi. See below.
- Shows what every answer cost. Cloudflare bills Workers AI in neurons and never reports them per request. Muxel counts tokens at the one door every model call passes through; the two together give a rate this account actually paid, so each reply carries
cost 27 · remaining 7,582 · total 10,000. - Updates itself. One button copies the new code into your own GitHub repository; Cloudflare builds from there. The console only shows 100% when it observes the new version running.
Business model
It costs us nothing to serve a customer. Not little. Nothing. Every reply is generated on the owner's Workers AI, stored in the owner's D1 and retrieved from the owner's Vectorize. The inference bill that eats 30 to 50 percent of revenue at a typical AI product is, here, the customer's own Cloudflare bill, and Cloudflare's free plan covers about 880 replies a day before it is a bill at all. So the runtime is free, and every dollar Muxel does earn is margin.
We charge for the parts that need a company behind them.
| Who pays | For what | Why it needs a company |
|---|---|---|
| Agencies and freelancers | Per business served, on one deployment | They are already the people putting the script tag on their clients' sites. One agency is thirty coffee shops, and what they want is one console, their own name on it, and someone to call. |
| Owners who want Messenger, Instagram or WhatsApp | A verified channel | Meta requires a verified business and an app review, which is weeks of paperwork a shop will never do. Telegram, Viber, Slack and the website widget stay free, because there the owner's own Worker talks to the platform directly and nothing of ours is in the path. |
| Owners who will not touch Cloudflare | A hosted deployment we run | The most expensive tier we sell, on purpose. It is the only one where we can see anything, and the only one that costs us anything. |
The line between free and paid is not a pricing decision. It falls where the architecture changes: everything the owner's own deployment can do by itself is free, and everything that needs somebody to be a company, in front of Meta or in front of the customer, is what we sell.
There is a second side. Every deployment opens a direct Cloudflare account and, if the owner turns them on, direct SerpApi and Nutrient accounts. That is distribution with no intermediary and no acquisition cost for the vendor, which is a revenue line that never touches the owner's wallet.
None of the paid tiers exist yet, and the product page says so in those words. There is no billing in the codebase. What exists is the part that makes the model unusual: a runtime whose cost to serve is zero because nothing of it runs on us.
The loop, closed: SerpApi to a customer, inside the film's first eleven seconds
This is the part of the demo video worth watching first, and it starts at four seconds.
- The owner asks their own agent: what do other cafes charge for a Chemex?
web_searchhits three SerpApi engines. Shopping comes back with three sellers and, crucially, who each of them is: Williams-Sonoma at $44.95, Brevé Coffee at $49.50.- The agent notices the shop sells the drink and not the pot, and proposes one card: Add Chemex Classic 6-cup (the pot) at 44.00.
- The owner taps Yes. One tap.
- On the shop's own website, a customer types do you sell the Chemex pot itself? and is told 44.00.
The video holds both surfaces in one frame for that last step, because cutting between them would ask you to take the connection on trust. Before the tap, that same widget answered that it did not have the item. The two recordings are joined only by the price list the approval wrote, which is the same thing that joins them in the product.
That is what an outside API is worth here: not a table in a console, but a thing the shop now sells.
Nutrient DWS: a confidence is what makes a human-in-the-loop worth having
Muxel already had the human in the loop. Every proposed change is a card you tap, and every tap is a row in operator_approval with who, what and when. The audit trail predates this challenge.
What it did not have was the number that tells you which rows to look at. Extraction asked a language model to read the prose a file had been flattened into. It works, and it has the failure a language model always has: it is exactly as fluent when it is wrong. Nothing said which rows it was sure of, so a price list with one hallucinated row looked precisely like one with none. "Approve each of these forty" is not review, it is a rubber stamp with extra steps.
DWS reads the original file and returns a confidence per field. Forty equally certain-looking rows become two worth your attention.
R2 is the one binding this needs, and the one Muxel does not create for you. Enabling R2 asks Cloudflare for a payment method even inside its own free tier, and the deploy is meant to cost nothing and ask for no card, so wrangler.jsonc binds no bucket. Without one an uploaded document is still read as text, indexed and answered from; what needs the original is reading it as rows, because extraction reads the file rather than the prose it was flattened into. On a deployment with no bucket read_document_data says exactly that rather than guessing from the text, and adding a DOCUMENTS binding turns it on.
Where it does the work: packages/runtime/src/rag/nutrient.ts posts the original PDF or XLSX to POST /extraction/extract with a typed JSON Schema and options.includeCitations. output.metadata mirrors output.data, so the confidence for data.items[3].price sits at metadata.items[3].price. The read_document_data tool feeds each row into a save_price card, and the confidence decides how it is described.
Three rules, each checked by putting the bug back:
- A confidence nobody gave is
null, never1. The API's own wording is "when available". Defaulting an unknown to certain turns "we do not know" into "we are sure", silently, on exactly the row that needed a person. - A row is only as sure as its weakest field.
recognitionScoreis a fallback and can never outrank the composite score. - A refusal arrives as the reason DWS gave. An exhausted credit balance reported as "no items found" is a document you are told is empty.
In the video: six rows come back, two of them flagged low. The owner taps the unsure one by hand, then Yes to all runs the rest one at a time, the count ticking 5, 4, 3, 2, 1 and each row turning green the moment its write lands. Then a customer on the website is quoted one of them.
SerpApi: three engines, three different questions
The core promise is that an agent answers from your own material and hands over when it cannot. That stays true: web search is not on the path that answers customers. It is your own tool, in your own console.
packages/runtime/src/web-search.ts uses three SerpApi engines:
| Engine | The question it answers |
|---|---|
google_shopping |
What does this sell for elsewhere, and who sells it? |
google_maps |
Who else is on the map near me, and how are they rated? |
google |
What does that page actually say? |
Every row carries where it came from. A price with no seller is a number you cannot check, and one you cannot check is one you should not copy.
Every key is yours, all four of them, sealed with the deployment's own master key in your own KV, called from your Worker. The panel that collects one says in plain words what leaves your account before you paste anything. The console shows which key is stored, 4d2a••••••••••f67c, and never the key: the mask is computed when it is saved and kept beside the sealed one, so nothing that draws a screen ever opens the envelope.
Challenges we ran into
Two integrations that would have failed silently. Both were first written from a sensible guess and both guesses were wrong in the worst way, which is quietly. google_maps refuses a query unless told type=search, so every local search would have returned nothing and been reported as "the web returned nothing for that": a search that was never run, described as one that found nothing. Its ll takes GPS coordinates, so a place name there is ignored; SerpApi's own docs say to put the city in the query. On the Nutrient side the schema must sit inside one outer instructions field: sent as its own field the API answers 400, which would have reached the owner as "Nutrient refused the file" and sent them hunting for a bad PDF.
A check that has not finished is not a check that failed. The self-update only applies an upstream commit whose checks passed, and read that from one jq expression. A run still going has conclusion: null, which failed every comparison in the pass branch and fell through to "failed", so an ordinary release landing mid-CI was logged as an upstream build that had broken. Unknown is its own answer and now says so.
The archive was write-only. Reading a document as data needs the original file, and Muxel had been keeping one, under a key containing a random id recorded nowhere. document.object_key had existed the whole time and had always been stored empty.
A deploy button does not create a fork. The self-update copied a git tree between two repositories and assumed shared objects. Forks share; the one-click deploy imports, so every update died on tree.sha … is not a valid blob.
A prompt that had to stop lying. The system prompt said "Muxel does not browse the web", and a test enforced that no web_* tool existed, deliberately, so a capability could never arrive while the model kept telling owners it had not. Product, prompt and test changed together, and the law got stricter: no denial for a capability a tool provides, and no tool for a capability the prompt denies.
A card that did not say which shop. Found on the demo deployment's own database the night before the deadline: two approved prices sat under the wrong business. The owner had asked for a new shop and its prices in one message; the model proposed the shop and, in the same message, twenty prices against the only business id it had. The cards said Price: Batch Brew at $4.00 and nothing about which shop, and the access check said yes to any id for an owner, existing or not. Now a change is bound to its business before it becomes a card: a business that does not exist cannot be proposed against, a card names the business it is to (Price: Batch Brew at $4.00 → hello is a card you can say no to), and once a message has proposed creating a business, nothing else in it that names a business is proposed, because the one it should name has no id yet. The prompt had said all of this in words. A rule the system only says is not a rule.
A promise is not a change. Asked to add six prices to a business that already existed, the model replied "I'll add those six prices to Shwe Coffee Shop for you. Tap Yes below to confirm." and called no tool at all: no cards under it, and operator_approval and operator_step both empty for that message. The loop had ended the turn on if (turn.toolCalls.length === 0) break;, which reads as "the model has finished" and, for a model that has just promised something, means the opposite. Nothing reads the prose to decide, because there is no way to tell a promise from an answer by looking at words and a rule tuned on an English sentence would fail on a Burmese one the same afternoon. What the system knows for certain is what was called, which was nothing, so that is what it says back, once. A turn that genuinely needs no tool repeats itself and is believed.
A service we claimed and had never bound. The landing page said "R2 holds the files" and listed it in the stack strip. wrangler.jsonc binds no R2 bucket and never has, because enabling R2 asks Cloudflare for a payment method even inside its own free tier and the deploy is meant to ask for no card. Meanwhile the README had drifted the other way and said the original was archived but that "nothing reads it back", which stopped being true the moment DWS started reading it. Both say the same true thing now, and the test that guards the page reads wrangler.jsonc, so binding a bucket later fails it rather than passing silently.
Green is a claim about a request. Tapping Yes on a card ended the conversation: the row turned green, a toast said "Done." and nothing else was ever said. An owner who had just approved six prices had to leave the chat and open the price list to find out what they now had, because a card describes what was asked for and never what happened. A tap now starts a turn of its own, and the instruction the deployment writes for it says to look rather than to describe: call get_business for each business the changes were to, read what comes back, and say what is there now and what did not go through. One turn for a run of taps, not one a card, because "Yes to all" on six prices is one question.
A header the browser refused before the Worker saw it. The console is served from somewhere else and talks to your Worker directly, so a request carrying a header of its own is preceded by a preflight. Adding x-chat-id to the new file door and not to access-control-allow-headers would have made every upload fail silently — no status, no body, indistinguishable from a deployment that is switched off. The fix is one word on one line; what it cost was noticing that the list existed at all. A test now walks the console's own source for the headers it sends and checks each one against that line, so the next header cannot arrive without it.
A product a third of the world could not have used. Setup refused to finish without
ADMIN_BOT_TOKEN and OWNER_TELEGRAM_ID, and the web console could only be signed into with a
pairing code that the Telegram console bot was the only thing able to issue. Between them,
somebody with no Telegram account could not use their own copy of this at all — and nothing
about the product needed that. It was a coupling nobody had noticed, because everyone who built
it had Telegram.
There are two doors now and either one alone is a finished deployment. The deployment issues
itself a console key on its first run, and CONSOLE_KEY is the owner's override of it;
POST /admin/claim trades the key for the same thirty-day session the pairing code has always
ended in, so one place mints a token and the two doors cannot drift in how long
somebody stays signed in. And changing the key ends the sessions it opened — without that,
"change your key" stopped the next sign-in and nothing else, while every browser already holding
a token kept working, which is exactly the case a leaked key is changed for.
The deploy form asks for nothing, because it reads .dev.vars.example and that file now lists
nothing. Cloudflare makes every name in it a required field, and one box was enough to stop a new
owner at "Please fill out this field", asked for a password to a thing that did not exist yet. A
row of empty boxes at the first step reads as work to do before anything happens, and there is
none.
A sentence that would have aged badly. The intro page said "we have no way to charge you, because there is nothing here to sign up for". True the day it was written, and a forecast the moment anybody read it. It now says "start at $0" and describes whose bill it is past the free plan, which is checkable today and binds nothing later. A test forbids eight phrasings of the old promise.
Accomplishments that we're proud of
- Nothing of the owner's ever reaches us. Not as policy, as the shape of the thing. The two new integrations run on your keys, from your Worker.
- 956 tests, and the ones that matter are checked by putting the bug back and watching them fail.
- The demo video is the product, recorded. The console is served from the repository and driven in a real browser at the frame's own resolution; the widget runs on an ordinary website that is not ours. The confidence table in the film is drawn by the console's own markdown renderer. The prices the widget quotes were written by the approvals in the clip immediately before it.
- 20 migrations, none of them an
ALTER TABLE. A migration stays safe to run twice.
What we learned
The interesting engineering was almost never the model. It was the seams: what a record means to two readers, which of two oks a caller is looking at, whether a number on screen was measured or assumed. Every real bug here was two halves each being reasonable on its own.
And that adding a capability is mostly a documentation problem in disguise. The hard part of web search was not the API call; it was the four places that had confidently told the owner it was impossible.
What's next for Muxel
- Routing a low-confidence DWS row straight into the waiting queue, so an unsure price is triaged the way an unanswerable customer question already is
- More front doors through the same one-record binding: WhatsApp, Instagram, and with them the first paid tier, verified channel setup
- Agency licences: one console over many businesses, with the agency's own name on it
- Owner-set thresholds on what counts as sure enough to propose without comment
Try it as a customer: https://muxel-demo.thankywal-bkk.workers.dev/w/z19qw38k69b3fzwbfrx4 (press the bubble and ask about the Chemex or the Siphon Brew) · The console: app.muxel.site, which will ask you for a deployment because it does not have one of its own · Source: github.com/thankywal/muxel · Deploy your own: one click, about ten minutes, start at $0.
Built With
- cloudflare-d1
- cloudflare-kv
- cloudflare-r2
- cloudflare-vectorize
- cloudflare-workers
- cloudflare-workers-ai
- github-api
- nutrient-dws
- serpapi
- telegram-bot-api
- typescript
- vitest
- wrangler