# actual-ai: letting a model sort your uncategorised bank transactions

> actual-ai is a TypeScript service that connects to an Actual Budget server, asks a language model to categorise each uncategorised transaction, and writes the category back, tagging every guess in the notes so a human can review it. It runs against OpenAI, Anthropic, Google Generative AI, Groq, Ollama or any OpenAI compatible endpoint, and it ships as a Docker image beside your Actual server.

**sakowicz/actual-ai** — Categorise transactions in Actual Budget using AI.

- Repository: https://github.com/sakowicz/actual-ai
- Stars: 532 · Forks: 59
- Language: TypeScript
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/sakowicz-actual-ai

## The problem is the uncategorised pile

Actual Budget is a local-first personal finance application, and the classic first hours with it are spent assigning categories to past transactions by hand. This project attacks exactly that chore. Its description is one line: categorise transactions in Actual Budget using AI.

The mechanism is straightforward. The service connects to an Actual server, syncs the accounts, finds the transactions that have no category, and sends each one to a language model together with the list of categories that already exist in the budget. The model replies with a category, and the service writes it back.

What the model is given is worth stating precisely, because it is the privacy question. The README says the app sends requests to the LLM to classify transactions based on their description, amount, and notes, and the prompt template in the environment file shows the full set of fields: the amount, the type, the description, the payee or, when that is empty, the imported payee, the date, the existing categories by group with their identifiers, and the existing rules.

So a merchant name, a transaction amount and a date leave your machine and go to a model provider, and for a local model they go to your own Ollama instance instead. That is the trade the project makes, and it is why the provider list includes Ollama at all.

The model itself is not fixed. The service works with OpenAI, Anthropic, Google Generative AI, Ollama, or any other compatible API including OpenRouter, and the environment configuration also names Groq. The implementation uses the Vercel AI SDK, with the anthropic, google, groq and openai provider packages, plus an ollama provider, which is why a single codebase can speak to all of them.

The target user is someone who already runs Actual Budget, wants the categorisation to happen automatically, and is prepared to check the result.

## Two tags are what make the automation reviewable

The two most important features in the README are not about the model at all. They are about making the automation auditable.

The first is the negative case. When a transaction cannot be classified, it is marked in the notes as not guessed, and it will not be classified again. The second is the positive case: every guessed transaction is marked as guessed in the notes, so you can review the classification.

Both are implemented with Actual tags, configured by two environment variables, and they are the design decision that makes the whole thing usable:

```bash
NOT_GUESSED_TAG=#actual-ai-miss
GUESSED_TAG=#actual-ai
```

So after a run, your budget contains a set of transactions tagged with the guessed marker and another set tagged with the miss marker, and you can filter on either. A transaction the model could not place is not silently dropped and is not retried forever, which matters because a cron schedule that keeps re-asking about the same impossible transaction is a slow way to burn an API budget.

The re-run feature closes the loop. Re-run missed transactions re-processes transactions previously marked as unclassified, so once you have created the missing category, or once you have fixed the payee name that confused the model, you can push the misses through again without resetting everything.

Two related features extend the same idea. The service can suggest and create new categories for transactions that don't fit existing ones, and when enabled the LLM can suggest entirely new categories for transactions it cannot classify and optionally create them automatically. And the web search feature, using the ValueSerp API, searches the web for information about unfamiliar merchants to help the model make better decisions, which is a targeted answer to the case where a payee string is an opaque reference rather than a name.

The net effect is a workflow rather than a one-shot: propose, mark, review, create the missing category, re-run the misses.

## The prompt template carries your budget's categories

The prompt is a template, and the template is in the environment, which means it is editable and inspectable. The PROMPT_TEMPLATE variable holds a Handlebars template, and the handlebars package is in the dependencies.

The structure of it shows how the model is given what it needs to choose a category rather than invent one:

```
I want to categorize the given bank transaction.

Transaction details:
* Amount: {{amount}}
* Type: {{type}}
{{#if description}}
* Description: {{description}}
{{/if}}
{{#if payee}}
* Payee: {{payee}}
{{^}}
* Payee: {{importedPayee}}
{{/if}}
```

Then it appends the existing categories by group, with the group name and identifier and each category name and identifier, followed by the existing rules. The category identifiers are what the service writes back, so the model is effectively choosing a primary key from a list it is shown, and any category you rename or delete changes the prompt as well as the budget.

The conditional blocks are worth noting. A transaction with no description falls back to the imported payee, which is how bank feeds that carry only a raw reference string still reach the model with something to work with. And because the template is yours, you can instruct the model in your own terms, for instance to prefer a particular category when a merchant is ambiguous, or to abstain rather than guess.

