This guide covers how to extend VXD with new runtimes, engine components, CLI commands, and LLM providers.
git clone https://github.com/tzone85/vortex-dispatch.git
cd vortex-dispatch
make build # Build the vxd binary
make test # Run tests with race detection + coverage
make lint # Run golangci-lint# Unit + integration tests
go test ./...
# With race detection and coverage
go test ./... -race -coverprofile=coverage.out
# E2E tests only
go test -tags e2e ./test/
# Specific package
go test ./internal/engine/...Runtimes are pluggable AI CLI tools (like Claude Code, Codex, Gemini). To add a new one:
In your vxd.yaml:
runtimes:
my-runtime:
command: my-cli-tool
args: ["--auto-mode"]
models: ["model-a", "model-b"]
detection:
idle_pattern: "my-cli>"
permission_pattern: "allow\\?"
plan_mode_pattern: ""That's it for basic support — VXD's CLIRuntime will use your config to spawn tmux sessions, send input, and detect status via the patterns you define.
The most important part is getting the regex patterns right. Test them by:
- Starting a tmux session manually:
tmux new -s test my-cli-tool - Observing the output format when the tool is idle, requesting permissions, or done
- Writing regex patterns that match those states
- Verifying with:
vxd config validate
In the models section of your config, reference models supported by your runtime:
models:
junior:
provider: my-provider
model: model-a
max_tokens: 4000Engine components live in internal/engine/ and follow a consistent pattern.
Every engine component:
- Accepts dependencies via constructor (interfaces, not concrete types)
- Has a primary method that does the work
- Emits events via
state.NewEvent()+ store append - Returns a result struct
// internal/engine/formatter.go
package engine
import (
"github.com/tzone85/vortex-dispatch/internal/state"
)
// FormatterResult holds the outcome of formatting.
type FormatterResult struct {
StoryID string
FilesFixed int
Passed bool
}
// CommandRunner is the interface for executing shell commands (already exists in qa.go).
// Formatter runs code formatting on a story's worktree.
type Formatter struct {
events state.EventStore
runner CommandRunner
}
// NewFormatter creates a Formatter with the given dependencies.
func NewFormatter(events state.EventStore, runner CommandRunner) *Formatter {
return &Formatter{events: events, runner: runner}
}
// Format runs the formatting command and returns results.
func (f *Formatter) Format(storyID, worktreePath, command string) (FormatterResult, error) {
output, err := f.runner.Run(worktreePath, "sh", "-c", command)
result := FormatterResult{
StoryID: storyID,
Passed: err == nil,
}
evt := state.NewEvent(state.EventStoryProgress, "", storyID, map[string]any{
"stage": "format",
"passed": result.Passed,
"output": output,
})
if appendErr := f.events.Append(evt); appendErr != nil {
return result, appendErr
}
return result, err
}- Depend on interfaces — use
CommandRunner,llm.Client,GitHubOps, etc. - Emit events — every significant action should produce an event
- Return result structs — callers should not need to parse events to know the outcome
- Write tests with mocks — use the existing mock patterns from
qa_test.goormerger_test.go
CLI commands live in internal/cli/ with one file per command.
// internal/cli/mycommand.go
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
func newMyCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "mycommand",
Short: "Description of what it does",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, events, projections, err := loadStores(cmd)
if err != nil {
return err
}
defer projections.Close()
// Your logic here
fmt.Println("Done")
return nil
},
}
// Add flags
cmd.Flags().Bool("verbose", false, "Enable verbose output")
return cmd
}In internal/cli/root.go, add your command to the root:
rootCmd.AddCommand(newMyCommand())Use the existing helpers in internal/cli/helpers.go:
loadStores(cmd)— loads config, event store, and projection storeexpandHome(path)— expands~in file pathsbuildLLMClient(modelCfg)— creates an LLM client from config
LLM providers live in internal/llm/ and implement the Client interface:
type Client interface {
Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error)
}// internal/llm/myprovider.go
package llm
import "context"
type MyProviderClient struct {
apiKey string
model string
}
func NewMyProviderClient(apiKey, model string) *MyProviderClient {
return &MyProviderClient{apiKey: apiKey, model: model}
}
func (c *MyProviderClient) Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error) {
// Call your provider's API
// Return CompletionResponse{Content: "...", TokensUsed: N}
}In internal/cli/helpers.go, update buildLLMClient:
case "myprovider":
return llm.NewMyProviderClient(os.Getenv("MYPROVIDER_API_KEY"), modelCfg.Model), nilFollow the pattern in anthropic_test.go or openai_test.go:
- Test request construction
- Test response parsing
- Test error handling
- Use
httptest.NewServerfor HTTP-level testing
In internal/state/events.go:
const (
// ... existing events
EventMyNewEvent EventType = "MY_NEW_EVENT"
)In internal/state/sqlite.go, add a case to the Project() method:
case EventMyNewEvent:
// Update relevant SQLite tablesAdd a migration file in migrations/ following the existing naming pattern.
- Interfaces for dependencies — all external calls go through interfaces
- Immutable events — events are append-only, never modified
- Small files — aim for 200-400 lines per file
- Table-driven tests — use Go's
t.Run()with test case slices - Error wrapping — use
fmt.Errorf("context: %w", err)for error chains
Follow conventional commits:
feat(engine): add code formatter component
fix(watchdog): increase fingerprint buffer size
test(qa): add failure path coverage
docs: update contributing guide