# PennyBot MCP — Agent Context

> Paste this file into your CLAUDE.md (or equivalent) to give your AI agent full context
> about PennyBot's data model, tools, and best practices.
>
> Download: `curl https://pennybot.io/mcp/agent-context > PENNYBOT_AGENT.md`
> Last updated: 2026-04-30 | Tool version: Phase 4 (32 tools)

---

## What is PennyBot?

PennyBot is a financial management platform for freelancers and small businesses. It connects to bank accounts via Teller, syncs with accounting software (QuickBooks, Xero, FreshBooks), and provides AI-powered transaction categorization, invoicing, expense tracking, mileage logging, and cash flow forecasting.

As an MCP-connected agent, you have direct read/write access to a user's financial data. Treat this with the same care you would a bank account — verify before writing, never delete data you cannot restore, and always confirm destructive operations with the user.

---

## Authentication

Your requests are authenticated via a Bearer token in every API call:

```
Authorization: Bearer pb_mcp_<64-char-hex>
```

Keys are generated in **Settings → Developer** inside PennyBot. MCP access is **free and available on all plans** — no upgrade required. Rate limit: 120 requests/min per key.

Each key has:
- **Scope**: `read` (safe for exploration) or `read_write` (required for categorizing, importing, etc.)
- **Sync mode**: `realtime` (writes apply immediately) or `batch` (writes staged until `commit_batch`)

To check your current config: call `get_mcp_config`.

---

## Data Model

### Accounts

Accounts represent bank accounts, credit cards, and manual ledger entries. A user may have dozens of connected accounts across multiple banks plus manual accounts.

| Field | Type | Notes |
|-------|------|-------|
| `id` | UUID | Use this for all account references |
| `account_name` | string | Human-readable name (e.g. "Chase Checking") |
| `account_type` | string | `checking`, `savings`, `credit_card`, `investment`, `loan`, `cash`, `other` |
| `current_balance` | decimal | Latest known balance (may be slightly stale for bank accounts) |
| `institution_name` | string | Bank name (e.g. "Chase") — null for manual accounts |
| `last_four` | string | Last 4 digits of account number |
| `is_active` | bool | Inactive accounts are hidden but not deleted |
| `source_system` | string | `teller` for bank-linked, `manual` for hand-entered, `mcp_import` for agent-imported |

**Important:** Categories also live in the accounts table with `category_source != null`. Use `list_categories` to get them — never assume account IDs are categories.

### Transactions

Every financial event is a transaction. PennyBot stores both bank-synced and manually-created transactions.

| Field | Type | Notes |
|-------|------|-------|
| `id` | UUID | Primary key — use for all operations |
| `date` | date | Transaction date (`YYYY-MM-DD`) |
| `description` | string | Raw merchant name or user description |
| `amount` | decimal | **Positive = income, negative = expense** |
| `total_amount` | decimal | Absolute value of amount (always positive) |
| `transaction_type` | string | `income` or `expense` (derived from amount sign) |
| `account_id` | UUID | Which account this belongs to |
| `category_id` | UUID | Category assignment (nullable) |
| `category_name` | string | Denormalized category name for display |
| `user_notes` | string | Agent or user notes (editable) |
| `is_flagged` | bool | Flagged for review |
| `source_type` | string | `teller`, `manual`, `mcp_import`, etc. |
| `source_transaction_id` | string | External deduplication key |
| `status` | string | `posted` or `pending` |

**Amount convention:** Expenses are negative. Income is positive. A $50 coffee would have `amount: -50`. A $2,000 client payment would have `amount: 2000`.

**Editing restrictions:** Only transactions with `source_type = 'manual'` or `source_type = 'mcp_import'` can have their amount and date edited. Bank-synced transactions (`source_type = 'teller'`) are read-only for amount/date to preserve audit integrity — you can still categorize and annotate them.

### Categories

Categories in PennyBot live in the `accounts` table with `category_source != null`. Don't confuse them with bank accounts. Always retrieve the current list via `list_categories` — IDs are stable but names vary per user.

Common system categories:
- Food & Dining, Transportation, Office Supplies, Software & Subscriptions
- Client Income, Consulting, Sales Revenue
- Payroll, Contractor Payments, Utilities, Rent & Lease

User-defined categories follow the same structure. Use `list_categories` with a `search` parameter to find a specific one.

### Reports

