Shep is an Ansible-like orchestration engine implemented in Go. It focuses on a plugin-forward architecture using the HashiCorp plugin model for process-isolated extensions and FlatBuffers for efficient, cross-language payloads over stdin/stdout.
Primary goals:
- Provide a lightweight, extensible runtime for running tasks and playbooks across hosts.
- Use HashiCorp's go-plugin architecture to allow plugins in multiple languages while keeping the core in Go.
- Use FlatBuffers for payload schemas to ensure compact, forward/backward-compatible messages and fast (de)serialization.
Quick Reference
make build # Build shep and all plugins
make test # Run all tests
make gen-schemas # Regenerate FlatBuffers Go code
make prettier # Format markdown files (run after editing .md)
make timeline # Show recent fossil history
Project Structure
cmd/shep/ # Host CLI application
plugins/ # Plugin implementations
basic/ # Reference plugin (echo capability)
inventory/ # Inventory parser plugin
internal/
fbs/shep/ # Generated FlatBuffers code (do not edit)
host/ # Plugin manager and lifecycle
protocol/ # Message framing and FlatBuffer builders
logger/ # Logging infrastructure
flatbuffers/ # FlatBuffers schema definitions
domain/ # Domain types (Inventory, Host, Group, etc.)
capabilities/ # Capability request/response types
docs/ # Architecture documentation
testdata/ # Test fixtures
Unbreakable Rules for Agents
python: this project does not use and does not accept python as a solution for anything. no pip packages, no python scripts or cli commands
go.mod: never modify a go.mod file directly, always use the go cli commands to accomplish a task or bail out and ask for help
markdown files: after changing markdown run
make prettierCLAUDE.md is a symlink to AGENTS.md and shall remain so
git: this project does not use git. do not try to run git commands. we use fossil, but you don't run the fossil command except
- to see recent history:
make timeline - to see change log:
fossil help changes - to see file diff:
fossil help diff - for blame:
fossil help blame
- to see recent history:
refs
Architecture Rules
no grpc: this project does not use gRPC. all plugin communication is via stdin/stdout with FlatBuffers. there is no network transport.
plugin naming: the host process is
shep, plugins areshep-plugin-*plugin communication: there is no plugin-to-plugin communication. all interactions are mediated by the host controller.
logging: logging is a responsibility of the host. plugins send log messages to the host via FlatBuffer messages.
directories:
- FlatBuffers schemas go in
flatbuffers/ - generated Go code goes in
internal/fbs/
- FlatBuffers schemas go in
Interface Design: Two Layers
There are two distinct layers in the system with different design principles:
| Layer | Principle | What it contains |
|---|---|---|
| Protocol | Capability-shaped | Envelope, Handshake, routing metadata |
| Domain | Strongly typed | Inventory, Task, Host, ExecuteResult, etc. |
The capability-shaped principle applies to the protocol layer. The domain layer should be strongly typed.
Protocol Layer (capability-shaped)
The protocol handles plugin discovery, routing, and framing. It should NOT know about specific behaviors.
// GOOD: open-ended capability routing
table Envelope {
protocol_version:int = 1;
capability:string; // "parse", "schedule", "execute" - open-ended
correlation_id:string;
is_response:bool;
payload:[ubyte]; // typed by context, not by enum
}
table Capability {
name:string;
version:int;
}
table Handshake {
protocol_version:int = 1;
plugin_name:string;
capabilities:[Capability];
}
Domain Layer (strongly typed)
Domain objects flow as payloads. They are strongly typed FlatBuffers schemas.
// flatbuffers/domain/inventory.fbs
table Host {
name:string;
address:string;
port:int = 22;
vars:[HostVar];
}
table Inventory {
hosts:[Host];
groups:[Group];
}
// flatbuffers/domain/task.fbs
table Task {
name:string;
module:string;
args:[TaskArg];
when:string;
become:bool;
}
Capability Request/Response Types (strongly typed)
Each capability has well-defined input and output types:
// flatbuffers/capabilities/parse.fbs
table ParseRequest {
file_path:string;
content:[ubyte];
}
table ParseResponse {
inventory:Inventory;
tasks:TaskList;
}
The Payload Is Not Untyped
The capability name acts as a type discriminator:
capability="parse" → payload is ParseRequest or ParseResponse
capability="schedule" → payload is ScheduleRequest or ScheduleResponse
capability="execute" → payload is ExecuteRequest or ExecuteResponse
This is like HTTP: the Content-Type header tells you how to interpret the
body. HTTP doesn't have a closed enum of message types.
The Wrong Way (baking behavior into protocol)
Do NOT design the protocol wrapper to know about all behaviors:
// BAD: closed enum ties host to specific message types
enum MessageType : byte { Parse, Schedule, Execute }
// BAD: one field per behavior
table Message {
type:MessageType;
parse_request:ParseRequest;
schedule_request:ScheduleRequest;
execute_request:ExecuteRequest;
}
This forces schema changes and host code changes for every new capability.
Adding a New Capability
- Define domain types (if new)
- Define capability request/response types
- Write the plugin
- No changes to Envelope, no changes to host routing code
Schema Organization
flatbuffers/
├── protocol/ # Envelope, Handshake (minimal, capability-shaped)
├── domain/ # Inventory, Task, Host (strongly typed)
└── capabilities/ # ParseRequest/Response, etc. (typed per capability)
See docs/shep-plugin-modeling.md for detailed examples.
FlatBuffers Generated Code vs Go Structs
Plugins define their own Go structs (e.g., Inventory, Host, Group in
plugins/inventory/parser.go) that mirror the FlatBuffers-generated types in
internal/fbs/shep/domain/.
Why duplicate? FlatBuffers-generated types are low-level accessors for serialized binary data, not idiomatic Go structs:
- They have no constructors; you must use builder pattern
- Field access returns
[]bytenotstring - No direct field assignment; read-only after deserialization
- Not suitable for in-memory manipulation or business logic
Plugin structs serve as the working model for parsing and business logic. FlatBuffers types are only used at serialization boundaries:
YAML file → Go structs (parse/manipulate) → FlatBuffers (serialize) → wire
wire → FlatBuffers (deserialize) → Go structs (use) → output
This separation keeps plugin code idiomatic and testable while FlatBuffers handles the wire format.
FlatBuffers Style Rules
Follow the official FlatBuffers style guide: https://flatbuffers.dev/schema/
- field names:
snake_case(lowercase with underscores) - table/struct/enum names:
UpperCamelCase - enum values:
UpperCamelCase - namespaces:
lowercase(single word preferred) - use typed fields instead of freeform JSON strings
- use enums instead of magic numbers
- protocol versioning uses monotonic integers (1, 2, 3...)
Example:
namespace shep;
enum Status : byte { Ok = 0, Failure = 1 }
table ExecuteTaskRequest {
task_name:string;
exit_code:int;
is_changed:bool;
}
Adding a New Plugin
- Create
plugins/<name>/main.gowith handshake and message loop - Define FlatBuffers schemas if needed:
- Domain types in
flatbuffers/domain/<name>.fbs - Capability types in
flatbuffers/capabilities/<name>.fbs
- Domain types in
- Update
Makefileto build the plugin and compile schemas - Run
make buildto generate code and build - Write tests in
plugins/<name>/*_test.go
See plugins/basic/ for a minimal example or plugins/inventory/ for a
complete implementation with parsing and FlatBuffer serialization.
Testing
make test # Run all tests
go test -v ./plugins/inventory/... # Test specific package
go test -v ./... -run TestName # Run specific test
Integration tests in internal/host/ build plugins to temp directories and test
the full lifecycle (handshake → invoke → shutdown).