Classify transactions with an LLM

This page covers the LLM-assisted classification workflow: setting up a provider, previewing a suggestion for one transaction, the review loop (apply, reject, or override), saturating the IVA tax fields, and reading an attached invoice with a model, on your own machine or, with explicit consent, through a cloud provider. The suggestion is always a starting point: you confirm or correct it, and the model never sets a euro amount.

Classification does not contact AEAT and does not submit anything. The provider CLI may contact its own external service depending on your provider setup; see the privacy boundary before using real taxpayer data.

The LLM and vision commands on this page need a provider CLI or a local vision model that the documentation sandbox does not run, so they are shown as display-only frames. The local commands around them run live at build time.

Before you start

You need:

  • An active profile, see set up your taxpayer profile, and at least one transaction in its ledger to classify. See Import and manage transactions.

  • Your master-key passphrase. The command opens the encrypted ledger, so it prompts for the passphrase.

  • A provider CLI installed, on PATH, and logged in.

The runtime emits help, prompts, and messages in Spanish.

Set up a provider

aeat does not store LLM credentials and has no separate LLM account configuration. It runs a provider CLI that is already installed and authenticated on your system. The classification command accepts provider names such as claude, antigravity, and codex; antigravity uses Google’s agy CLI, the supported successor to the retired standalone gemini CLI.

Check which provider CLIs are visible on PATH with aeat config check. It lists each provider as disponible or ausente with the fix for each problem, alongside the profile service capabilities. This reports discoverability only, not account login. The local vision reader appears there too: it shows ausente until the runtime answers. aeat config provision status reports the runtime and each reader model in detail.

Install and authenticate the provider with its own CLI or account flow. The login command and data-retention settings belong to the provider, not to aeat. Then smoke-test with a preview, which saves nothing:

1Smoke-test the provider with a preview that saves nothing.
aeat app ledger classify <transaction-id> --llm claude
aeat app ledger classify <transaction-id> --llm claude

A logged-out provider makes the command refuse and relay the provider’s own error (for example La clasificacion por LLM fallo: claude CLI exited with 1: 'Not logged in ...'). Complete that provider’s login and retry.

Ask for a suggestion

Find a row that still needs classification and inspect it. Then send that one row to the provider for a preview:

1Find a row that still needs classification.
aeat app ledger list --filter classification=NOT_YET_PROCESSED
aeat app ledger list --filter classification=NOT_YET_PROCESSED
ACCOUNTING LEDGER TRANSACTIONS
id	full_id	date	amount	description	IVA category	review_status
71a5db2b	71a5db2bb891df295bee72544c40497d24d5c8cfc7edab745ce7339dcf8d6518	2026-02-10	1210	Cobro factura F-2026-001 servicios de consultoria		pending
e3eeac5e	e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d	2026-02-11	605	Compra de material de oficina proveedor local		pending
2Inspect the row before previewing.
aeat --format json app ledger view e3eeac5e
aeat --format json app ledger view e3eeac5e
{
  "active_profile": "docs-sequence-sandbox",
  "command": "ledger.view",
  "notices": [],
  "result": {
    "bucket_id": "<bucket-id>",
    "review_status": "pending",
    "transaction": {
      "amount": "605",
      "attachment_ids": [],
      "booked_date": "2026-02-11",
      "business_classification": "NOT_YET_PROCESSED",
      "business_pct": null,
      "category_id": null,
      "classification_confidence": null,
      "classification_reason": "",
      "classified_at": null,
      "classified_by": "auto",
      "counterparty": "",
      "counterparty_country": null,
      "counterparty_identification_state": null,
      "created_at": "2026-04-01T09:00:00+00:00",
      "currency": "EUR",
      "date": "2026-02-11",
      "description": "Compra de material de oficina proveedor local",
      "direction": "OUTGOING",
      "fx_rate": null,
      "invoice_id": null,
      "irpf_category": null,
      "iva_amount": null,
      "iva_category": null,
      "iva_rate": null,
      "lifecycle_state": "ACTIVE",
      "m210_income_classification": null,
      "modified_at": "2026-04-01T09:00:00+00:00",
      "notes": "",
      "prorrata_reference": null,
      "purchase_invoice_evidence_id": null,
      "source_jurisdiction": null,
      "taxable_base": null,
      "transaction_id": "e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d",
      "usage_ratio_id": null,
      "value_date": "2026-02-11",
      "value_in_eur": null
    },
    "transaction_id": "e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d"
  },
  "schema_version": "2",
  "status": "success"
}

