fuast is in alpha. Read the capsule contract →
fuast
← Blog
TutorialJuly 15, 2026

Build an expense-approval app in an afternoon

A start-to-finish walkthrough: the prompt you give your AI, the capsule it writes, an email to the approver, a daily digest, fuast deploy, and a link for your team.


Let us build a real tool, not a toy. An expense-approval app: someone submits an expense, an approver decides, the approver gets an email, and a digest goes out every morning. It is the kind of small software every team needs and nobody builds. Here it is in an afternoon.

Step 1: the prompt

You are not going to hand-write this. You are going to describe it to your AI, which reads the fuast contract at /llms.txt and writes the capsule. A good prompt names the data, the actions, and the notifications:

Build a fuast expense-approval app. A requests table with amount, reason, submitter, and status (pending, approved, denied). A mutation to submit a request, and one to decide it (approve or deny) that only works if the caller is not the submitter. Email the approver when something is submitted. Send me a daily digest of everything still pending at 9am. Simple Preact UI: a submit form and a list with approve/deny buttons.

That is enough. The AI knows the rest because the contract tells it.

Step 2: the server the AI writes

Here is the backend it produces. Note the two things that make it trustworthy: identity comes from ctx.auth, never from an argument, and the decide mutation re-checks who is calling.

import { capsule, table, string, number, query, mutation, schedule } from "fuast/server";

export default capsule({
  schema: {
    requests: table({
      amount: number(),
      reason: string(),
      submitterId: string(),
      status: string(),   // "pending" | "approved" | "denied"
    }).index("by_status", ["status"]),
  },

  queries: {
    all: query(async (ctx) => ctx.db.requests.order("desc").collect()),
  },

  mutations: {
    submit: mutation(async (ctx, amount: number, reason: string) => {
      if (ctx.auth.isGuest) throw new Error("sign in first");
      const row = await ctx.db.requests.insert({
        amount, reason, submitterId: ctx.auth.userId, status: "pending",
      });
      await ctx.email.send({
        to: "approver@you.com",
        subject: `New expense: $${amount}`,
        text: `${ctx.auth.displayName} submitted "${reason}" for $${amount}.`,
      });
      return row;
    }),

    decide: mutation(async (ctx, id: string, approved: boolean) => {
      const req = await ctx.db.requests.get(id);
      if (!req) throw new Error("not found");
      if (req.submitterId === ctx.auth.userId) throw new Error("cannot decide your own");
      await ctx.db.requests.update(id, { status: approved ? "approved" : "denied" });
    }),
  },

  schedules: {
    digest: schedule("0 9 * * *", async (ctx) => {
      const pending = await ctx.db.requests
        .withIndex("by_status", (q) => q.eq("status", "pending")).collect();
      await ctx.email.send({
        to: "approver@you.com",
        subject: `${pending.length} expenses awaiting approval`,
        text: pending.map((r) => `$${r.amount} - ${r.reason}`).join("\n") || "All clear.",
      });
    }),
  },
});

Two batteries are doing real work here without any setup. ctx.email.send is transactional email, no SMTP to configure. And schedule is a cron job: 0 9 * * * runs the digest at 9am UTC every day, and fuast runs it for you. There is no separate scheduler to stand up.

Step 3: the client

The UI is one Preact component. A form to submit, and a list with approve and deny.

import { h, useQuery, useMutation, useState } from "fuast/client";
import { Page, PageHeader, Card, Field, Input, Button, Badge } from "fuast/ui";

type Req = { id: string; amount: number; reason: string; status: string };

export function App() {
  const rows = useQuery<Req[]>("all");
  const submit = useMutation<[number, string]>("submit");
  const decide = useMutation<[string, boolean]>("decide");
  const [amount, setAmount] = useState("");
  const [reason, setReason] = useState("");

  return (
    <Page>
      <PageHeader title="Expenses" />
      <form class="mb-5 grid gap-3"
        onSubmit={(e: Event) => { e.preventDefault(); void submit(Number(amount), reason); setAmount(""); setReason(""); }}>
        <Field label="Amount"><Input value={amount} onInput={(e: any) => setAmount(e.currentTarget.value)} /></Field>
        <Field label="Reason"><Input value={reason} onInput={(e: any) => setReason(e.currentTarget.value)} /></Field>
        <div><Button type="submit">Submit</Button></div>
      </form>
      {(rows ?? []).map((r) => (
        <Card key={r.id} class="mb-3">
          ${r.amount} - {r.reason} <Badge tone="neutral">{r.status}</Badge>
          {r.status === "pending" ? (
            <div class="mt-2">
              <Button variant="primary" onClick={() => void decide(r.id, true)}>Approve</Button>
              <Button variant="danger" onClick={() => void decide(r.id, false)}>Deny</Button>
            </div>
          ) : null}
        </Card>
      ))}
    </Page>
  );
}

useQuery refetches after each mutation, so approving a row updates the list on its own. Remember e.preventDefault() on the form and it behaves.

Step 4: deploy and share

Two commands. Deploy builds the capsule and prints a URL. Share opens it to your team.

fuast deploy --name "Expenses"
fuast share expenses --visibility workspace

New apps deploy private, so nothing is exposed until you share it. Once you do, coworkers open the link, sign in with their own account, and the app knows exactly who submitted and who approved, because identity is verified for you. That is the share like a doc motion doing the last mile.

That is a working approval tool: a schema, two mutations, an email, a daily digest, and a link. Built this afternoon, not next quarter. See the finished pattern on the expense approvals use case, then build your own.

Build one this afternoon.

Tell your AI what your team needs. fuast runs it and signs everyone in.

Start free

Keep reading