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 ships with two official plugins that are automatically installed alongside the CLI via the install script:
| 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.
These plugins are maintained in the pacto-plugins repository. 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-helm
User->>Pacto: pacto generate helm ./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 3 file(s) using helm
- 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 helm looks for:
- pacto-plugin-helm in $PATH
- ~/.config/pacto/plugins/pacto-plugin-helm
Protocol (v1)¶
Request (stdin)¶
Pacto writes a JSON object to the plugin's stdin:
{
"protocolVersion": "1",
"contract": {
"pactoVersion": "1.2",
"service": {
"name": "my-service",
"version": "1.0.0"
},
"interfaces": [...],
"runtime": {...},
...
},
"bundleDir": "/path/to/bundle",
"outputDir": "/path/to/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 | Absolute path to the bundle directory. Treat as read-only (for local contracts this is the real bundle directory; Pacto does not enforce read-only). |
outputDir |
string | Absolute path where output files should go |
options |
object | User-provided key-value options (from CLI flags) |
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: 1. Write a message to stderr 2. 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.runtime.workload // "n/a"')
STATE=$(echo "$REQUEST" | jq -r '.contract.runtime.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. Not all contracts have
runtime,configurations,dependencies,scaling, etc.