A household app from one paragraph, and what the hub still checks
We described a poll app to an assistant in one paragraph. Here is the package it wrote, what the hub verified on the way in, and what it left open.
Engineering โบ Building an app from one prompt
Writing a small app is no longer the hard part. Someone who has never opened an editor can describe a task list to an assistant and get one back. What they get back is a one-off: it has no accounts, nowhere to keep data, no way to reach the rest of the family, and no phone app.
So the interesting question for a platform has moved. It is not "can a model write the page", it is "what does a page written by a model get for free, and what stops it from doing damage once it is installed". This post walks through one real app, from the paragraph that asked for it to the row in the database, and marks which parts we verified and which parts we did not.
Watch the demo: the app being built and used (YouTube)
The whole specification
This is everything the assistant was told:
Create an application in Chickadee Bandit that lets me create a quick poll
to let users choose between a few options with a configurable time limit
to respond that is under 60 minutes. Household users should get a
notification of the poll so they can respond. Users should be able to pick
one of the options that was configured and then see the poll results. The
app should show how much time is left in the poll.The assistant was connected to the hub over MCP with an admin token for one household. It has three tools that matter here: one returns the authoring guide, one validates a package, one publishes it. There is no repository, no build step and no deploy. The files it sends are the app.
A package has three parts, and that is the entire surface:
manifest what the app is and what it may touch
files index.html, plus any named queries
migrations the SQL that creates its tablesWhat it asked permission for
The manifest it wrote declares one read and no writes:
{
"id": "quick-poll",
"runtime": "static",
"storage": "db",
"data_access": { "reads": ["family.members"], "writes": [] },
"permissions": { "default_audience": "everyone", "requires_approval": false }
}`family.members` is there so the results can say who voted for what. Nothing else about the household is reachable from the page, because nothing else is declared, and the hub serves an app only the keys in that list. The page runs in a sandboxed frame under a content security policy, so it cannot quietly fetch a script from somewhere else either. An app that does name outside hosts is accepted and then held: it does not run until a household admin approves it.
This is the same manifest format, checked by the same code, as every app in the catalog. The publish tool does not have its own lighter path. It hands the package to the function the catalog installer and the file upload also call, and the validate tool runs those same checks and stops before the install. That is the property that makes validation worth calling: it judges exactly what publishing would install.
A few refusals are specific to a package that arrives this way. It may not use the id of a built-in app, which would let it impersonate one. It may not publish a lower version over a higher one. Its migrations may only add: a `DROP`, a rename, a trigger or a view is refused before anything runs.
Two constraints that are real
The prompt contains two rules: the time limit is under sixty minutes, and a person picks one option. The page enforces both, with a form that rejects 60 and a button that replaces your vote rather than adding one. A check in a page is a suggestion. What matters is where else the rules live, and here they are in the schema:
CREATE TABLE IF NOT EXISTS app_quick_poll__polls (
id TEXT PRIMARY KEY,
question TEXT NOT NULL,
options_json TEXT NOT NULL,
duration_minutes INTEGER NOT NULL CHECK (duration_minutes >= 1 AND duration_minutes <= 59),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
closes_at TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_quick_poll__votes (
id TEXT PRIMARY KEY,
poll_id TEXT NOT NULL REFERENCES app_quick_poll__polls(id),
member_id TEXT NOT NULL,
option_index INTEGER NOT NULL CHECK (option_index >= 0),
created_at TEXT NOT NULL,
UNIQUE (poll_id, member_id)
);"Under 60 minutes" is a `CHECK`. "One vote each" is a `UNIQUE`. Neither can be skipped by a second tab, a stale page or someone typing SQL into the console, because the database refuses the row.
They are in the first migration for a reason the authoring guide states outright: migrations are additive, so a constraint cannot be added later. A model that leaves them for a second pass has lost them for good. Telling it that in one sentence turned out to be enough.
Two things are absent from that schema, and both are deliberate. There is no `household_id` column, because each household has its own database and there is nothing to filter by. And nothing marks `question` as sensitive, because the hub encrypts text columns at rest by default. The columns the page sorts and compares on, `created_at`, `closes_at` and `status`, stay readable by a naming rule, which is why the app needed no encryption configuration at all.
The parts the author did not write
The prompt asks for a notification. The page's entire contribution is this:
const url = hubAppUrl(window.__APP_ID, { poll: poll.id });
const sent = await sendHubNotification({
title: "๐ณ๏ธ New poll from " + who,
body: poll.question + " โ vote within " + poll.duration_minutes + " min",
generic: { body: who + " started a poll โ " + poll.duration_minutes + " min to vote" },
audience: isAdult() ? "all" : "adults",
url,
});Everything after that call belongs to the hub. It decides who can open the app and so who is in the audience, drops members who muted it, holds a push back during someone's quiet hours, and rate limits the sender. The page cannot override any of it.
Three details in those few lines came from the guide rather than from the prompt, and they are the ones a first draft usually misses:
- The `generic` body. A notification shows on a locked screen, and the question is something a family member typed. People who turned previews off get the substitute.
- The audience switch. A child may notify only the adults, so a poll started by a child reaches the adults and the page says so, instead of sending to everyone and being refused.
- The return value. A send that is refused or rate limited returns null and does not throw. The page checks, and tells the person the household was not notified.
The link is the other half. The `poll` parameter comes back to the page in its query string, so a tap on the notification opens that poll and scrolls to it. The same URL works on the phone, because the mobile app does not have a second implementation of anything: it opens an installed app in a web view pointed at the same page. That is why an app nobody wrote mobile code for showed up in the mobile app the moment it was published.
The manifest also lists one named query the assistant may run later, read only. That is what lets the person ask next week what the family decided about dinner. It is opt-in per app, and the guide tells the model to leave it out of anything private.
What it does not enforce
The app works, and the two rules from the prompt hold. It is still worth being exact about what was not checked, because "an assistant built it and it passed validation" is easy to hear as more than it is.
Validation proves the package is well formed and stays inside its declarations. It says nothing about whether the page is correct. The guide says so to the model in as many words: you cannot see the app run, so make failures visible on the page.
More specifically, this app declares no row policies. Without one, any member of the household can read and write any row in its tables. So three things the page presents as rules are habits:
- A vote carries a `member_id` the page fills in. Nothing stops a member from writing a row with a sibling's id.
- A poll closes because the page stops offering buttons once `closes_at` has passed. A write after that moment is still accepted.
- The `status` column flips to closed when somebody's browser next loads the list and notices. No server job owns the deadline.
For a vote on what to have for dinner, among people who share a kitchen, that is the right amount of engineering. It would be the wrong amount for a vote that mattered.
If the paragraph had asked for more
Suppose the prompt had gone on for two more sentences: nobody may vote as someone else, and nothing counts once the time is up. Could the assistant have delivered that?
The first, yes. The second, most of it, and the guide is written so that the remainder gets said out loud.
The main guide is read in full for every app, so it stays short. It teaches two row policies and neither fits a vote: one lets only adults write, the other makes each person's rows private, and everyone else's votes are the results. What it carries instead is a short index written the way a person asks for things. "Each person may change only their own rows while everyone sees them" and "nothing may change once something is closed" each send the model to fetch a deeper section before it writes the manifest. The index is by need because a model cannot ask for a policy it has never heard of.
That section gives it this for the votes table:
{
"votes": {
"kind": "sealed_until",
"fk_column": "poll_id",
"parent_table": "polls",
"writer_column": "member_id",
"parent_status_column": "status",
"visible_parent_status_values": ["open", "closed"],
"frozen_when": { "status_column": "status", "locked_values": ["closed"] },
"column_write_acls": {
"member_id": { "writable_by": [], "actions": ["update"] },
"poll_id": { "writable_by": [], "actions": ["update"] }
}
}
}It is still only a declaration, and each part of it is a refusal:
- `writer_column` makes the hub write the caller's own id into `member_id` on every insert, replacing whatever the page sent. Updates and deletes reach only rows the caller wrote, and there is no exception for adults.
- `column_write_acls` locks `member_id` and `poll_id` once the row exists. Without it a member cannot create a vote in a sibling's name but can still hand their own over with an update, which is the sort of gap no demo shows.
- `frozen_when` reads the poll's status. Once it is closed, a new vote is refused and a changed one changes nothing. The same lock on the polls table means a closed poll cannot be reopened.
The section's example is run through the real policy engine by the test suite, statement by statement, so each of those three is a refusal that happens and not a description of one.
What is left is "once the time is up". Closing a poll here is a change of status, not a clock. A deadline that has passed does nothing until some member's page marks the poll closed, and a vote cast in between is accepted. The section says exactly that, and tells the model to say it to the person instead of hiding it behind a countdown.
A deadline the hub enforces by its own clock does exist, and the built-in Polls app is where to see it. Polls is the same kind of thing as the app above, a static page and a manifest, but its page cannot insert a vote at all. A vote goes to an endpoint the hub runs, which takes the voter from the signed-in session, refuses a poll that is not open, can check a declared deadline against the hub's clock, and keeps a receipt so a second vote is refused before it reaches the table. Polls also keeps votes sealed until an adult closes the poll, records no voter at all for an anonymous one, and lets people outside the household vote by link. It is a more careful product and a slower one, and it is deliberately not what the paragraph asked for.
So one paragraph buys the app, and two more sentences buy the two guarantees that matter in a household. The clock is a longer document, and for this particular problem it already exists as an app whose source is open to install or copy from. The guide teaches the part of the platform a model can get right from a description, validation catches a wrong declaration, and neither pretends the remainder is safe to improvise.
Where the trust sits
None of this depends on the model being careful. The page it wrote could have been careless or hostile and the outcome would differ only in quality: it would still see one context key, still be unable to load outside code, still be confined to tables carrying its own prefix in one family's database.
That is also why we cannot tell you what else people have built this way. An app published into a household lives in that household's database, its text encrypted under a key derived for that household, and we keep no report of what gets published or what is in it. For a platform that would like to know what its users are making, that is an inconvenient property, and it is the one the whole product exists to have.
Read next
Written at Chickadee Bandit, a private hub for running a household โ the thing all of this is load-bearing for. More engineering posts.