CLI-Enabled Reasoners

Expose reasoners as CLI commands with WithCLI() options

Overview

Reasoners become CLI commands when registered with CLI options. The same reasoner can serve both API requests (server mode) and CLI invocations (CLI mode).

CLI Reasoner Options

WithCLI()

Enable CLI access for this reasoner, making it available as a command.

agent.RegisterReasoner("greet", handler, agent.WithCLI())

Usage:

# Explicit command name
./binary greet --set name=Alice

WithDefaultCLI()

Set as default command - users can omit the command name. Must be used with WithCLI().

agent.RegisterReasoner("greet", handler,
    agent.WithCLI(),
    agent.WithDefaultCLI(),
)

Usage:

# Default command (no "greet" needed)
./binary --set name=Alice

# Can still use explicit name
./binary greet --set name=Alice

Only one reasoner can be marked with WithDefaultCLI(). The last one registered wins.

Input Methods

CLI mode supports four input methods, merged with priority order:

1. Individual Flags (--set key=value)

Pass parameters as individual flags. Can be repeated.

./binary greet --set name=Alice --set greeting="Hello" --set age=30

Maps to:

{
  "name": "Alice",
  "greeting": "Hello",
  "age": "30"
}

Values are strings by default. Your reasoner handler should parse types as needed.

2. JSON String (--input)

Pass entire input as JSON string.

./binary greet --input '{"name":"Alice","age":30}'

3. JSON File (--input-file)

Load input from a JSON file.

# data.json
{
  "name": "Alice",
  "age": 30
}

# Usage
./binary greet --input-file data.json

4. Stdin (Implicit)

Pipe JSON data to stdin.

echo '{"name":"Alice"}' | ./binary greet

# Or from file
cat data.json | ./binary greet

Merging Priority

When multiple input methods are used, they merge with this priority:

--set > --input > --input-file > stdin

Example:

# base.json: {"name":"Alice","age":30}
./binary --input-file base.json --set name=Bob --input '{"greeting":"Hi"}'

# Result: {"name":"Bob","age":30,"greeting":"Hi"}
# "Bob" from --set overrides "Alice" from file

Multiple CLI Reasoners

Register multiple reasoners as CLI commands:

a.RegisterReasoner("greet", greetHandler,
    agent.WithCLI(),
    agent.WithDefaultCLI(), // Default command
)

a.RegisterReasoner("analyze", analyzeHandler,
    agent.WithCLI(),
    agent.WithDescription("Analyze text with AI"),
)

a.RegisterReasoner("summarize", summarizeHandler,
    agent.WithCLI(),
    agent.WithDescription("Summarize documents"),
)

Usage:

# Default command (greet)
./binary --set name=Alice

# Explicit commands
./binary greet --set name=Bob
./binary analyze --input-file doc.txt
./binary summarize --set url=https://example.com/article

List available commands:

./binary --help

CLI-Only vs Server-Accessible

Not all reasoners need CLI access. You can have:

  • CLI-only reasoners: Registered with WithCLI(), not called by other agents
  • Server-only reasoners: No WithCLI(), only accessible via control plane
  • Dual-access reasoners: With WithCLI(), accessible both ways
// CLI-only (not exposed to control plane routing)
a.RegisterReasoner("local-debug", handler, agent.WithCLI())

// Server-only (internal reasoner, no CLI)
a.RegisterReasoner("internal-process", handler)

// Dual-access (both CLI and server)
a.RegisterReasoner("analyze", handler, agent.WithCLI())

CLI Context Detection

Reasoners can detect if they're running in CLI mode:

a.RegisterReasoner("greet", func(ctx context.Context, input map[string]any) (any, error) {
    name := input["name"].(string)

    if agent.IsCLIMode(ctx) {
        // CLI mode: return simple string
        return fmt.Sprintf("Hello, %s!", name), nil
    }

    // Server mode: return structured data
    return map[string]any{
        "greeting": fmt.Sprintf("Hello, %s!", name),
        "timestamp": time.Now().Unix(),
    }, nil
}, agent.WithCLI())

Command Naming

Command names are derived from reasoner names:

Reasoner NameCLI Command
"greet"./binary greet
"analyze-text"./binary analyze-text
"summarize_doc"./binary summarize_doc

Best practices:

  • Use lowercase names
  • Use hyphens for multi-word commands (analyze-text not analyzeText)
  • Keep names short and descriptive

Built-in Commands

These commands are always available:

CommandDescription
serveStart server mode (connect to control plane)
--helpShow help information
--versionShow version information

You cannot override these commands with custom reasoners.

Examples

Single Default Command

a.RegisterReasoner("process", func(ctx context.Context, input map[string]any) (any, error) {
    // Process input
    return result, nil
},
    agent.WithCLI(),
    agent.WithDefaultCLI(),
    agent.WithDescription("Process input data"),
)
./tool --set input="data"
./tool process --input-file data.json  # Also works

Multiple Commands

a.RegisterReasoner("greet", greetHandler,
    agent.WithCLI(),
    agent.WithDefaultCLI(),
)

a.RegisterReasoner("farewell", farewellHandler,
    agent.WithCLI(),
)

a.RegisterReasoner("analyze", analyzeHandler,
    agent.WithCLI(),
)
./tool --set name=Alice               # Uses default (greet)
./tool greet --set name=Bob          # Explicit greet
./tool farewell --set name=Charlie   # Different command
./tool analyze --input-file doc.txt  # Another command

Mixed Access Modes

// Public CLI command
a.RegisterReasoner("search", searchHandler,
    agent.WithCLI(),
    agent.WithDescription("Search documents"),
)

// Internal server-only reasoner
a.RegisterReasoner("index-documents", indexHandler)

// Both CLI and server
a.RegisterReasoner("analyze", analyzeHandler,
    agent.WithCLI(),
)

Advanced Input Parsing

Type Conversion

Values from --set are strings. Parse as needed:

a.RegisterReasoner("calculate", func(ctx context.Context, input map[string]any) (any, error) {
    // Parse age to int
    ageStr, ok := input["age"].(string)
    if !ok {
        return nil, fmt.Errorf("age required")
    }
    age, err := strconv.Atoi(ageStr)
    if err != nil {
        return nil, fmt.Errorf("age must be a number: %w", err)
    }

    // Use parsed value
    return age * 2, nil
}, agent.WithCLI())

Boolean Flags

# Boolean as string
./binary --set enabled=true --set verbose=false
func parseBool(input map[string]any, key string) bool {
    val, ok := input[key].(string)
    if !ok {
        return false
    }
    return val == "true" || val == "1" || val == "yes"
}

JSON Nested Values

Use --input or --input-file for complex structures:

./binary --input '{
  "user": {"name": "Alice", "age": 30},
  "options": {"verbose": true, "format": "json"}
}'

Next Steps