Reports are generated on demand from live transaction data — they are not stored. Available via `generate_expense_report` and `generate_net_worth_report`.

---

## All 31 Tools

### Accounts (3 tools)

**`get_accounts`** — List all accounts with current balances.
```
Input: { account_type?: string, include_inactive?: bool }
Output: { accounts: [{ id, account_name, account_type, current_balance, institution_name, last_four, is_active }] }
```

**`get_account_detail`** — Full detail on a single account including sync metadata.
```
Input: { account_id: UUID }
Output: { account: { ...all fields..., last_synced_at, sync_status } }
```

**`list_connected_banks`** — Show Teller-connected institutions with sync health.
```
Input: {}
Output: { banks: [{ institution_name, account_count, last_sync_at, sync_status }] }
```

---

### Transactions — Read (4 tools)

**`list_transactions`** — Paginated transaction list with rich filtering.
```
Input: {
  account_id?: UUID,
  start_date?: "YYYY-MM-DD",
  end_date?: "YYYY-MM-DD",
  category_id?: UUID,
  min_amount?: number,
  max_amount?: number,
  search?: string,        // searches description
  is_flagged?: bool,
  uncategorized?: bool,   // filter to transactions with no category
  page?: number,          // default 1
  limit?: number          // default 50, max 100
}
Output: { transactions: [...], pagination: { page, limit, total, total_pages, has_more } }
```

**`get_transaction_detail`** — Single transaction with full history.
```
Input: { transaction_id: UUID }
Output: { transaction: { ...all fields..., category_history: [...], notes_history: [...] } }
```

**`get_transaction_category_history`** — Audit trail of category changes on a transaction.
```
Input: { transaction_id: UUID }
Output: { history: [{ changed_at, old_category, new_category, changed_by }] }
```

**`list_connected_banks`** — (see Accounts section above)

---

### Transactions — Write (6 tools, require read_write scope)

**`categorize_transaction`** — Set or clear the category on one transaction. Logged to audit history.
```
Input: {
  transaction_id: UUID,
  category_id: UUID | null,  // null to clear
  note?: string              // optional audit note
}
Output: { success: true, transaction_id, old_category, new_category }
```

**`bulk_categorize`** — Apply one category to up to 1,000 transactions at once.
```
Input: {
  transaction_ids: UUID[],  // max 1000
  category_id: UUID,
  note?: string
}
Output: { updated: number, failed: number }
```

**`update_transaction`** — Update notes, description, tag, amount (manual only), date (manual only).
```
Input: {
  transaction_id: UUID,
  notes?: string,
  description?: string,
  tag?: string,
  amount?: number,    // manual transactions only
  date?: "YYYY-MM-DD" // manual transactions only
}
Output: { success: true, updated_fields: [...] }
```

**`bulk_flag`** — Flag or unflag multiple transactions for review.
```
Input: { transaction_ids: UUID[], flagged: bool }
Output: { updated: number }
```

**`create_transaction`** — Create a manual transaction from scratch.
```
Input: {
  account_id: UUID,
  amount: number,           // positive=income, negative=expense
  date: "YYYY-MM-DD",
  description: string,
  category_id?: UUID,
  notes?: string,
  tag?: string
}
Output: { transaction: { id, ...all fields } }
```

**`delete_transaction`** — Soft-delete a manual transaction. Cannot delete bank-synced transactions.
```
Input: { transaction_id: UUID, reason?: string }
Output: { success: true, deleted_at }
```

---

### Categories (1 tool)

**`list_categories`** — All categories available to this user (system + user-created).
```
Input: { search?: string }
Output: { count: number, categories: [{ id, name, source, is_system }] }
```

---

### Reports (4 tools)

**`generate_expense_report`** — Spending breakdown by category for a period.
```
Input: {
  start_date: "YYYY-MM-DD",
  end_date: "YYYY-MM-DD",
  account_id?: UUID  // omit for all accounts
}
Output: { period, total_expenses, by_category: [{ category, amount, count, pct }] }
```

**`generate_net_worth_report`** — Current assets, liabilities, and net worth.
```
Input: {}
Output: { total_assets, total_liabilities, net_worth, accounts: [...] }
```

**`list_transaction_reports`** — List available pre-built report types.
```
Input: {}
Output: { reports: [{ type, description, parameters }] }
```

**`get_user_profile`** — User info and account summary.
```
Input: {}
Output: { user: { id, name, email, member_since }, plan: { tier, status }, accounts: { total_active, connected_banks } }
```