Confirm the unclassified row is ready to send for a suggestion.

3Preview a suggestion for that one row.
aeat app ledger classify e3eeac5e --llm claude
aeat app ledger classify e3eeac5e --llm claude

aeat sends that one row to the selected provider CLI, which suggests a classification (BUSINESS, PERSONAL, or MIXED), an expense category when it can choose one from the allowed list, a confidence, and a short reason. In preview mode nothing is saved. For the full machine-readable record, including the provenance (llm:<provider>) and persisted fields, put the global JSON flag before the subcommand:

1Preview with machine-readable output.
aeat --format json app ledger classify <transaction-id> --llm claude
aeat --format json app ledger classify <transaction-id> --llm claude

Use the row description, amount, direction, counterparty, and source documents to decide whether the suggestion makes sense. For the underlying manual concepts, see Classify transactions.

Review, apply, reject, or override

The review loop has four terminals:

  • Review. Preview without --apply. Nothing is saved; walking away leaves the row unchanged.

  • Apply. Persist the suggestion after review. The apply output shows the transaction id, clasificado-por llm:<provider>, and the new review status; provenance, confidence, and reason are recorded with the classification event.

  • Reject. Record that the model was wrong, with your reason, as an audit event; the row stays unclassified and the record stays in history. --reject cannot be combined with --apply. Previewing and walking away also changes nothing, but --reject is what writes the audit trail.

  • Override. Classify manually whenever the suggestion is wrong or incomplete. Manual classification always wins and supersedes a derived or model-applied value.

Apply and reject both run the provider, so they are shown as display-only frames:

1Apply persists the reviewed suggestion.
aeat app ledger classify <transaction-id> --llm claude --apply
aeat app ledger classify <transaction-id> --llm claude --apply
2Reject records the model as wrong, with your reason, in history.
aeat app ledger classify <transaction-id> --llm claude --reject --reason \
  this is personal
aeat app ledger classify <transaction-id> --llm claude --reject --reason `
  this is personal

Override runs entirely on your machine. Classify the row by hand with the classification and category you choose:

1Classify the row manually; a manual decision always wins.
aeat --format json app ledger classify e3eeac5e --classification BUSINESS --category-id \
  material_oficina
aeat --format json app ledger classify e3eeac5e --classification BUSINESS --category-id `
  material_oficina
{
  "active_profile": "docs-sequence-sandbox",
  "command": "ledger.classify",
  "notices": [],
  "result": {
    "bucket_event_ids": [
      "cf03296ca740dd5d6130ecd3349d24ea25d09a125819597f400d0489945da3ab"
    ],
    "bucket_id": "<bucket-id>",
    "review_status": "reviewed",
    "transaction": {
      "amount": "605",
      "attachment_ids": [],
      "booked_date": "2026-02-11",
      "business_classification": "BUSINESS",
      "business_pct": null,
      "category_id": "material_oficina",
      "classification_confidence": "1",
      "classification_reason": "aeat app ledger classify",
      "classified_at": "2026-04-01T09:00:00+00:00",
      "classified_by": "manual",
      "counterparty": "",
      "counterparty_country": null,
      "counterparty_identification_state": null,
      "created_at": "2026-04-01T09:00:00+00:00",
      "currency": "EUR",
      "date": "2026-02-11",
      "description": "Compra de material de oficina proveedor local",
      "direction": "OUTGOING",
      "fx_rate": null,
      "invoice_id": null,
      "irpf_category": null,
      "iva_amount": null,
      "iva_category": null,
      "iva_rate": null,
      "lifecycle_state": "ACTIVE",
      "m210_income_classification": null,
      "modified_at": "2026-04-01T09:00:00+00:00",
      "notes": "",
      "prorrata_reference": null,
      "purchase_invoice_evidence_id": null,
      "source_jurisdiction": null,
      "taxable_base": null,
      "transaction_id": "e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d",
      "usage_ratio_id": null,
      "value_date": "2026-02-11",
      "value_in_eur": null
    },
    "transaction_id": "e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d"
  },
  "schema_version": "2",
  "status": "success"
}

Confirm the manual override classifies the row.

If the row is mixed-use, the LLM suggestion alone is not enough; supply the business share through the normal mixed-use workflow. After important corrections, re-run preflight for the period:

