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:
aeat app ledger classify <transaction-id> --llm claudeaeat app ledger classify <transaction-id> --llm claudeA 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:
aeat app ledger list --filter classification=NOT_YET_PROCESSEDaeat app ledger list --filter classification=NOT_YET_PROCESSEDACCOUNTING 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
aeat --format json app ledger view e3eeac5eaeat --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.
aeat app ledger classify e3eeac5e --llm claudeaeat app ledger classify e3eeac5e --llm claudeaeat 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:
aeat --format json app ledger classify <transaction-id> --llm claudeaeat --format json app ledger classify <transaction-id> --llm claudeUse 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.
--rejectcannot be combined with--apply. Previewing and walking away also changes nothing, but--rejectis 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:
aeat app ledger classify <transaction-id> --llm claude --applyaeat app ledger classify <transaction-id> --llm claude --applyaeat app ledger classify <transaction-id> --llm claude --reject --reason \
this is personalaeat app ledger classify <transaction-id> --llm claude --reject --reason `
this is personalOverride runs entirely on your machine. Classify the row by hand with the classification and category you choose:
aeat --format json app ledger classify e3eeac5e --classification BUSINESS --category-id \
material_oficinaaeat --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:
aeat --format json app ledger preflight --year 2026 --period 1Taeat --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:
aeat app ledger classify <transaction-id> --llm claude --saturateaeat app ledger classify <transaction-id> --llm claude --saturateaeat app ledger classify <transaction-id> --llm claude --saturate --applyaeat app ledger classify <transaction-id> --llm claude --saturate --applyThe 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:
aeat --format json app ledger classify e3eeac5e --iva-category domestic_general \
--saturateaeat --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:
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.00aeat --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
--llmprovider 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
--llmproviders). 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:
aeat app ledger classify <transaction-id> --read-evidence --saturateaeat app ledger classify <transaction-id> --read-evidence --saturateaeat app ledger classify <transaction-id> --read-evidence --saturate --applyaeat app ledger classify <transaction-id> --read-evidence --saturate --applyEvidence 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:
aeat app ledger classify <transaction-id> --read-evidence --auto-splitaeat app ledger classify <transaction-id> --read-evidence --auto-splitaeat app ledger classify <transaction-id> --read-evidence --auto-split --applyaeat app ledger classify <transaction-id> --read-evidence --auto-split --applyA 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.
aeatderives 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 andaeatderived the rest.manual: you set the classification by hand.
Inspect a transaction and its history at any time:
aeat app ledger view e3eeac5eaeat app ledger view e3eeac5eID 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
aeat --format json app ledger history e3eeac5eaeat --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.