Writing Plugins
A plugin is a WebAssembly module that Zhang runs while it loads a ledger, or when an HTTP request reaches it. With a plugin you can:
- transform the ledger: add, change or remove directives, e.g. generate recurring transactions, split expenses or tag entries;
- validate it: report problems in the ledger’s error list without changing anything;
- serve pages and data: answer HTTP requests under
/api/plugins/{name}, e.g. a custom report.
Plugins are built with Extism, so any language with an Extism plug-in kit works. This guide uses the Rust SDK, zhang-plugin-sdk, and documents the ABI underneath it for everyone else.
Enabling plugins
Section titled “Enabling plugins”Plugins are off by default. Turn them on in the ledger:
option "features.plugin" "true"features.plugins works too. Without the option, plugin directives are ignored and their modules are never read.
The plugin directive
Section titled “The plugin directive”plugin "plugins/guard.wasm" "strict" threshold: "500 CNY" allowed_paths: "guard" timeout: "10s"- The first value is the module. Zhang reads it through the ledger’s data source, so the path is relative to the ledger root, and works the same for ledgers on S3, WebDAV or GitHub.
- The following values are positional arguments (here
"strict"). Beancount’splugin "module" "config"passes its config string this way. - The meta lines hold the capabilities Zhang grants the plugin (below) and the plugin’s own config (here
threshold).
Meta keys starting with zhang. are reserved for values Zhang sets.
Capabilities
Section titled “Capabilities”A plugin can do nothing outside its own memory unless its directive grants it.
| Meta | Grants | Default |
|---|---|---|
allowed_hosts |
HTTP requests to these hosts. Repeat the key for several hosts. | no network at all |
allowed_paths |
read-only access to these files and directories of the ledger. Repeat the key for several. | no file access |
timeout |
how long one call into the plugin may run: whole seconds ("90") or a number with a unit ms, s, m or h ("500ms", "2m"), at most a day |
60 seconds |
seed |
nothing; any text, mixed into the plugin’s seed | none |
stage |
where the plugin’s processor and mapper run: "booked", after Zhang has booked the transactions, or "raw", before, on the transactions as written (see the stage order contract) |
"booked" |
An invalid timeout, allowed_paths or stage value is reported as a ParseInvalidMeta error on the directive, and the plugin gets the default.
allowed_paths
Section titled “allowed_paths”plugin "plugins/receipts.wasm" allowed_paths: "documents" allowed_paths: "statements/2024.csv"- Each value is a file or a directory, relative to the ledger root and written with
/."."grants the whole root. - A directory grants everything under it, compared component by component:
documentsdoes not grantdocuments-private/. - The dot rule: below a grant, a hidden name (one starting with
.) is readable only when a value names it. So"."does not expose.git/config,.envor.cache/, while".config"grants.config/…, and"documents/.receipts"grants exactly that directory. Listings leave hidden entries out. - A value that is empty or absolute, or that holds a
..component, a NUL or a backslash, grants nothing. - Access is read-only, and nothing outside the ledger root can be reached: on a local disk, a symlink is followed only while it stays inside the grant. A file can be at most 16 MiB, and a listing at most 10 000 entries.
- Only a processor or mapper reads files. Every file or directory a plugin reads is recorded, and
zhang servereloads a local ledger when one of them changes, appears or disappears.
Plugin types
Section titled “Plugin types”A plugin declares what it is in its supported_type export, and can be several things at once.
| Type | Export | Runs | Input → output |
|---|---|---|---|
Processor |
processor |
once per load | the whole directive stream → the new stream |
Mapper |
mapper |
once per directive, in one plugin instance for the whole stream | one directive → the directives replacing it (none, itself, or several) |
Router |
router |
once per HTTP request, each in a fresh instance | the request → the response |
A plugin that is both a processor and a mapper runs its processor first. A type Zhang does not know is ignored with a warning, so a plugin written for a newer Zhang still loads.
The stage order contract
Section titled “The stage order contract”While Zhang loads a ledger, the directive stream runs through these stages, in this order:
- your plugins declared
stage: "raw", in the order theirplugindirectives are declared; - booking: every transaction is booked, as Beancount books before its plugins run. A posting written without an amount gets the amount interpolated from the others; a cost becomes the per-unit cost and acquisition date of the lot it books against; a sale across several lots becomes one posting per lot. A transaction Zhang cannot book (two postings without an amount, say) is left as written;
- your other plugins, in the order their
plugindirectives are declared; - booking again, only when a plugin ran in step 3: what the plugins added or changed is booked against the real lots, so the next steps see the stream exactly as Zhang will build the ledger from it. Booking a booked transaction again changes nothing, so a plugin that changed nothing costs nothing here;
- active accounts: postings to accounts that are not open are reported;
- pad: each
padandbalance … with padadds the padding transaction (flagP) its assertion needs; - balance check: each balance assertion is checked. It books nothing: a failing one is an error.
Then the ledger is built from the final stream: it books again what the stages left unbooked (a transaction a plugin added with a posting without an amount, the padding transactions), reports booking errors and checks that every transaction balances.
What a plugin sees:
-
The full stream, sorted by date: undated directives (
option,plugin,include, comments) first; within one date,openandcommodity, then balance directives, then everything else. Zhang re-sorts the stream after every stage, so a plugin may return directives in any order of dates; directives of the same date and kind keep the order the plugin returns them in, which is the order of their day. -
Every directive kind, including
custom,optionandplugindirectives. Options and plugins are applied before the stages run, so anoptionorplugindirective a plugin adds has no effect. -
Booked transactions, as a Beancount plugin sees them: every posting has an amount, every cost has a per-unit number and a date, and a sale across several lots is several postings, one per lot. A posting booking changed carries a
writtenfield with what the user wrote; pass it through. A plugin declaredstage: "raw"sees the transactions as written instead: a posting written without an amount has none yet, and costs are not matched to lots. Choose it for a plugin that rewrites postings before booking, such as one filling in accounts or amounts. -
Booked legs stay booked. A leg names the lot booking matched before your plugins ran. A plugin that changes the stream so that the leg would now book differently, by inserting an earlier sale of that lot, say, does not re-book it: Zhang books the final stream once more when it builds the ledger, reports what no longer fits as an error on the transaction (a lot sold short is a
NoEnoughCommodityLot), and keeps the leg as it is, as Beancount keeps what a plugin returns. The per-unit cost of a leg bought at a total cost,{{1000 USD}}over 3 units, is the exact quotient, which can be long; the total as written is in the leg’swritten.cost. -
The
balancedirectives themselves, but not the padding transactions (flagP) the pad stage creates: it runs after the plugins. -
No
paddirectives. ABI v1 predates thepaddirective, and a plugin built against an olderzhang-astcannot read it. So Zhang sets everypadaside before it calls a plugin. Abalancethat apadserves is shown to the plugin as thebalance … with padit was before Zhang hadpad, with the pad’s account: a plugin sees the stream a Beancount ledger gave it before. What the plugin returns is its word, and Zhang puts eachpadback only where it pads what the plugin returned:- a
padwhosebalance … with pads all come back asbalance … with pad, of one account from one pad account, is put back with that account and pad account, so a plugin may rename either or change the pad account. Its balances turn back intobalances, with their tolerance, and keep everything else the plugin changed in them. A plugin that changes nothing gets exactly the stream it was given; - a
padone of whosebalance … with pads the plugin dropped, turned into a plainbalance, or gave another account or pad account than the others, is left out, and so is apadthat, put back, would serve other balances than the ones it stood for (when a plugin moves one to another date, or adds a balance of the account before one). Everybalance … with padthe plugin returned then pads its own assertion, as abalance … with paddoes; - a
padthat serves no balance is invisible to a plugin, which cannot change or drop it. It is put back as it is, and must still serve none: put back where it would serve one, it is left out.
A
padleft out pads nothing, and is reported as anUnusedPaderror, as apadput back that pads nothing is.A
padput back serves only the balances it stood for. Any otherbalancethe plugin returns, such as one it adds or one it turned into a plainbalance, is not padded by apadit could not see, and is checked as it is.Pads are not visible to plugins yet; exposing them is future ABI work.
- a
Why plugins run before pad and balance check. Running plugins first means a pad is sized after every transaction a plugin adds, so the account always ends at the amount you wrote. Beancount runs pad before plugins and checks balance after them, so there a transaction a plugin adds to a padded account makes the assertion fail. For a working beancount ledger the padded amount is the same either way. Only a plugin that inspects the padding transactions themselves notices the difference, and it still sees the balance directive.
Quickstart with the Rust SDK
Section titled “Quickstart with the Rust SDK”zhang-plugin-sdk lives in the Zhang repository and is versioned with it; it is not on crates.io yet. Pin it to the Zhang release you run: the directives cross the boundary in zhang-ast’s JSON shape, and a plugin built against an older zhang-ast cannot read a directive kind a newer Zhang added. Zhang keeps the kinds added since ABI v1 (the pad directive) away from v1 plugins.
-
Create a library crate and make it a
cdylib:Cargo.toml [package]name = "large-expense"version = "0.1.0"edition = "2021"[lib]crate-type = ["cdylib"][dependencies]zhang-plugin-sdk = { git = "https://github.com/zhang-accounting/zhang", tag = "vX.Y.Z" } -
Write the plugin.
plugin!exportsname,version,supported_type(derived from the handlers you give) and the handlers, and does the JSON for you:src/lib.rs use zhang_plugin_sdk::config::Config;use zhang_plugin_sdk::{custom, errors, plugin, Directive, Error, Stream};const NAME: &str = "large-expense";plugin! {name: NAME,version: env!("CARGO_PKG_VERSION"),processor: process,}fn process(stream: Stream) -> Result<Stream, Error> {// flat config, the directive's meta, and `custom "large-expense" …` directiveslet config = Config::load().with_custom(custom::entries(NAME, &stream));for directive in &stream {let Directive::Transaction(txn) = &directive.data else { continue };let date = txn.date.naive_date();let Some(threshold) = config.resolve("threshold", date, Some(&txn.meta)) else { continue };let threshold = threshold.amount(0)?;for posting in &txn.postings {if let Some(units) = &posting.units {if units.commodity == threshold.commodity && units.number > threshold.number {errors::emit_error_at(&directive.span, format!("{} is a large expense", posting.account.name()), [("rule", "threshold")]);}}}}Ok(stream)} -
Build it for
wasm32-unknown-unknown:Terminal window rustup target add wasm32-unknown-unknowncargo build --release --target wasm32-unknown-unknowncp target/wasm32-unknown-unknown/release/large_expense.wasm ~/ledger/plugins/ -
Declare it in the ledger:
option "features.plugin" "true"plugin "plugins/large_expense.wasm"threshold: "500 CNY"2024-07-01 custom "large-expense" "threshold" 300 CNY
What the SDK offers:
| Module | For |
|---|---|
plugin! |
the exports, with typed handlers: fn(Stream) -> Result<Stream, Error>, fn(Spanned<Directive>) -> Result<Stream, Error>, fn(Request) -> Result<Response, Error> |
config |
Config::load(), get (flat keys), abi, plugin (arguments and multi-valued meta), meta, option, seed, resolve, and Values to parse numbers, amounts, dates, booleans and accounts |
custom |
entries(name, &stream) and latest(…) for custom config |
clock |
now(), today(), rng(), rng_for(&directive) |
fs |
read_file, read_to_string, list_dir |
errors |
emit_error(message), emit_error_at(span, message, metas) |
prices |
PriceMap::from_stream(&stream), rate(base, quote, date), convert(amount, target, date) for exchange rates |
realization |
SparseRealization for selected account quantities and costs |
router |
Request, Response, query(bql), ledger_info() |
On a native target the SDK still compiles: plugin! exports nothing and host functions answer unavailable, so cargo test runs your plugin logic without Zhang, with a Config::from_map(...) standing in for the host’s config. Four complete plugins live in zhang-plugin-sdk/examples: three processors (guard, lots, which shows the booked view, and balances, which records running quantities and costs) and a router. Zhang’s own tests build and run them.
Determinism
Section titled “Determinism”A ledger should load the same way every time. Zhang cannot enforce it, so it is a contract:
- Read the time from Zhang, with the
zhang_nowhost function (clock::now()/clock::today()in the SDK). Zhang reads its clock once per load, so every plugin sees the same instant, in the ledger’s timezone. Reading it makes the ledger depend on the date, andzhang servereloads such a ledger at midnight. - Derive randomness from the seed, the
zhang.seedconfig (clock::rng()andclock::rng_for(&directive)in the SDK). The seed depends only on the plugin’s directive — its module path, its position among directives of the same module, and itsseedmeta — so generated ids and links stay the same on every reload.rng_foralso mixes in the directive’s text, so an id does not move when other parts of the file change. - Build for
wasm32-unknown-unknown. A plugin built for WASI can read the host’s real clock and entropy, which Zhang cannot intercept; such a plugin is not reproducible.
Config in custom directives
Section titled “Config in custom directives”Config that changes over time belongs in the ledger, as dated custom directives whose first value is the plugin’s name:
2024-01-01 custom "large-expense" "threshold" 100 CNY2024-07-01 custom "large-expense" "threshold" "150 CNY"An entry dated 2024-03-05 sees the threshold of 2024-01-01; one dated 2024-08-01 the one of 2024-07-01. Values stay strings: 100 CNY arrives as the two values "100" and "CNY", and the SDK’s Values::amount reads both shapes.
Config::resolve(key, date, entry_meta) looks a setting up in this order, the first one holding the key winning:
- the metadata of the entry being processed;
- the latest
custom "<plugin>" "<key>" …dated on or before the entry (of several on one day, the last); - the meta of the plugin’s
plugindirective; - a ledger option of that name.
Only a processor sees the whole stream, so only a processor can read custom config; a mapper sees one directive at a time.
Exchange rates
Section titled “Exchange rates”Zhang never precomputes prices for plugins. A processor that needs exchange rates builds them from the stream it receives, with the SDK’s PriceMap:
use zhang_plugin_sdk::prices::PriceMap;
let prices = PriceMap::from_stream(&stream);let rate = prices.rate("USD", "CNY", date); // Option<BigDecimal>let value = prices.convert(&posting_units, "CNY", date); // Option<Amount>It gives the same rates as Zhang’s query engine uses for convert, value and getprice, so a plugin’s valuations agree with Zhang’s. The rules are beancount’s:
- The rate on a date is the latest
priceon or before it. There is no rate before the first price. - Prices of a pair on the same day replace each other: the last one in the stream wins.
- A pair without prices of its own uses the inverse of the opposite pair,
1 / rate; zero prices have no inverse and are skipped. - A pair quoted in both directions has one merged history. The direction with more prices is kept and the other one is inverted into it, so the rate is the latest quote in either direction. Of two quotes on the same day, the one of the less-quoted direction wins.
- A commodity’s rate to itself is 1.
Precision: rates from price directives are exact. An inverse that terminates is exact too (1 / 8 = 0.125); one that does not is rounded half-even to 28 significant digits, as beancount’s decimal context and Zhang’s query engine do (1 / 7 = 0.1428571428571428571428571429). convert rounds a product to 28 significant digits only when it has more.
Implicit prices: PriceMap::from_stream_with_implicit also takes the prices written on postings, @ per unit and @@ in total, like beancount’s implicit_prices plugin. Zhang itself does not use them, so the rates they add differ from what Zhang shows; it is an opt-in for plugins ported from beancount. A posting without units, which only a plugin running stage: "raw" sees, is skipped: it carries no price yet.
Selected account balances
Section titled “Selected account balances”A plugin that needs balances for a few accounts can build a SparseRealization from its booked stream. The host does not precompute it, and the helper keeps only the requested account totals:
use zhang_plugin_sdk::realization::{AccountScope, SparseRealization};
let balances = SparseRealization::from_stream( &stream, ["Assets:Broker", "Income:Gains"], AccountScope::Subtree,)?;let broker = balances.get("Assets:Broker").unwrap();let shares = broker.units.get("AAPL").cloned().unwrap_or_default();let usd_cost = broker.cost.get("USD").cloned().unwrap_or_default();Exactcounts only the named account’s postings;Subtreealso includes its descendants.Assets:Bank:Cashbelongs toAssets:Bank, butAssets:Bankingdoes not. Overlapping targets have independent totals, and duplicate targets count once.unitsandcostare ordered maps by commodity. Units are the booked quantities; cost is units times each lot’s resolved per-unit cost, or the units themselves when there is no cost. For example, buying 10 AAPL at 100 USD and 10 at 110 USD, then selling 15, leaves5 AAPLand550 USDat cost. Every split leg counts once, and interpolated postings count too. Posting prices (@/@@) andPosting.writtendo not affect the sums.- Arithmetic is exact. Zero totals are removed; a missing commodity means zero. A requested account with no postings still has an empty balance, while
getreturnsNonefor an unrequested account.accounts()lists only the requested accounts, in name order. - For a running balance, start with
SparseRealization::new(accounts, scope)and callapply(&entry.data)in stream order. Non-transactions, including balance assertions, do nothing. Pads count only when their transactions are present: ordinary plugins run before the built-in pad stage and do not see padding it has yet to generate. - The helper never books or infers. A matching posting without units, or with a cost missing its number/date or still marked as total, returns
UnbookedPostingwith its account and current posting index. None of that transaction updates any balance. A validator can report it witherrors::emit_error_atand continue. Unbooked postings outside the requested accounts are ignored. A raw-stage plugin should not use this helper to infer balances.
The balances example processor records these running totals in transaction metadata and reports unbooked input as plugin errors. This helper computes cost totals, not market value; use PriceMap to convert an amount at a date’s exchange rate.
Reporting errors
Section titled “Reporting errors”There are two ways for a plugin to say something is wrong:
- Report it with
zhang_emit_error(errors::emit_error/emit_error_at). The problem becomes aPluginErrorin the ledger’s error list, on the directive whose span you pass (or the plugin’s directive), with the metasplugin,messageand your own. The ledger still loads. This is how validators work. - Fail the call: return an error from the handler (or trap, or panic). A failing processor or mapper aborts the whole load, and a call running past its
timeoutdoes too. Use it for problems the user must fix before the ledger means anything, such as invalid plugin config.
Router plugins
Section titled “Router plugins”A router plugin serves /api/plugins/{name} and every path below it, for any HTTP method:
use zhang_plugin_sdk::router::{self, Request, Response};use zhang_plugin_sdk::{plugin, Error};
plugin! { name: "summary", version: env!("CARGO_PKG_VERSION"), router: route,}
fn route(request: Request) -> Result<Response, Error> { match request.path.as_str() { "/balances" => Response::json(&router::query("SELECT account, sum(position) AS balance GROUP BY account")?), _ => Ok(Response::text("not found").with_status(404)), }}- Route:
{name}is the plugin’s name; the request’spathis the part below the route. - Authentication: the routes sit behind Zhang’s own sign-in, and the credential headers never reach the plugin.
- Read-only: a router reads the ledger only through
zhang_query(BQL) andzhang_ledger_info; no host function changes it. Every request runs in a fresh instance, and the ledger does not reload while it runs. - Security: a router’s pages are served from Zhang’s own address, so a script on such a page can call Zhang’s API, including the endpoints that change your ledger files, with your session. Escape everything you put into HTML.
Router Plugins has the request and response JSON, the error statuses and the details.
ABI v1 reference
Section titled “ABI v1 reference”This is what crosses the boundary, for plugins written without the Rust SDK. Every change is additive: a field is never removed or made required, so a plugin compiled for an older Zhang keeps working.
Exports
Section titled “Exports”Inputs and outputs are Extism plug-in input and output, as JSON.
| Export | Input | Output |
|---|---|---|
name |
none | the plugin’s name, a JSON string: "guard" |
version |
none | its version, a JSON string: "0.1.0" |
supported_type |
none | a JSON array of "Processor", "Mapper", "Router" |
processor |
the stream: an array of directives | the new stream |
mapper |
one directive | an array of directives |
router |
the request | the response |
A directive is the serde JSON of zhang-ast’s Spanned<Directive>, of a kind ABI v1 knows: Zhang never hands a plugin a pad directive (see the stage order contract). For example:
{"data": {"Comment": {"content": "; a note"}}, "span": {"start": 0, "end": 8, "content": "; a note", "filename": "/ledger/main.zhang", "line": 1, "column": 1}}start and end are byte offsets in the file, line and column where the directive starts, 1-based, the column counting characters; both are left out for a directive that was not read from a file. A span a plugin sends back, to zhang_emit_error, may leave them out too.
An export fails by returning a non-zero code with an Extism error; for a processor or mapper that aborts the load.
A posting may carry a written field: what the user wrote when booking changed the posting, as {"index": 0, "units": …, "cost": …} (index is the position of the written posting; the legs of a reduction booking split across lots are adjacent and share it, units is null for a posting written without an amount, cost is the cost spec as written). It is advisory: Zhang never reads it for balances, lots or errors, only to show journal rows and exports as written. It is absent when Zhang left the posting as written, so such a posting serializes exactly as before the field existed, and a plugin built against an older zhang-ast drops it, which only changes how those rows look. Pass it through unchanged; a plugin that changes the units or cost of a posting should drop its written field, so the row shows what the plugin wrote. The legs of a split must stay adjacent, on one account, and keep their written for Zhang to show and export the posting as written; legs moved apart, or two postings given the same index, are shown and exported as booked, so nothing is lost.
Config
Section titled “Config”Zhang hands every plugin instance a string-to-string Extism config:
| Key | Value |
|---|---|
| a ledger option’s key | the option’s value |
a meta key of the plugin directive |
its value, the last one of a repeated key; wins over an option of the same key. allowed_hosts is never passed this way |
zhang.abi |
the ABI version, "1" |
zhang.plugin |
the directive as written, as JSON: {"module": "…", "args": ["…"], "meta": {"key": ["value", …]}}, with every value of every meta key (allowed_hosts included) |
zhang.seed |
the plugin’s seed, a decimal u64 |
A Zhang that sets no zhang.abi predates ABI v1: it sets none of the zhang.* keys and links none of the host functions below.
Host functions
Section titled “Host functions”They live in the extism:host/user namespace. Arguments and results are i64 offsets of Extism memory blocks. Every function returning a value answers JSON, {"Ok": value} or {"Err": {"kind": "…", "message": "…"}}, and none of them traps.
| Function | Argument | Ok value |
Error kinds |
|---|---|---|---|
zhang_emit_error |
{"message": "…", "span": {…}?, "metas": {"k": "v"}?} |
no result | none: an unreadable payload is itself reported |
zhang_now |
none | {"now": "2024-03-16T00:30:00+08:00", "today": "2024-03-16", "timezone": "Asia/Shanghai"} |
none today; handle Err anyway |
zhang_read_file |
the path, UTF-8 | {"content": "…", "encoding": "utf8" | "base64"} |
denied, not_found, too_large, unsupported, invalid |
zhang_list_dir |
the path, UTF-8 | {"entries": [{"name": "…", "kind": "file" | "dir"}]}, sorted by name |
as zhang_read_file |
zhang_query |
the BQL text | {"columns": [{"name": "…", "type": "…"}], "rows": [[…]]} |
query (with line and column), invalid_input, unavailable |
zhang_ledger_info |
none | {"title": "…" | null, "operating_currency": "CNY", "timezone": "Asia/Shanghai"} |
unavailable |
Where they work:
zhang_emit_errorreports into the error list from a processor or mapper. While the plugin registers (name,version,supported_type) what it reports is dropped, and in a router it is only logged.zhang_nowworks everywhere. In a router it reads the clock afresh for each request, and a call while registering does not make the ledger depend on the date.zhang_read_fileandzhang_list_diranswerdeniedoutside a processor or mapper, and for any pathallowed_pathsdoes not grant.zhang_queryandzhang_ledger_infoanswerunavailableoutside a router request.
Minimum Zhang version
Section titled “Minimum Zhang version”Importing a host function makes a plugin fail to load on a Zhang that does not have it, with an unknown import error, even if the plugin never calls it. The Rust SDK imports only the host functions your plugin actually calls.
| Feature | Zhang |
|---|---|
processor and mapper exports, flat config (options and meta), allowed_hosts |
0.2.0 |
router exports actually served, zhang.abi, zhang.plugin, zhang.seed, every host function above, allowed_paths, timeout, seed, features.plugins, unknown plugin types ignored |
the release after 0.2.0 |
