Plugin Development¶
Pacto uses an out-of-process plugin architecture for artifact generation. A plugin is a standalone executable that receives a contract via JSON on stdin and writes generated file descriptions to stdout.
A plugin can turn a contract into any artifact — Helm charts, Terraform, Kubernetes manifests — in any language.
Official plugins¶
Pacto has two official plugins:
| Plugin | Description |
|---|---|
| pacto-plugin-schema-infer | Infers a JSON Schema from sample configuration files (JSON, YAML, TOML) for use in Pacto contracts. |
| pacto-plugin-openapi-infer | Auto-detects web frameworks and extracts OpenAPI 3.1 specs from source code. Currently supports FastAPI and Huma. |
Both *-infer plugins are the composition on-ramp: they derive the interfaces you already have — a service's HTTP API from its source, its config shape from real config files — instead of asking you to hand-author a schema. Compose what already exists rather than reinvent it.
Those two are the whole official set, and both run inward. No deployment-artifact plugin ships with Pacto. pacto generate helm is a plugin you write or install, not one Pacto provides — without a pacto-plugin-helm on your PATH it exits 1 with plugin "helm" not found. The rest of this page is how to build that plugin.
Installing them¶
They are separate binaries maintained in the
pacto-plugins repository, not part
of the pacto binary, so how you installed Pacto decides whether you have them:
| Install method | Plugins |
|---|---|
Installer script (get-pacto.sh) |
Installed alongside the CLI — best-effort. If the plugin release cannot be fetched the script prints Warning: failed to fetch latest plugins version, skipping plugin installation and continues, so a successful Pacto install does not guarantee them. |
go install |
Not installed |
From source (make build) |
Not installed |
Without them, pacto generate schema-infer and pacto generate openapi-infer
fail with plugin "<name>" not found. To install them by hand, download the
binaries for your platform from the pacto-plugins releases,
make them executable and put them on your PATH or in
~/.config/pacto/plugins/ (see Plugin discovery).
Refer to each plugin's README for detailed usage and options:
Why plugins?¶
Pacto describes what a service is. Plugins decide how to deploy it. The contract is the input; deployment artifacts are the output.
Plugins run in both directions: the *-infer plugins run inward (composing interfaces you already have into the contract), while generate plugins run outward (turning the contract into deployment artifacts). One schema describes a single interface; the contract describes how those interfaces relate and change.
The plugin design is:
- Language-agnostic — write plugins in Go, Python, Rust, Bash or anything
- Version-independent — plugins don't link against Pacto libraries
- Isolated input — the plugin gets a serialized, read-only snapshot of the contract on stdin and cannot mutate Pacto's state; Pacto bounds it with a timeout and output cap but does not OS-sandbox the process
How it works¶
sequenceDiagram
participant User
participant Pacto as pacto CLI
participant Plugin as pacto-plugin-schema-infer
User->>Pacto: pacto generate schema-infer ./my-service
Pacto->>Pacto: Load and parse contract
Pacto->>Plugin: Spawn process, write JSON to stdin
Plugin->>Plugin: Read contract, generate files
Plugin->>Pacto: Write JSON response to stdout
Pacto->>Pacto: Write files to output directory
Pacto->>User: Generated 1 file(s) using schema-infer
- The user runs
pacto generate <plugin-name> [dir | oci://ref]— see the command reference for flags like--option key=value(populatesoptions) and-o/--output - Pacto loads and parses the contract
- Pacto finds the plugin binary (
pacto-plugin-<name>) - Pacto writes a
GenerateRequestJSON to the plugin's stdin - The plugin reads the request, generates artifacts, and writes a
GenerateResponseJSON to stdout - Pacto reads the response and writes the generated files to disk
Plugin discovery¶
Pacto searches for plugin binaries in this order:
$PATH— any binary namedpacto-plugin-<name>~/.config/pacto/plugins/— user plugin directory
For example, pacto generate schema-infer looks for:
pacto-plugin-schema-inferin$PATH~/.config/pacto/plugins/pacto-plugin-schema-infer
Neither found is an error, not a no-op — pacto generate helm on a machine with
no pacto-plugin-helm exits 1 and says so:
Protocol (v1)¶
Request (stdin)¶
Pacto writes a JSON object to the plugin's stdin:
{
"protocolVersion": "1",
"contract": {
"pactoVersion": "2.0",
"service": {
"name": "my-service",
"version": "1.0.0"
},
"interfaces": [...],
"workload": "service",
"state": {...},
...
},
"bundleDir": "my-service",
"outputDir": "helm-output",
"options": {
"namespace": "production"
}
}
| Field | Type | Description |
|---|---|---|
protocolVersion |
string | Always "1" for the current protocol |
contract |
object | The full parsed contract (same structure as pacto.yaml) |
bundleDir |
string | Path to the bundle directory, as resolved by the CLI — for a local contract this is the reference the user typed, so it may be relative; for an oci:// reference it is an absolute temp directory that Pacto deletes when the plugin exits. Treat as read-only (Pacto does not enforce that). |
outputDir |
string | Path where output files should go — -o/--output verbatim, or <plugin-name>-output when the flag is omitted. May be relative, and Pacto creates the directory before spawning the plugin. |
options |
object | Optional. User-provided key-value options (from --option key=value). Omitted entirely when no --option is passed, so read it defensively: request.get("options", {}). |
A relative bundleDir or outputDir is relative to Pacto's working directory,
which the plugin process inherits. A plugin must therefore not chdir before it
resolves either path, and must not assume either one is absolute.
The contract object mirrors pacto.yaml exactly.
Response (stdout)¶
The plugin writes a JSON object to stdout:
{
"files": [
{
"path": "deployment.yaml",
"content": "apiVersion: apps/v1\nkind: Deployment\n..."
},
{
"path": "service.yaml",
"content": "apiVersion: v1\nkind: Service\n..."
}
],
"message": "Generated Kubernetes manifests for my-service"
}
| Field | Type | Description |
|---|---|---|
files |
array | List of generated files |
files[].path |
string | Relative path within the output directory |
files[].content |
string | File content |
message |
string | Optional message displayed to the user |
Path safety¶
files[].path must be a relative path within the output directory. Absolute paths and paths containing .. are rejected. Always return clean relative paths.
Errors¶
If the plugin encounters an error, it should:
- Write a message to stderr
- Exit with a non-zero exit code
Pacto captures stderr and presents it to the user.
Execution limits¶
Pacto bounds plugin execution defensively: a plugin is killed if it runs longer than 60 seconds, and its stdout is capped at 64 MB (output beyond the cap is an error). Keep plugins fast and bounded; long-running work should be split or streamed differently.
Example: Minimal plugin in Bash¶
#!/usr/bin/env bash
# pacto-plugin-readme — Generates a README from a Pacto contract
set -euo pipefail
# Read the full JSON request from stdin
REQUEST=$(cat)
# Extract fields using jq
NAME=$(echo "$REQUEST" | jq -r '.contract.service.name')
VERSION=$(echo "$REQUEST" | jq -r '.contract.service.version')
WORKLOAD=$(echo "$REQUEST" | jq -r '.contract.workload // "n/a"')
STATE=$(echo "$REQUEST" | jq -r '.contract.state.type // "n/a"')
# Generate a README
CONTENT="# ${NAME}
**Version:** ${VERSION}
**Workload:** ${WORKLOAD}
**State:** ${STATE}
This file was auto-generated by pacto-plugin-readme.
"
# Write the response JSON to stdout
jq -n \
--arg path "README.md" \
--arg content "$CONTENT" \
--arg msg "Generated README for ${NAME}" \
'{files: [{path: $path, content: $content}], message: $msg}'
Make it executable and place it in your $PATH:
chmod +x pacto-plugin-readme
mv pacto-plugin-readme /usr/local/bin/
# Use it
pacto generate readme my-service
Example: Plugin in Python¶
#!/usr/bin/env python3
"""pacto-plugin-env — Generates a .env.example from the contract's configuration schema."""
import json
import sys
def main():
request = json.load(sys.stdin)
contract = request["contract"]
name = contract["service"]["name"]
# Read the first configuration schema from the bundle
configs = contract.get("configurations", [])
if not configs or "schema" not in configs[0]:
response = {"files": [], "message": "No configurations section found"}
json.dump(response, sys.stdout)
return
schema_path = f"{request['bundleDir']}/{configs[0]['schema']}"
try:
with open(schema_path) as f:
schema = json.load(f)
except FileNotFoundError:
print(f"Schema file not found: {schema_path}", file=sys.stderr)
sys.exit(1)
# Generate .env.example from schema properties
lines = [f"# Configuration for {name}", ""]
for prop, details in schema.get("properties", {}).items():
desc = details.get("description", "")
comment = f" # {desc}" if desc else ""
lines.append(f"{prop.upper()}={comment}")
content = "\n".join(lines) + "\n"
response = {
"files": [{"path": ".env.example", "content": content}],
"message": f"Generated .env.example for {name}",
}
json.dump(response, sys.stdout)
if __name__ == "__main__":
main()
Guidelines¶
- Read only from
bundleDir. Don't access files outside the bundle. - Write only to stdout. Don't write files directly; return them in the response. Pacto handles file creation.
- Follow the protocol. Return clean relative paths, write errors to stderr and exit non-zero on failure — see Path safety and Errors above for what Pacto enforces.
- Be deterministic. Given the same input, produce the same output.
- Handle missing optional fields. Only
pactoVersionandserviceare required — not all contracts haveworkload,state,configurations,dependenciesorcapabilities.