1Re-run the period preflight after important corrections.
aeat --format json app ledger preflight --year 2026 --period 1T
aeat --format json app ledger preflight --year 2026 --period 1T
{
  "active_profile": "docs-sequence-sandbox",
  "command": "ledger.preflight",
  "notices": [],
  "result": {
    "bucket_id": "<bucket-id>",
    "checked_transaction_count": 2,
    "issues": [],
    "period": {
      "code": "1T",
      "filing_year": 2026
    },
    "ready": true
  },
  "schema_version": "2",
  "status": "success"
}

Confirm the period preflight reads ready after corrections.

Fill in the tax fields automatically

A plain applied suggestion saves the classification and the expense category; it does not fill in the regulated tax fields. Add --saturate to also select an IVA category and derive the taxable base, IVA rate, and IVA amount. The model never invents a number: it only selects the IVA category; the rate comes from the registry, and the base and IVA amount are computed from the transaction total:

1Preview a suggestion that also selects the IVA category.
aeat app ledger classify <transaction-id> --llm claude --saturate
aeat app ledger classify <transaction-id> --llm claude --saturate
2Apply the saturated suggestion.
aeat app ledger classify <transaction-id> --llm claude --saturate --apply
aeat app ledger classify <transaction-id> --llm claude --saturate --apply

The preview adds the selected IVA category and, when the category has a Spanish rate, the derived figures; base and IVA always add up to the transaction total. A category with no simple Spanish rate (an intra-community supply, a reverse-charge purchase) shows a note instead of numbers, and you complete those by hand. The model may also decline and return unknown rather than guess; re-run, or pick the category yourself.

When you already know the IVA category, or the model returned unknown, classify the row as business first, then let the system derive the numbers without any --llm. This derives the figures from the official rate exactly as the model path does and records them as system-derived. It only touches the IVA fields, and the row must already be classified business or mixed:

1Derive the IVA fields from the official rate, without a provider.
aeat --format json app ledger classify e3eeac5e --iva-category domestic_general \
  --saturate
