Surse oficiale · Nu este un site al Guvernului României
Pentru dezvoltatori

Build a plugin.

Every area of public services in România.gov is a plugin: identity documents, companies, NGOs, taxes and the rest. This guide shows how to add a new area or extend one, from a single TOML file to a Rust crate with its own tools.

Ghidul tehnic este în engleză, ca și codul și documentația din depozit.

What a plugin is

A plugin owns one area of public services. The core (rogov-core) never names an area: it collects the enabled plugins in a Registry, checks that they fit together, and everything downstream reads from it: the Jev pipeline that routes questions, the HTTP API, and the export that builds the static site on GitHub Pages. Adding an area means adding a plugin, not touching the core.

Guides

Hand-written answers, in Romanian only (the site answers only in Romanian): title, summary, keywords, steps, related questions, and official https sources. Jev picks the guide and the steps that matter; the text is always yours.

Document hints

Which of your guides explain a kind of letter people attach: a tax assessment, a fine, a form, a certificate. They are suggested first when such a document is attached.

Tools (optional)

Small deterministic operations next to the guides, like checking a company's CUI or working out a 3.5% tax redirection. Typed fields in, JSON out, no personal data.

A plugin comes in one of two shapes:

  • Data-only: a plugin.toml manifest. It can be compiled in as a crate, or dropped into a folder and loaded at start-up without recompiling.
  • Crate with tools: the same manifest wrapped in a DataPlugin with tools attached in src/lib.rs. Plugins that need more can implement the Plugin trait directly.
browser ──▶ /api/ask ──▶ rogov-jev pipeline ──▶ Registry (rogov-core) ◀── plugins
                                                     │
                       rogov export ◀────────────────┘──▶ assets/js/answers.js
                                                           (static fallback on GitHub Pages)

Plugins in this build

Read live from assets/js/answers.js, which rogov export generates from the plugins. A running server gives the same list, with tools, at GET /api/plugins; cargo run -p rogov-server -- plugins prints it.

IdAreaInstitutionsGuides
The plugin list loads with the page's scripts.

Tools are not part of the static export; see the README or GET /api/plugins.

Quick start: no compiling

The fastest way to try an area is a data-only plugin. Write a manifest, put it in a folder and point ROGOV_PLUGIN_DIR at it. Every *.toml file in the folder is loaded as a plugin and validated with the compiled-in ones.

# /tmp/plugins/cultura.toml
id = "cultura"
order = 200
institutions = ["Ministerul Culturii"]

name = "Cultură"
description = "Muzee și patrimoniu."

[[guides]]
id = "muzee"
sources = [{ name = "Ministerul Culturii", url = "https://www.cultura.ro" }]
title = "Vizitarea unui muzeu"
summary = "Programul, biletele și reducerile la muzeele publice."
keywords = ["muzeu", "muzee", "bilet muzeu", "patrimoniu"]
steps = ["Verifică programul pe site-ul muzeului.", "Cumpără biletul online sau la casă."]
related = ["Cum vizitez un parc național?"]
cd backend
ROGOV_PLUGIN_DIR=/tmp/plugins JEV_MODE=fake cargo run -p rogov-server
# http://localhost:8787 — ask "cât costă biletul la muzeu?"

ROGOV_PLUGIN_DIR=/tmp/plugins cargo run -p rogov-server -- plugins   # cultura is listed
ROGOV_PLUGIN_DIR=/tmp/plugins cargo run -p rogov-server -- export    # and reaches the static site

JEV_MODE=fake runs the whole pipeline with a keyword-based stand-in for Jev, so no API key is needed. If the manifest has a problem, the server refuses to start and says which guide and why.

Data-only plugins loaded from a folder cannot have tools. When the area is ready to ship with the site, or needs a tool, turn it into a crate.

A plugin crate, step by step

1. Create the crate

Copy a data-only plugin such as backend/plugins/sanatate/ to backend/plugins/<id>/. The workspace picks up every folder under plugins/.

backend/plugins/cultura/
├── Cargo.toml
├── plugin.toml
└── src/lib.rs
# backend/plugins/cultura/Cargo.toml
[package]
name = "rogov-plugin-cultura"
description = "România.gov plugin: Culture."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish.workspace = true

[dependencies]
rogov-core.workspace = true
serde_json.workspace = true   # only if you add tools

2. Write the manifest

Same format as above; see the reference. Pick an order that does not clash with the others (the bundled plugins use 10 to 110), since it decides where your guides and hints appear.

3. Wrap it in src/lib.rs

The manifest is embedded at compile time, so the binary needs no files next to it.

//! Culture: guides from `plugin.toml`.
use rogov_core::DataPlugin;

pub fn plugin() -> DataPlugin {
    DataPlugin::from_toml(include_str!("../plugin.toml")).expect("plugin.toml is valid")
}

