Plugin Development¶
This guide shows how to build a Datus plugin, from a minimal working hello
command to the full contract. For what plugins are, how users install and
configure them, and how profiles are resolved, start with the
introduction.
A plugin is an installable Python package discovered through the
datus.plugins entry-point group. The defining constraint: a plugin never
imports datus.* and depends on no shared SDK. The contract is a small set
of method names that Datus calls by structure (duck typing). Datus is the
config broker — it reads agent.yml, expands ${VAR} references, resolves
the active profile, constructs your plugin with a plain dict, and calls it.
You just implement the methods.
Prerequisites¶
- A Python package you can install into the same environment as
datus. datusinstalled (pip install datus-agentor a source checkout).- Python 3.12+ — a plugin runs inside datus' own interpreter
(
datus-agentdeclaresrequires-python >= 3.12), so your code and dependencies must be compatible with it.
Quickstart: a minimal plugin¶
A plugin is one class registered under datus.plugins. Here is the smallest
useful example — a hello command.
1. Package layout
2. The plugin class (datus_plugin_hello/plugin.py)
from __future__ import annotations
from typing import Any, Dict, List, Optional
class HelloPlugin:
def __init__(self, profile: Optional[Dict[str, Any]] = None) -> None:
# `profile` is the resolved agent.plugins.hello.<profile> dict
# (already ${VAR}-expanded by datus). Empty dict is fine.
self.profile: Dict[str, Any] = profile or {}
def run_cli(self, argv: List[str]) -> int:
greeting = self.profile.get("greeting", "Hello")
name = argv[0] if argv else "world"
print(f"{greeting}, {name}!")
return 0
3. Register the entry point (pyproject.toml)
[project]
name = "datus-plugin-hello"
version = "0.1.0"
dependencies = [] # note: NOT datus
[project.entry-points."datus.plugins"]
hello = "datus_plugin_hello.plugin:HelloPlugin"
The entry-point name (hello) alone determines the CLI command
(datus hello) and the config key (agent.plugins.hello) — the class and
module names are free. Two names are reserved and never dispatched to
plugins: upgrade and skill. A plugin registered under either is silently
unreachable, and names starting with - cannot be dispatched at all.
4. Install and run
That is a complete plugin. Everything below is optional surface area.
The contract¶
Datus calls these members by name on the class the entry point resolves to. Your class does not import or subclass anything from Datus.
| Member | Kind | Purpose |
|---|---|---|
PluginClass(profile: dict) |
constructor | Datus passes the resolved agent.plugins.<name>.<profile> dict (env-expanded) as a keyword argument — PluginClass(profile=...) — so the parameter must be named profile. A config-free plugin may ignore its value. |
run_cli(self, argv: list[str]) -> int \| None |
instance method | Runs the subcommand. argv is everything after datus <plugin>, with Datus' own --profile / --config already stripped. Return an exit code; None means 0. |
skills_dir() -> str \| None |
optional, class-level | Returns the bundled skill directory. See Bundling skills. |
system_prompt(profiles: dict[str, dict]) -> str \| None |
optional, class-level | Returns a markdown block injected into the agent's system prompt. See System-prompt injection. |
cli_permissions() -> dict \| None |
optional, class-level | Declares bash-permission rules for the plugin's own CLI namespace, per permission profile. See CLI bash permissions. |
tool_transformers() -> dict \| None |
optional, class-level | Declares tool argument transformers that rewrite or deny the agent's tool calls before execution. See Tool argument transformers. |
skills_dir and system_prompt must be class-reachable
Datus resolves both at startup, without an active profile (skill
discovery and prompt building happen before any command runs). Declare them
as @classmethod / @staticmethod (or a plain class attribute for
skills_dir) — they must not depend on __init__.
Configuration: what Datus hands you¶
Users configure your plugin under agent.plugins.<name>, where each key below
<name> is a profile (an environment):
agent:
plugins:
hello:
prod:
default: true
greeting: Hi
token: ${HELLO_TOKEN} # prefer ${ENV_VAR} for secrets
staging:
greeting: Yo
Datus parses this into agent.plugins.<name>.<profile> -> dict, expands
${VAR} per profile, and injects a name key equal to the profile name.
Which profile dict reaches your constructor is decided by Datus — explicit
--profile, project pin, default: true, sole profile, or an empty dict
when nothing is configured. The full resolution order is documented in the
introduction; you never write any of
that logic. Your constructor simply receives the resolved dict.
When testing locally, put your profile in whichever config file your datus
session actually loads (explicit --config → ./conf/agent.yml →
~/.datus/conf/agent.yml).
Implementing run_cli¶
argv is the command tail with Datus' global flags removed:
Only --profile / --config appearing before the first non-option token
are consumed as Datus globals; from the first command token onward everything
belongs to the plugin. datus hello greet --profile staging therefore passes
["greet", "--profile", "staging"] through untouched — your subcommands are
free to define their own --profile option.
Return an integer exit code. Suggested conventions:
| Code | Meaning |
|---|---|
0 |
success |
1 |
runtime error |
2 |
usage error |
3 |
config error |
8 |
missing optional dependency |
Raising is also fine — Datus catches exceptions from run_cli and maps them to
exit code 1 rather than crashing the CLI — but returning an explicit code
gives users clearer signals.
Recipes: wrapping functions and APIs into a CLI¶
run_cli receives a raw argv list, so you are free to route it however you
like. Here are four common patterns, from quickest to richest.
A. Dict dispatch — a few functions, zero dependencies¶
The fastest way to expose a handful of functions. Map the first token to a
handler; each handler gets the rest of argv.
class ToolboxPlugin:
def __init__(self, profile=None):
self.profile = profile or {}
def run_cli(self, argv):
if not argv:
print("usage: datus toolbox <add|upper> ...")
return 2
cmd, rest = argv[0], argv[1:]
handlers = {"add": self._add, "upper": self._upper}
handler = handlers.get(cmd)
if handler is None:
print(f"unknown command: {cmd}")
return 2
return handler(rest)
def _add(self, args): # datus toolbox add 1 2 3
print(sum(float(a) for a in args))
return 0
def _upper(self, args): # datus toolbox upper hello
print(" ".join(args).upper())
return 0
B. argparse — typed args, flags, auto usage/-h¶
Stdlib, no extra dependency. argparse prints usage and raises SystemExit
on -h or a bad invocation; Datus surfaces that as the exit code (0 for -h,
2 for usage errors), which is the conventional CLI behavior.
import argparse
class ToolboxPlugin:
def __init__(self, profile=None):
self.profile = profile or {}
def run_cli(self, argv):
parser = argparse.ArgumentParser(prog="datus toolbox")
sub = parser.add_subparsers(dest="cmd", required=True)
p_add = sub.add_parser("add", help="sum numbers")
p_add.add_argument("nums", nargs="+", type=float)
p_grep = sub.add_parser("grep", help="filter lines in a file")
p_grep.add_argument("pattern")
p_grep.add_argument("path")
p_grep.add_argument("-i", "--ignore-case", action="store_true")
ns = parser.parse_args(argv) # SystemExit on -h / bad usage
if ns.cmd == "add":
print(sum(ns.nums))
return 0
if ns.cmd == "grep":
return self._grep(ns.pattern, ns.path, ns.ignore_case)
def _grep(self, pattern, path, ignore_case):
needle = pattern.lower() if ignore_case else pattern
with open(path, encoding="utf-8") as fh:
for line in fh:
hay = line.lower() if ignore_case else line
if needle in hay:
print(line.rstrip())
return 0
C. Wrapping a REST API¶
Read the endpoint and credentials from the profile (Datus already expanded
${VAR}), then map subcommands to requests. Keep credentials in the profile —
never hard-code them, and never echo them.
import argparse
import json
class PetstorePlugin:
def __init__(self, profile=None):
self.profile = profile or {}
def run_cli(self, argv):
import requests # a plugin may depend on its own libraries
base = self.profile.get("api_base_url")
if not base:
print("no api_base_url configured for the profile")
return 3
headers = {}
if self.profile.get("token"):
headers["Authorization"] = f"Bearer {self.profile['token']}"
parser = argparse.ArgumentParser(prog="datus petstore")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("list-pets")
p_get = sub.add_parser("get-pet")
p_get.add_argument("id")
ns = parser.parse_args(argv)
base = base.rstrip("/")
if ns.cmd == "list-pets":
resp = requests.get(f"{base}/pets", headers=headers, timeout=30)
else:
resp = requests.get(f"{base}/pets/{ns.id}", headers=headers, timeout=30)
if resp.status_code >= 400:
print(f"error {resp.status_code}: {resp.text}")
return 1
print(json.dumps(resp.json(), indent=2))
return 0
Corresponding config:
agent:
plugins:
petstore:
prod:
default: true
api_base_url: https://api.example.com/v1
token: ${PETSTORE_TOKEN}
D. Typer / Click — richest UX, one extra dependency¶
For a large command surface, a framework like Typer gives you help text, type coercion, and completion. Because Datus constructs your plugin per-invocation but the Typer app is a module-level object, expose the active profile through a module global that commands read.
import typer
app = typer.Typer(add_completion=False)
_ACTIVE_PROFILE: dict = {}
@app.command("greet")
def greet(name: str, loud: bool = False):
greeting = _ACTIVE_PROFILE.get("greeting", "Hello")
msg = f"{greeting}, {name}!"
print(msg.upper() if loud else msg)
class GreeterPlugin:
def __init__(self, profile=None):
self.profile = profile or {}
def run_cli(self, argv):
global _ACTIVE_PROFILE
_ACTIVE_PROFILE = self.profile
try:
# standalone_mode=False stops Click from calling sys.exit itself,
# so we can return a code and always clear the profile.
app(args=argv, standalone_mode=False)
return 0
except SystemExit as exc: # -h / usage
return int(exc.code or 0)
except typer.Exit as exc:
return exc.exit_code
finally:
_ACTIVE_PROFILE = {}
Add typer to your package's dependencies (your plugin's deps are its own —
just not datus).
Bundling skills¶
If your package ships a skill directory, expose it via a class-level
skills_dir() and Datus will discover the skills at startup (they show up in
/skill list, alongside project and user skills).
class HelloPlugin:
@classmethod
def skills_dir(cls) -> str:
from pathlib import Path
return str(Path(__file__).parent / "skills")
Layout and packaging:
A minimal SKILL.md is YAML frontmatter plus markdown instructions (the
frontmatter follows the agentskills.io spec used by
the Skills system):
---
name: hello
description: Say hello to someone via the `datus hello` CLI
---
# Hello
Run `datus hello <name>` to greet someone. ...
See the Skills docs for the full frontmatter field reference.
Make sure the skill files are included in the wheel (they are data, not
Python). Hatchling packages every file under the package directory by default,
so nothing extra is needed unless the files are VCS-ignored (then list them
under [tool.hatch.build.targets.wheel] artifacts). With setuptools you must
opt in explicitly:
After building, verify with unzip -l dist/*.whl | grep SKILL.md.
System-prompt injection¶
A plugin can tell the agent, up front, what it is and which environments are
configured — so the model chooses it proactively instead of guessing. Expose a
class-level system_prompt(profiles):
class HelloPlugin:
@classmethod
def system_prompt(cls, profiles):
if not profiles:
# Installed but unconfigured: point the agent at the setup
# skill instead of disappearing from the prompt.
return (
"## Hello (installed, not configured)\n"
"The `datus hello` CLI is installed but has no environment "
"configured.\nRun the `hello-setup` skill to configure one."
)
envs = "\n".join(
f"- {name}: {cfg.get('greeting', '?')}"
for name, cfg in profiles.items()
)
return (
"## Hello\n"
"Say hello via `datus hello <name>`.\n"
f"Environments ({len(profiles)}):\n{envs}"
)
Datus passes the plugin's full profile mapping (all environments, not just
the active one) and appends the returned markdown to the system prompt of every
agentic node. An installed-but-unconfigured plugin receives {} — return a
short "installed, not configured" note pointing at your bundled setup skill
(see below) so the agent can walk the user through configuration. Return
None only when there is truly nothing to say.
When at least one plugin contributes a section, Datus prepends its own
## Plugins preamble naming the loaded config file and the
agent.plugins.<plugin>.<profile> shape — your text never needs to hard-code
config paths.
Never surface secrets
The returned text enters the LLM context. Datus hands you the full profile
dicts — which include password, secret keys, access keys — but you must
emit only non-secret fields (endpoints, region, environment names).
Datus never splices profile values itself; keeping credentials out of the
prompt is the plugin's responsibility. Use a field allow-list.
CLI bash permissions¶
When the agent (not a human) runs your CLI through its bash tool — e.g. the
model decides to execute datus hello greet Ada — the command goes through
Datus' permission layer. Without a declaration, every such command prompts the
user for confirmation. A class-level cli_permissions() lets your plugin
declare, per permission profile, which of its subcommands are safe to auto-run
(allow), which must be confirmed (ask), and which are blocked (deny):
class HelloPlugin:
@classmethod
def cli_permissions(cls):
return {
"normal": {"allow": ["greet:*"], "ask": ["config set:*"]},
"auto": {"allow": ["greet:*", "config set:*"]},
}
Semantics:
- Patterns are relative to your namespace. Datus prefixes each pattern
with
datus <name>, sogreet:*becomesdatus hello greet:*. A plugin can never affect commands outsidedatus <name>— notrm, not another plugin. - Pattern syntax matches
permissions.bash_commandsinagent.yml:cmdis an exact match,cmd:*a prefix match,cmd:globa prefix match whose first argument must satisfy the glob (e.g.greet:A*). A bare:*covers the whole namespace. - Profile keys: only
normalandautoare accepted. Thedangerousprofile ignores all command-level bash rules by design; adangerouskey is warned about and dropped. - Users always win. A user
denyrule inagent.ymloverrides a pluginallow(deny > ask > allow, regardless of declaration order), and plugin declarations can never change a profile's default posture. askrules can be relaxed per project. When the agent hits one of yourasksubcommands, the confirmation prompt offers "allow (project)" — choosing it persists the exact matched pattern (e.g.datus hello config set:*) to the project's.datus/config.ymlbash_allowlist, and that subcommand auto-runs from then on. The grant is exact-match only: it never widens to the rest of your namespace, and yourdenyrules are unaffected. (User-authoredaskrules fromagent.ymldo not get this option — relaxing those belongs in the user's own config.)- Scope: only the agent's bash tool is gated. A human typing
datus hello ...in a terminal is never affected.plugins_enabled: falsedisables collection along with the rest of the plugin system (see the introduction). --profileis transparent to matching.datus hello --profile prod config set xmatches the same rules (and the same project grants) as the unqualified form — the leading datus-global flag is normalized away before evaluation.--config <path>is deliberately not normalized: pointing datus at a different config file rebinds credentials, so those invocations always fall back to a confirmation.- Malformed declarations (wrong types, unknown keys, empty patterns) are logged and skipped — they never break Datus startup.
Declare read-only subcommands as allow and state-changing ones as ask
under normal; promote routine state changes to allow under auto only
when re-running them is harmless.
Tool argument transformers¶
A class-level tool_transformers() lets your plugin intercept the agent's
tool calls — inspect and rewrite the arguments before the tool executes, or
deny the call outright. The canonical use case is SQL policy enforcement:
append a tenant-scope predicate to every execute_sql query, using the
request principal the deployment injects.
class ScopedSqlPlugin:
@classmethod
def tool_transformers(cls):
return {"db_tools.execute_sql": enforce_tenant_scope}
def enforce_tenant_scope(tool_name, args, context):
tenant_id = (context.get("principal") or {}).get("tenant", {}).get("id")
if not tenant_id:
raise PermissionError("missing principal.tenant.id; cannot scope query")
args["sql"] = add_where_predicate(args["sql"], f"tenant_id = '{tenant_id}'")
return args
Semantics:
- Declaration shape: a dict mapping tool patterns to a transformer or a
list of transformers. Patterns use the proxy syntax — a bare tool name
(
execute_sql), orcategory.methodwith fnmatch globs (db_tools.*). - Transformer signature:
transformer(tool_name, args, context) -> dict, sync or async. Return the (possibly modified) argument dict to continue. Raise to deny: the tool never runs and the model receives your exception message as a normal tool failure. Returning anything that is not a dict also denies, fail closed. contextis a plain dict withnode_name,principal(request-scoped caller attributes, empty when the deployment sets none),project_root, andagent_config(the live agent configuration object — read your own profile viacontext["agent_config"].get_plugin_profile("<name>"); access it duck-typed, never importdatus.*for it). It is rebuilt on every call, so per-request values are always fresh.- Coverage: transformers wrap the agent's
FunctionToollayer, which both execution paths (SDK Runner and the native loop) go through. They do not cover direct Python invocations of tool methods (e.g. reference-template execution) or tools proxied to an external client — server-side enforcement that must survive those paths belongs in the tool layer itself (seeagent.sql_policy). - Trust model: transformers run in-process with full access to every
matched tool call's arguments. They are trusted code, gated by the same
plugins_enabledmaster switch as the rest of the plugin surface. - Use a SQL parser or a database-safe query builder when rewriting SQL — never string concatenation for policy predicates.
- Malformed declarations (non-dict, non-callable entries, empty patterns) are logged and skipped — they never break Datus startup. A declaration that collects successfully but fails to apply aborts the agent node instead of silently running without enforcement.
Bundling a setup skill¶
Editing YAML by hand is the main friction after pip install. Ship a
<name>-setup skill next to your main skill so the agent can collect the
values and write the profile itself:
The setup SKILL.md should cover, in order:
- When to use — the plugin is unconfigured, or the user wants another environment.
- Config structure — a complete YAML template for
agent.plugins.<name>.<profile>, with comments marking required / optional / secret fields. - Ask the user — list the fields that must come from the user (endpoint,
auth choice, ...). For secrets, instruct the agent to have the user export
an environment variable and reference it as
${VAR}in the YAML — never write literal secrets to the file. - Write the config — into the file named by the
## Pluginsprompt preamble, marking the first profiledefault: true. - Verify — a cheap read-only command (e.g.
datus hello version).datus <plugin>reloads the config on every invocation, so the profile works immediately; the prompt's environment list refreshes next session.
Add a guard note: if the current environment cannot edit the config file
(API / VSCode / web deployment), the agent should tell the user to edit
agent.yml on the server instead.
A complete minimal hello-setup/SKILL.md:
---
name: hello-setup
description: Configure an environment profile for the `datus hello` plugin
---
# Hello Setup
Use this skill when `datus hello` is installed but has no configured
environment, or when the user wants to add another one.
## Config structure
Profiles live under `agent.plugins.hello.<profile>` in the config file named
by the `## Plugins` section of the system prompt:
```yaml
agent:
plugins:
hello:
prod:
default: true # mark the first profile as default
greeting: Hi # required
token: ${HELLO_TOKEN} # secret — reference an env var, never a literal
```
## Steps
1. Ask the user for `greeting` and which environment variable holds the
token. Have the user export the variable; write `${VAR}` into the YAML —
never a literal secret.
2. Write the profile into the config file above; mark the first profile
`default: true`.
3. Verify with a cheap read-only call: `datus hello Ada`.
If this environment cannot edit the config file (API / web deployment), tell
the user to edit `agent.yml` on the server instead.
Verifying your plugin end-to-end¶
After pip install -e, each surface can be checked without restarting
anything (plugins are discovered per invocation):
- CLI dispatch — run
datus <name> ...from any directory. If it falls through to the REPL instead, the entry point is missing or misnamed; checkpip show -f your-packagefor theentry_points.txt. - Skills — start
datusand run/skill list; plugin-bundled skills appear alongside project and user skills. - Prompt injection — the easiest check is a unit test calling
system_prompt()directly (next section). To confirm it lands in a live session, startdatusand ask the agent "which plugins are configured?" — the answer comes from the injected section. Note that config edits take effect on the nextdatus <plugin>invocation immediately, but the prompt section refreshes only on the next session.
Testing your plugin¶
Because Datus is the broker, unit tests construct your plugin with a plain dict
— no agent.yml, no Datus imports:
from datus_plugin_hello.plugin import HelloPlugin
def test_run_cli_uses_profile_greeting(capsys):
rc = HelloPlugin(profile={"name": "prod", "greeting": "Hi"}).run_cli(["Ada"])
assert rc == 0
assert "Hi, Ada!" in capsys.readouterr().out
def test_system_prompt_lists_envs_without_secrets():
text = HelloPlugin.system_prompt({
"prod": {"name": "prod", "greeting": "Hi", "token": "s3cr3t"},
})
assert "## Hello" in text
assert "s3cr3t" not in text # secrets must never leak
def test_system_prompt_unconfigured_points_to_setup_skill():
text = HelloPlugin.system_prompt({})
assert "not configured" in text
assert "hello-setup" in text
Constraints checklist¶
Before publishing, verify:
- The package does not
import datusanywhere (grep -rn "import datus" your_pkg/). - The package does not depend on
datusor a shared plugin SDK inpyproject.toml. -
__init__accepts the profile as a keyword argument namedprofile(Datus callsPluginClass(profile=...)). - The entry-point name is not a reserved name (
upgrade,skill) and does not start with-. -
skills_dir,system_prompt, andcli_permissionsare class-reachable (@classmethod/@staticmethod/ class attribute). -
system_promptemits only non-secret fields. -
cli_permissionspatterns are namespace-relative (nodatus <name>prefix — Datus adds it), and state-changing subcommands areaskundernormal. -
run_clireturns an int (orNone) and does not callsys.exit()on the success path. - Skill files are packaged into the wheel.
- The
datus.pluginsentry-point name matches the intendeddatus <name>command and theagent.plugins.<name>config key.
Reference¶
- Entry-point group:
datus.plugins— one entry per plugin, resolving to a plugin class. - Contract source of truth:
datus/plugins/base.py(documentedDatusPluginprotocol). - Related: Plugin Introduction, Skills.