Expense Tracker
Agent-ready recipe for creating an expense tracker in andibase
This recipe tells an agent exactly how to build an expense tracker in andibase.
Goal
Create three things in one workspace:
- A
budgetdata definition. - An
expensedata definition. - One agent that helps users manage budgets and log expenses.
- One mini app that shows budget progress and recent expenses.
Expected outcome
After following this recipe, the workspace should have:
- a
budgetdefinition for monthly or custom budgets - an
expensedefinition for individual spending entries - an
expense-tracker-agentagent - an
expense-trackermini app
Use the API documented in:
- Data Model
- Apps
- Get started (for AI Agents)
- Data definitions API
- Data API
- Agents API
- Apps API reference
Agent instructions
When an agent executes this recipe, it should follow these rules:
- Create the
budgetdefinition first. - Create the
expensedefinition second. - In the current API, data definition handles are generated from
name. Usename: "Budget"andname: "Expense"so the created handles normalize tobudgetandexpense. - If your execution environment can resolve the created budget definition id, wire the expense link as a
relationshipfield. If it cannot, use a text foreign key such asbudgetRecordIdinstead, because the public HTTP API still expectsdataDefinitionIdfor relationship fields. - Create the expense-tracker agent with instructions focused on budget management and expense logging.
- Create the mini app last.
- Do not invent custom fields outside this recipe unless the user explicitly asks for them.
- 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:monthlycurrency: use the workspace default or ask oncespentAmount:0remainingAmount: same value astotalBudgetstatus: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, andfields - the handle is derived from
name - data rows are created with
data, notattributes
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
spentAmountandremainingAmounton 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/appscurrently acceptsname,handle,description, andcode- app access fields such as
memberRoleandpublicPermissionsare 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.
Recommended acceptance checks
An agent should consider the recipe complete only if all of the following are true:
budgetexists and includes the required fields.expenseexists and includes category options plus either thebudgetIdrelationship or thebudgetRecordIdfallback used by the current public HTTP API flow.expense-tracker-agentexists.expense-trackerexists.- At least one sample budget row can be created successfully.
- At least one sample expense row can be created successfully.
- 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