andibase

Expense Tracker

Agent-ready recipe for creating an expense tracker in andibase

Open Markdown

This recipe tells an agent exactly how to build an expense tracker in andibase.

Goal

Create three things in one workspace:

  1. A budget data definition.
  2. An expense data definition.
  3. One agent that helps users manage budgets and log expenses.
  4. One mini app that shows budget progress and recent expenses.

Expected outcome

After following this recipe, the workspace should have:

  • a budget definition for monthly or custom budgets
  • an expense definition for individual spending entries
  • an expense-tracker-agent agent
  • an expense-tracker mini app

Use the API documented in:

Agent instructions

When an agent executes this recipe, it should follow these rules:

  1. Create the budget definition first.
  2. Create the expense definition second.
  3. In the current API, data definition handles are generated from name. Use name: "Budget" and name: "Expense" so the created handles normalize to budget and expense.
  4. If your execution environment can resolve the created budget definition id, wire the expense link as a relationship field. If it cannot, use a text foreign key such as budgetRecordId instead, because the public HTTP API still expects dataDefinitionId for relationship fields.
  5. Create the expense-tracker agent with instructions focused on budget management and expense logging.
  6. Create the mini app last.
  7. Do not invent custom fields outside this recipe unless the user explicitly asks for them.
  8. Use sensible defaults and ask the user only when a missing choice materially changes money behavior, such as currency or budget period.

Step 1: Create the data model

Create two data definitions: budget and expense.

1. Budget definition

Use name: "Budget". The current API derives the handle from the name, so this will create the budget handle.

Recommended fields:

{
  "name": "Budget",
  "description": "Tracks a planned spending period and its limits.",
  "fields": {
    "periodName": {
      "name": "Period name",
      "type": "text"
    },
    "periodType": {
      "name": "Period type",
      "type": "select",
      "options": [
        { "value": "weekly", "label": "Weekly", "color": "sky" },
        { "value": "monthly", "label": "Monthly", "color": "blue" },
        { "value": "quarterly", "label": "Quarterly", "color": "violet" },
        { "value": "yearly", "label": "Yearly", "color": "emerald" },
        { "value": "custom", "label": "Custom", "color": "zinc" }
      ]
    },
    "startDate": {
      "name": "Start date",
      "type": "date"
    },
    "endDate": {
      "name": "End date",
      "type": "date"
    },
    "currency": {
      "name": "Currency",
      "type": "select",
      "options": [
        { "value": "CLP", "label": "CLP", "color": "red" },
        { "value": "USD", "label": "USD", "color": "green" },
        { "value": "EUR", "label": "EUR", "color": "blue" },
        { "value": "GBP", "label": "GBP", "color": "violet" }
      ]
    },
    "totalBudget": {
      "name": "Total budget",
      "type": "number"
    },
    "spentAmount": {
      "name": "Spent amount",
      "type": "number"
    },
    "remainingAmount": {
      "name": "Remaining amount",
      "type": "number"
    },
    "status": {
      "name": "Status",
      "type": "select",
      "options": [
        { "value": "draft", "label": "Draft", "color": "zinc" },
        { "value": "active", "label": "Active", "color": "green" },
        { "value": "paused", "label": "Paused", "color": "amber" },
        { "value": "closed", "label": "Closed", "color": "blue" },
        { "value": "archived", "label": "Archived", "color": "stone" }
      ]
    },
    "categoryBudgets": {
      "name": "Category budgets",
      "description": "Optional JSON map of category to planned amount.",
      "type": "json"
    },
    "notes": {
      "name": "Notes",
      "type": "text",
      "variant": "long-text"
    }
  }
}

Recommended defaults for agent-created budget rows:

  • periodType: monthly
  • currency: use the workspace default or ask once
  • spentAmount: 0
  • remainingAmount: same value as totalBudget
  • status: active

2. Expense definition

Use name: "Expense". The current API derives the handle from the name, so this will create the expense handle.

Recommended fields:

{
  "name": "Expense",
  "description": "Tracks one spending event or charge.",
  "fields": {
    "title": {
      "name": "Title",
      "type": "text"
    },
    "description": {
      "name": "Description",
      "type": "text",
      "variant": "long-text"
    },
    "amount": {
      "name": "Amount",
      "type": "number"
    },
    "currency": {
      "name": "Currency",
      "type": "select",
      "options": [
        { "value": "CLP", "label": "CLP", "color": "red" },
        { "value": "USD", "label": "USD", "color": "green" },
        { "value": "EUR", "label": "EUR", "color": "blue" },
        { "value": "GBP", "label": "GBP", "color": "violet" }
      ]
    },
    "expenseDate": {
      "name": "Expense date",
      "type": "date"
    },
    "category": {
      "name": "Category",
      "type": "select",
      "options": [
        { "value": "housing", "label": "Housing", "color": "stone" },
        { "value": "groceries", "label": "Groceries", "color": "green" },
        { "value": "transport", "label": "Transport", "color": "sky" },
        { "value": "utilities", "label": "Utilities", "color": "yellow" },
        { "value": "health", "label": "Health", "color": "red" },
        { "value": "education", "label": "Education", "color": "blue" },
        { "value": "shopping", "label": "Shopping", "color": "pink" },
        { "value": "dining", "label": "Dining", "color": "orange" },
        {
          "value": "entertainment",
          "label": "Entertainment",
          "color": "violet"
        },
        { "value": "travel", "label": "Travel", "color": "cyan" },
        {
          "value": "subscriptions",
          "label": "Subscriptions",
          "color": "indigo"
        },
        { "value": "debt", "label": "Debt", "color": "rose" },
        { "value": "taxes", "label": "Taxes", "color": "amber" },
        { "value": "gifts", "label": "Gifts", "color": "fuchsia" },
        { "value": "other", "label": "Other", "color": "zinc" }
      ]
    },
    "paymentMethod": {
      "name": "Payment method",
      "type": "select",
      "options": [
        { "value": "cash", "label": "Cash", "color": "zinc" },
        { "value": "debit-card", "label": "Debit card", "color": "blue" },
        { "value": "credit-card", "label": "Credit card", "color": "violet" },
        {
          "value": "bank-transfer",
          "label": "Bank transfer",
          "color": "emerald"
        },
        {
          "value": "digital-wallet",
          "label": "Digital wallet",
          "color": "cyan"
        },
        { "value": "other", "label": "Other", "color": "stone" }
      ]
    },
    "status": {
      "name": "Status",
      "type": "select",
      "options": [
        { "value": "pending", "label": "Pending", "color": "amber" },
        { "value": "cleared", "label": "Cleared", "color": "green" },
        { "value": "reimbursable", "label": "Reimbursable", "color": "blue" },
        { "value": "reimbursed", "label": "Reimbursed", "color": "cyan" },
        { "value": "cancelled", "label": "Cancelled", "color": "stone" }
      ]
    },
    "merchant": {
      "name": "Merchant",
      "type": "text"
    },
    "isRecurring": {
      "name": "Is recurring",
      "type": "boolean"
    },
    "recurringCadence": {
      "name": "Recurring cadence",
      "type": "select",
      "options": [
        { "value": "one-time", "label": "One time", "color": "zinc" },
        { "value": "weekly", "label": "Weekly", "color": "sky" },
        { "value": "monthly", "label": "Monthly", "color": "blue" },
        { "value": "yearly", "label": "Yearly", "color": "emerald" }
      ]
    },
    "receipt": {
      "name": "Receipt",
      "type": "files"
    },
    "tags": {
      "name": "Tags",
      "type": "multi-select",
      "options": [
        { "value": "personal", "label": "Personal", "color": "blue" },
        { "value": "business", "label": "Business", "color": "violet" },
        { "value": "family", "label": "Family", "color": "green" },
        { "value": "urgent", "label": "Urgent", "color": "red" }
      ]
    },
    "budgetRecordId": {
      "name": "Budget record id",
      "description": "Store the linked budget row id when the client only has public HTTP API access.",
      "type": "text"
    }
  }
}

If your client already has the created budget definition id, you can replace budgetRecordId with:

{
  "budgetId": {
    "name": "Budget",
    "type": "relationship",
    "dataDefinitionId": "<budget-definition-id>"
  }
}

3. Example create requests

Create the definitions with POST /api/v1/data-definitions.

Current API note:

  • the create payload uses name, description, and fields
  • the handle is derived from name
  • data rows are created with data, not attributes

Example for budget:

curl -X POST "https://andibase.com/api/v1/data-definitions" \
  -H "Authorization: Bearer $ANDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Budget",
    "description": "Tracks a planned spending period and its limits.",
    "fields": {
      "periodName": { "name": "Period name", "type": "text" },
      "periodType": {
        "name": "Period type",
        "type": "select",
        "options": [
          { "value": "monthly", "label": "Monthly", "color": "blue" },
          { "value": "custom", "label": "Custom", "color": "zinc" }
        ]
      },
      "startDate": { "name": "Start date", "type": "date" },
      "endDate": { "name": "End date", "type": "date" },
      "currency": {
        "name": "Currency",
        "type": "select",
        "options": [
          { "value": "CLP", "label": "CLP", "color": "red" },
          { "value": "USD", "label": "USD", "color": "green" }
        ]
      },
      "totalBudget": { "name": "Total budget", "type": "number" },
      "spentAmount": { "name": "Spent amount", "type": "number" },
      "remainingAmount": { "name": "Remaining amount", "type": "number" },
      "status": {
        "name": "Status",
        "type": "select",
        "options": [
          { "value": "active", "label": "Active", "color": "green" },
          { "value": "closed", "label": "Closed", "color": "blue" }
        ]
      }
    }
  }'