One inconsistency is worth flagging here, because it will confuse an adopter comparing the two sources of truth. The sample compose file in the README and the .env.example in the repository disagree on several model defaults. The README's compose example shows OPENAI_MODEL as gpt-5-mini while .env.example shows gpt-4.1-mini. The Ollama default is llama3.1 in the README and llama3.2 in the env file, and the Groq default is mixtral-8x7b-32768 in the README and llama-3.3-70b-versatile in the env file. The other defaults agree, including the OpenRouter model of deepseek/deepseek-v3.2, the Anthropic model of claude-3-5-sonnet-latest, the Google model of gemini-1.5-flash, and the LLM_TIMEOUT_MS of 120000. Treat .env.example as the current one, since it is the file the container actually loads.

## Feature flags, and the dry run that is on by default

The feature set is controlled by an environment variable rather than by editing code, and there are two syntaxes for it. The FEATURES variable accepts a JSON array, and ENABLED_FEATURES accepts a comma-separated list or a JSON array, and the README states the equivalence directly:

```bash
FEATURES='["suggestNewCategories", "classifyOnStartup", "syncAccountsBeforeClassify"]'

# Equivalent:
ENABLED_FEATURES='suggestNewCategories,classifyOnStartup,syncAccountsBeforeClassify'
```

The available features are webSearch, which enables web search for merchant information, suggestNewCategories, which allows suggesting new categories, classifyOnStartup, which runs classification when the application starts, syncAccountsBeforeClassify, which syncs accounts before running classification, rerunMissedTransactions, which re-processes transactions previously marked as unclassified, and disableRateLimiter.

One of them deserves particular attention: dryRun, which runs in dry run mode and is enabled by default. The tool's default posture is therefore to classify nothing until you turn that off. For a system that writes to your financial records and spends money on API calls per transaction, starting in a mode that proposes without applying is the right default, and the fact that it is a flag rather than a configuration file means you have to make a deliberate choice to change it.

The rate limiter is a separate concern that the README documents in its own section. The flag to disable it exists for a reason, presumably for local testing against a local model, but a rate limiter on LLM calls is what keeps a cron run from exceeding a provider's rate limit and failing halfway through.

The dependency list explains the rest of the implementation. The @actual-app/api and @actual-app/core packages at 26.10.0 are Actual's own API client. axios handles the HTTP calls, including the ValueSerp search. node-cron is the scheduler, zod validates the configuration, and natural is a natural language processing library, which is presumably used for the local similarity work behind category suggestions.

## Why a fresh process per run, and what the scheduling section is really about

The scheduling section is the most interesting engineering note in the README, and it explains a design decision that would otherwise look arbitrary.

With CLASSIFICATION_SCHEDULE_CRON set, the container stays up and runs every scheduled classification in a fresh child process. The reason given is specific: @actual-app/api keeps state in module-level singletons that survive shutdown(), and in a container running for weeks that state drifts until incremental sync reports no new messages and imports quietly stop.

That is a nasty failure mode. Nothing crashes, no error is logged, the sync simply stops bringing anything new, and the service looks alive. The fix is architectural rather than a patch: run each classification in a new process, so the module-level state starts empty every time. The README says a new process per run keeps that state fresh without needing a restart, that logs from the run appear as usual, and that a schedule which fires while the previous run is still going is skipped.

The skipping behaviour is the other half of the design. A cron schedule that fires on top of an in-flight run would produce two processes mutating the same budget, and the tool avoids that by refusing to start a second run.

The alternative shape is documented too. Without a cron schedule, classifyOnStartup classifies once and the process exits, and the README calls that the right shape when an external scheduler owns the timing. So the container is usable in two modes, a resident scheduled service, or a one-shot job that an external cron or CI pipeline invokes.

If you deploy this on a home lab with a scheduler like systemd or a Kubernetes CronJob, the second mode is usually the better fit, because it sidesteps the long-lived container entirely.

## Running it: a container beside your Actual server

The sample compose file in the README is the deployment, and it is a good one to copy because it shows the dependency ordering:

```yaml
  actual_server:
    image: docker.io/actualbudget/actual-server:latest
    ports:
      - '5006:5006'
    volumes:
      - ./actual-data:/data
    restart: unless-stopped

  actual-ai:
    image: docker.io/sakowicz/actual-ai:latest
    restart: unless-stopped
    depends_on:
      actual_server:
        condition: service_healthy
    environment:
      ACTUAL_SERVER_URL: http://actual_server:5006
      ACTUAL_PASSWORD: your_actual_password
      ACTUAL_BUDGET_ID: your_actual_budget_sync_id
      CLASSIFICATION_SCHEDULE_CRON: 0 */4 * * *
      LLM_PROVIDER: openai
```