#[cfg(test)]
mod tests {
    #[test]
    fn manifest_is_valid() {
        rogov_core::Registry::builder().plugin(super::plugin()).build().unwrap();
    }
}

4. Register it in rogov-plugins

Each plugin is a Cargo feature of backend/crates/rogov-plugins, on by default through all. Add three lines to its Cargo.toml and one to builtin():

# backend/crates/rogov-plugins/Cargo.toml
[features]
all = [..., "cultura", ...]
cultura = ["dep:rogov-plugin-cultura"]

[dependencies]
rogov-plugin-cultura = { path = "../../plugins/cultura", optional = true }
// backend/crates/rogov-plugins/src/lib.rs, in builtin()
#[cfg(feature = "cultura")]
plugins.push(Arc::new(rogov_plugin_cultura::plugin()));

The test in that crate counts plugins and guides under the all feature; update the numbers.

5. Test and export

cd backend
cargo test --workspace                     # your manifest test, the registry, the pipeline, HTTP
cargo run -p rogov-server -- plugins       # your area, guides and tool endpoints
cargo run -p rogov-server -- export        # regenerate assets/js/answers.js
JEV_MODE=fake cargo run -p rogov-server    # try it at http://localhost:8787

Commit the regenerated answers.js with the plugin: CI runs export --check and fails when the static site does not match the plugins.

plugin.toml reference

Every table is parsed with deny_unknown_fields: a misspelt key is an error, not something silently ignored.

KeyTypeNotes
idstringRequired. Lowercase letters, digits and single dashes, up to 64 characters. Also the Cargo feature name and the path in /api/plugins/{id}.
orderintegerPosition among plugins, default 0. Ties are broken by id.
institutionsarray of stringsInstitutions responsible for the area, shown in the API.
[name]ro, enRequired. Area name; shown as the badge on answers and given to Jev with each guide.
[description]ro, enRequired. One sentence describing the area.
[[documents]]type, guidesOptional, repeatable. See document hints.
[[guides]]tableRepeatable. id (unique across all plugins, not altceva) and sources, a list of { name, url }.
title, summary, keywords, steps, relatedstrings / arraysThe guide itself, in Romanian. All required except related. A [guides.en] table is rejected: answers are only in Romanian.

A guide's owning plugin is set by the registry; never write a plugin key in a guide.

Writing good guides

Guides are the answer people read, so they are written by hand and kept short. Jev does not generate text: it routes the question to a guide and judges which steps are relevant.

  • summary does the routing. Jev reads every guide's title and summary (prefixed with your area name) to decide which one answers a question. Say plainly who it is for and what it covers.
  • steps are judged one by one. Jev answers "is step N relevant?" for each, and steps under 0.5 are shown dimmed. Keep one action per step, 1 to 20 steps.
  • keywords feed the keyword matchers: the browser fallback on GitHub Pages and the fake Jev. Diacritics are ignored when matching, but include common spellings, abbreviations (CI, PFA, SRL) and how people actually phrase it ("buletin" for the identity card).
  • sources are official pages only, https, shown as link pills under the answer. No forums, news or commercial sites.
  • related are follow-up questions shown as chips; phrase them as a person would ask, in the guide's language.
  • Check fees, deadlines and URLs against the official page. The answer always points there, and the official page prevails.

Document hints

People can attach a letter or form. Personal data is removed in the browser, then Jev classifies the document into one of ten shared types. A [[documents]] entry says which of your guides explain a type; hints from every plugin are merged in plugin order and suggested first.

[[documents]]
type = "amenda"
guides = ["taxe-online"]
typeMeaning
decizie-impunereTax assessment or payment notice
somatieSummons to pay or enforcement title
amendaFine report or fine notice
notificareNotification or official letter that asks for no payment
decizie-pensiePension decision or recalculation
citatieSummons from a court, bailiff or prosecutor
formularForm or application to fill in
certificatCertificate issued by an institution
contractContract, policy or agreement
altcevaNone of the above

A hint may only name guides the same plugin owns. The types are defined in rogov-core (documents.rs); adding one is a core change, since Jev's classification question lists them all.

Tools

A tool is a type implementing rogov_core::Tool: it describes its input fields and runs on a flat JSON object. It is served at POST /api/plugins/{plugin}/tools/{tool}.

pub trait Tool: Send + Sync {
    fn info(&self) -> ToolInfo;
    /// Any text in the output is in Romanian.
    fn run(&self, input: &Map<String, Value>) -> Result<Value, ToolError>;
}

A complete example, a tool that works out a museum ticket with a 50% student discount:

use rogov_core::tool::{number_field, optional_string_field};
use rogov_core::{DataPlugin, FieldKind, Tool, ToolError, ToolField, ToolInfo};
use serde_json::{json, Map, Value};