---

### Record Locking (3 tools, require read_write scope)

Use locks before bulk edits to prevent the sync engine from overwriting your changes mid-operation.

**`lock_records`** — Acquire advisory locks (30-min TTL).
```
Input: {
  session_id: string,       // your agent's session identifier
  resource_type: "transaction" | "account",
  resource_ids: UUID[]      // max 500
}
Output: { locked: number, already_locked: number, expires_at }
```

**`release_locks`** — Release all locks for your session.
```
Input: { session_id: string, resource_type?: string }
Output: { released: number }
```

**`get_active_locks`** — List your currently held locks. (read scope OK)
```
Input: { session_id: string }
Output: { locks: [{ resource_type, resource_id, expires_at }] }
```

---

### Batch Mode (3 tools, require read_write scope)

Batch mode lets you stage multiple writes and apply them atomically, or discard if something goes wrong.
Only available when your key's `sync_mode` is set to `batch`.

**`get_pending_changes`** — Preview staged writes before committing. (read scope OK)
```
Input: { session_id: string }
Output: { count: number, changes: [{ operation, resource_type, resource_id, payload }] }
```

**`commit_batch`** — Apply all staged changes atomically.
```
Input: { session_id: string, confirm: true }
Output: { committed: number, errors: number }
```

**`discard_batch`** — Discard all staged changes without applying.
```
Input: { session_id: string, confirm: true }
Output: { discarded: number }
```

---

### Historical Import (3 tools)

**`preview_import`** — Dry-run analysis before committing an import. Always call this first. (read scope OK)
```
Input: {
  transactions: ImportRow[],
  source_name?: string
}
Output: {
  preview_token: string,    // use this in import_transactions
  summary: { total_submitted, valid, will_create, will_skip_duplicate, validation_errors },
  accounts_to_create: string[],
  validation_errors: string[]
}
```

**`import_transactions`** — Execute bulk import. Requires read_write scope.
```
Input: {
  transactions: ImportRow[],
  confirmed: true,          // required
  source_name?: string
}
Output: { created, skipped_duplicate, errors, accounts_created, categories_created }
```

**Import row format:**
```json
{
  "date": "2024-03-15",           // YYYY-MM-DD, required
  "description": "Coffee Shop",   // required
  "amount": -4.50,                // positive=income, negative=expense, required
  "account_name": "Chase Checking", // required; created if not found
  "account_type": "checking",     // required if account is new
  "external_id": "chase-2024-03-15-abc123", // required; dedup key
  "category": "Food & Dining",    // optional; matched case-insensitively
  "notes": "Client meeting"       // optional
}
```

**`find_duplicates`** — Search for existing transactions that might match an import row.
```
Input: { date, amount, description, date_tolerance_days?: 1, amount_tolerance?: 0.01 }
Output: { match_count, matches: [{ id, date, description, amount, source_type }] }
```

**`get_import_history`** — Summary of past MCP imports.
```
Input: { limit?: 20 }
Output: { import_sessions: [{ import_date, transaction_count, earliest_transaction, latest_transaction }] }
```

---

### Session & Meta (3 tools)

**`get_mcp_config`** — Your current key's configuration and rate limits.
```
Input: {}
Output: { sync_mode, scope, plan_tier, rate_limits: { requests_per_minute: 120, bulk_operations_max: 1000, import_batch_max: 5000 }, tool_count: 31, phase: "4" }
```

**`create_session_note`** — Persist a finding or summary across sessions.
```
Input: { title: string, content: string (markdown OK), session_id?: string }
Output: { note_id, created_at }
```

**`list_session_notes`** — Retrieve past notes you've saved.
```
Input: { limit?: 20 }
Output: { notes: [{ id, title, session_id, created_at, content_preview }] }
```

---

## Rate Limits

| Limit | Value |
|-------|-------|
| Requests per minute | 120 |
| Bulk operations max | 1,000 items |
| Import batch max | 5,000 rows |

If you hit a rate limit, pause 60 seconds before retrying.

---

## Common Workflows

### 1. Monthly Reconciliation

```
1. get_accounts → identify accounts to reconcile
2. list_transactions(start_date, end_date, uncategorized: true) → get uncategorized
3. list_categories → build a reference map (name → id)
4. For each uncategorized transaction, infer category from description
5. bulk_categorize(transaction_ids, category_id) per category batch
6. generate_expense_report(start_date, end_date) → confirm totals make sense
7. create_session_note(title: "Reconciliation Apr 2026", content: summary)
```