aeat --format json app ledger classify e3eeac5e --iva-category domestic_general `
  --saturate
{
  "active_profile": "docs-sequence-sandbox",
  "command": "ledger.classify",
  "notices": [],
  "result": {
    "bucket_event_ids": [
      "61e7203679a2689c9a11cf6a24ddaa370cfcbd16cad1a677ef3aa2dc14e52433",
      "017f5fb8270351a164fc4a36993989c06504305ae5dcf0d07335084d30267c75"
    ],
    "bucket_id": "<bucket-id>",
    "review_status": "reviewed",
    "transaction": {
      "amount": "605",
      "attachment_ids": [],
      "booked_date": "2026-02-11",
      "business_classification": "BUSINESS",
      "business_pct": null,
      "category_id": "material_oficina",
      "classification_confidence": "1",
      "classification_reason": "aeat app ledger classify --iva-category --saturate",
      "classified_at": "2026-04-01T09:00:00+00:00",
      "classified_by": "derived:iva-category",
      "counterparty": "",
      "counterparty_country": null,
      "counterparty_identification_state": null,
      "created_at": "2026-04-01T09:00:00+00:00",
      "currency": "EUR",
      "date": "2026-02-11",
      "description": "Compra de material de oficina proveedor local",
      "direction": "OUTGOING",
      "fx_rate": null,
      "invoice_id": null,
      "irpf_category": null,
      "iva_amount": "105",
      "iva_category": "domestic_general",
      "iva_rate": "0.21",
      "lifecycle_state": "ACTIVE",
      "m210_income_classification": null,
      "modified_at": "2026-04-01T09:00:00+00:00",
      "notes": "",
      "prorrata_reference": null,
      "purchase_invoice_evidence_id": null,
      "source_jurisdiction": null,
      "taxable_base": "500",
      "transaction_id": "e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d",
      "usage_ratio_id": null,
      "value_date": "2026-02-11",
      "value_in_eur": null
    },
    "transaction_id": "e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d"
  },
  "schema_version": "2",
  "status": "success"
}

Confirm the derived IVA fields land without any provider.

To override any field by hand, classify manually with the figures yourself. The taxable base plus IVA must equal the transaction total to the cent:

1Classify manually with the reduced-rate IVA figures.
aeat --format json app ledger classify {transaction_id} --classification BUSINESS \
  --iva-category domestic_reduced --taxable-base 110.00 --iva-rate 0.10 --iva-amount \
  11.00
aeat --format json app ledger classify {transaction_id} --classification BUSINESS `
  --iva-category domestic_reduced --taxable-base 110.00 --iva-rate 0.10 --iva-amount `
  11.00
{
  "active_profile": "docs-sequence-sandbox",
  "command": "ledger.classify",
  "notices": [],
  "result": {
    "bucket_event_ids": [
      "4709827e7a2d385fce18501344d5594c060f64e17ce7aefa6b7aa52adbb1a268",
      "2e292ca6b3c73c0340365ffeadcb123a7e8b8993a025610e198a38e59902c2d9"
    ],
    "bucket_id": "<bucket-id>",
    "review_status": "reviewed",
    "transaction": {
      "amount": "121",
      "attachment_ids": [],
      "booked_date": "2026-03-10",
      "business_classification": "BUSINESS",
      "business_pct": null,
      "category_id": null,
      "classification_confidence": "1",
      "classification_reason": "aeat app ledger classify",
      "classified_at": "2026-04-01T09:00:00+00:00",
      "classified_by": "manual",
      "counterparty": "",
      "counterparty_country": null,
      "counterparty_identification_state": null,
      "created_at": "2026-04-01T09:00:00+00:00",
      "currency": "EUR",
      "date": "2026-03-10",
      "description": "Compra tipo reducido",
      "direction": "OUTGOING",
      "fx_rate": null,
      "invoice_id": null,
      "irpf_category": null,
      "iva_amount": "11",
      "iva_category": "domestic_reduced",
      "iva_rate": "0.1",
      "lifecycle_state": "ACTIVE",
      "m210_income_classification": null,
      "modified_at": "2026-04-01T09:00:00+00:00",
      "notes": "",
      "prorrata_reference": null,
      "purchase_invoice_evidence_id": null,
      "source_jurisdiction": "ES",
      "taxable_base": "110",
      "transaction_id": "b0a6349c28d268e65a577b9709ab304661cd8f8a3f29a7acd5527adfd1f8740c",
      "usage_ratio_id": null,
      "value_date": null,
      "value_in_eur": null
    },
    "transaction_id": "b0a6349c28d268e65a577b9709ab304661cd8f8a3f29a7acd5527adfd1f8740c"
  },
  "schema_version": "2",
  "status": "success"
}

Confirm the hand-entered IVA figures land on the row.

IRPF category is still entered manually in Classify transactions. Use Review and supply calculation inputs when a modelo later reports missing values.

Read the attached invoice

Attach a purchase invoice or receipt to a transaction (see Attach invoices and receipts), then let the model read it while classifying with --read-evidence. The model chooses the spending category and the IVA situation from what it reads; aeat derives every euro amount from the registry. How the document is read depends on the file:

  • A scanned PDF or an image is read on your own machine by a local vision model. Nothing leaves the machine, no acknowledgement is needed, no --llm provider is needed, and it works in gestor and professional deployments.

  • A text-layer PDF has its text extracted and sent to a cloud provider (the same --llm providers). This sends the text off your machine, so it is off by default, barred for gestor deployments, and gated behind an explicit per-run acknowledgement.

Prefer the on-host path. Provision the local runtime first: install it with aeat config provision install, start it with aeat config provision start, and fetch the vision model with aeat config provision pull, narrowed to the vision role with --role. See Set up a workstation for the whole sequence.

The default vision model, qwen3-vl:2b, is about 2 GB and runs on a consumer GPU or on CPU. To use a larger model on a GPU with room for it, name it with the pull command’s --model option. Then classify from the attached image, previewing first. Override the vision model for one run with --vision-model qwen2.5vl:7b:

1Preview the on-host read of the attached image.
aeat app ledger classify <transaction-id> --read-evidence --saturate
aeat app ledger classify <transaction-id> --read-evidence --saturate
2Apply the on-host read.
aeat app ledger classify <transaction-id> --read-evidence --saturate --apply
aeat app ledger classify <transaction-id> --read-evidence --saturate --apply

Evidence is read on-host only. There is no path that uploads an invoice off the machine: a transaction with no attached evidence sends nothing extra either, so the provider receives only the transaction row, exactly as in the plain suggestion flow.

Split a multi-line invoice automatically

When the model reads an invoice with several lines at different rates or categories, the preview adds a split recommended note with the exact command to separate them. Each line must become its own entry so its deductible IVA and base-rate expense file independently. To act on it in one step, add --auto-split. --auto-split requires --read-evidence and cannot combine with the manual override flags:

1Preview one child transaction per invoice line.
aeat app ledger classify <transaction-id> --read-evidence --auto-split
aeat app ledger classify <transaction-id> --read-evidence --auto-split
2Apply the split, one entry per line.
aeat app ledger classify <transaction-id> --read-evidence --auto-split --apply
aeat app ledger classify <transaction-id> --read-evidence --auto-split --apply

A multi-line invoice previews one child transaction per line, each with its own category, IVA category, and registry-derived base and IVA; the children sum exactly to the original amount. A single-line invoice is classified in place with no split.

How aeat protects the documents it reads

  • Invoice bytes live only in encrypted secure storage. Reading decrypts them into memory for the one call and never writes them to a temp file, a log, or a cache.

  • A scanned or image read sends the image only to the local model over a loopback connection on your machine. Nothing leaves the machine.

  • A text-layer read sends only the extracted text, and only to a cloud provider you explicitly permitted and acknowledged. It never sends the PDF bytes.

  • The model selects only the classification, the category, the IVA category, and a split proportion. aeat derives every rate, base, and IVA amount from the registry, and refuses to persist a result whose parts do not add up. When the printed IVA does not match the computed IVA, the review shows an advisory so you can check before filing.

Evidence deployment settings

Consent and model settings are deployment configuration, off by default and administered outside this workflow. The full list lives in the environment overrides reference.

See how each suggestion was produced

Every applied result records how it was produced, so a later review shows the source:

  • llm:local-vision:<model>: read on-host by the local vision model.

  • llm:<provider>:<model>: classified by a cloud provider.

  • derived:iva-category: you chose the IVA category and aeat derived the rest.

  • manual: you set the classification by hand.

Inspect a transaction and its history at any time:

1Inspect the transaction.
aeat app ledger view e3eeac5e
aeat app ledger view e3eeac5e
ID	e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d
Date	2026-02-11
Value date	2026-02-11
Amount	605
Currency	EUR
Direction	OUTGOING
Description	Compra de material de oficina proveedor local
Counterparty	-
Classification	NOT_YET_PROCESSED
Business %	-
Category	-
Usage ratio id	-
Taxable base	-
IVA rate	-
IVA amount	-
IVA category	-
Established in	-
VAT identified in	-
IRPF category	-
Notes	-
Purchase invoice evidence	-
Attachments	-
Lifecycle state	ACTIVE
Classified by	auto
Classified at	-
Classification confidence	-
Classification reason	-
Review status	pending
2Read the transaction's history.
aeat --format json app ledger history e3eeac5e
aeat --format json app ledger history e3eeac5e
{
  "active_profile": "docs-sequence-sandbox",
  "command": "ledger.history",
  "notices": [],
  "result": {
    "bucket_id": "<bucket-id>",
    "event_count": 1,
    "events": [
      {
        "actor": "<profile-id>",
        "bucket_id": "<bucket-id>",
        "event_id": "216049abc14de7287f01ae4f31b75e164a3ae520c7ae88ea7bc6b9ac017cef30",
        "event_type": "ledger.transaction.imported",
        "object_id": "e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d",
        "object_type": "ledger_transaction",
        "occurred_at": "2026-04-01T09:00:00Z",
        "payload": {
          "import_batch_id": "644ceb0a0aa9829b9cd0bf4d3a2ba43ce73f7ab6b333d46776496818650a0816",
          "imported_count": "2",
          "provider_name": "CSV provider",
          "skipped_count": "0",
          "source_command": "aeat app ledger import",
          "source_format": "csv",
          "source_row_index": "3"
        },
        "payload_version": 1
      }
    ],
    "transaction_id": "e3eeac5e306efe4e82a72896e92e7c236245ec85348834b2c10b120c67fe087d"
  },
  "schema_version": "2",
  "status": "success"
}

Confirm the transaction's history records its events.

Limits and batch alternatives

The LLM path is single-transaction only; it cannot be combined with --file or manual --classification flags. For bulk work use the CSV-based manual path (aeat app ledger classify --file ./classifications.csv) or deterministic stored rules (aeat app ledger rule add then rule apply --dry-run then rule apply); both are covered in Classify transactions.

Privacy boundary

LLM classification calls the selected provider CLI on your machine. That CLI may send prompt data to the provider’s cloud service, depending on how that provider works and how your account is configured. Treat transaction descriptions, counterparties, amounts, categories, and any profile context in the prompt as taxpayer data. Do not use production data with an LLM provider unless your provider setup and privacy policy permit it.

Next steps