Create sample expense rows with POST /api/v1/data-definitions/expense/data/upsert-many.

curl -X POST "https://andibase.com/api/v1/data-definitions/expense/data/upsert-many" \
  -H "Authorization: Bearer $ANDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "data": {
          "title": "Supermarket",
          "amount": 42500,
          "currency": "CLP",
          "expenseDate": "2026-03-12",
          "category": "groceries",
          "paymentMethod": "debit-card",
          "status": "cleared",
          "merchant": "Jumbo",
          "isRecurring": false,
          "recurringCadence": "one-time",
          "budgetRecordId": "replace-with-created-budget-row-id"
        }
      }
    ]
  }'

Step 2: Create the budget assistant agent

Create one workspace agent with handle expense-tracker-agent.

Recommended behavior:

  • help users create a budget for a period
  • update an existing budget when the user changes limits or dates
  • add expenses one by one from natural language
  • suggest the closest matching category when the user does not provide one
  • warn when an expense would push the active budget over its limit
  • keep spentAmount and remainingAmount on the linked budget in sync after each expense change

Recommended agent payload:

{
  "name": "Expense Tracker Agent",
  "handle": "expense-tracker-agent",
  "description": "Creates budgets, logs expenses, and keeps budget totals updated.",
  "model": "openai/gpt-5.4",
  "capabilities": {
    "webAccess": false,
    "browserAccess": false,
    "objectsAccess": true
  },
  "instructions": "You manage a workspace expense tracker. Use the budget and expense data definitions as the source of truth. Be action-oriented: make reasonable assumptions, complete the work when the intended outcome is clear, and avoid asking for confirmation on every small step. When the user asks to create a budget, create one budget row with a clear periodName, startDate, endDate, currency, totalBudget, spentAmount, remainingAmount, and status. When the user adds an expense, create an expense row, attach it to the correct active budget when possible, then update the linked budget spentAmount and remainingAmount. If category is missing, infer the best category from the title or merchant and say which category you used. If currency is missing, use the active budget currency. Never create duplicate budgets for the same period unless the user explicitly asks for a separate budget. If multiple active budgets could match, ask one short clarifying question. If no budget exists, create one first when the intended budget is obvious; otherwise ask one focused question."
}

Create the agent with POST /api/v1/agents.

Step 3: Create the mini app

Create one app with handle expense-tracker.

Mini app requirements:

  • read budgets and expenses using window.andibase.fetch
  • show the active budget summary at the top
  • show total budget, spent amount, remaining amount, and percent used
  • show recent expenses in a simple table
  • group expenses by category and show totals
  • keep styling simple and flat

Current API note:

  • POST /api/v1/apps currently accepts name, handle, description, and code
  • app access fields such as memberRole and publicPermissions are server-managed defaults on create
  • new apps are created as member-only by default

Example app code:

import { useEffect, useMemo, useState } from "react";

const currencyFormatters = {
  CLP: new Intl.NumberFormat("es-CL", {
    style: "currency",
    currency: "CLP",
    maximumFractionDigits: 0,
  }),
  USD: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }),
  EUR: new Intl.NumberFormat("en-US", { style: "currency", currency: "EUR" }),
  GBP: new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" }),
};

function formatMoney(value, currency) {
  const formatter = currencyFormatters[currency] ?? currencyFormatters.USD;
  return formatter.format(Number(value || 0));
}

async function fetchAllPages(path, pageSize = 100) {
  const items = [];
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await window.andibase.fetch(
      `${path}?page=${page}&pageSize=${pageSize}`,
    );
    const payload = await response.json();

    items.push(...(payload.items ?? []));
    hasMore = Boolean(payload.hasMore);
    page += 1;
  }

  return items;
}