### 2. Historical CSV Import

```
1. Parse CSV into ImportRow objects (map your columns to the canonical format)
2. Generate external_id: MD5(filename + row_number + date + amount + description)
3. preview_import(rows, source_name: "Chase 2020-2023")
   → Review: will_create, accounts_to_create, validation_errors
4. If validation_errors > 0: fix those rows and re-preview
5. import_transactions(rows, confirmed: true)
6. get_import_history() → confirm import landed
7. list_transactions(source_type: "mcp_import") → spot-check a few rows
```

### 3. Tax Prep Pull

```
1. generate_expense_report(start_date: "2025-01-01", end_date: "2025-12-31")
2. list_transactions(start_date, end_date, min_amount: 0)  → income only (positive)
3. list_transactions(start_date, end_date, max_amount: 0)  → expenses only (negative)
4. For flagged items: list_transactions(is_flagged: true)
5. Summarize by category and note any uncategorized transactions
6. create_session_note("Tax Prep 2025", detailed markdown summary)
```

### 4. Net Worth Snapshot

```
1. generate_net_worth_report() → assets, liabilities, net worth
2. get_accounts(account_type: "loan") → drill into liabilities
3. get_accounts(account_type: "investment") → drill into assets
4. create_session_note("Net Worth Snapshot", summary with key numbers)
```

### 5. Safe Bulk Edit with Locking

```
1. list_transactions(...) → collect transaction IDs to edit
2. lock_records(session_id, "transaction", transaction_ids)
3. bulk_categorize(transaction_ids, category_id)
4. release_locks(session_id, "transaction")
```

---

## Error Handling

| Situation | What to do |
|-----------|-----------|
| `isError: true` response | Read the text field — it contains a human-readable explanation |
| `"read-only key"` error | User needs to generate a `read_write` key in Settings → Developer |
| `"transaction not found"` | Transaction doesn't exist or belongs to a different user |
| `"account not found"` | Account ID is wrong; call `get_accounts` to refresh |
| `"category not found"` | Category was deleted; call `list_categories` to refresh |
| `"max keys exceeded"` | User has 10 active keys; revoke one in Settings → Developer |
| HTTP 401 | Key is invalid, revoked, or expired |
| HTTP 500 | PennyBot server error — retry once after 10 seconds |

Never silently swallow errors. Always surface them to the user with context.

---

## Batch Mode vs. Realtime Mode

| | Realtime (default) | Batch |
|-|----|----|
| Writes apply | Immediately | Only after `commit_batch` |
| Safe to explore | Yes (but writes are live) | Yes — discard if uncertain |
| Atomic multi-step edits | No | Yes |
| Switch mode | Settings → Developer → toggle key sync mode | Same |

**When to use batch mode:** Categorizing 500+ transactions in one session, importing historical data, any operation where you want to review before committing.

---

## Session Notes Best Practices

Session notes persist across your MCP sessions. Use them to:
- Record what you've done (so next session picks up where you left off)
- Save summaries the user asked for (monthly reports, tax summaries)
- Flag items that need human review

```
create_session_note(
  title: "April 2026 Reconciliation — Complete",
  content: "Categorized 142 transactions. 3 flagged for user review (large cash withdrawals).\nNet expenses: $8,420. Top category: Office Supplies ($1,240).",
  session_id: "my-agent-session-001"
)
```

---

## CLAUDE.md Setup

Add this to your project's `CLAUDE.md` to configure your Claude Code session:

```markdown
## PennyBot MCP

Connected to PennyBot financial data via MCP.

Key scope: [read / read_write]
Sync mode: [realtime / batch]

Rules:
- Always call get_mcp_config at session start to verify connection
- Never delete bank-synced transactions (source_type = 'teller')
- Confirm with user before bulk_categorize on > 50 transactions
- Use lock_records before large bulk edits in realtime mode
- Save session notes after completing any significant task
- Amount convention: positive = income, negative = expense
- Dates: always YYYY-MM-DD format

Reference: https://pennybot.io/mcp/agent-context
```

---

## Support

- Docs: https://pennybot.io/for-agents
- Settings: https://pennybot.io/settings.html (Developer tab)
- Support: support@pennybot.io
