Brod

Use case

An expense approval tool for your finance team

Employees submit an expense with a receipt. A manager approves or rejects it. Finance sees what was decided and by whom. Your agent writes the app; Brod gives it sign-in, roles, a database and somewhere to keep the receipts.

What the tool does

Most teams start with a spreadsheet and a shared inbox. The spreadsheet has no idea who is editing it, and the receipts live in someone's email. A small app fixes both, as long as it knows who each person is and what they are allowed to do.

  • Employees

    Employees submit an amount, a note and a photo or PDF of the receipt, and see the status of their own expenses.

  • Managers

    Managers see everything waiting, open the receipt, and approve or reject. They cannot approve their own.

  • Everyone else

    Everyone else in the company cannot open the app at all.

The parts of Brod it uses

NeedHow it works on Brod
Who is this?People sign in to the workspace with an emailed link. The app's server code gets the person in the x-brod-user-email header and their role in x-brod-role. Brod strips any x-brod-* header a visitor sends, so they cannot be faked.
Who may approve?The app declares its own roles, most rights first: here approver, then submitter. A grant maps a person, a group or an email domain to a role. The app owner and workspace admins get the top role.
Expense recordsThe app's own SQLite database, env.DB.
ReceiptsThe app's file storage, env.FILES. Keys up to 512 characters.
Who can open itAccess mode people: only the people and groups granted a role. Everyone else gets the same "App not found" page as an address with no app.
Brod passes the role. The app enforces it.

Brod decides who can open the app at all. Inside the app, "only approvers can approve" is your code's job. Brod tells the app the role on every request; the app has to check it. Ask your agent to check it on the server, not only by hiding a button.

The prompt

With Brod connected to your agent (Claude Code, Cursor, Codex), something like this is enough:

The agent can deploy and change the app on its own. Sharing it with the two groups returns a confirmation link, and nothing changes until a person signed in to the workspace opens it.

Build an expense approval app and deploy it to Brod as expenses. Two roles: approver first, then submitter. Submitters add an amount, a note and a receipt (image or PDF) and see their own expenses. Approvers see everything pending and approve or reject, but never their own. Use env.DB for records and env.FILES for receipts, and read the person from the x-brod headers. Give the managers group the approver role and the staff group the submitter role.

get_workspace · deploy_app · set_access

Deployed: https://expenses.acme.brod.sh (version 1). It is private to you until the grants are confirmed: open this link to give managers and staff access.

The server code

Server code lives in a _worker.js file at the top of the bundle. This is the part that touches Brod, trimmed to the essentials:

// _worker.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const email = request.headers.get('x-brod-user-email');
    const role = request.headers.get('x-brod-role');
    if (!email) return new Response('Sign in first', { status: 401 });

    await env.DB.run(`CREATE TABLE IF NOT EXISTS expenses (
      id INTEGER PRIMARY KEY, by TEXT, cents INTEGER, note TEXT,
      receipt TEXT, status TEXT DEFAULT 'pending', decided_by TEXT)`);

    // Submit an expense with its receipt
    if (request.method === 'POST' && url.pathname === '/api/expenses') {
      const form = await request.formData();
      const file = form.get('receipt');
      if (!file || !/^(image\/|application\/pdf)/.test(file.type)) {
        return new Response('Receipt must be an image or a PDF', { status: 400 });
      }
      const key = 'receipts/' + crypto.randomUUID();
      await env.FILES.put(key, await file.arrayBuffer(), file.type);
      await env.DB.run(
        'INSERT INTO expenses (by, cents, note, receipt) VALUES (?, ?, ?, ?)',
        [email, Math.round(Number(form.get('amount')) * 100), form.get('note'), key]);
      return Response.json({ ok: true });
    }

    // Approve or reject: approvers only, never their own
    if (request.method === 'POST' && url.pathname === '/api/decide') {
      if (role !== 'approver') return new Response('Approvers only', { status: 403 });
      const { id, approve } = await request.json();
      const { changes } = await env.DB.run(
        "UPDATE expenses SET status = ?, decided_by = ? WHERE id = ? AND status = 'pending' AND by != ?",
        [approve ? 'approved' : 'rejected', email, id, email]);
      return Response.json({ ok: changes === 1 });
    }

    // A receipt: the person who submitted it, or an approver
    if (url.pathname.startsWith('/api/receipts/')) {
      const id = Number(url.pathname.split('/').pop());
      const [row] = await env.DB.query('SELECT by, receipt FROM expenses WHERE id = ?', [id]);
      if (!row || (row.by !== email && role !== 'approver')) return new Response('Not found', { status: 404 });
      return (await env.FILES.get(row.receipt)) ?? new Response('Not found', { status: 404 });
    }

    // The list: approvers see what is pending, submitters see their own
    if (url.pathname === '/api/expenses') {
      const rows = role === 'approver'
        ? await env.DB.query("SELECT * FROM expenses WHERE status = 'pending'")
        : await env.DB.query('SELECT * FROM expenses WHERE by = ?', [email]);
      return Response.json({ expenses: rows });
    }
    return new Response('Not found', { status: 404 });
  },
};

The receipt check matters: only accepting images and PDFs keeps someone from uploading an HTML file and having it served back to a manager from the app's own address.

Changing it later

  • Staging first. Finance will ask for a cost-centre field within a week. When the agent updates the live app, the new version goes to a staging address first, which only the owner, admins, builders and approvers can open. Staging uses the same database and files as the live app, so an expense you approve while testing is really approved. Try changes with test entries you delete afterwards.
  • Rollback. If a published version breaks, rollback serves the previous version from the next request. Rollback changes the code, not the data, so ask the agent to add columns rather than rename or drop them.

What it will not do yet

  • No email or scheduled jobs. Brod apps cannot send email or run scheduled jobs today, so the tool cannot email a manager when something is waiting or send a weekly summary. Managers open the app to see the queue.
  • No per-path blocking by role. There is also no per-path blocking by role: the role checks in your code are the whole of the in-app permission model.

Questions

Do managers and employees need separate accounts for the app?

No. They sign in to the workspace once, with an emailed link, and that sign-in works for every app they have access to. The app never stores a password.

Can the agent give the whole company access by mistake?

No. Widening access returns a confirmation link for a person to open. Narrowing access applies straight away.

What happens when someone leaves?

Remove them from the workspace and their access to every app ends on their next request. Their past expenses stay in the database under their email.

Can an outside accountant see it?

Yes. A builder can invite them as a guest to this one app and grant them a role. If an agent sends the invite, it waits for a person to confirm. Guests see only the apps they were given.

Your AI already built the app. Put it in your team's hands.