Tabularis supports extending its capabilities via a JSON-RPC based external plugin system. By building a standalone executable that implements the JSON-RPC interface, you can add support for virtually any SQL or NoSQL database (such as DuckDB, MongoDB, etc.) using the programming language of your choice.
This guide details how to implement and register a custom external plugin.
An external plugin in Tabularis is a separate executable (binary or script) that runs alongside the main application. Tabularis communicates with the plugin using JSON-RPC 2.0 over standard input/output (stdin / stdout).
- Requests: Tabularis writes JSON-RPC request objects to the plugin's
stdin, separated by a newline (\n). - Responses: The plugin processes the request and writes a JSON-RPC response object to its
stdout, followed by a newline (\n). - Logging: Any output to
stderrfrom the plugin is inherited/logged by Tabularis without interfering with the JSON-RPC communication.
- Tabularis discovers plugins in its data folder at startup:
- Linux:
~/.local/share/tabularis/plugins/ - macOS:
~/Library/Application Support/com.debba.tabularis/plugins/ - Windows:
%APPDATA%\com.debba.tabularis\plugins\
- Linux:
- It reads the
manifest.jsonfor each plugin to discover its capabilities and data types. - The plugin is registered as a driver and appears in the "Database Type" list.
- When the user opens a connection using the plugin's driver, Tabularis spawns the executable and begins sending JSON-RPC messages.
- The same process instance is reused for all operations in that session.
A Tabularis plugin is distributed as a .zip file. When extracted into the plugins folder, it must have the following structure:
plugins/
└── duckdb/
├── manifest.json
└── duckdb-plugin (or duckdb-plugin.exe on Windows)
The manifest tells Tabularis everything about your plugin.
JSON Schema available:
plugins/manifest.schema.json— add"$schema": "./manifest.schema.json"to your manifest for IDE autocompletion and validation.
{
"$schema": "https://tabularis.dev/schemas/plugin-manifest.json",
"id": "duckdb",
"name": "DuckDB",
"version": "1.0.0",
"description": "DuckDB file-based analytical database",
"default_port": null,
"executable": "duckdb-plugin",
"capabilities": {
"schemas": false,
"views": true,
"routines": false,
"file_based": true,
"identifier_quote": "\"",
"alter_primary_key": false
},
"data_types": [
{
"name": "INTEGER",
"category": "numeric",
"requires_length": false,
"requires_precision": false
},
{
"name": "VARCHAR",
"category": "string",
"requires_length": true,
"requires_precision": false
}
]
}| Field | Type | Description |
|---|---|---|
id |
string | Unique driver identifier (lowercase, no spaces). Must match the folder name. |
name |
string | Display name shown in the UI (e.g., "DuckDB"). |
version |
string | Plugin version (semver). |
description |
string | Short description shown in the plugins list. |
default_port |
number | null | Default TCP port. Use null for file-based databases. |
executable |
string | Relative path to the executable inside the plugin folder. |
capabilities |
object | Feature flags (see below). |
data_types |
array | List of supported data types (see below). |
| Flag | Type | Description |
|---|---|---|
schemas |
bool | true if the database supports named schemas (like PostgreSQL). Controls whether the schema selector is shown in the UI. |
views |
bool | true if the database supports views. Enables the views section in the explorer. |
routines |
bool | true if the database supports stored procedures/functions. |
file_based |
bool | true for local file databases (e.g., SQLite, DuckDB). Replaces host/port with a file path input in the connection form. |
folder_based |
bool | true for plugins that connect to a directory rather than a single file (e.g. CSV plugin). Replaces host/port with a folder picker. |
no_connection_required |
bool | true for API-based plugins that need no host, port, or credentials (e.g. a public REST API). Hides the entire connection form — the user only fills in the connection name. |
identifier_quote |
string | Character used to quote SQL identifiers. Use "\"" for ANSI standard or "`" for MySQL style. |
alter_primary_key |
bool | true if the database supports altering primary keys after table creation. |
Each entry in data_types describes a type the driver supports for column creation in the UI:
| Field | Type | Description |
|---|---|---|
name |
string | SQL type name (e.g., "VARCHAR", "BIGINT"). |
category |
string | UI grouping category (see below). |
requires_length |
bool | true if this type requires a length argument (e.g., VARCHAR(255)). |
requires_precision |
bool | true if this type requires a precision/scale argument (e.g., DECIMAL(10,2)). |
default_length |
string? | Optional default length pre-filled in the UI (e.g., "255" for VARCHAR). |
Type Categories:
| Category | Examples |
|---|---|
numeric |
INTEGER, BIGINT, DECIMAL, FLOAT, DOUBLE |
string |
VARCHAR, TEXT, CHAR |
date |
DATE, TIME, TIMESTAMP, DATETIME |
binary |
BLOB, BYTEA, VARBINARY |
json |
JSON, JSONB |
spatial |
GEOMETRY, POINT, POLYGON |
other |
BOOLEAN, UUID |
Plugins can declare custom configuration fields that Tabularis renders in the Settings → gear icon modal for that plugin. Users fill in the values, Tabularis persists them in config.json, and passes them to the plugin at startup via an initialize RPC call.
Add an optional settings array at the top level of your manifest:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "A custom plugin with settings",
"executable": "my-plugin",
"capabilities": { ... },
"data_types": [ ... ],
"settings": [
{
"key": "api_key",
"label": "API Key",
"type": "string",
"required": true,
"description": "Your API key for authentication."
},
{
"key": "region",
"label": "Region",
"type": "select",
"options": ["us-east-1", "eu-west-1", "ap-southeast-1"],
"default": "us-east-1",
"description": "Deployment region."
},
{
"key": "max_connections",
"label": "Max Connections",
"type": "number",
"default": 10
},
{
"key": "ssl",
"label": "Enable SSL",
"type": "boolean",
"default": true
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
key |
string | yes | Unique identifier used as the key in the settings map. |
label |
string | yes | Human-readable label shown in the UI. |
type |
string | yes | One of: "string", "boolean", "number", "select". |
default |
any | no | Default value pre-filled when no saved value exists. |
description |
string | no | Optional hint displayed below the field. |
required |
boolean | no | If true, saving the modal is blocked until the field is filled. |
options |
string[] | no | For "select" type: the list of choices shown in the dropdown. |
Immediately after spawning the plugin process, Tabularis sends an initialize call:
{
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"settings": {
"api_key": "abc123",
"region": "eu-west-1",
"max_connections": 10,
"ssl": true
}
},
"id": 1
}- The
settingsobject contains only the keys the user has configured (merged with defaults). - Returning an error response is safe — Tabularis silently ignores any
initializefailure. - Plugins that do not implement
initializeare unaffected (the error is ignored). - Use
initializeto store settings in your plugin's state before any query arrives.
"initialize" => {
let settings = ¶ms["settings"];
// Store settings in your plugin state, e.g.:
// API_KEY.set(settings["api_key"].as_str().unwrap_or("").to_string());
json!({
"jsonrpc": "2.0",
"result": null,
"id": id
})
}elif method == "initialize":
settings = params.get("settings", {})
# Store settings for later use:
# api_key = settings.get("api_key", "")
send_response({"result": None, "id": req_id})Your plugin must run an event loop that:
- Reads one JSON line from
stdin. - Parses the JSON-RPC request.
- Executes the requested database operation.
- Writes a JSON-RPC response to
stdoutfollowed by\n.
{
"jsonrpc": "2.0",
"method": "get_tables",
"params": {
"params": {
"driver": "duckdb",
"host": null,
"port": null,
"database": "/path/to/my_database.duckdb",
"username": null,
"password": null,
"ssl_mode": null
},
"schema": null
},
"id": 1
}The params.params object is a ConnectionParams — the same values the user entered in the connection form. The top-level params may contain additional method-specific fields (e.g., schema, table, column_name, etc.).
{
"jsonrpc": "2.0",
"result": [
{ "name": "users", "schema": "main", "comment": null }
],
"id": 1
}{
"jsonrpc": "2.0",
"error": {
"code": -32603,
"message": "Database file not found or inaccessible."
},
"id": 1
}Standard JSON-RPC error codes:
| Code | Meaning |
|---|---|
-32700 |
Parse error |
-32600 |
Invalid request |
-32601 |
Method not found |
-32602 |
Invalid params |
-32603 |
Internal error |
Your plugin must respond to the following JSON-RPC methods. For unsupported features, return an empty array [] or a -32601 (Method not found) error.
Test whether a connection can be established.
Params: { "params": ConnectionParams }
Result: { "success": true } or an error response.
List available databases.
Params: { "params": ConnectionParams }
Result: ["db1", "db2"]
List schemas within the current database.
Params: { "params": ConnectionParams }
Result: ["public", "private"]
Return
[]ifcapabilities.schemasisfalse.
List tables in a schema/database.
Params: { "params": ConnectionParams, "schema": string | null }
Result:
[
{ "name": "users", "schema": "public", "comment": "User accounts" }
]Get column information for a table.
Params: { "params": ConnectionParams, "schema": string | null, "table": string }
Result:
[
{
"name": "id",
"data_type": "INTEGER",
"is_nullable": false,
"column_default": null,
"is_primary_key": true,
"is_auto_increment": true,
"comment": null
}
]Get foreign key relationships for a table.
Params: { "params": ConnectionParams, "schema": string | null, "table": string }
Result:
[
{
"constraint_name": "fk_user_id",
"column_name": "user_id",
"referenced_table": "users",
"referenced_column": "id",
"on_update": "CASCADE",
"on_delete": "SET NULL"
}
]Get indexes for a table.
Params: { "params": ConnectionParams, "schema": string | null, "table": string }
Result:
[
{
"index_name": "idx_email",
"columns": ["email"],
"is_unique": true,
"is_primary": false
}
]List views in a schema/database.
Params: { "params": ConnectionParams, "schema": string | null }
Result: [{ "name": "active_users", "schema": "public" }]
Get the SQL definition of a view.
Params: { "params": ConnectionParams, "schema": string | null, "view": string }
Result: "SELECT * FROM users WHERE active = true"
Get column information for a view.
Params: { "params": ConnectionParams, "schema": string | null, "view": string }
Result: Same structure as get_columns.
Create a new view.
Params: { "params": ConnectionParams, "schema": string | null, "name": string, "definition": string }
Result: null on success, or an error.
Replace or modify an existing view.
Params: { "params": ConnectionParams, "schema": string | null, "name": string, "definition": string }
Result: null on success, or an error.
Drop a view.
Params: { "params": ConnectionParams, "schema": string | null, "name": string }
Result: null on success, or an error.
List stored procedures and functions.
Params: { "params": ConnectionParams, "schema": string | null }
Result:
[
{ "name": "calculate_total", "routine_type": "FUNCTION", "schema": "public" }
]Get parameters of a stored routine.
Params: { "params": ConnectionParams, "schema": string | null, "routine": string }
Result:
[
{ "name": "p_user_id", "data_type": "INTEGER", "mode": "IN" }
]Get the SQL body of a stored routine.
Params: { "params": ConnectionParams, "schema": string | null, "routine": string }
Result: "BEGIN ... END"
Execute a SQL query and return results.
Params:
{
"params": ConnectionParams,
"query": "SELECT * FROM users",
"page": 1,
"page_size": 100
}Result:
{
"columns": ["id", "name", "email"],
"rows": [
[1, "Alice", "alice@example.com"]
],
"total_count": 1,
"execution_time_ms": 12
}Insert a new row into a table.
Params:
{
"params": ConnectionParams,
"schema": null,
"table": "users",
"data": { "name": "Bob", "email": "bob@example.com" }
}Result: null on success, or an error.
Update a single field in a row.
Params:
{
"params": ConnectionParams,
"schema": null,
"table": "users",
"primary_key_column": "id",
"primary_key_value": "42",
"column": "name",
"value": "Robert"
}Result: null on success, or an error.
Delete a row from a table.
Params:
{
"params": ConnectionParams,
"schema": null,
"table": "users",
"primary_key_column": "id",
"primary_key_value": "42"
}Result: null on success, or an error.
These methods are used to build ER diagrams efficiently by loading all metadata in one call.
Return the complete schema structure (tables + columns + foreign keys).
Params: { "params": ConnectionParams, "schema": string | null }
Result:
{
"tables": [{ "name": "users", "schema": "public", "comment": null }],
"columns": { "users": [ /* column list */ ] },
"foreign_keys": { "users": [ /* FK list */ ] }
}Return columns for all tables at once.
Params: { "params": ConnectionParams, "schema": string | null, "tables": ["users", "orders"] }
Result: { "users": [ /* columns */ ], "orders": [ /* columns */ ] }
Return foreign keys for all tables at once.
Params: { "params": ConnectionParams, "schema": string | null, "tables": ["users", "orders"] }
Result: { "users": [ /* FKs */ ], "orders": [ /* FKs */ ] }
These methods generate SQL statements. Tabularis may display the SQL to the user before executing it.
Params: { "params": ConnectionParams, "schema": string | null, "table": string }
Result: "CREATE TABLE users (...)"
Params: { "params": ConnectionParams, "schema": string | null, "table": string, "column": ColumnDefinition }
Result: "ALTER TABLE users ADD COLUMN ..."
Params: { "params": ConnectionParams, "schema": string | null, "table": string, "column": ColumnDefinition }
Result: "ALTER TABLE users MODIFY COLUMN ..."
Params: { "params": ConnectionParams, "schema": string | null, "table": string, "index": IndexDefinition }
Result: "CREATE INDEX idx_email ON users(email)"
Params: { "params": ConnectionParams, "schema": string | null, "table": string, "fk": ForeignKeyDefinition }
Result: "ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)"
Params: { "params": ConnectionParams, "schema": string | null, "table": string, "index_name": string }
Result: null on success, or an error.
Params: { "params": ConnectionParams, "schema": string | null, "table": string, "constraint_name": string }
Result: null on success, or an error.
Here is a minimal but functional skeleton for a plugin executable in Rust.
use std::io::{self, BufRead, Write};
use serde_json::{json, Value};
fn main() {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = line.unwrap();
if line.trim().is_empty() {
continue;
}
let req: Value = serde_json::from_str(&line).unwrap_or_else(|_| {
// Ignore unparseable lines
return Value::Null;
});
if req.is_null() {
continue;
}
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
let params = &req["params"];
let response = dispatch(method, params, id);
let mut res_str = serde_json::to_string(&response).unwrap();
res_str.push('\n');
stdout.write_all(res_str.as_bytes()).unwrap();
stdout.flush().unwrap();
}
}
fn dispatch(method: &str, params: &Value, id: Value) -> Value {
match method {
"test_connection" => json!({
"jsonrpc": "2.0",
"result": { "success": true },
"id": id
}),
"get_databases" => json!({
"jsonrpc": "2.0",
"result": ["my_database"],
"id": id
}),
"get_schemas" => json!({
"jsonrpc": "2.0",
"result": [],
"id": id
}),
"get_tables" => {
// Connect to the database using params["params"]["database"], etc.
json!({
"jsonrpc": "2.0",
"result": [
{ "name": "example_table", "schema": null, "comment": null }
],
"id": id
})
},
"execute_query" => {
let query = params["query"].as_str().unwrap_or("");
// Execute query and return results
json!({
"jsonrpc": "2.0",
"result": {
"columns": ["id", "name"],
"rows": [[1, "Alice"]],
"total_count": 1,
"execution_time_ms": 5
},
"id": id
})
},
_ => json!({
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": format!("Method '{}' not implemented", method)
},
"id": id
}),
}
}Add serde_json to your Cargo.toml:
[dependencies]
serde_json = "1"You can test your plugin directly by piping JSON-RPC messages:
echo '{"jsonrpc":"2.0","method":"get_tables","params":{"params":{"driver":"duckdb","database":"/tmp/test.duckdb"},"schema":null},"id":1}' \
| ./duckdb-pluginYou should see a valid JSON-RPC response on stdout.
- Create the plugin directory in Tabularis's data folder:
- Linux:
~/.local/share/tabularis/plugins/myplugin/ - macOS:
~/Library/Application Support/com.debba.tabularis/plugins/myplugin/ - Windows:
%APPDATA%\com.debba.tabularis\plugins\myplugin\
- Linux:
- Place your
manifest.jsonand the compiled executable in that directory. - On Linux/macOS, make the executable runnable:
chmod +x myplugin - Restart Tabularis (or install via Settings to hot-reload without restart).
- Open Settings → Installed Plugins — your driver should appear.
- Try creating a new connection using your driver from the connection form.
To make your plugin available in the official registry:
- Build release binaries for all supported platforms.
- Package each platform binary with
manifest.jsoninto a.zipfile. - Create a GitHub Release (or host on another URL) with the ZIP files.
- Open a pull request to this repository adding your plugin entry to
plugins/registry.json.
See README.md for the full registry.json format.
Note:
min_tabularis_versionis specified per-release inside thereleases[]array, not at the root plugin level. This allows older Tabularis installs to install an older compatible release even when a newer release requires a higher app version.