The actual-ai service waits for the Actual server to be healthy before it starts, which matters because the first thing it does is sync the accounts. The four hour cron in the example is a reasonable default for a personal budget, and the LLM_PROVIDER value selects the backend, with the README listing openai, openrouter, anthropic, google-generative-ai, ollama and groq as the choices.

Two configuration details in the environment block deserve attention. The ACTUAL_BUDGET_ID is the Sync ID from Settings, Show advanced settings, Sync ID in the Actual interface, so the service knows which budget to work on. And ACTUAL_E2E_PASSWORD is there for the case where you have end to end encryption enabled on your Actual server, in which case the service needs the passphrase to read your data at all.

There is also a NODE_TLS_REJECT_UNAUTHORIZED setting documented as required if you have trouble connecting to the Actual server. That is a flag worth being careful with, because disabling TLS verification removes the protection that stops a proxy between you and your own server from reading the traffic, and a local Actual server is usually reached over HTTP on a LAN anyway.

The Dockerfile is small and sensible. It starts from node:22.21-alpine3.22, installs python3, make and g++ because the natural dependency has native modules, switches to the non-root node user before copying the application, installs with npm ci, builds the TypeScript and runs the compiled output. The published images are on Docker Hub as sakowicz/actual-ai, and the package is also on npm as @sakowicz/actual-ai at version 3.0.2, with the release history showing 3.0.2 on 2026-10-02 and 3.0.0 and 3.0.1 in September 2026.

The licence is MIT, with the LICENSE file at the root of the repository, the author is Szymon Sakowicz, and the package requires Node 22 or later.

## Conclusion

Adopt actual-ai if you run Actual Budget, have a backlog of uncategorised transactions, and want a model to propose categories that you then confirm, since the two tag scheme makes every guess visible and reversible. Do not adopt it if you want the categories created for you without review, because the design is explicitly built around a human checking the notes, and do not adopt it if you cannot accept sending transaction descriptions and amounts to a third party API, since that is what the classification prompt contains. Verify first that dry run is left on for your first pass, that the provider and model you choose are one of the supported ones, that the LLM prompt template in the environment matches the categories in your budget, since the template embeds the category group names and identifiers, and that the cron schedule and the container restart policy are consistent with the fresh child process per run model the README describes.

## FAQ

### What does actual-ai do?

It categorises uncategorised transactions for Actual Budget using a language model. The service syncs the accounts, sends each transaction's description, amount and notes to the model together with your existing categories, and writes the chosen category back.

### Which AI providers does actual-ai support?

OpenAI, Anthropic, Google Generative AI, Ollama, Groq, OpenRouter, or any other OpenAI compatible API, selected with the LLM_PROVIDER environment variable. The implementation uses the Vercel AI SDK with the provider packages for Anthropic, Google, Groq and OpenAI, plus an Ollama provider.

### How do I review the categorisation actual-ai makes?

Every guessed transaction is marked as guessed in the notes and tagged with GUESSED_TAG, while transactions that could not be classified are marked as not guessed and tagged with NOT_GUESSED_TAG, and they are not classified again. The defaults are #actual-ai and #actual-ai-miss, and the re-run feature processes the misses again later.

### Is dry run enabled by default in actual-ai?

Yes. The dryRun feature is listed as enabled by default, so the service proposes classifications without applying them until you change it. The other flags are webSearch, suggestNewCategories, classifyOnStartup, syncAccountsBeforeClassify, rerunMissedTransactions and disableRateLimiter, and they are set through the FEATURES or ENABLED_FEATURES environment variables.

### How is actual-ai run on a schedule?

With CLASSIFICATION_SCHEDULE_CRON set, the container stays up and runs each scheduled classification in a fresh child process, because @actual-app/api keeps state in module-level singletons that survive shutdown and drift until imports quietly stop. A schedule that fires while the previous run is still going is skipped, and without a cron schedule the classifyOnStartup feature classifies once and the process exits.

## Sources

- [Issues](https://github.com/sakowicz/actual-ai/issues)
- [License: MIT](https://github.com/sakowicz/actual-ai/blob/master/LICENSE)
- [README](https://github.com/sakowicz/actual-ai/blob/master/README.md)
- [Releases](https://github.com/sakowicz/actual-ai/releases)
- [sakowicz/actual-ai on GitHub](https://github.com/sakowicz/actual-ai)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/sakowicz-actual-ai