pub fn plugin() -> DataPlugin {
    DataPlugin::from_toml(include_str!("../plugin.toml")).expect("plugin.toml is valid").with_tool(TicketTool)
}

pub struct TicketTool;

impl Tool for TicketTool {
    fn info(&self) -> ToolInfo {
        ToolInfo {
            id: "bilet",
            title: "Preț bilet".into(),
            description: "Calculează prețul unui bilet, cu reducere pentru elevi și studenți.".into(),
            input: vec![
                ToolField::required("pret", FieldKind::Number, "Prețul întreg (lei)"),
                ToolField::optional("categorie", FieldKind::String, "Categoria (elev, student)"),
            ],
        }
    }

    fn run(&self, input: &Map<String, Value>) -> Result<Value, ToolError> {
        let price = number_field(input, "pret")?;
        if price < 0.0 {
            return Err(ToolError::Invalid { field: "pret", reason: "must not be negative" });
        }
        let discounted = matches!(optional_string_field(input, "categorie")?, Some("elev" | "student"));
        let amount = if discounted { price / 2.0 } else { price };
        // Romanian writes decimals with a comma.
        let message = format!("Biletul costă {} lei.", format!("{amount:.2}").replace('.', ","));
        Ok(json!({ "amount": amount, "discounted": discounted, "message": message }))
    }
}
POST /api/plugins/cultura/tools/bilet   { "pret": 30, "categorie": "student" }
→ 200 { "ok": true, "plugin": "cultura", "tool": "bilet", "result": { "amount": 15.0, "discounted": true, "message": "Biletul costă 15,00 lei." } }

POST /api/plugins/cultura/tools/bilet   { }
→ 400 { "error": "missing_field", "field": "pret", "message": "missing field pret" }

Helpers

  • string_field: a required, non-empty, trimmed string.
  • optional_string_field: as above, empty counts as absent.
  • number_field: a finite number; numeric strings with a decimal comma ("1240,50") are accepted.
  • rogov_core::identifiers: parse_cui and parse_iban for company tax codes and IBANs.

Rules for tools

  • Deterministic and offline. Same input, same output; no network calls, no state.
  • No personal data. Do not ask for a CNP, a name or an address. The server never logs tool input, only which tool ran and whether it succeeded.
  • Romanian only. Labels and any text in the result are in Romanian, amounts with a decimal comma.
  • Return errors, do not panic. ToolError::Missing and ToolError::Invalid become a 400 with the field name, so the page can point at it.
  • Tool ids follow the same rule as plugin ids and must be unique within the plugin. Requests are limited to 8 KB and share the per-IP rate limit with questions and feedback.

What the registry checks

The server refuses to start, and cargo test fails, when plugins do not fit together. That way a bad manifest is caught before anyone sees a broken answer.

ErrorCause
InvalidIdAn id with uppercase, spaces, double dashes, over 64 characters, or the reserved altceva.
DuplicatePluginTwo plugins with the same id, for example a crate and a ROGOV_PLUGIN_DIR file.
DuplicateGuideA guide id already used by another plugin. Guide ids are global.
InvalidGuideNo sources, a source that is not https, an empty title or summary, no keywords, 0 or more than 20 steps, or a different number of steps in ro and en.
UnknownHintA [[documents]] hint naming a guide the plugin does not own.
DuplicateToolTwo tools with the same id in a plugin, or an invalid tool id.
UnknownPlugin, EmptyROGOV_PLUGINS names a plugin that is not available, or leaves none enabled.

Choosing plugins per deployment

Plugins are linked statically rather than loaded as shared libraries: Rust has no stable ABI, and a typed registry validated at start-up is simpler to reason about and test. There are three levels of choice:

WhenHow
Build timecargo build -p rogov-server --no-default-features --features "rogov-plugins/identitate,rogov-plugins/ong"
Start-upROGOV_PLUGINS=identitate,firme,ong keeps only those ids.
Without recompilingROGOV_PLUGIN_DIR=/etc/rogov/plugins adds every *.toml in the folder as a data-only plugin.

On GitHub Pages there is no server: the site answers from answers.js, so whatever rogov export saw is what Pages serves.

Before you open a pull request

CI (.github/workflows/backend.yml) runs the same commands; run them locally first.

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets --locked -- -D warnings
  • cargo test --workspace --locked
  • cargo run -p rogov-server -- export --check (after export, commit the regenerated files)
  • A partial build still compiles: --no-default-features --features "rogov-plugins/identitate,rogov-plugins/ong"
  • Every guide has official sources and was checked against them.
  • Tools take no personal data and have unit tests for valid, missing and invalid input.
  • The README's Plugins section describes the new area.

Source: github.com/eloquentix/govromania. The plugin contract lives in backend/crates/rogov-core.