export default function App() {
  const [budgets, setBudgets] = useState([]);
  const [expenses, setExpenses] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;

    async function load() {
      setLoading(true);

      const [budgetItems, expenseItems] = await Promise.all([
        fetchAllPages("/data-definitions/budget/query", 20),
        fetchAllPages("/data-definitions/expense/query", 100),
      ]);

      if (cancelled) return;

      setBudgets(budgetItems);
      setExpenses(expenseItems);
      setLoading(false);
    }

    load().catch(() => setLoading(false));

    return () => {
      cancelled = true;
    };
  }, []);

  const activeBudget = useMemo(
    () =>
      budgets.find((item) => item.data?.status === "active") ??
      budgets[0] ??
      null,
    [budgets],
  );

  const currency = activeBudget?.data?.currency ?? "USD";
  const totalBudget = Number(activeBudget?.data?.totalBudget ?? 0);
  const spentAmount = Number(activeBudget?.data?.spentAmount ?? 0);
  const remainingAmount = Number(
    activeBudget?.data?.remainingAmount ?? totalBudget - spentAmount,
  );
  const percentUsed =
    totalBudget > 0 ? Math.min(100, (spentAmount / totalBudget) * 100) : 0;

  const categoryTotals = useMemo(() => {
    const totals = new Map();

    for (const item of expenses) {
      const category = item.data?.category ?? "other";
      const amount = Number(item.data?.amount ?? 0);
      totals.set(category, (totals.get(category) ?? 0) + amount);
    }

    return Array.from(totals.entries()).sort((a, b) => b[1] - a[1]);
  }, [expenses]);

  if (loading) {
    return (
      <main
        style={{ minHeight: "100vh", padding: 24, fontFamily: "system-ui" }}
      >
        Loading...
      </main>
    );
  }

  return (
    <main style={{ minHeight: "100vh", padding: 24, fontFamily: "system-ui" }}>
      <h1>Expense tracker</h1>
      <section>
        <h2>{activeBudget?.data?.periodName ?? "Current budget"}</h2>
        <p>Total: {formatMoney(totalBudget, currency)}</p>
        <p>Spent: {formatMoney(spentAmount, currency)}</p>
        <p>Remaining: {formatMoney(remainingAmount, currency)}</p>
        <p>Used: {percentUsed.toFixed(1)}%</p>
      </section>

      <section>
        <h2>By category</h2>
        <ul>
          {categoryTotals.map(([category, amount]) => (
            <li key={category}>
              {category}: {formatMoney(amount, currency)}
            </li>
          ))}
        </ul>
      </section>

      <section>
        <h2>Recent expenses</h2>
        <table>
          <thead>
            <tr>
              <th align="left">Date</th>
              <th align="left">Title</th>
              <th align="left">Category</th>
              <th align="right">Amount</th>
            </tr>
          </thead>
          <tbody>
            {expenses.map((item) => (
              <tr key={item.id}>
                <td>{item.data?.expenseDate ?? "-"}</td>
                <td>{item.data?.title ?? "-"}</td>
                <td>{item.data?.category ?? "-"}</td>
                <td align="right">
                  {formatMoney(
                    item.data?.amount,
                    item.data?.currency ?? currency,
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </section>
    </main>
  );
}

Create the app with POST /api/v1/apps.

Example create request:

curl -X POST "https://andibase.com/api/v1/apps" \
  -H "Authorization: Bearer $ANDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Expense Tracker",
    "handle": "expense-tracker",
    "description": "Shows budget progress and recent expenses.",
    "code": "import { useEffect, useMemo, useState } from \"react\";\\n\\nconst currencyFormatters = {\\n  CLP: new Intl.NumberFormat(\"es-CL\", { style: \"currency\", currency: \"CLP\", maximumFractionDigits: 0 }),\\n  USD: new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\" }),\\n  EUR: new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"EUR\" }),\\n  GBP: new Intl.NumberFormat(\"en-GB\", { style: \"currency\", currency: \"GBP\" }),\\n};\\n\\nfunction formatMoney(value, currency) {\\n  const formatter = currencyFormatters[currency] ?? currencyFormatters.USD;\\n  return formatter.format(Number(value || 0));\\n}\\n\\nasync function fetchAllPages(path, pageSize = 100) {\\n  const items = [];\\n  let page = 1;\\n  let hasMore = true;\\n\\n  while (hasMore) {\\n    const response = await window.andibase.fetch(`${path}?page=${page}&pageSize=${pageSize}`);\\n    const payload = await response.json();\\n\\n    items.push(...(payload.items ?? []));\\n    hasMore = Boolean(payload.hasMore);\\n    page += 1;\\n  }\\n\\n  return items;\\n}\\n\\nexport default function App() {\\n  const [budgets, setBudgets] = useState([]);\\n  const [expenses, setExpenses] = useState([]);\\n  const [loading, setLoading] = useState(true);\\n\\n  useEffect(() => {\\n    let cancelled = false;\\n\\n    async function load() {\\n      setLoading(true);\\n\\n      const [budgetItems, expenseItems] = await Promise.all([\\n        fetchAllPages(\"/data-definitions/budget/query\", 20),\\n        fetchAllPages(\"/data-definitions/expense/query\", 100),\\n      ]);\\n\\n      if (cancelled) return;\\n\\n      setBudgets(budgetItems);\\n      setExpenses(expenseItems);\\n      setLoading(false);\\n    }\\n\\n    load().catch(() => setLoading(false));\\n\\n    return () => {\\n      cancelled = true;\\n    };\\n  }, []);\\n\\n  const activeBudget = useMemo(() => budgets.find((item) => item.data?.status === \"active\") ?? budgets[0] ?? null, [budgets]);\\n\\n  const currency = activeBudget?.data?.currency ?? \"USD\";\\n  const totalBudget = Number(activeBudget?.data?.totalBudget ?? 0);\\n  const spentAmount = Number(activeBudget?.data?.spentAmount ?? 0);\\n  const remainingAmount = Number(activeBudget?.data?.remainingAmount ?? totalBudget - spentAmount);\\n  const percentUsed = totalBudget > 0 ? Math.min(100, (spentAmount / totalBudget) * 100) : 0;\\n\\n  const categoryTotals = useMemo(() => {\\n    const totals = new Map();\\n\\n    for (const item of expenses) {\\n      const category = item.data?.category ?? \"other\";\\n      const amount = Number(item.data?.amount ?? 0);\\n      totals.set(category, (totals.get(category) ?? 0) + amount);\\n    }\\n\\n    return Array.from(totals.entries()).sort((a, b) => b[1] - a[1]);\\n  }, [expenses]);\\n\\n  if (loading) {\\n    return <main style={{ minHeight: \"100vh\", padding: 24, fontFamily: \"system-ui\" }}>Loading...</main>;\\n  }\\n\\n  return (\\n    <main style={{ minHeight: \"100vh\", padding: 24, fontFamily: \"system-ui\" }}>\\n      <h1>Expense tracker</h1>\\n      <section>\\n        <h2>{activeBudget?.data?.periodName ?? \"Current budget\"}</h2>\\n        <p>Total: {formatMoney(totalBudget, currency)}</p>\\n        <p>Spent: {formatMoney(spentAmount, currency)}</p>\\n        <p>Remaining: {formatMoney(remainingAmount, currency)}</p>\\n        <p>Used: {percentUsed.toFixed(1)}%</p>\\n      </section>\\n\\n      <section>\\n        <h2>By category</h2>\\n        <ul>\\n          {categoryTotals.map(([category, amount]) => (\\n            <li key={category}>\\n              {category}: {formatMoney(amount, currency)}\\n            </li>\\n          ))}\\n        </ul>\\n      </section>\\n\\n      <section>\\n        <h2>Recent expenses</h2>\\n        <table>\\n          <thead>\\n            <tr>\\n              <th align=\"left\">Date</th>\\n              <th align=\"left\">Title</th>\\n              <th align=\"left\">Category</th>\\n              <th align=\"right\">Amount</th>\\n            </tr>\\n          </thead>\\n          <tbody>\\n            {expenses.map((item) => (\\n              <tr key={item.id}>\\n                <td>{item.data?.expenseDate ?? \"-\"}</td>\\n                <td>{item.data?.title ?? \"-\"}</td>\\n                <td>{item.data?.category ?? \"-\"}</td>\\n                <td align=\"right\">\\n                  {formatMoney(item.data?.amount, item.data?.currency ?? currency)}\\n                </td>\\n              </tr>\\n            ))}\\n          </tbody>\\n        </table>\\n      </section>\\n    </main>\\n  );\\n}\\n"
  }'

If the app later adds host-driven drawers for creating or editing expenses, refresh data with useAndibaseRefreshOnReturn(loadData) from @andibase/hooks. Do not attach unconditional window.focus refresh listeners inside the iframe, because the first click into the app will usually focus the iframe and can look like a full reload.

An agent should consider the recipe complete only if all of the following are true:

  1. budget exists and includes the required fields.
  2. expense exists and includes category options plus either the budgetId relationship or the budgetRecordId fallback used by the current public HTTP API flow.
  3. expense-tracker-agent exists.
  4. expense-tracker exists.
  5. At least one sample budget row can be created successfully.
  6. At least one sample expense row can be created successfully.
  7. The app loads and displays budget and expense data without runtime auth errors.

Minimal delivery summary

When the agent finishes, it should report:

  • the created data definition handles
  • the created agent handle
  • the created app handle
  • whether the app is member-only
  • any assumptions made for default currency or budget period

On this page