Scaffold a Backend-less Web App with One Prompt: What the Agent Does and Why in That Order

Connect an agent to altengine's MCP server and describe the app in one line. It fetches the scaffold-backendless-app prompt and builds five things in a fixed order: a datastore instance, an auth instance, access rules on the auth instance that confine each user to their own rows, a function for the one job a browser must not do, and a static instance serving the built site. The browser then talks to the data directly with the user's own token, and no server of yours is involved. The agent does the building, but the rules are what protect the data, so you still have to read them.

What the prompt is

MCP servers can publish prompts as well as tools: a named, step-by-step plan the agent fetches with prompts/get before it starts. altengine publishes three. scaffold-backendless-app takes one required argument, description, and plans and creates the instances for a static app that calls the services directly, with per-user row rules.

It is a plan, not a template. You get no boilerplate repository. The agent follows a sequence and makes ordinary tool calls (create_instance, auth_set_rules, functions_deploy, static_deploy), each of which you can see and approve in your client. Getting the prompt is not billed. The tool calls are, at $0.02 per 1,000.

Say you ask for a notes app where each person sees only their own notes, and can share a note with a teammate. Here is what happens.

The five steps, and what each one needs from the step before

The scaffold steps, the tool used, and what each step depends on
StepToolNeeds
1. Datastore instancecreate_instanceNothing
2. Auth instancecreate_instanceNothing, but its id goes into the front end
3. Access rulesauth_set_rulesThe datastore instance's name
4. Functionfunctions_deployInstance names for its grants
5. Static sitestatic_deployThe auth instance id and the function URL, baked into the build

1. The datastore

The data goes in first because everything else points at it. There is no schema step: a collection exists once something is written to it. While the agent is here it should read the docs://datastore/query resource. The query language is App Engine's, and a model that has not read it tends to write SQL.

2. The auth instance

This holds your end users' accounts and issues the id_token the browser sends to Datastore. Choosing the sign-up fields is configuration (email and password, or a username) and does not need code. One detail trips up hand-written front ends: the auth endpoints are addressed by the instance's id, /v1/auth/{instance-id}/…, not by its name. The id is public and belongs in client code. Every other service accepts the name.

3. The rules

This step is the one that matters. Rules live on the auth instance as a map keyed by target, so they cannot be written until the datastore exists and has a name. For the notes app, the rules the agent sets should look something like this:

{
  "datastore:appdb": {
    "level": "full",
    "rules": {
      "_default": {
        "notes": {
          "read": { "any": [
            [{ "field": "owner", "op": "=", "value": "$auth.uid" }],
            [{ "field": "shared_with", "op": "=", "value": "$auth.uid" }]
          ] },
          "create": { "stamp": { "owner": "$auth.uid" } },
          "update": {
            "match": [{ "field": "owner", "op": "=", "value": "$auth.uid" }],
            "immutable": ["owner"]
          },
          "delete": {
            "match": [{ "field": "owner", "op": "=", "value": "$auth.uid" }]
          }
        }
      }
    }
  }
}

The read filter is ANDed into every query, so a user who lists notes gets back their own rows plus the ones shared with them, and cannot page past that. stamp writes the owner from the verified token and overrides anything the client sent, so a user cannot create a note in someone else's name. "level": "full" is there only because deleting is gated at full. delete.match still limits every delete to the caller's own rows. A collection the rules do not name cannot be reached at all.

4. The function

A browser with an identity token gets one collection per request, filtered by the rules. Some jobs need more than that: transactions, query joins and container jobs all need an organization key, and an identity token is refused. That work goes in a function. In the notes app, an obvious candidate is share this note with the teammate whose email is X. That means looking up another user, which no row rule can express. The function verifies the caller first, because function URLs are public:

const token = (request.headers.get("authorization") || "").replace(/^Bearer /, "");
const identity = await env.auth.verifyToken({ instance: "users" }, token);
if (!identity) return new Response("unauthorized", { status: 401 });
// identity.sub is the end user's id — now do the privileged work on their behalf.

Row rules do not apply inside a function. Its grants are its whole blast radius, so it is deployed with grants naming the exact instances it needs (datastore:appdb at write and auth:users at read, for example), which is why it comes after both exist. The front end calls it from another origin, so the functions instance also needs its allowed CORS origins set. Until they are, a function sends no CORS headers and the browser blocks the response.

5. The static site

The front end goes last because it is the only piece that needs everything else to exist: it ships with the auth instance id and the function's URL in it. static_deploy puts the build on its own subdomain, and the agent hands you that URL. For sign-in screens, the agent can drop in the auth instance's embeddable form (one script tag) instead of building its own.

The checklist before real users arrive

Even when every step succeeds, check these yourself, in this order.

  • Read the rules back. Ask the agent to call auth_get_rules and show you the result, then read it. Look for an entry with a level and no rules. That entry is not default deny: { "level": "full" } on its own lets any signed-in user read, write and delete any row in that instance. Look for $auth.profile.* in a rule that decides access. Profile is what the user typed at sign-up, so anyone can claim any role there. Authorize on $auth.claims.*, which only you can set.
  • Make sure no key shipped. The browser should never hold an organization API key, and it does not need one. But a static deploy uploads everything in the directory, dotfiles included, so a stray .env in the build output goes out with the site. Search the build output for ae_ before you trust it.
  • Turn off auto-indexing on the datastore. It is on by default and it applies to end-user tokens too, so a client query can trigger an index build, billed as writes. Once your query shapes are settled, turn it off so a missing index returns 400 INDEX_REQUIRED instead.
  • Check the function's grants and versions. Grant the least it needs. Each deploy keeps a version, 3 by default (keepVersions), at $0.10 per retained version per month whether or not anything calls it.
  • Set the auth instance's allowed origins to your site's address. This is hardening and not the trust boundary (the token is), but it costs nothing.

Where this approach stops

  • A rule constrains the document, not the caller. Every filter compares a field on the row with a value from the token. Only an admin may set this cannot be written directly. Stamp the deciding value onto the row, or move the write into a function.
  • Identity tokens reach Datastore and Channel only. An access entry can name only datastore: and channel: instances, so search, blob uploads and containers all go through a function. Within Datastore, transactions, joins and index management also need an organization key.
  • Some things the agent cannot finish for you. Nothing over MCP reads or rotates an auth signing secret or a CAPTCHA secret, or mints a channel token. If you want CAPTCHA on sign-up, you save the secret in the console.
  • The last step does not rehearse locally. The local emulator (altengine dev) serves the same MCP endpoint and tool names, but it does not serve static_*. Locally, every token also acts as an owner with full access, so a refusal a narrower key would get in production never appears there.
  • The prompt is a plan, not a guarantee. The agent can misread your description or write a rule that is valid but wrong.

What it costs to run

There is no idle cluster. In this app, the only things that bill while nobody is using it are stored data, stored users and retained function versions. Auth is $0.10 per 1,000 sign-ups and $0.10 per 10,000 sign-ins (a token refresh counts as one). Verifying a token on a Datastore request is not an auth charge; that request bills as Datastore reads and writes. The static site is $1.00 per million served requests, and bandwidth is not metered. The pricing page's example of an app with 10,000 users, 2,000 new sign-ups and 100,000 sign-ins comes to about $1.30 of auth for the month. The first $3 of usage each month is free across every service, with no card needed to start.

The full rule grammar, including field-level masking and channel patterns, is in the Auth reference. The prompts, resources and tool list are in the MCP server reference.

Start free in the console

← Back to the blog