StabisoDocs

Autopilot and routines

AI agents

Stabiso is designed to be driven by AI agents without handing them the keys to the books. There are two ways to use it:

  • Bring your own agent: Claude Code, the Claude apps or any MCP client connects to /mcp (or calls the REST API) with an agent API key. Claude's custom connector gets that key by signing the person in: no key to copy (MCP: Claude, ADR-0082).
  • Built-in agents: six workflows under /v1/agents/*, running on Vertex AI inside the project with Gemini 3.8 Flash by default, or Claude.

Both kinds pass through the same guardrails. Where AI is used lists every model call in both products and the data each one sees.

Guardrails

Guardrail Effect
Agent keys POST /v1/api-keys with "actor_type": "agent". Every action is audited as agent.
Scopes read, draft, post, approve, admin. Give an agent read and draft so all of its work waits for review, or add post for routine postings within the policy limit.
Approval policy Agents never approve, close periods, manage keys, change the chart of accounts, send support replies, close/reopen support cases, record a customer's quote answer or erase personal data (agents_no_controls, agents_no_support_or_crm_controls). Their manual journals and postings above 5,000 always go to a human (the default; an organization can change its posting rules, never the rest).
Four-eyes Nobody approves their own request. A built-in agent's work counts as the work of the person who ran it, so that person cannot approve it, or release a payment the agent created.
Idempotency Every write needs an Idempotency-Key, so an agent can retry safely.
Database invariants Balanced entries, immutability, closed periods and RLS are enforced by Postgres (see ledger).

Connecting an MCP client

# an agent key: read + draft (+ post for routine postings)
curl -X POST "$ERP_URL/v1/api-keys" -H "Authorization: Bearer $ADMIN_KEY" \
  -H 'Idempotency-Key: key-1' -H 'Content-Type: application/json' \
  -d '{"name": "bookkeeping agent", "actor_type": "agent", "scopes": ["read", "draft", "post"]}'

claude mcp add --transport http vnext-erp "$ERP_URL/mcp" --header "Authorization: Bearer <agent key>"

MCP tools (the same service functions as REST; each names the REST operations it mirrors in _meta.rest, see REST parity):

  • Identity and setup: whoami, list_entities, search_accounts, find_records.
  • Reports: trial_balance, profit_and_loss, balance_sheet, general_ledger, aging_report, vat_report.
  • Journals: get_journal_entry, search_journal_entries, create_journal_entry, reverse_journal_entry (a person's tool: an agent key is refused, the policy action reverse_entry is denied to agents; an agent drafts a correcting entry instead).
  • Trade: create_party, create_document, record_payment, reverse_payment.
  • Close: list_pending_approvals, verify_ledger_integrity, close_checklist, flux_analysis, unusual_entries, run_period_end (depreciation, revaluation, deferred tax, revenue recognition, leases, the allowance for doubtful receivables), asset_register.
  • Month-end close with a person (ADR-0083): month_end_close (what is open, in order: who acts, which tools, what to ask, and the link for what only a person does), request_period_close (the person's one-click link to close), list_periods, close_schedule, receivable_allowance_proposal, run_recurring_journals, run_recurring_invoices, review_close (the close memo).
  • VAT returns: vat_periods, list_vat_returns, get_vat_return, prepare_vat_return, recompute_vat_return, vat_box_lines, discard_vat_return; finalising, amending and recording the submission or payment are the person's, at the return's link.
  • Banking: unmatched_bank_lines, reconcile_bank_line, bank_reconciliation_status, bank_reconciliation_history (marking an account reconciled is the person's, at its link).
  • Paying vendors and collecting: propose_vendor_payments, create_payment_run, payments_to_release, customer_mandates, propose_direct_debits, create_direct_debit_run, list_direct_debits. The assistant proposes and, once the person agrees, makes the payments or collections through the approval policy; releasing the payment or pain.008 file and recording a mandate are the person's, at the link.
  • Invoice output: invoice_content_rules, invoice_output_check (what a record lacks by law, how a send would go, the XRechnung check of a German invoice); issuing, e-mailing and recording a hand-over are the person's, at the document's link.
  • Withholding and US sales tax: withholding_tax_codes, withholding_tax_report, sales_tax_setup, sales_tax_periods, sales_tax_return, customer_sales_tax, sales_tax_payment_candidates; recording a return as filed (a second person approves it) and its payment are the person's, at the return's link.
  • Sales and leads: list_leads, get_lead, create_lead, update_lead, convert_lead, disqualify_lead, list_opportunities, opportunity_pipeline, create_opportunity (a lead is a deal at the stage lead), update_opportunity, move_opportunity_stage, list_people, list_activities, log_activity, complete_task, list_contacts, list_custom_fields, create_contact, update_contact, list_quotes, get_sales_document, create_quote, update_quote. A sales assistant drafts: no tool sends a quote or records the customer's answer (record_quote_answer is denied to agents by the approval policy), no tool erases a contact or lead (erase_personal_data is denied to agents), and a deal is won only with a customer a person set up.
  • Support desk: list_support_queues, list_support_cases, get_support_case, create_support_case, triage_support_case, add_support_note, draft_support_reply. An assistant triages and drafts: sending a reply (send_support_reply) and closing or reopening a case (close_support_case) are denied to agents by the approval policy.
  • Purchasing: list_purchase_orders, get_purchase_order, purchase_order_history, create_purchase_order, update_purchase_order, submit_purchase_order, cancel_purchase_order, close_purchase_order, get_bill_match, match_bill, purchase_settings; a bill names its order with order_reference (create_document). A second person approves an order or a bill's variance, and the person records what arrived, at the order's or the bill's link: no tool does them.
  • Stock and deliveries: list_warehouses, create_warehouse, update_warehouse, list_stock_items, create_stock_item, update_stock_item, stock_valuation, list_stock_postings, adjust_stock, transfer_stock, list_stock_counts, open_stock_count, enter_stock_count, post_stock_count, cancel_stock_count, list_delivery_notes, get_delivery_note, create_delivery_note, update_delivery_note, post_delivery_note, cancel_delivery_note, invoice_delivery_note, delivered_not_invoiced. An agent's adjustment or count difference books value, so it waits for a person (approvals_link).
  • Autopilot (ADR-0094): autopilot_status (what it keeps true for a company, what waits for a person), autopilot_passes (what each pass did). Switching an outcome on and answering its questions are a person's, in the app.

Every write tool takes an idempotency_key and returns the policy decision: posted, or pending_approval with the reason.

Built-in agents

These need ERP_GCP_PROJECT (Terraform sets it). ERP_LLM_PROVIDER chooses the model family: gemini (the default, gemini-3.8-flash) or claude (claude-opus-5-5, which must be enabled in Model Garden). The agents, prompts, validation and tests are the same for both. When the model fails or does not answer within two minutes, the next model of the chain answers (by default gemini-3.7-flash; ERP_LLM_FALLBACK_MODELS). The operator can check and choose the chain, including newer Gemini models as Google lists them, on the Models page of the operator portal; the deployment's chain follows that choice as the last resort (ADR-0035). Each agent acts as an agent actor derived from the caller, for example agent:bill-capture@user:alice, with at most read and draft. Nothing an agent prepares posts without a human approval, and the audit trail shows both the agent and the person it ran for. Tool calls run inside the request transaction, each under a savepoint. Errors go back to the model as tool errors.

Reading a bill: POST /v1/agents/bill-capture/read

Multipart: entity and file, as for bill capture. Reads the file and books nothing: the first step of the bill wizard (ADR-0038). An e-invoice is read from its XML and checked (einvoice); any other file is read by the model, and the reading is stored with the file, so that bill capture of the same file books that reading without asking the model again. The answer names the vendor it matches (vendor, with its status: a draft vendor's bill stays a draft) or proposes a new one from what is printed (new_vendor: a free code, name, country, VAT number, IBAN, address, email), and says whether a bill can be made from it (bookable, else warnings say why). Creating the proposed vendor is a person's act through POST /v1/entities/{entity}/parties, under the approval policy; with the file's source_sha256 the vendor's audit entry and activation request record where its data came from (bank_source: "invoice") and what the register said. For a vendor we already have, warnings say when the invoice asks for payment to an IBAN that is not one of its accounts, or shows another VAT number; bill capture repeats them.

POST /v1/vat-registers/check (body country, vat_number, optional name; draft scope) asks the public register of the number's country (VIES for the EU and XI, the Swiss UID register, HMRC for GB) and answers valid, invalid, unavailable or no_register, with the registered name and address where the register gives them and whether the name matches. It is advice: nothing is refused on it. An organization may make 30 calls a minute that reach a register (cached answers are free); past that it answers 429.

Bill capture: POST /v1/agents/bill-capture

Multipart: entity, file (PDF, PNG, JPEG or WebP, up to 20 MB), and optionally vendor and create_vendor. A PDF or image can also give line_accounts, a JSON array of reviewed account codes, one per line. Choices must be allowed expense or asset accounts of this company and are recorded with the bill; the stored reading is unchanged. Missing or invalid suggestions answer needs_review without creating a bill, rather than substituting a default account.

A structured e-invoice skips the model: an XRechnung XML file, or a PDF with an embedded ZUGFeRD / Factur-X / XRechnung XML, is booked from the XML with the same agent identity and approval (source: einvoice, confidence 1; see E-invoicing).

  1. The file is stored content-addressed. Uploading an already-captured file returns 409 (unless its bill was rejected or voided). A file read before with .../read is booked from that stored reading: step 2 is skipped.
  2. The model extracts vendor, invoice number, our order number, dates, currency, lines, printed VAT and totals as structured output. It also returns a confidence value and verbatim evidence quotes. Accounts are suggested by the nature of the purchase, using vendor history only as a hint between suitable accounts. No suitable account means no suggestion.
  3. The vendor is matched by VAT number, IBAN or name, or created.
  4. VAT is re-determined by data-vNext for every line. The result flags:
    • printed VAT that differs from the determined amount;
    • totals that don't tie;
    • low confidence.
  5. A draft bill is submitted, and it waits in /v1/approvals with the warnings and evidence. Approving it posts it. When it names our purchase order (the order number, as printed, with an evidence quote; from an e-invoice, BT-13), it is matched to the order as it is submitted and a variance blocks its payment (purchase orders).

Reconciliation assist: POST /v1/agents/entities/{entity}/reconcile

This covers unmatched bank lines after the deterministic matcher. The model sees each line with its scored candidates (reference, exact amount, IBAN and name signals) and the postable accounts. For each line it proposes a match, a direct posting (fees, interest, FX) or no action. The code validates every proposal before applying it:

  • documents must be among the line's candidates;
  • allocations must add up to the line and stay within open amounts;
  • all documents must belong to one party;
  • accounts must be postable and not control accounts.

Valid proposals above min_confidence are submitted for approval. Use "apply": false for a dry run.

Close review: POST /v1/agents/periods/{period_id}/close-review

A tool-using agent with:

  • read tools: reports, ledger search, aging and VAT;
  • checklist and analysis: the close checklist, flux analysis against the previous period (at a materiality threshold) and unusual entries (manual, agent-created, round, large, weekend, reversals);
  • adjustments: depreciation and FX revaluation runs, which are queued for approval.

It records issues (blocker, warning or info) and writes a Markdown close memo, stored as a document. It has no tool to close the period, and the policy would refuse one anyway.

Assistant: POST /v1/agents/assistant

A conversation with the person, behind the top bar's Assistant button and Close with the assistant on a period's close page (ADR-0093). The model reaches every MCP tool (src/erp/mcp_server/bridge.py), so it does what an MCP client with an agent key does, plus show_chart: a few directly, the others by an index in its instructions through describe_tools and call_tool (all ~200 declarations on every call used up the model's quota). POST /v1/agents/assistant/stream answers the same turn as NDJSON with a step line per tool call, which the app shows while the assistant works. Each call runs in the request's transaction under a savepoint, as the agent agent:assistant@<person> with read and draft (read only for a person who may only read).

It answers sooner by being targeted. Besides its few standing tools, the model is given directly the tools the question needs (up to 16), so it need not look them up first. Where the deployment has a key of TypeSafe's decision model Jev, Jev picks the areas of tools from the person's message, the one before it and the page they are on (page, e.g. /cash-forecast), in about a quarter of a second and in any language. Without one, or when Jev does not answer within three seconds, word rules pick the tools whose name, area or API path share a word with the page or the question: the cash forecast's on the cash forecast page, the VAT tools on the VAT report. Anything else stays one describe_tools away. Everyday questions think little (Gemini's low thinking level), which answers in a fraction of the time, and a model that has not answered one step within a minute hands over to the next. Closing a period with the assistant thinks hard, as before. Each answer is logged with how long the model took for each step, the tools together and the pick (assistant answered), so a slow answer can be explained.

{"messages": [{"role": "user", "text": "Unearned revenue this year as a graph"}], "entity": "CH01", "language": "de", "page": "/analytics"}: the conversation so far (at most 40 messages, the last the person's), kept by the client. The answer is reply (in language), charts (each value no tool returned is listed in the chart's unverified), and tool_calls. With period_id the assistant closes that period with the person, and close gives the checklist's state and the open steps only a person takes (actions, each with its path in the app). It never closes a period.

The assistant also talks: the voice module holds a spoken conversation with it over the WebSocket /v1/agents/assistant/voice, with the same set-up and tools, through Google's live voice model (Talking with the assistant, ADR-0113).

Q&A: POST /v1/agents/ask

{"question": "…", "entity": "CH01"}. This is a read-only agent: reports, ledger search, entry lookup, aging, VAT, documents and bank lines, and never raw SQL. Every tool call also runs inside a database savepoint that is unconditionally rolled back (read_only=True), plus ORM flush and raw SQL guards. It answers with citations. Entry and document numbers in the answer are checked against the tool outputs, and any number no tool returned is listed under unverified_references.

Form suggestions: POST /v1/agents/form-suggestions

{"entity": "CH01", "form": "A support queue", "language": "de", "fields": [{"name": "name", "label": "Name", "help": "What the team calls it", "kind": "text"}, …]}. The model proposes a value and a one-sentence reason for each field it can, from the fields' explanations and the company's name, country, currency, industry and e-mail domain. A value the field cannot take is dropped; the model is told never to invent names, addresses, amounts or bank details. Nothing is saved: the app's Suggest with AI button puts the values into the form for the person to check. When no model answers within about 30 seconds the answer is status: "waiting" with retry_after_seconds (ADR-0112).

Development and tests

The agents talk to the provider-neutral datavnext.llm interface. tests/erp/test_agents.py drives every agent with a scripted fake at that interface, so the suite never calls a model. tests/unit/test_llm_providers.py checks the wire mapping of the Gemini and Claude adapters against fake SDK clients. The tests cover the guardrails as well as the happy paths:

  • invented documents are refused;
  • control accounts are refused;
  • a missing close_period tool returns an error to the model;
  • a second depreciation run is refused;
  • replayed idempotent requests do not call the model again.
Stabiso, the Enterprise System of Record · generated from the code and its guides · release 20b7dd5b0c4d